Game/MonolithManager.cs
namespace Monolith;

/// <summary>
/// Owns the shared monolith. The host is authoritative: it applies every blast and broadcasts
/// the operation (centre plus radius), never the resulting voxel list. Clients apply the same
/// deterministic operation, so a whole wall coming down costs a handful of bytes.
///
/// All wire traffic goes through <see cref="MonolithNet"/> static RPCs, which is why this
/// component does not need to be a networked object.
/// </summary>
public sealed class MonolithManager : Component
{
	public static MonolithManager Instance { get; private set; }

	// Note: blast radius is uncapped by design (see Tuning). The only clamp is the world size,
	// applied in HostHandleBlast.

	/// <summary>Stage index to start a fresh session on. Handy for jumping straight to a shape.</summary>
	[Property] public int StartStage { get; set; } = 0;

	[Property] public MonolithRenderer Renderer { get; set; }

	public VoxelWorld World { get; private set; }

	/// <summary>Which rung of the ladder we are on. Each shape is different, not just bigger.</summary>
	public int Tier { get; private set; }

	/// <summary>
	/// Solo climbs the ladder; Monolith is the shared rock everyone attacks. The door is open
	/// from the first minute: the gate is power, not permission (see GOALS.md).
	/// </summary>
	public bool InMonolith { get; private set; }

	public string StageName => InMonolith ? "THE MONOLITH" : Stages.NameFor( Tier );

	/// <summary>Set for a few seconds after a clear, so the HUD can celebrate before the next
	/// shape condenses. Purely cosmetic.</summary>
	public bool StageJustCleared { get; private set; }
	public string LastClearedName { get; private set; }

	public long RemainingCubes => World?.SolidCount ?? 0;
	public long TotalCubes => World?.InitialCount ?? 0;

	/// <summary>False on a client until the host has streamed us the current voxel state.</summary>
	public bool SnapshotReady { get; private set; }

	private VoidGrid grid;
	private FloorHazard floor;
	private ShieldShell shield;
	private bool clearAnnounced;
	private float clearedBannerUntil;
	private bool residueLogged;
	private GameTimeSince timeSinceResidueScan;
	private float monolithStartedAt;

	/// <summary>Seconds since the current shared Monolith condensed, for the speedrun board.</summary>
	public float MonolithSeconds => InMonolith ? GameTime.Now - monolithStartedAt : 0f;

	/// <summary>
	/// World position of what is left when only a handful of cubes remain, so the HUD can
	/// point at it. Null when there is plenty left and no guidance is needed.
	/// </summary>
	public Vector3? ResidueCentre { get; private set; }

	/// <summary>
	/// Positions of what is left, once there is little enough to be worth listing. Drones read
	/// this so they keep working through the tail of a stage, where a random ray finds nothing.
	/// </summary>
	private readonly List<Vector3Int> residueVoxels = new();

	/// <summary>
	/// Hands a drone one of the remaining cubes. Returns false while the shape is still full,
	/// which is correct: random rays are cheaper and the list is not maintained then.
	/// </summary>
	public bool TryPickResidueVoxel( out Vector3Int voxel )
	{
		voxel = default;

		if ( residueVoxels.Count == 0 )
			return false;

		voxel = residueVoxels[Game.Random.Int( 0, residueVoxels.Count - 1 )];

		// It may have been shot since the last scan. Cheap to verify, and handing back a hole
		// would waste the drone's shot on empty space.
		return World != null && World.IsSolid( voxel.x, voxel.y, voxel.z );
	}

