Rounds/WorldCleanup.cs

Utility that clears runtime-spawned world objects when a round ends. It iterates the active Scene and destroys Pickups, Powerups, ZombieAI instances, and Placeable objects, optionally clearing bullet decals, and exposes a console command nz_sweep to run or toggle it.

File Access
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// WHAT A FINISHED RUN LEAVES ON THE FLOOR.
/// </summary>
///
/// ⛔ THE MAP'S OWN OBJECTS ARE NOT THIS METHOD'S JOB AND MUST NOT BECOME IT. `RoundManager.StartGame`
/// already rebuilds sixteen managers — debris, power, barricades, boxes, walls, links, tables — and
/// every one of them knows how to destroy what it built and build it again from the config. This
/// sweep is for the things that were never in a config at all: what the RUN dropped. Two systems
/// clearing the same object is how one of them ends up clearing it at the wrong moment.
///
/// ⛔ REPORTED AS *"the map is not properly cleaned up on game over, like salvage and shield drops,
/// and maybe soul boxes too"*, and all three named cases were real but for two different reasons.
/// The drops are THIS: local litter with a 30–120s lifetime that nothing shortens when the run
/// ends, so it follows you into the lobby. The soul boxes were the other kind — `SoulBoxManager`
/// was simply missing from `StartGame`'s list of sixteen, so a new run began with the last run's
/// souls still counted. That one is fixed where the other fifteen live, not here.
///
/// ⛔ ON EVERY MACHINE, WHICH IS WHY IT HANGS OFF `NZGame.SetMode` AND NOT OFF `ReturnToLobby`.
/// Pickups are `NetworkMode.Never` — each machine spawns and owns its own copy and the network
/// carries the EVENT, not the object (`Pickup.Drop`'s own note). So there is no host that can
/// destroy everybody's litter; each machine has to clear its own, and `SetMode` is the one thing
/// in the project that runs everywhere. `ReturnToLobby` is the host's alone, which is exactly the
/// bug `SetMode` already carries a comment about for the lobby menu itself.
///
/// ⚠️ IT IS NOT A LEAK FIX AND DOES NOT PRETEND TO BE. Everything here already expires on its own —
/// pickups at 30 or 120 seconds, corpses at 6. The complaint is about the seconds AFTER a run, when
/// a lobby that should be quiet is still holding a round's worth of dropped plates. For anything
/// that grows and never comes back, `nz_leak` is the instrument.
public static class WorldCleanup
{
	/// <summary>Sweep at all. `nz_sweep 0` to leave the litter where it falls.</summary>
	public static bool Enabled { get; set; } = true;

	/// <summary>Wipe bullet holes as part of a sweep. They are capped at 30, so this is cosmetic.</summary>
	public static bool ClearDecals { get; set; } = true;

	/// <summary>
	/// Destroy everything this run dropped. Returns how many objects went.
	/// </summary>
	///
	/// ⚠️ MATERIALISED WITH ToList FIRST, the same reason `PowerupEffects.Nuke` gives: destroying a
	/// component mutates the scene's component list, and iterating it live while it changes throws.
	///
	/// ⚠️ ZOMBIES INCLUDED, CORPSES AND ALL. `RoundManager.ClearZombies` already does this on the
	/// host's own path and doing it twice costs nothing — but a client returning to the lobby never
	/// runs that method, and a corpse is a `SkinnedModelRenderer` plus a full `ModelPhysics` group.
	/// They are the dearest thing on this list by a distance.
	public static int Sweep( Scene scene, string why = "" )
	{
		if ( !Enabled || !scene.IsValid() ) return 0;

		int drops = 0, powerups = 0, bodies = 0, placed = 0;

		foreach ( var p in scene.GetAllComponents<Pickup>().ToList() )
			if ( p.IsValid() ) { p.GameObject.Destroy(); drops++; }

		foreach ( var p in scene.GetAllComponents<Powerup>().ToList() )
			if ( p.IsValid() ) { p.GameObject.Destroy(); powerups++; }

		foreach ( var z in scene.GetAllComponents<ZombieAI>().ToList() )
			if ( z.IsValid() ) { z.GameObject.Destroy(); bodies++; }

		// ⚠️ BANANA COLADA'S FURNITURE — a slick bar, a wall, a decoy stand, a springboard. Placed
		// by a player during a run and belonging to nothing that gets rebuilt, so without this they
		// are the one dropped thing that survives into the NEXT game as well as into the lobby.
		foreach ( var p in scene.GetAllComponents<Placeable>().ToList() )
			if ( p.IsValid() ) { p.GameObject.Destroy(); placed++; }

		// ⚠️ GRENADES IN FLIGHT ARE DELIBERATELY LEFT. One is a live explosive with a fuse and an
		// owner; destroying it mid-air silently eats a grenade somebody threw a quarter of a second
		// before the round ended. It lands, it goes off, it is gone — which is already the answer.

		if ( ClearDecals ) BulletDecals.ClearCmd();

		var total = drops + powerups + bodies + placed;

		if ( total > 0 )
			Log.Info( $"[nz] swept{(string.IsNullOrEmpty( why ) ? "" : $" ({why})")}"
				+ $" — {drops} drop(s), {powerups} powerup(s), {bodies} zombie(s)/corpse(s),"
				+ $" {placed} placed item(s)" );

		return total;
	}

	/// <summary>
	/// `nz_sweep [0|1]` — clear the floor now, or switch the automatic sweep off.
	/// </summary>
	///
	/// ⚠️ THE BARE COMMAND SWEEPS. A switch that only printed its own state would need a second
	/// command to do the thing, and the thing is the point — this is also how you check whether a
	/// frame-rate problem is the litter or something else, by clearing it and looking.
	[ConCmd( "nz_sweep" )]
	public static void SweepCmd( int on = -1 )
	{
		if ( on >= 0 )
		{
			Enabled = on != 0;
			Log.Info( $"[nz] automatic sweep {(Enabled ? "on" : "OFF")}" );
			if ( !Enabled ) return;
		}

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

		// ⚠️ SAYS SO EVEN WHEN IT FINDS NOTHING, because "nothing happened" is the answer the
		// command is usually being asked for.
		if ( Sweep( scene, "by hand" ) == 0 )
			Log.Info( "[nz] swept — nothing on the floor" );
	}
}