EasterEgg/BuildPartManager.cs

Manager and component for in-game collectible build parts. BuildPartManager creates, rebuilds and tracks authored and runtime-dropped Prisma parts, replicates runtime drops to joiners, hides parts when the team has found them. BuildPart is the component on each part that provides proximity lookup, availability checks (power/links) and collection logic, including host-authoritative networked take/drop calls.

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

namespace NZombies;

/// <summary>
/// BUILD PARTS — the three pieces of the Prisma, lying about the map.
///
/// ⚠️ Same shape as `BuildTableManager` and its siblings: `Ensure` on demand, `NotSaved`, one
/// `Rebuild` entry point.
/// </summary>
public sealed class BuildPartManager : Component
{
	public static BuildPartManager Instance { get; private set; }

	static float? _reach;
	/// <summary>
	/// How close you must stand to pick one up. 70 units.
	/// </summary>
	///
	/// ⚠️ TIGHTER THAN THE TABLE'S, and deliberately: a part is a small object on the floor and
	/// you should have to walk to it. It is also what `UsePrompt` reads, so the prompt and the
	/// pickup can never disagree about reach — the trap `UsePrompt`'s own header calls out.
	public static float Reach { get => _reach ?? 70f; set => _reach = value; }

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

	public static BuildPartManager Ensure( Scene scene = null )
	{
		if ( Instance.IsValid() ) return Instance;

		scene ??= Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

		var go = scene.CreateObject();
		go.Name = "Build Part Manager";
		go.Flags |= GameObjectFlags.NotSaved;
		return go.Components.Create<BuildPartManager>();
	}

	readonly List<GameObject> _built = new();

	/// <summary>
	/// Parts dropped into the world at runtime, as opposed to placed by the map author.
	/// </summary>
	///
	/// ⛔ IT EXISTS SO A JOINER CAN BE TOLD ABOUT THEM. The authored parts need no replication at
	/// all — every machine builds the same list from the same config — but a part dropped by a
	/// soul box is in nobody's config, so a player who arrives afterwards has no way to learn it is
	/// lying there. This is the record `NZNet.PushState` replays to them.
	readonly List<(int Part, Vector3 Pos, float Yaw)> _drops = new();

	/// <summary>Every runtime drop, for the network layer to replay to a joiner.</summary>
	public IReadOnlyList<(int Part, Vector3 Pos, float Yaw)> Drops => _drops;

	/// <summary>
	/// Is this part already out there — dropped and waiting, or taken off the map?
	/// </summary>
	///
	/// ⚠️ BOTH HALVES, AND NEITHER IS ENOUGH ALONE. `BuildParts.Found` only goes true when somebody
	/// COLLECTS one, so a part lying on the floor is still unfound and a second killer would drop
	/// another; the drop list only knows about runtime drops, so it says nothing about a part
	/// collected from an authored spot.
	///
	/// ⚠️ IT DOES NOT KNOW ABOUT AN AUTHORED SPOT NOBODY HAS TOUCHED. A map that both places part 2
	/// and asks a napalm for it has two sources by the author's own choice, and this will let both
	/// exist — which is a map-config question rather than something to second-guess here.
	public bool AlreadyAwarded( int part )
		=> BuildParts.Found( part ) || _drops.Any( d => d.Part == part );

	public int Built => _built.Count( g => g.IsValid() );

	public void Rebuild()
	{
		foreach ( var g in _built ) g?.Destroy();
		_built.Clear();

		// ⛔ A NEW GAME EMPTIES THE POOL, AND THIS IS THE ONE PLACE THAT KNOWS. `Rebuild` runs from
		// `NZGame` and `RoundManager` when a game starts, and nowhere per round — so it is exactly
		// the moment the team should stop holding last game's parts and the map should be whole
		// again. The editor and `nz_buildpart_rebuild` also land here, which is correct: they are
		// re-laying the parts.
		BuildParts.Reset();
		_drops.Clear();

		var list = ActiveConfig.Current?.BuildParts;
		if ( list is null || list.Count == 0 ) return;

		foreach ( var spot in list )
			Build( spot );

		Log.Info( $"[nz-build] {Built} of {list.Count} build part(s) placed" );
	}

	void Build( BuildPartSpot spot )
	{
		var path = BuildParts.Model( spot.Part );
		if ( string.IsNullOrEmpty( path ) )
		{
			Log.Warning( $"[nz-build] spot has no such part: {spot.Part}" );
			return;
		}

		var go = Scene.CreateObject();
		go.Name = $"Build Part — {BuildParts.Name( spot.Part )}";
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		go.WorldPosition = spot.Position;
		go.WorldRotation = Rotation.From( 0f, spot.Yaw, 0f );

		var r = go.Components.Create<ModelRenderer>();
		r.Model = Model.Load( path );

		if ( r.Model is null )
			Log.Warning( $"[nz-build] part model not found: {path} — nothing will draw" );

		var part = go.Components.Create<BuildPart>();
		part.Spot = spot;

		_built.Add( go );
	}