	/// <summary>
	/// Average position of every remaining cube, and the list of them.
	///
	/// **This was the stall.** It used to walk every voxel in the world, which is fine for a
	/// 3,000 cube ladder stage and catastrophic for the shared Monolith: 256^3 is 16.7 MILLION
	/// iterations, and it ran every 0.75 seconds for as long as the residue counter was on
	/// screen. That is exactly the "slows down considerably at ~200 cubes left" report, and the
	/// cruel part is that it got slower the CLOSER you were to finishing.
	///
	/// Now it walks chunks and skips empty ones. With 200 cubes left almost every chunk is
	/// empty, so the work collapses from the whole world to the two or three chunks that still
	/// hold anything. Each chunk also stops early once it has found its own SolidCount, so a
	/// chunk holding one cube costs one hit rather than 32,768 misses.
	/// </summary>
	private Vector3? FindResidueCentre()
	{
		residueVoxels.Clear();

		if ( World == null ) return null;

		long count = 0;
		Vector3 sum = Vector3.Zero;

		for ( int i = 0; i < World.ChunkCount; i++ )
		{
			var chunk = World.GetChunkByIndex( i );

			if ( chunk == null || chunk.IsEmpty )
				continue;

			var coord = World.ChunkCoordFromIndex( i );

			int baseX = coord.x * VoxelChunk.Size;
			int baseY = coord.y * VoxelChunk.Size;
			int baseZ = coord.z * VoxelChunk.Size;

			int found = 0;

			for ( int z = 0; z < VoxelChunk.Size && found < chunk.SolidCount; z++ )
			for ( int y = 0; y < VoxelChunk.Size && found < chunk.SolidCount; y++ )
			for ( int x = 0; x < VoxelChunk.Size && found < chunk.SolidCount; x++ )
			{
				if ( !chunk.Get( x, y, z ) ) continue;

				found++;

				int wx = baseX + x;
				int wy = baseY + y;
				int wz = baseZ + z;

				sum += World.VoxelToWorld( wx, wy, wz );
				count++;

				if ( residueVoxels.Count < 512 )
					residueVoxels.Add( new Vector3Int( wx, wy, wz ) );
			}
		}

		return count == 0 ? null : sum / count;
	}

	// Host-side queue for streaming a snapshot to a joining client without stalling a frame.
	private readonly Queue<(Connection target, int chunkIndex)> snapshotQueue = new();
	private const int SnapshotChunksPerFrame = 24;

	protected override void OnAwake()
	{
		Instance = this;
		Renderer ??= Components.Get<MonolithRenderer>();
		grid = Components.Get<VoidGrid>() ?? Components.Create<VoidGrid>();
		floor = Components.Get<FloorHazard>() ?? Components.Create<FloorHazard>();
		shield = Components.Get<ShieldShell>() ?? Components.Create<ShieldShell>();
	}

	protected override void OnStart()
	{
		if ( Networking.IsHost )
		{
			BuildWorld( StartStage );
			SnapshotReady = true;
		}
		else
		{
			// Wait for the host's header before building anything: it tells us the size.
			MonolithNet.RequestSnapshot();
		}
	}

	protected override void OnDestroy()
	{
		if ( Instance == this )
			Instance = null;
	}

	private TimeSince timeSinceFrameCheck;

	/// <summary>
	/// Catches long frames and prints what the world looked like when one happened.
	///
	/// Collapse has now hung four times with nothing in the log but a stall warning, and the
	/// `[collapse]` timer proved the hang is NOT inside Collapse: it completes in under 50ms and
	/// the freeze lands seconds later. Everything after that has been guesswork, and guessing has
	/// cost more than instrumenting would have.
	///
	/// `Time.Delta` on the frame AFTER a hang reports the hang, so this names the duration and
	/// dumps the state that could plausibly explain it. Whatever is actually responsible, the
	/// next occurrence will say so instead of leaving another hypothesis.
	/// </summary>
	private void WatchFrameTime()
	{
		// A tenth of a second is already a visible hitch and nowhere near normal.
		if ( Time.Delta < 0.1f )
			return;

		// Never fires twice for the same event, and stays quiet through the genuinely heavy
		// first frames after a world is built.
		if ( timeSinceFrameCheck < 1f )
			return;

		timeSinceFrameCheck = 0f;

		int pending = Renderer.IsValid() ? Renderer.PendingRebuilds : -1;
		var progress = PlayerProgress.Local;

		Log.Warning( $"[hitch] frame took {Time.Delta * 1000f:0}ms | "
			+ $"stage {Tier + 1}{(InMonolith ? " (monolith)" : "")} "
			+ $"chunks {World?.ChunkCount ?? 0} solid {RemainingCubes:N0} | "
			+ $"mesh backlog {pending} | "
			+ $"bolts {Projectile.LiveCount} drones {MiningDrone.All.Count} "
			+ $"spotters {Spotter.All.Count} sentinels {Sentinel.All.Count} "
			+ $"orbs {SentinelOrb.All.Count} anchors {Anchor.All.Count} "
			+ $"leeches {Leech.All.Count} barriers {OrbitalBarrier.All.Count} | "
			// Parenthesised: inside an interpolation hole a bare `??` runs into the `:` that
			// starts the format specifier.
			+ $"proj/shot {progress?.ProjectileCount ?? 0} "
			+ $"blast {(progress?.BlastRadius ?? 0f):0.0}" );

		LogRenderStats();
	}

