EasterEgg/BuildTableManager.cs

Manager and component for in-world build tables (workbenches) and the individual BuildTable behavior. BuildTableManager creates and rebuilds table GameObjects from config, positions and orients them, and tracks created instances. BuildTable handles proximity, build/hold/take logic, state syncing across network via NZNet, visual model placement, Prisma holder tracking, and host/client delegation for authoritative actions.

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

namespace NZombies;

/// <summary>
/// BUILDING TABLES — where scattered parts become a wonder weapon.
///
/// ⚠️ Deliberately the same shape as `TradeTableManager` / `WunderfizzManager` /
/// `AmmoBoxManager`: `Ensure` creates on demand, `NotSaved` keeps it out of the map, `Rebuild` is
/// the single entry point. A manager refreshed differently from its siblings is one more thing to
/// remember at every call site that puts a config into the world, and this project has already
/// been bitten by one that was.
///
/// ⚠️ THE BUILD ITSELF IS A HOLD, AND IT LIVES ON THE PLAYER. `NZPlayer.TickBuildTable` runs the
/// four-second timer because holding is something a player does, not something a table does — and
/// because the timer has to be cancelled by things the table cannot see: walking away, going down,
/// letting go of the key. The table owns the rules (`Unavailable`, `Build`); the player owns the
/// clock.
/// </summary>
public sealed class BuildTableManager : Component
{
	public static BuildTableManager Instance { get; private set; }

	/// <summary>
	/// The trading table's model, by reference rather than by copy.
	/// </summary>
	///
	/// ⛔ A `const` POINTING AT THE OTHER `const`, WHICH IS THE POINT. The two tables are meant to
	/// be the same object — the BO2 TranZit workbench, which is what a buildable is assembled on
	/// in the game this borrows from. Writing the path out a second time would let them drift the
	/// first time either is re-ported, and "the same model" would quietly become "the same model
	/// as of today". This resolves at compile time, so they cannot disagree.
	///
	/// ⚠️ IF THEY EVER SHOULD DIFFER, this is the one line to change, and changing it says so.
	public const string ModelPath = TradeTableManager.ModelPath;

	static float? _yaw;
	/// <summary>
	/// Extra yaw on top of the spot's own facing, in degrees. 90.
	/// </summary>
	///
	/// ⛔ THE SAME NUMBER AS THE TRADING TABLE'S FOR THE SAME REASON — THE MESH. `zm_work_bench` is
	/// 32 units across and 88 long (`$bbox -16 -44 -0.3  16 44 65`), so its forward runs along the
	/// SHORT edge; placed on the shared `OnFloor` yaw it presents its end to you instead of its
	/// working surface.
	///
	/// ⚠️ ITS OWN FIELD RATHER THAN A READ OF `TradeTableManager.YawOffset`, because that one is
	/// settable at runtime and a mapper nudging their trading tables should not silently rotate
	/// every building table on the map.
	public static float YawOffset { get => _yaw ?? 90f; set => _yaw = value; }

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

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

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

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

	readonly List<GameObject> _built = new();

	/// <summary>How many are standing right now.</summary>
	public int Built => _built.Count( g => g.IsValid() );

	/// <summary>Destroy what is standing and build the config again.</summary>
	public void Rebuild()
	{
		foreach ( var g in _built ) g?.Destroy();
		_built.Clear();

		// New benches: nobody holds their weapon (`BuildTable.PrismaHolder`).
		BuildTable.ForgetHolder();

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

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

		// ⚠️ Says how many are STANDING, not how many are configured. A model that fails to load
		// leaves a spot in the config and nothing in the world, and those two numbers disagreeing
		// is the cheapest way to see it.
		Log.Info( $"[nz-build] {Built} of {list.Count} building table(s) built" );
	}

