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.
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 );
}
}