	/// <summary>
	/// Draw-call side of a hitch. GOALS 7e: measure before changing the renderer.
	///
	/// The suspicion is that two GameObjects per chunk (rock and volatile) means a 512 chunk
	/// Monolith is up to 1024 renderers and 1024 draws. These counters settle it rather than
	/// leaving it as a plausible story, which is the mistake that cost four sessions on the
	/// mesher: `DrawCalls` tracking renderer count means the split is worth removing, while
	/// `ObjectsCulledByVis` near zero means nothing is being culled and the win is elsewhere.
	///
	/// Wrapped because `FrameStats` is a diagnostics surface, not a gameplay one, and a
	/// diagnostic must never be the thing that takes the game down.
	/// </summary>
	private static void LogRenderStats()
	{
		try
		{
			var f = Sandbox.Diagnostics.FrameStats.Current;

			// Triangles included because it is the mesher's OUTPUT, so it separates "too many
			// objects" from "too much geometry per object". Greedy meshing is supposed to keep
			// the second small, and if it is not, the problem is the mesh rather than the count.
			Log.Warning( $"[draws] calls {f.DrawCalls} | rendered {f.ObjectsRendered} "
				+ $"of {f.ObjectsTested} tested | tris {f.TrianglesRendered:N0} | "
				+ $"batched {f.RenderBatchDraws} unbatchable {f.UnbatchableMaterialDraws} | "
				+ $"culled vis {f.ObjectsCulledByVis} screen {f.ObjectsCulledByScreenSize}" );
		}
		catch ( Exception )
		{
			// Counter names are engine-owned. Losing the diagnostic is acceptable; losing the
			// frame is not.
		}
	}

	protected override void OnUpdate()
	{
		WatchFrameTime();

		// Gated AFTER the watchdog on purpose. That measures wall time to catch hitches, so it
		// is the one thing here that must keep counting while the world is frozen: a pause is
		// not a stall, and the log should not confuse the two.
		if ( GameTime.Paused )
			return;

		if ( StageJustCleared && GameTime.Now > clearedBannerUntil )
			StageJustCleared = false;

		// Runs everywhere: it is derived purely from local voxel state, and a client that
		// cannot see the last few cubes is just as stuck as the host would be.
		UpdateResidue();
		UpdateShield();

		if ( !Networking.IsHost )
			return;

		PumpSnapshotQueue();

		// Guarded, because the rebuild happens when the broadcast comes back around and we
		// must not fire a second one in the meantime. Also held while the ladder-complete
		// summary is up, so an empty world cannot re-announce a clear every frame.
		if ( clearAnnounced || LadderComplete
			|| World == null || World.SolidCount > 0 || World.InitialCount <= 0 )
			return;

		clearAnnounced = true;

		if ( InMonolith )
		{
			// Felling the shared Monolith is an event in its own right, not a rung on the
			// ladder. Everyone who did a real share of it gets prestige credit.
			MonolithStats.ReportMonolithTime( MonolithSeconds );
			MonolithNet.MonolithFelled( World.InitialCount );
		}
		else
		{
			MonolithNet.MonolithCleared( Tier + 1 );
		}
	}

	// ---------------------------------------------------------------- world lifecycle

	public Vector3Int SizeForTier( int tier ) => Stages.SizeFor( tier );

	/// <summary>
	/// Remeshes everything because the set of volatile blocks changed. Only happens on an
	/// Instability purchase, so a full rebuild is acceptable.
	/// </summary>
	public void RefreshVolatileAppearance()
	{
		World?.MarkAllDirty();
	}

	// ---------------------------------------------------------------- shields

	/// <summary>
	/// While shielded, DRONES do nothing. You can still mine by hand, so the answer to a shield
	/// is to play, which is the entire reason it exists: it stops idling being the fastest way
	/// through a stage.
	///
	/// Three states, deliberately separate:
	///   <see cref="ShieldUp"/>      the stage rolled a shield and its timer has not run out
	///   <see cref="ShieldBroken"/>  you shot the node, so it is briefly down
	///   <see cref="ShieldActive"/>  up AND not broken, which is the only state that jams drones
	/// </summary>
	public bool ShieldUp => shieldRolled && GameTime.Now < shieldEndsAt;

	public bool ShieldBroken => ShieldUp && GameTime.Now < shieldBrokenUntil;