	void Build( BuildTableSpot spot )
	{
		var go = Scene.CreateObject();
		go.Name = "Building Table";
		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;

		// ⚠️ Yaw from the spot, tilt from the floor normal — so a table on a ramp sits on the ramp
		// rather than through it.
		go.WorldRotation = OnFloor( spot );

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

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

		var table = go.Components.Create<BuildTable>();
		table.Spot = spot;

		// ⚠️ THE CONFIG INDEX, NOT A COUNTER OF WHAT SUCCEEDED. A bench whose model failed to load
		// still occupies a slot in the list on every other machine, so numbering by "how many have
		// I built so far" would shift every later bench's name on exactly the machine that had a
		// problem — and the two would then disagree about which table anybody built at.
		table.Index = ActiveConfig.Current?.BuildTables.IndexOf( spot ) ?? -1;

		_built.Add( go );
	}

	/// <summary>
	/// Face the spot's yaw while lying flat on its floor.
	///
	/// ⚠️ LIFTED FROM `TradeTableManager.OnFloor`, deliberately identical — which in turn took it
	/// from `WunderfizzManager`. Projecting the heading onto the floor plane is what stops a
	/// sloped placement tipping the object over, and two placeables aligning differently on the
	/// same ramp is a bug nobody would think to look for.
	/// </summary>
	static Rotation OnFloor( BuildTableSpot spot )
	{
		// ⚠️ THE OFFSET GOES INTO THE HEADING, NOT ONTO THE RESULT. Multiplying a finished LookAt
		// would spin the bench about the WORLD up, which tips it on a sloped floor; folding the
		// offset into the yaw before the floor projection keeps the tilt doing its job.
		var yaw = spot.Yaw + YawOffset;

		var up = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;
		var heading = Rotation.FromYaw( yaw ).Forward;

		var forward = (heading - up * heading.Dot( up )).Normal;
		if ( forward.IsNearlyZero() )
			return Rotation.From( 0f, yaw, 0f );

		return Rotation.LookAt( forward, up );
	}
}

/// <summary>
/// A placed building table — hold E here with all three parts to build the Prisma.
/// </summary>
public sealed class BuildTable : Component
{
	public BuildTableSpot Spot { get; set; }

	static float? _range;
	/// <summary>
	/// How close you must stand. 90 units.
	/// </summary>
	///
	/// ⚠️ ONE NUMBER READ BY BOTH THE PROMPT AND THE HOLD. `UsePrompt`'s own header records what
	/// happens when a prompt owns its reach separately from the thing it describes: the text
	/// appears somewhere the action does not work, which reads as the action being broken.
	public static float UseRange { get => _range ?? 90f; set => _range = value; }

	static float? _hold;
	/// <summary>How long E must be held to build. 4s.</summary>
	public static float HoldSeconds { get => _hold ?? 4f; set => _hold = value; }

	public static BuildTable Near( Vector3 pos )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

		BuildTable best = null;
		var bestDist = UseRange;

		foreach ( var t in scene.GetAllComponents<BuildTable>() )
		{
			if ( !t.IsValid() ) continue;

			var d = pos.Distance( t.WorldPosition );
			if ( d > bestDist ) continue;

			bestDist = d;
			best = t;
		}