	/// <summary>
	/// Put a part into the world at runtime — a reward, rather than a spot the map author placed.
	/// </summary>
	///
	/// ⛔ IT GOES INTO `_built` DELIBERATELY, AND THAT IS NOT A LEAK. That list is what `OnUpdate`
	/// walks to hide parts the team has already taken, so a dropped part left out of it would stay
	/// visible after somebody collected it. It is also what `Rebuild` clears, which is right in the
	/// other direction: a part earned last game must not still be lying there in the next one.
	///
	/// ⚠️ A SYNTHETIC SPOT WITH NO LINK AND NO POWER REQUIREMENT. Those gates exist so an authored
	/// part can sit behind a door that is not open yet; a part that has just been EARNED has passed
	/// whatever gate it had, and re-applying one would drop a piece nobody is allowed to pick up.
	/// ⛔ THE HOST DECIDES, AND THIS IS A REVERSAL OF THE FIRST VERSION. That one let every machine
	/// build its own copy off the back of the soul box's replayed fill, which works right up until
	/// somebody joins — the drop is in no config, so a joiner has nothing to build it from and the
	/// part is invisible to them forever. One authority, one broadcast, one record to replay.
	public BuildPart Drop( int part, Vector3 position, float yaw = 0f )
	{
		if ( !BuildParts.Valid( part ) ) return null;
		if ( Networking.IsActive && !NZGame.IsHost ) return null;

		var made = ApplyDrop( part, position, yaw );

		if ( Networking.IsActive )
			NZNet.BuildPartDropped( part, position, yaw );

		return made;
	}

	/// <summary>
	/// Put a dropped part here on THIS machine. What the broadcast and the join replay land on.
	/// </summary>
	///
	/// ⚠️ IDEMPOTENT BY POSITION, because it is called three ways that overlap: the host applies it
	/// before broadcasting, the broadcast comes back to the host too, and `PushState` replays every
	/// drop to everyone rather than only to the joiner. Without the guard one soul box would leave
	/// a stack of claws growing by one each time somebody connected.
	public BuildPart ApplyDrop( int part, Vector3 position, float yaw )
	{
		if ( !BuildParts.Valid( part ) ) return null;

		if ( _drops.Any( d => d.Part == part && d.Pos.Distance( position ) < 1f ) )
			return null;

		_drops.Add( (part, position, yaw) );

		Build( new BuildPartSpot { Part = part, Position = position, Yaw = yaw } );

		return _built.LastOrDefault()?.Components.Get<BuildPart>();
	}

	/// <summary>Hide the ones the team has taken, show the rest.</summary>
	///
	/// ⛔ STILL VISIBILITY, NEVER A DESTROY, EVEN NOW THAT PARTS ARE SHARED. Hiding is reversible
	/// and a destroy is not: `Rebuild` lays the parts out again for a new game, and an object torn
	/// down on pickup would need respawning instead of re-enabling. It also costs nothing — the
	/// list is three objects.
	///
	/// ⚠️ KEYED ON `Found`, NOT ON `Has`. The team stops HOLDING the parts the moment the weapon is
	/// built; they have still been taken. Keying on `Has` would lay all three back out on the map
	/// at the exact moment the hunt finished.
	protected override void OnUpdate()
	{
		foreach ( var g in _built )
		{
			if ( !g.IsValid() ) continue;

			var part = g.Components.Get<BuildPart>();
			if ( part?.Spot is null ) continue;

			g.Enabled = !BuildParts.Found( part.Spot.Part );
		}
	}
}

/// <summary>One piece of the Prisma, on the floor, waiting to be picked up.</summary>
public sealed class BuildPart : Component
{
	public BuildPartSpot Spot { get; set; }

	/// <summary>
	/// The nearest part this player could still pick up, or null.
	/// </summary>
	///
	/// ⚠️ IT SKIPS PARTS THEY ALREADY CARRY, which is what stops a collected piece holding the
	/// prompt open over a part they can still use standing behind it.
	public static BuildPart Near( NZPlayer player )
	{
		if ( !player.IsValid() ) return null;

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

		var reach = BuildPartManager.Reach;

		return scene.GetAllComponents<BuildPart>()
			.Where( b => b.IsValid() && b.Spot is not null )
			.Where( b => !BuildParts.Found( b.Spot.Part ) )
			.Where( b => b.WorldPosition.Distance( player.WorldPosition ) <= reach )
			.OrderBy( b => b.WorldPosition.Distance( player.WorldPosition ) )
			.FirstOrDefault();
	}

	/// <summary>Why this player cannot take it, or empty.</summary>
	public string Unavailable( NZPlayer player )
	{
		if ( Spot is null ) return "";

		if ( Spot.RequiresPower && !Power.IsOn )
			return "Needs power";

		if ( !string.IsNullOrEmpty( Spot.Link ) && !DoorLinks.IsOpen( Spot.Link ) )
			return "Locked";

		return "";
	}

	/// <summary>Take it. Returns what to say, or empty if nothing happened.</summary>
	public string Collect( NZPlayer player )
	{
		if ( Spot is null || !player.IsValid() ) return "";

		var blocked = Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		// ⛔ A CLIENT ASKS THE HOST, WHICH COLLECTS IT FOR THEM WITH THIS SAME METHOD (the co-op audit, 2026-09-27). The part
		// goes from every screen when the host's answer arrives; two players reaching for one part get it once.
		if ( Networking.IsActive && !NZGame.IsHost )
		{
			NZNet.BuildPartTakeAsk( Spot.Part, WorldPosition );
			return "";
		}

		if ( !BuildParts.Take( Spot.Part ) ) return "";

		NZSound.PlayShared( NZSound.PickupSalvage, WorldPosition );

		var held = BuildParts.Held();
		return held >= BuildParts.Count
			? $"Picked up the {BuildParts.Name( Spot.Part )} — that is all three"
			: $"Picked up the {BuildParts.Name( Spot.Part )} ({held}/{BuildParts.Count})";
	}
}