	public bool ShieldActive => ShieldUp && !ShieldBroken;

	private bool shieldRolled;
	private float shieldEndsAt;
	private float shieldBrokenUntil = -999f;

	public float ShieldSecondsLeft
		=> ShieldUp ? MathF.Max( 0f, shieldEndsAt - GameTime.Now ) : 0f;

	/// <summary>Seconds of drone time left in the current break. Zero when the shield is live.</summary>
	public float ShieldBreakSecondsLeft => MathF.Max( 0f, shieldBrokenUntil - GameTime.Now );

	private void RollShield()
	{
		shieldRolled = false;
		shieldBrokenUntil = -999f;
		shield?.Hide();

		if ( InMonolith )
			return;

		// Channel 3. Seeded from the stage so the shield is part of the fixed challenge rather
		// than a coin flip, which is what lets the ladder speedrun board compare like with like.
		InterceptorSpawner.SeedForStage( Tier, 3 );

		if ( Game.Random.Float() > Tuning.ShieldChance )
			return;

		shieldRolled = true;
		shieldEndsAt = GameTime.Now
			+ Game.Random.Float( Tuning.ShieldMinSeconds, Tuning.ShieldMaxSeconds );

		if ( World != null )
			shield?.Show( World.WorldBounds );

		Log.Info( $"Stage shielded for {shieldEndsAt - GameTime.Now:0}s. " +
			"Drones are jammed. Shoot the node above the shape to drop it." );
	}

	/// <summary>
	/// Called when a PLAYER shot strikes the node. Drops the shield for a couple of seconds and
	/// then it comes back, so the stage becomes a repeating beat: break it, cash in the drone
	/// window, break it again. Re-striking during a break does not stack.
	/// </summary>
	public void BreakShield()
	{
		if ( !ShieldUp || ShieldBroken )
			return;

		shieldBrokenUntil = GameTime.Now + Tuning.ShieldBreakSeconds;
		Log.Info( $"Shield node struck. Drones free for {Tuning.ShieldBreakSeconds:0}s." );
	}

	private void UpdateShield()
	{
		if ( shieldRolled && !ShieldUp )
		{
			shieldRolled = false;
			shield?.Hide();
		}
	}

	/// <summary>
	/// Rebuilds the current stage from scratch. Used when a Spotter completes a lock.
	///
	/// Deliberately scoped: it restores the SHAPE only. Dust, upgrades, cores and marks are all
	/// untouched, so losing a stage costs you time and nothing else. Taking earned progression
	/// away would make the whole system feel punishing rather than tense.
	/// </summary>
	/// <summary>
	/// Takes the stage back and rebuilds it.
	/// </summary>
	/// <param name="reason">
	/// What did it. This used to be hardcoded to the Spotter, which was true when the Spotter was
	/// the only thing that could take a stage and became a lie the moment the Crawler and then the
	/// lethal floor could too. It showed up immediately in a real log: "caught by the floor"
	/// followed on the same millisecond by "Spotter lock complete", which is exactly the kind of
	/// thing that sends you hunting a bug in the wrong system.
	/// </param>
	public void ResetStageProgress( string reason = "Stage progress reset" )
	{
		if ( World == null ) return;

		Log.Info( $"{reason}. Stage progress reset." );

		StageResetAt = GameTime.Now;

		if ( InMonolith )
			BuildMonolith();
		else
			BuildWorld( Tier );

		NotifyMinersOfReset();
	}

	/// <summary>Time of the last Spotter-caused reset, for the HUD banner.</summary>
	public float StageResetAt { get; private set; } = -999f;

	/// <summary>Sends the player back to the bottom of the ladder. Used by Collapse.</summary>
	public void RestartLadder()
	{
		InMonolith = false;
		LadderComplete = false;

		// Foresight lets a prestiged player skip the opening stages entirely.
		int start = PlayerProgress.Local.IsValid() ? PlayerProgress.Local.StartingStage : 0;

		BuildWorld( start );
		NotifyMinersOfReset();
		RepositionPlayers();

		// A collapse starts a fresh speedrun attempt.
		PlayerProgress.Local?.StartRunTimer();
	}

	/// <summary>Travel to (or back from) the shared Monolith.</summary>
	public void SetMonolithMode( bool monolith )
	{
		if ( InMonolith == monolith )
			return;

		InMonolith = monolith;
		LadderComplete = false;

		if ( monolith )
			BuildMonolith();
		else
			BuildWorld( Tier );

		NotifyMinersOfReset();
		RepositionPlayers();
	}