		return best;
	}

	/// <summary>Why the table will not serve at all, or empty.</summary>
	///
	/// ⚠️ NOT THE SAME QUESTION AS "DO THEY HAVE THE PARTS". This is the table being shut — no
	/// power, door still locked — and it outranks the parts message, because a player told they
	/// are missing pieces at a table that would not work anyway has been sent on a pointless walk.
	public string Unavailable( NZPlayer player )
	{
		if ( !player.IsValid() || Spot is null ) return "";

		if ( Spot.RequiresPower && !Power.IsOn ) return "Building Table — needs power";
		if ( !DoorLinks.IsOpen( Spot.Link ) ) return "Building Table — locked";

		return "";
	}

	/// <summary>
	/// Is the finished weapon lying on the table, waiting to be taken.
	/// </summary>
	///
	/// ⛔ IT IS NOT HANDED STRAIGHT TO THE PLAYER, AND THAT IS THE POINT. The build puts the
	/// weapon ON the bench the way the trading table shows what it holds — you watch it appear,
	/// then take it. A gun that materialised in your hands would make the four-second hold feel
	/// like a loading bar rather than like assembling something.
	///
	/// ⛔ SYNCED THROUGH `NZNet.BuildTableState`, BY CONFIG INDEX. The bench objects are not network
	/// entities — every machine creates its own from the same config list in the same order — so
	/// the index is a stable name for "the same bench" without anything having to be replicated.
	///
	/// ⚠️ `TradeTable` DOES NOT DO THIS AND SHOULD. Its contents are local-only today: one player
	/// leaves a weapon and nobody else sees it on the table. That is a bug in it, not a pattern
	/// worth copying here, and it is why this was written with a relay from the start.
	///
	/// ⚠️ THE MESSAGE IS AN IDEMPOTENT SET, so it needs no sender guard the way `WorldSound` does.
	/// The machine that built it has already set the same value locally, and applying `true` twice
	/// is still `true`.
	public bool Built { get; private set; }

	/// <summary>
	/// A one-time bench that has already given its weapon away.
	/// </summary>
	///
	/// ⛔ A THIRD STATE, NOT THE ABSENCE OF THE SECOND. "Not built" and "built, taken, and never
	/// again" look identical from `Built` alone, so without this a one-time bench would simply
	/// reset and could be built a second time by anyone who still had parts — which is exactly
	/// what one-time is supposed to prevent.
	public bool Spent { get; private set; }

	/// <summary>Does this bench keep handing the weapon out.</summary>
	public bool Permanent => Spot?.Permanent ?? false;

	/// <summary>Which config entry this bench came from — its name on the wire.</summary>
	public int Index { get; set; } = -1;

	/// <summary>The standing bench for a config index, or null.</summary>
	public static BuildTable Find( int index )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return null;

		foreach ( var t in scene.GetAllComponents<BuildTable>() )
			if ( t.IsValid() && t.Index == index ) return t;

		return null;
	}

	/// <summary>Apply a state that arrived from another machine.</summary>
	///
	/// ⚠️ IT MATCHES ON INDEX AND KEEPS GOING rather than stopping at the first hit. Two benches
	/// cannot share an index, but a stale object mid-`Rebuild` can still be in the scene, and
	/// setting both is correct where picking one arbitrarily is not.
	public static void ApplyState( int index, bool built, bool spent )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		foreach ( var t in scene.GetAllComponents<BuildTable>() )
		{
			if ( !t.IsValid() || t.Index != index ) continue;

			t.Built = built;
			t.Spent = spent;
			t.RefreshVisual();
		}
	}

	GameObject _shownGO;

	static float? _showHeight;
	/// <summary>
	/// How high above the table's origin the weapon rests. 47.
	/// </summary>
	///
	/// ⚠️ THE SAME FIGURE `TradeTable.ShowHeight` USES, because it is the same bench. Its own
	/// knob rather than a read of that one, so tuning where a traded gun sits does not move this.
	public static float ShowHeight { get => _showHeight ?? 47f; set => _showHeight = value; }

	static Angles? _showAngles;
	/// <summary>Which way the weapon lies on the table.</summary>
	public static Angles ShowAngles
	{
		get => _showAngles ?? new Angles( 0f, 0f, 0f );
		set => _showAngles = value;
	}

	/// <summary>
	/// The hold finished. Put the weapon on the table and spend the parts.
	/// </summary>
	///
	/// ⛔ THE PARTS ARE SPENT ONLY IF THE MODEL RESOLVED. Clearing first and then failing to draw
	/// anything would leave a player with no parts, nothing on the bench and nothing left on the
	/// map to pick up. Ordered this way a failed build is a retry rather than a dead run.
	public string Build( NZPlayer player )
	{
		if ( !player.IsValid() ) return "";

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

		if ( Built ) return "";

		// ⚠️ A SPENT BENCH IS DONE, even for a player carrying a fresh set of parts. Letting them
		// build again is the whole thing one-time exists to stop, and they would have spent three
		// pieces to find out.
		if ( Spent ) return "This one has already been built";

		// ⚠️ THE TEAM'S SET, NOT THIS PLAYER'S. Any of them may build it once between them they
		// have all three — which is the whole point of pooling the parts.
		if ( !BuildParts.HasAll() )
			return $"You are missing pieces — {BuildParts.Missing()}";

		// ⛔ A CLIENT ASKS; THE HOST BUILDS, FOR EVERYONE (the co-op audit, 2026-09-27). It was built wherever the key was held
		// and announced from there — two machines could each build the one bench.
		if ( Networking.IsActive && !NZGame.IsHost )
		{
			NZNet.BuildTableBuildAsk( Index );
			return "";
		}

		return HostBuild();
	}

	/// <summary>The build itself. HOST (or solo) — `Build` for its own player, `NZNet.BuildTableBuildAsk` for a client's.</summary>
	public string HostBuild()
	{
		if ( Built || Spent || !BuildParts.HasAll() ) return "";

		Built = true;

		if ( !RefreshVisual() )
		{
			Built = false;
			return $"Build failed — no model for {BuildParts.WeaponPrefab}";
		}

		// ⚠️ EVERYBODY LOSES THEM, not just the builder. Asked for in those words, and it is what
		// stops three players pooling one set and then each building their own weapon.
		BuildParts.Clear();
		NZSound.PlayShared( NZSound.PickupSalvage, WorldPosition );
		NZNet.BuildTableState( Index, true, Spent );

		return $"Built the {BuildParts.WeaponName} — take it off the table";
	}

	/// <summary>
	/// Take the finished weapon off the table.
	/// </summary>
	///
	/// ⛔ TAKE ONLY. THERE IS NO DEPOSIT, AND THAT IS WHAT MAKES IT NOT A TRADING TABLE. The
	/// trading table swaps what you hold for what is on it; this bench builds one thing and hands
	/// it over. Nothing can be left here, so the prompt never has to say which of three things E
	/// is about to do.
	///
	/// ⚠️ THE BENCH EMPTIES ONLY IF THE WEAPON ARRIVED, the same ordering `Build` uses. A
	/// `GiveWeapon` that fails must leave the gun where it is rather than delete it.
	public string Take( NZPlayer player )
	{
		if ( !player.IsValid() || !Built ) return "";

		// ⛔ A CLIENT ASKS; THE HOST DECIDES WHO GETS IT (the co-op audit, 2026-09-27). Each machine checked its own `Built` and
		// handed the gun over itself, so two players pressing E in the same moment both walked away with the one Prisma.
		if ( Networking.IsActive && !NZGame.IsHost )
		{
			NZNet.BuildTableTakeAsk( Index );
			return "";
		}

		return HostTake( player, Connection.Local?.Id.ToString() ?? "" );
	}

	/// <summary>
	/// The take itself. HOST (or solo): the bench is emptied for everyone, the taker is remembered as the Prisma's holder,
	/// and the gun is given on the taker's own machine — here for the host's own player, through `NZNet.BuildTableGive` for a
	/// client's.
	/// </summary>
	public string HostTake( NZPlayer player, string owner )
	{
		if ( !player.IsValid() || !Built ) return "";

		// ⚠️ NOT WHILE DOWN — a downed player's weapons are not theirs to keep through a revive.
		if ( player.IsDown || player.DownedNet || player.IsOutOfRound || player.OutOfRoundNet ) return "";

		var mine = string.IsNullOrEmpty( owner ) || owner == (Connection.Local?.Id.ToString() ?? "");

		if ( mine )
		{
			var got = player.GiveWeapon( BuildParts.WeaponPrefab );
			if ( !got.IsValid() )
				return $"Could not take it — {BuildParts.WeaponPrefab} did not load";
		}

		if ( !Permanent )
		{
			PrismaHolder = owner ?? "";
			_prismaBench = Index;
		}

		if ( !mine ) NZNet.BuildTableGive( owner, Index );

		// ⛔ PERMANENT LEAVES THE WEAPON WHERE IT IS. The gun on the bench is not an object being
		// moved into an inventory — it is a display of what this bench makes — so a permanent one
		// simply never clears. Anyone can keep taking copies, without parts, forever.
		if ( !Permanent )
		{
			Built = false;
			Spent = true;

			_shownGO?.Destroy();
			_shownGO = null;
		}

		NZSound.PlayShared( NZSound.PickupSalvage, WorldPosition );
		NZNet.BuildTableState( Index, Built, Spent );
		Log.Info( $"[nz-build] {(mine ? "I" : NameOf( owner ))} took the {BuildParts.WeaponName} from bench #{Index}" );

		return Permanent
			? $"Took the {BuildParts.WeaponName} — the bench keeps it"
			: $"Took the {BuildParts.WeaponName}";
	}

	/// <summary>
	/// Put the finished weapon's model on the bench, or clear it. False if it has no model.
	/// </summary>
	///
	/// ⛔ `MysteryBox.ModelFor`, NOT A SECOND COPY OF THE PATH RULE — exactly as `TradeTable`
	/// does it. That method strips the directory, drops the `.prefab`, tries the name with and
	/// without the `nz_` prefix, and caches the answer. Rewriting four lines of it here is how two
	/// placeables end up disagreeing about which weapons have a viewable model.
	// ── the Prisma's holder, and its way back to the bench ───────────────────────────────────────────────────────────────

	/// <summary>
	/// Who holds the one-time bench's weapon (the Prisma), by connection id — "" while it lies on a bench or a trade table.
	/// HOST only: set by `HostTake`, moved by a trade table's swap (`OnTraded`), and emptied by a new game (`Rebuild`).
	/// </summary>
	public static string PrismaHolder { get; private set; } = "";

	/// <summary>The bench it came from, so it can go back there (`HolderLeft`).</summary>
	static int _prismaBench = -1;

	/// <summary>A new game's benches: nobody holds anything. `BuildTableManager.Rebuild`.</summary>
	public static void ForgetHolder()
	{
		PrismaHolder = "";
		_prismaBench = -1;
	}

	/// <summary>
	/// A player left. HOST only. If they held the Prisma it goes back on its bench, for anyone to take — *"if the holding
	/// player leaves make the prisma appear on the crafting table again"* (the co-op audit, 2026-09-27: it was gone for good,
	/// and with it the Mastermind, the junctions and the boss).
	/// </summary>
	public static void HolderLeft( string id )
	{
		if ( NZGame.IsClient || string.IsNullOrEmpty( id ) || id != PrismaHolder ) return;

		var bench = Find( _prismaBench );
		if ( !bench.IsValid() )
		{
			Log.Warning( $"[nz-build] the {BuildParts.WeaponName}'s holder left, and its bench #{_prismaBench} is gone" );
			ForgetHolder();
			return;
		}

		bench.HostRestore( "its holder left the game" );
	}

	/// <summary>Put the weapon back on this bench, for everyone. HOST only.</summary>
	public void HostRestore( string why )
	{
		if ( NZGame.IsClient ) return;

		Built = true;
		Spent = false;
		RefreshVisual();
		ForgetHolder();

		NZSound.PlayShared( NZSound.PickupSalvage, WorldPosition );
		NZNet.BuildTableState( Index, true, false );
		Log.Info( $"[nz-build] the {BuildParts.WeaponName} is back on bench #{Index} — {why}" );
	}

	/// <summary>
	/// A trade table swapped — `left` onto it and `taken` off it, by `owner`. HOST only. Keeps the Prisma's holder true: left
	/// on a table, nobody holds it; taken off one, the taker does.
	/// </summary>
	public static void OnTraded( string owner, string left, string taken )
	{
		if ( NZGame.IsClient ) return;

		if ( IsPrisma( left ) && owner == PrismaHolder ) PrismaHolder = "";
		if ( IsPrisma( taken ) && _prismaBench >= 0 ) PrismaHolder = owner ?? "";
	}

	static bool IsPrisma( string prefab )
		=> !string.IsNullOrEmpty( prefab ) && string.Equals( prefab, BuildParts.WeaponPrefab, StringComparison.OrdinalIgnoreCase );

	/// <summary>The host said this machine's player took the bench's weapon: give it here. `NZNet.BuildTableGive`.</summary>
	public static void GiveHere( int index )
	{
		var me = NZPlayer.Local;
		if ( !me.IsValid() ) return;

		var got = me.GiveWeapon( BuildParts.WeaponPrefab );
		if ( got.IsValid() )
		{
			Log.Info( $"[nz-build] took the {BuildParts.WeaponName}" );
			return;
		}

		Log.Warning( $"[nz-build] could not take it — {BuildParts.WeaponPrefab} did not load; it goes back on the bench" );
		NZNet.BuildTableGiveFailed( index );
	}

	static string NameOf( string id ) => NZPlayers.BodyOf( id ) is { } p && p.IsValid() ? p.GameObject.Name : id;

	internal bool RefreshVisual()
	{
		_shownGO?.Destroy();
		_shownGO = null;

		if ( !Built ) return true;

		// ⚠️ A THROWAWAY ENTRY, because `ModelFor` only reads `Prefab` and the Prisma need not be
		// in the box's library to be shown here.
		var model = MysteryBox.ModelFor(
			new WeaponLibrary.Entry( BuildParts.WeaponPrefab, BuildParts.WeaponName, "", "" ) );

		if ( model is null || model.IsError ) return false;

		var go = Scene.CreateObject();
		go.Name = "built weapon";
		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.SetParent( GameObject );

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

		PlaceShown( go, model );
		_shownGO = go;
		return true;
	}

	/// <summary>
	/// Sit the weapon on the tabletop.
	/// </summary>
	///
	/// ⛔ SOLVED FOR THE MESH, NOT SET ON THE ORIGIN — `TradeTable.PlaceShown`'s lesson, kept.
	/// A viewmodel's origin is wherever the artist left it: its own debugging found an ASP whose
	/// mesh sat at z=94.5 against a Makarov asking for z=-7.2. Putting the ORIGIN at the tabletop
	/// hangs most guns somewhere else entirely.
	///
	/// ⚠️ AND THE BOTTOM IS PLACED, NOT THE CENTRE. Centring the mesh on the surface buries half
	/// the gun in the wood; `+ Size.z / 2` rests its lowest point there instead.
	static void PlaceShown( GameObject go, Model model )
	{
		if ( !go.IsValid() || model is null ) return;

		var pose = ShowAngles.ToRotation();
		var centre = pose * model.Bounds.Center;

		// ⚠️ ABS ON THE HALF EXTENT: a rotated extent can come out negative, and a negative half
		// height would push the gun DOWN by its own size instead of up.
		var half = (pose * model.Bounds.Size) * 0.5f;

		go.LocalRotation = pose;
		go.LocalPosition = new Vector3(
			-centre.x,
			-centre.y,
			ShowHeight - centre.z + MathF.Abs( half.z ) );
	}

	// ⚠️ WHAT THE CURRENT PLACEMENT WAS COMPUTED FROM, so a console change is picked up without
	// anything calling back in. Comparing two values is cheaper than wiring an event.
	float _placedHeight = float.NaN;
	Angles _placedAngles;

	protected override void OnUpdate()
	{
		if ( !_shownGO.IsValid() ) return;
		if ( _placedHeight == ShowHeight && _placedAngles == ShowAngles ) return;

		_placedHeight = ShowHeight;
		_placedAngles = ShowAngles;

		PlaceShown( _shownGO, _shownGO.Components.Get<ModelRenderer>()?.Model );
	}
}