	private void NotifyMinersOfReset()
	{
		foreach ( var miner in Scene.GetAllComponents<Miner>() )
			miner.OnMonolithReset();
	}

	/// <summary>Reframes the local rig on whatever shape is now in front of it.</summary>
	/// <summary>
	/// Stands every player on the arena floor a little back from the shape, looking at it.
	/// The floor sits at the shape's base so you walk up to it rather than orbit it.
	/// </summary>
	private void RepositionPlayers()
	{
		if ( World == null ) return;

		var bounds = World.WorldBounds;
		float floorZ = bounds.Mins.z;

		// Far enough back that the whole shape is comfortably in frame.
		float distance = MathF.Max( 420f, bounds.Size.Length * 0.85f );
		var offset = new Vector3( 0.35f, -1f, 0f ).Normal * distance;
		var position = bounds.Center.WithZ( floorZ ) + offset;

		// Look at the middle of the shape rather than its base.
		var lookTarget = bounds.Center;

		float extent = ArenaHalfExtent;

		foreach ( var player in Scene.GetAllComponents<PlayerMovement>() )
		{
			player.FloorZ = floorZ;
			player.ArenaCentre = bounds.Center;
			player.ArenaHalfExtent = extent;

			var eye = position.WithZ( floorZ + player.EyeHeight );
			player.PlaceOnFloor( position, Rotation.LookAt( (lookTarget - eye).Normal ) );
		}

		grid?.FitTo( bounds );

		// Relaid for every shape, because the arena resizes with the stage and a tile grid that
		// did not follow it would put lethal squares outside the walls and none inside them.
		// Tier is 0-based; FloorHazard and its tuning both talk in the stage number the player
		// sees. Converting here rather than there is what stopped a threshold of 3 arming on 4.
		floor?.FitTo( bounds, extent, Tier + 1, InMonolith );

		// Shrapnel belongs to the shape that threw it. Carrying it across a rebuild would let a
		// stage you just lost kill you again in the one you were given to replace it.
		Shrapnel.ClearAll();
	}

	/// <summary>
	/// Half-width of the walled arena for the CURRENT shape.
	///
	/// This has to scale, not sit at a constant. The shared Monolith is 4096 units per axis, so
	/// its barriers orbit at roughly 3900 while the old fixed arena stopped you at 2600: they
	/// were permanently outside the walls, unreachable, and blocked nothing you could ever fire.
	/// </summary>
	public float ArenaHalfExtent
	{
		get
		{
			if ( World == null )
				return Tuning.ArenaMinHalfExtent;

			return MathF.Max( Tuning.ArenaMinHalfExtent,
				World.WorldBounds.Size.Length * Tuning.ArenaShapeMultiple );
		}
	}

	private void BuildMonolith()
	{
		clearAnnounced = false;
		residueLogged = false;

		// THE SHARED MONOLITH IS NEVER SHIELDED.
		//
		// RollShield already refuses to roll one here, but it is only called from BuildWorld, so
		// a shield rolled on the ladder stage you were standing on stayed active when you
		// travelled: you arrived at a weeks-long communal rock with your drones jammed and no
		// stage change coming to clear it. Jamming the drones is a nudge to play actively for
		// thirty seconds; on the Monolith it is just a tax on the one place idle throughput is
		// the entire point.
		shieldRolled = false;
		shieldBrokenUntil = -999f;
		shield?.Hide();

		if ( Renderer.IsValid() )
			Renderer.HighlightResidue = false;

		World = new VoxelWorld( Tuning.MonolithSize );
		World.FillShape( ShapeKind.Box );

		// Its own speedrun clock, independent of the solo ladder timer.
		monolithStartedAt = GameTime.Now;

		Renderer?.Attach( World );
		RepositionPlayers();

		Log.Info( $"THE MONOLITH: {World.Size.x}x{World.Size.y}x{World.Size.z}, " +
			$"{World.InitialCount:N0} cubes across {World.ChunkCount} chunks." );
	}

	private void BuildWorld( int stage )
	{
		Tier = stage;
		clearAnnounced = false;
		residueLogged = false;

		if ( Renderer.IsValid() )
			Renderer.HighlightResidue = false;

		var def = Stages.Get( stage );
		var size = Stages.SizeFor( stage );

		// A Slag reroll overrides the archetype for this stage only.
		var shape = shapeOverride ?? def?.Shape ?? ShapeKind.Box;
		shapeOverride = null;

		World = new VoxelWorld( size );
		World.FillShape( shape );

		Renderer?.Attach( World );
		RepositionPlayers();
		RollShield();

		// Reports the shape actually built, not the stage definition's. After a Slag reroll those
		// differ, and logging the definition made a rerolled stage look like the cube count had
		// changed on its own.
		Log.Info( $"Stage {stage + 1} \"{Stages.NameFor( stage )}\" condensed: " +
			$"{size.x}x{size.y}x{size.z} {shape}, {World.InitialCount:N0} cubes " +
			$"across {World.ChunkCount} chunks." );
	}

	// ---------------------------------------------------------------- residue

	/// <summary>
	/// The last handful of cubes are effectively invisible: one 16 unit cube adrift in a void
	/// hundreds of units across. Light them up, and log exactly where they are the first time
	/// we drop below the threshold so a genuine stall can be told apart from a hunt.
	/// </summary>
	private void UpdateResidue()
	{
		if ( World == null )
			return;

		bool revealing = World.SolidCount > 0 && World.SolidCount <= Tuning.ResidueRevealThreshold;

		if ( Renderer.IsValid() )
			Renderer.HighlightResidue = revealing;

		if ( !revealing )
		{
			ResidueCentre = null;
			residueVoxels.Clear();
			return;
		}

		// Refresh the beacon target periodically so it tracks as the last cubes get eaten.
		if ( timeSinceResidueScan > 0.75f )
		{
			timeSinceResidueScan = 0;
			ResidueCentre = FindResidueCentre();
		}

		if ( residueLogged )
			return;

		residueLogged = true;

		var found = new List<Vector3Int>();

		for ( int z = 0; z < World.Size.z && found.Count < 32; z++ )
		for ( int y = 0; y < World.Size.y && found.Count < 32; y++ )
		for ( int x = 0; x < World.Size.x && found.Count < 32; x++ )
		{
			if ( World.IsSolid( x, y, z ) )
				found.Add( new Vector3Int( x, y, z ) );
		}

		Log.Info( $"[residue] SolidCount says {World.SolidCount}; scan found {found.Count} " +
			$"(capped at 32). Voxels: {string.Join( ", ", found.Select( v => $"({v.x},{v.y},{v.z})" ) )}" );

		if ( found.Count == 0 )
		{
			Log.Warning( "[residue] SolidCount is above zero but no solid voxel exists. " +
				"That is a counting bug, not a findability problem." );
		}
	}

	// ---------------------------------------------------------------- fracture

	/// <summary>Spends Slag to shatter half of what remains. Routed through the host.</summary>
	public void RequestFracture()
	{
		MonolithNet.RequestFracture( Tuning.FractureFraction );
	}

	public void HostHandleFracture( float fraction, Guid causedBy )
	{
		if ( !Networking.IsHost || World == null )
			return;

		// NOT int.MaxValue. Game.Random.Int is INCLUSIVE of max, so it calls Next(min, max + 1),
		// and max + 1 overflows to int.MinValue: every Fracture threw
		// "'minValue' cannot be greater than maxValue" and silently did nothing.
		int seed = Game.Random.Int( 1, int.MaxValue - 1 );
		int removed = World.Fracture( fraction, seed );

		if ( removed <= 0 )
			return;

		MonolithNet.ApplyFracture( fraction, seed, causedBy, removed );
	}

	public void ClientApplyFracture( float fraction, int seed, Guid causedBy, int removed )
	{
		if ( World == null )
			return;

		if ( !Networking.IsHost )
			World.Fracture( fraction, seed );

		var bounds = World.WorldBounds;
		BlastEffect.Spawn( Scene, bounds.Center, bounds.Size.Length * 0.4f, true );
		Debris.Burst( Scene, bounds.Center, bounds.Size.Length * 0.25f, true );

		if ( PlayerProgress.Local.IsValid() && Connection.Local?.Id == causedBy )
			PlayerProgress.Local.AwardCubes( removed );
	}

	/// <summary>Rerolls the current stage into a different shape. Host authoritative.</summary>
	public void RerollShape()
	{
		if ( !Networking.IsHost || InMonolith )
			return;

		shapeOverride = PickDifferentShape();
		BuildWorld( Tier );
		NotifyMinersOfReset();
	}

	private ShapeKind? shapeOverride;

	private ShapeKind PickDifferentShape()
	{
		var current = Stages.Get( Tier )?.Shape ?? ShapeKind.Box;
		var kinds = Enum.GetValues<ShapeKind>();

		for ( int attempt = 0; attempt < 12; attempt++ )
		{
			var candidate = kinds[Game.Random.Int( 0, kinds.Length - 1 )];
			if ( candidate != current )
				return candidate;
		}

		return current;
	}

	/// <summary>
	/// The shared Monolith fell. Each client scores its own contribution, then a fresh one
	/// condenses so the server always has something to work on.
	/// </summary>
	public void ClientMonolithFelled( long totalCubes )
	{
		LastClearedName = "THE MONOLITH";
		StageJustCleared = true;
		clearedBannerUntil = GameTime.Now + Tuning.StageClearedPauseSeconds;

		// The single biggest event in the game announced itself with a banner and no sound.
		Audio.StageClear( WorldPosition );
		Audio.PlayUi( Audio.StingLow, 0.6f, 0.75f );

		MonolithFelledCredit = PlayerProgress.Local.IsValid()
			&& PlayerProgress.Local.TryAwardMonolithCredit( totalCubes );

		PlayerProgress.Local?.ResetMonolithContribution();

		BuildMonolith();
		NotifyMinersOfReset();
	}

	/// <summary>True if the local player earned credit from the last Monolith felling.</summary>
	public bool MonolithFelledCredit { get; private set; }

	/// <summary>
	/// True once the ladder has been completed and the run is waiting on a decision.
	///
	/// The ladder used to just roll on: clearing stage 100 condensed stage 101, then 102, with
	/// no acknowledgement that the thing the entire progression is built around had been
	/// achieved. Reaching the end of a hundred stage climb has to be an EVENT, and it has to
	/// stop, or the achievement is indistinguishable from the stage before it.
	/// </summary>
	public bool LadderComplete { get; private set; }

	/// <summary>Seconds the completed run took. Frozen at completion for the summary.</summary>
	public float LadderCompleteSeconds { get; private set; }

	/// <summary>Dismisses the summary. Called by the HUD once a choice is made.</summary>
	public void ClearLadderComplete() => LadderComplete = false;

	public void ClientMonolithCleared( int nextStage )
	{
		LastClearedName = Stages.NameFor( Tier );
		StageJustCleared = true;
		clearedBannerUntil = GameTime.Now + Tuning.StageClearedPauseSeconds;

		Audio.StageClear( WorldPosition );

		// THE LADDER ENDS AT 100. It does not overflow into 101.
		if ( nextStage >= Tuning.PrestigeStageRequirement )
		{
			LadderComplete = true;
			LadderCompleteSeconds = PlayerProgress.Local.IsValid()
				? PlayerProgress.Local.RunSeconds
				: 0f;

			if ( PlayerProgress.Local.IsValid() )
				// PrestigeStageRequirement - 1, because ReportStage takes a 0-BASED index and
				// adds one itself. Passing 100 recorded the completed ladder as stage 101: the
				// start screen read "stage 101 of 100", it paid a core it had not earned, and
				// it would have put 101s on a public leaderboard that cannot be cleaned up
				// afterwards. The other caller passes `Tier`, which is why the convention is
				// an index rather than a count.
				PlayerProgress.Local.ReportStage( Tuning.PrestigeStageRequirement - 1 );

			Log.Info( $"LADDER COMPLETE in {LadderCompleteSeconds:0.0}s. " +
				"Awaiting Collapse or the Monolith." );

			// The world is deliberately left standing. Rebuilding underneath the summary would
			// put the player back to work behind their own results screen.
			SnapshotReady = true;
			return;
		}

		Log.Info( $"\"{LastClearedName}\" cleared. Next: \"{Stages.NameFor( nextStage )}\"." );

		BuildWorld( nextStage );
		SnapshotReady = true;

		foreach ( var miner in Scene.GetAllComponents<Miner>() )
			miner.OnMonolithReset();
	}

	// ---------------------------------------------------------------- blasts

	/// <summary>Called by the local Miner. Routes through the host so everyone converges.</summary>
	public void RequestBlast( Vector3Int centre, float radius, bool isCharge )
	{
		MonolithNet.RequestBlast( centre.x, centre.y, centre.z, radius, isCharge );
	}

	/// <summary>Host side: validate, apply, then mirror the exact result to everyone.</summary>
	public void HostHandleBlast( int vx, int vy, int vz, float radius, bool isCharge, Guid causedBy )
	{
		if ( !Networking.IsHost || World == null )
			return;

		// Blast radius is uncapped by design, so the only limit here is the world itself:
		// a sphere larger than the shape cannot remove more than the shape.
		float worldMax = Math.Max( World.Size.x, Math.Max( World.Size.y, World.Size.z ) );
		radius = Math.Clamp( radius, 0f, worldMax );

		var centre = new Vector3Int( vx, vy, vz );

		// The CENTRE may sit outside the world. A demolition charge detonating just off the
		// surface, or in open air beside the shape, still has most of its sphere overlapping
		// rock, and rejecting it outright is why an area weapon appeared to do nothing unless
		// it was buried.
		//
		// `RemoveSphere` already clamps its own iteration bounds to the world, so the only thing
		// needed here is to check the sphere REACHES the world at all rather than that its
		// centre is inside it.
		int reach = (int)MathF.Ceiling( radius );

		bool touchesWorld =
			centre.x + reach >= 0 && centre.x - reach < World.Size.x &&
			centre.y + reach >= 0 && centre.y - reach < World.Size.y &&
			centre.z + reach >= 0 && centre.z - reach < World.Size.z;

		if ( !touchesWorld )
			return;

		int removed = World.RemoveSphere( centre, radius );
		if ( removed <= 0 )
			return;

		MonolithNet.ApplyBlast( vx, vy, vz, radius, isCharge, causedBy, removed );
	}

	/// <summary>Applies a mirrored blast. The host already applied it, so it only does effects.</summary>
	public void ClientApplyBlast( int vx, int vy, int vz, float radius, bool isCharge, Guid causedBy, int removed )
	{
		if ( World == null )
			return;

		var centre = new Vector3Int( vx, vy, vz );

		if ( !Networking.IsHost )
			World.RemoveSphere( centre, radius );

		var worldPos = World.VoxelToWorld( centre.x, centre.y, centre.z );
		float worldRadius = radius * Tuning.VoxelSize;

		BlastEffect.Spawn( Scene, worldPos, worldRadius, isCharge );
		Debris.Burst( Scene, worldPos, worldRadius, isCharge );

		// Credit the player who caused it, on their own machine only.
		if ( PlayerProgress.Local.IsValid() && Connection.Local?.Id == causedBy )
			PlayerProgress.Local.AwardCubes( removed );
	}

	// ---------------------------------------------------------------- snapshots

	public void HostQueueSnapshot( Connection caller )
	{
		if ( !Networking.IsHost || caller == null || World == null )
			return;

		using ( Rpc.FilterInclude( caller ) )
		{
			MonolithNet.SnapshotHeader( World.Size.x, World.Size.y, World.Size.z, Tier, World.InitialCount );
		}

		for ( int i = 0; i < World.ChunkCount; i++ )
			snapshotQueue.Enqueue( (caller, i) );
	}

	private void PumpSnapshotQueue()
	{
		int budget = SnapshotChunksPerFrame;

		while ( budget-- > 0 && snapshotQueue.Count > 0 )
		{
			var (target, index) = snapshotQueue.Dequeue();
			if ( target == null )
				continue;

			var payload = Convert.ToBase64String( World.GetChunkByIndex( index ).Serialize() );

			using ( Rpc.FilterInclude( target ) )
			{
				MonolithNet.SnapshotChunk( index, payload );

				if ( snapshotQueue.Count == 0 )
					MonolithNet.SnapshotComplete();
			}
		}
	}

	public void ClientSnapshotHeader( int sx, int sy, int sz, int tier, long initial )
	{
		if ( Networking.IsHost )
			return;

		Tier = tier;
		World = new VoxelWorld( new Vector3Int( sx, sy, sz ) );
		World.SetInitialCount( initial );
		SnapshotReady = false;

		Renderer?.Attach( World );
	}

	public void ClientSnapshotChunk( int index, string payload )
	{
		if ( Networking.IsHost || World == null )
			return;

		if ( index < 0 || index >= World.ChunkCount )
			return;

		World.GetChunkByIndex( index ).Deserialize( Convert.FromBase64String( payload ) );
		World.DirtyChunks.Add( index );
	}

	public void ClientSnapshotComplete()
	{
		if ( Networking.IsHost )
			return;

		World?.RecountSolid();
		World?.MarkAllDirty();
		SnapshotReady = true;

		Log.Info( "Monolith snapshot received." );
	}
}