A game component representing a trade table that stores a weapon as data (prefab, PAP level, rarity, reserve, display name) and lets players deposit, collect, or swap weapons. It manages visual presentation of the stored weapon, synchronises state across networked machines via NZNet messages, enforces usage rules like cooldown/power/lock and anti-stranding, and handles host-authoritative resolution of client requests.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// THE TRADING TABLE — leave a weapon on it, and it stays there until someone swaps it out.
///
/// Ported from `entities/entities/nz_tradetable`. Its two halves are `TakePlayerWeapon` and
/// `GivePlayerWeapon`, and the second one is the interesting part: taking the stored gun DEPOSITS
/// the one you were holding in its place. It is a swap, not a locker with a door.
///
/// ⛔ IT STORES DATA, NOT THE WEAPON OBJECT. Upstream reparents the live SWEP onto the table
/// (`wep:SetParent(self)`) and hands the same entity back. Ours cannot: a weapon here is a prefab
/// instance whose upgrades live on the PLAYER — `PapLevels` and `RarityTiers` are dictionaries on
/// NZPlayer keyed by prefab path, not fields on the gun. Parking the object would strand its MK3 in
/// the depositor's dictionary, so player B collects an MK3 Galil and gets a stock one. The table
/// therefore records prefab + tier + rarity + reserve, and STAMPS them onto whoever collects.
/// That is what makes it a trade rather than a shelf.
///
/// ⚠️ IT CANNOT LEAVE YOU EMPTY-HANDED. Upstream guards this too (the `fuckinator` loop): you may
/// only deposit if you have a second weapon to fall back on. Ours checks `NZInventory.Count`.
///
/// ⚠️ IT IS FREE. Upstream charges a per-table price to anyone who did not deposit the weapon; that
/// is deliberately not ported. The table's job is storage and handing a gun to a teammate, and a toll
/// on collecting your own gun back would undercut both.
///
/// ⚠️ CONTENTS SURVIVE ROUNDS, NOT GAMES. There is no per-round reset — "it stays there for the rest
/// of the game" is the point. A new game rebuilds the managers, and Rebuild destroys these objects,
/// so the table comes back empty without needing a reset path of its own.
/// </summary>
public sealed class TradeTable : Component
{
/// <summary>Every live table, for the use trace and the prompt.</summary>
public static readonly List<TradeTable> All = new();
protected override void OnEnabled() { if ( !All.Contains( this ) ) All.Add( this ); }
protected override void OnDisabled() => All.Remove( this );
/// <summary>The config row this was built from.</summary>
[Property] public TradeTableSpot Spot { get; set; }
/// <summary>How close you must stand. Matches the box, Pack-a-Punch and the ammo box.</summary>
public const float UseRange = 90f;
/// <summary>Cooldown after a swap, so one press cannot bounce a weapon twice.</summary>
public const float UseCooldown = 0.4f;
// ── what is on the table ─────────────────────────────────────────────────────────────────
/// <summary>Prefab path of the stored weapon, or empty.</summary>
public string StoredPrefab { get; private set; } = "";
/// <summary>Its Pack-a-Punch level, stamped onto whoever collects it.</summary>
public int StoredPap { get; private set; }
/// <summary>Its rarity tier, stamped onto whoever collects it.</summary>
public int StoredRarity { get; private set; }
/// <summary>
/// Its reserve at the moment it was put down.
///
/// ⚠️ CARRIED ACROSS ON PURPOSE. A table that handed back a full reserve would be a free ammo
/// refill cheaper than the ammo box, and one that handed back an empty gun would make storing
/// anything a punishment. Keeping what was deposited is the only option that is neither.
/// </summary>
public int StoredReserve { get; private set; }
/// <summary>Its name as it was shown in the HUD, so the prompt can say "MUSTANG MK3".</summary>
public string StoredName { get; private set; } = "";
// ⛔ `Depositor` REMOVED WITH THE PRICE. It existed only to answer "did this player put it
// there", which was the one question the cross-player charge needed. With the table free there
// is no reader left, and a tracked field nothing reads is a field that will be trusted later
// (§12: grep for the READ, not the type).
public bool HasWeapon => !string.IsNullOrEmpty( StoredPrefab );
/// <summary>
/// Which config entry this table came from — its name on the wire.
/// </summary>
///
/// ⛔ THE CONTENTS USED TO BE LOCAL-ONLY, AND THAT WAS A BUG. One player left a weapon and
/// nobody else saw it on the table, could not take it, and got "nothing to leave" at a table
/// that was plainly occupied on the depositor's screen. The whole point of this placeable is
/// handing a gun to somebody else.
///
/// ⚠️ BY CONFIG INDEX, BECAUSE THESE ARE NOT NETWORK ENTITIES. Every machine creates its own
/// tables from the same config list in the same order, so the index names the same table
/// everywhere without anything being replicated. `BuildTable` is wired the same way.
public int Index { get; set; } = -1;
/// <summary>
/// Apply contents that arrived from another machine.
/// </summary>
///
/// ⛔ IT WRITES THE FIELDS DIRECTLY RATHER THAN CALLING `StoreFrom` / `ClearStored`, which is
/// what stops it looping: those two announce, and an applier that went through them would
/// rebroadcast every message it received.
public static void ApplyState( int index, string prefab, int pap, int rarity,
int reserve, string name )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
foreach ( var t in scene.GetAllComponents<TradeTable>() )
{
if ( !t.IsValid() || t.Index != index ) continue;
t.StoredPrefab = prefab ?? "";
t.StoredPap = pap;
t.StoredRarity = rarity;
t.StoredReserve = reserve;
t.StoredName = name ?? "";
t.RefreshVisual();
}
}
/// <summary>Tell the other machines what is on this table now.</summary>
void Announce()
=> NZNet.TradeTableState( Index, StoredPrefab, StoredPap, StoredRarity,
StoredReserve, StoredName );
TimeUntil _nextUse;
// ── the displayed weapon ─────────────────────────────────────────────────────────────────
GameObject _shownGO;
/// <summary>
/// The height the weapon RESTS ON, above the table's origin.
///
/// ⛔ THIS IS A SURFACE, NOT THE OBJECT'S POSITION. `PlaceShown` solves for "the mesh's lowest
/// point sits here", so the gun lies on the wood rather than being centred in it.
///
/// ⚠️ 47 WAS DIALLED IN WITH `nz_trade_tune`, NOT DERIVED. Three attempts to reason it out were
/// all wrong, which is why the tuner exists: 24 was upstream's offset from the BBOX CENTRE
/// rather than the origin, so the gun sat among the legs; 46 placed the mesh CENTRE on the
/// surface, sinking half of it; 44 came from the model's own
/// `$attachment "1" "woodtable_lod0" 0 0 44` and was the closest guess but still read as low.
/// The tabletop mesh evidently sits a little above the attachment it declares.
/// </summary>
public static float ShowHeight { get; set; } = 47f;
/// <summary>
/// How the shown weapon is angled, in the TABLE's local space.
///
/// ⚠️ IDENTITY, AND THAT IS THE ANSWER RATHER THAN AN OMISSION. The table itself is already
/// turned by `TradeTableManager.YawOffset` (90) to present its long side, and the weapon is
/// PARENTED to it — so the gun inherits that and needs nothing of its own. Two earlier values
/// were reasoned out and both wrong: 90 laid it across the bench, 180 pointed it away from the
/// viewer so it read as a vertical sliver. Settled with `nz_trade_tune`.
///
/// ⛔ SO DO NOT "FIX" THIS TO MATCH YawOffset. They are separate rotations with separate jobs;
/// changing the table's facing does not imply changing the weapon's.
/// </summary>
public static Angles ShowAngles { get; set; } = new( 0f, 0f, 0f );
/// <summary>
/// Put the stored weapon's model on the table, or clear it.
///
/// ⛔ THE VIEWMODEL, matching `MysteryBox.ModelFor` — `nz_galil` -> `weapons/galil/v_galil.vmdl`.
/// There are no world models for these weapons, and a table that showed nothing would give the
/// player no way to know what is on it without walking into range and reading the prompt.
///
/// ⚠️ REBUILT WHOLESALE rather than having its model swapped. The object also carries the rarity
/// outline, whose colour changes with the contents, and destroying it is how that gets cleaned
/// up without tracking the component separately.
/// </summary>
void RefreshVisual()
{
_shownGO?.Destroy();
_shownGO = null;
if ( !HasWeapon ) return;
// ⛔ `MysteryBox.ModelFor`, NOT A SECOND COPY OF THE PATH RULE. That method already strips
// the directory, drops the `.prefab`, tries the name with and without the `nz_` prefix, and
// CACHES the result. Rewriting four lines of it here is how the box and the table end up
// disagreeing about which weapons have a viewable model (§3).
//
// ⚠️ A throwaway Entry when the prefab is not in the library, because ModelFor only reads
// `Prefab` — a table holding something the box would never offer still needs to show it.
var entry = WeaponLibrary.All.FirstOrDefault( e =>
e.Prefab.Equals( StoredPrefab, System.StringComparison.OrdinalIgnoreCase ) )
?? new WeaponLibrary.Entry( StoredPrefab, StoredName, "", "" );
var model = MysteryBox.ModelFor( entry );
if ( model is null || model.IsError ) return;
var go = Scene.CreateObject();
go.Name = "stored 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 );
go.LocalRotation = ShowAngles.ToRotation();
var r = go.Components.Create<ModelRenderer>();
r.Model = model;
PlaceShown( go, model );
_shownGO = go;
}
/// <summary>
/// Put the shown weapon where <see cref="ShowHeight"/> and <see cref="ShowAngles"/> say.
///
/// ⛔ SOLVED FOR THE MESH, NOT SET ON THE ORIGIN. A viewmodel's origin is wherever the artist
/// left it and has nothing to do with where the gun is — `MysteryBox.OfferLocalPos` makes the
/// same correction, and its own debugging found an ASP whose mesh sat at z=94.5 against a
/// Makarov asking for z=-7.2. Positioning the ORIGIN at the tabletop hung most guns somewhere
/// else entirely, which is what "it is below the table" was.
///
/// ⚠️ AND THE BOTTOM IS PLACED, NOT THE CENTRE. Centring the mesh on the surface buries half the
/// gun in the wood; `+ Size.z / 2` lifts it so its lowest point rests there instead.
///
/// ⚠️ `Size.z` UNROTATED IS EXACT WHILE ShowAngles IS YAW-ONLY — a yaw cannot change a bounding
/// box's height. The tuner can dial in pitch and roll, so it uses the ROTATED extent instead;
/// that is why this takes the rotation rather than reading the static twice.
/// </summary>
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 EACH COMPONENT: 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. The tuner edits the statics from a UI, so
// the object has to follow them without anything calling back into here — comparing is cheaper
// than wiring an event, and it also picks up a change made from the console.
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 );
}
// ── lookup ───────────────────────────────────────────────────────────────────────────────
/// <summary>The nearest table, or null.</summary>
public static TradeTable Near( Vector3 pos )
{
TradeTable best = null;
float bestDist = UseRange;
foreach ( var t in All )
{
if ( !t.IsValid() ) continue;
var d = pos.Distance( t.WorldPosition );
if ( d > bestDist ) continue;
bestDist = d;
best = t;
}
return best;
}
// ── availability ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// Why this table will not serve, or "" when it will.
///
/// ⚠️ ONE METHOD ANSWERS FOR BOTH THE PROMPT AND THE KEY — the rule `NZPlayer.TickUse` and
/// `UsePrompt.Text` are both written against.
/// </summary>
public string Unavailable( NZPlayer player )
{
if ( !player.IsValid() ) return "";
if ( Spot is not null )
{
if ( Spot.RequiresPower && !Power.IsOn ) return "Trading Table — needs power";
if ( !DoorLinks.IsOpen( Spot.Link ) ) return "Trading Table — locked";
}
// ⚠️ NOT AN ERROR, JUST NOT READY. The cooldown exists so one press cannot bounce a weapon
// straight back, and saying so would put a message on screen for four tenths of a second.
if ( !_nextUse ) return " ";
var inv = Inventory( player );
if ( inv is null ) return "Trading Table — no inventory";
var held = HeldWeapon( player );
if ( !HasWeapon )
{
if ( !held.IsValid() ) return "Trading Table — nothing to leave";
// ⛔ THE ANTI-STRANDING GUARD, upstream's `fuckinator` check. Depositing your only
// weapon leaves you holding nothing with a horde inbound, which is not a trade anyone
// meant to make.
if ( inv.Count < 2 ) return "Trading Table — you need a second weapon first";
}
return "";
}
// ── use ──────────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Press E: deposit, collect, or swap. Returns what happened.
///
/// ⚠️ THE SWAP IS ONE ACTION, not a collect followed by a deposit. Doing it in two steps means
/// a frame where the player holds both or neither, and `NZInventory.Remove` falls back to
/// "whatever is left" — which during a two-step swap is the wrong gun.
/// </summary>
public string Use( NZPlayer player )
{
var blocked = Unavailable( player );
if ( !string.IsNullOrWhiteSpace( blocked ) ) return blocked;
if ( !_nextUse ) return "";
// ⛔ A CLIENT ASKS; THE HOST DECIDES WHAT THE TABLE HOLDS, AND TELLS THE ASKER WHAT TO HAND OVER AND TAKE (the co-op
// audit, 2026-09-27). Each machine read its own copy of the table and swapped against it, so two players pressing E
// together both took the one gun on it — the Prisma, if it lay there.
if ( Networking.IsActive && !NZGame.IsHost )
{
var offer = OfferOf( player );
NZNet.TradeTableUseAsk( Index, StoredPrefab, offer.Prefab, offer.Pap, offer.Rarity, offer.Reserve, offer.Name );
_nextUse = UseCooldown;
return "";
}
var hostLeaves = OfferOf( player ).Prefab;
var hostTakes = StoredPrefab;
var held = HeldWeapon( player );
// ── collect, and leave what you were holding ──
if ( HasWeapon )
{
// ⚠️ NO CHARGE, AND NO OWNER CHECK. The table is free by request — it is storage and a
// way to hand a gun to a teammate, not a shop. Upstream has a per-entity Price and only
// bills a player who did not deposit; both are gone rather than left set to zero, so
// there is no half-wired pricing path to rediscover later.
var takePrefab = StoredPrefab;
var takePap = StoredPap;
var takeRarity = StoredRarity;
var takeReserve = StoredReserve;
var takeName = StoredName;
// ⚠️ THE TABLE IS EMPTIED BEFORE THE DEPOSIT so the deposit can fill it — otherwise the
// swap has to special-case "already occupied by the thing I am removing".
ClearStored();
if ( held.IsValid() && Inventory( player )?.Count >= 1 )
Store( player, held );
GiveStored( player, takePrefab, takePap, takeRarity, takeReserve );
_nextUse = UseCooldown;
BuildTable.OnTraded( Connection.Local?.Id.ToString() ?? "", hostLeaves, hostTakes );
return HasWeapon
? $"swapped {StoredName} onto the table, took {takeName}"
: $"took {takeName}";
}
// ── deposit ──
Store( player, held );
_nextUse = UseCooldown;
BuildTable.OnTraded( Connection.Local?.Id.ToString() ?? "", hostLeaves, "" );
return $"left {StoredName} on the table";
}
/// <summary>What the player would leave on the table: the weapon in their hands, as `Store` records it.</summary>
static (string Prefab, int Pap, int Rarity, int Reserve, string Name) OfferOf( NZPlayer player )
{
var wep = HeldWeapon( player );
if ( !wep.IsValid() ) return ("", 0, 0, 0, "");
var prefab = PrefabOf( player, wep );
var ammo = wep.GameObject.Components.Get<NZAmmo>( FindMode.EverythingInSelfAndAncestors );
return (prefab, player.PapLevelFor( prefab ), player.RarityTierFor( prefab ), ammo.IsValid() ? ammo.Reserve : 0, wep.DisplayName);
}
static string PrefabOf( NZPlayer player, SWB.Base.Weapon wep )
{
var src = wep.Components.Get<WeaponSource>( FindMode.EverythingInSelf )?.Prefab;
return string.IsNullOrEmpty( src ) ? player.StartingWeapon : src;
}
/// <summary>The table with this config index, on this machine.</summary>
public static TradeTable ByIndex( int index )
{
foreach ( var t in All )
if ( t.IsValid() && t.Index == index ) return t;
return null;
}
/// <summary>
/// A client's swap, decided. HOST only (`NZNet.TradeTableUseAsk`). `expect` is what the asker saw on the table: if it
/// holds something else by now — somebody was quicker — nothing happens. Otherwise the table takes the offer (or is
/// emptied), everyone is told, and the asker is told what to hand over and what to take (`NZNet.TradeTableGrant`).
/// </summary>
public void HostUse( string owner, string expect, string offerPrefab, int offerPap, int offerRarity, int offerReserve, string offerName )
{
if ( NZGame.IsClient ) return;
if ( !string.Equals( StoredPrefab ?? "", expect ?? "", StringComparison.OrdinalIgnoreCase ) )
{
Log.Info( $"[nz] trade table #{Index}: {owner} saw '{expect}', it holds '{StoredPrefab}' — somebody was quicker" );
return;
}
if ( !HasWeapon && string.IsNullOrEmpty( offerPrefab ) ) return;
var take = (Prefab: StoredPrefab, Pap: StoredPap, Rarity: StoredRarity, Reserve: StoredReserve);
StoredPrefab = offerPrefab ?? "";
StoredPap = offerPap;
StoredRarity = offerRarity;
StoredReserve = offerReserve;
StoredName = offerName ?? "";
RefreshVisual();
Announce();
NZNet.TradeTableGrant( owner, Index, take.Prefab, take.Pap, take.Rarity, take.Reserve, offerPrefab ?? "" );
BuildTable.OnTraded( owner, offerPrefab, take.Prefab );
}
/// <summary>
/// The host's answer to this machine's swap: hand over `leftPrefab` (the weapon offered), take `takePrefab`.
/// `NZNet.TradeTableGrant`, on the asker's machine only.
/// </summary>
public static void ApplyGrant( string takePrefab, int pap, int rarity, int reserve, string leftPrefab )
{
var me = NZPlayer.Local;
if ( !me.IsValid() ) return;
var inv = Inventory( me );
if ( inv is not null && !string.IsNullOrEmpty( leftPrefab ) )
{
var held = HeldWeapon( me );
GameObject give = held.IsValid() && string.Equals( PrefabOf( me, held ), leftPrefab, StringComparison.OrdinalIgnoreCase )
? held.GameObject
: inv.Weapons.FirstOrDefault( g =>
g.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf ) is { } w && w.IsValid()
&& string.Equals( PrefabOf( me, w ), leftPrefab, StringComparison.OrdinalIgnoreCase ) );
if ( give.IsValid() ) inv.Remove( give );
}
if ( !string.IsNullOrEmpty( takePrefab ) ) GiveStored( me, takePrefab, pap, rarity, reserve );
}
/// <summary>Record a weapon onto the table and take it off the player.</summary>
void Store( NZPlayer player, SWB.Base.Weapon wep )
{
if ( !wep.IsValid() ) return;
var src = wep.Components.Get<WeaponSource>( FindMode.EverythingInSelf )?.Prefab;
var prefab = string.IsNullOrEmpty( src ) ? player.StartingWeapon : src;
var ammo = wep.GameObject.Components
.Get<NZAmmo>( FindMode.EverythingInSelfAndAncestors );
StoredPrefab = prefab;
StoredPap = player.PapLevelFor( prefab );
StoredRarity = player.RarityTierFor( prefab );
StoredReserve = ammo.IsValid() ? ammo.Reserve : 0;
StoredName = wep.DisplayName;
// ⚠️ `Remove` DESTROYS IT AND FALLS BACK to whatever is left in the inventory, which is
// exactly the behaviour wanted here — see its own note.
Inventory( player )?.Remove( wep.GameObject );
RefreshVisual();
Announce();
}
/// <summary>Hand a stored weapon to a player, upgrades and all.</summary>
static void GiveStored( NZPlayer player, string prefab, int pap, int rarity, int reserve )
{
if ( string.IsNullOrEmpty( prefab ) ) return;
// ⛔ THE TIER AND RARITY ARE STAMPED BEFORE THE WEAPON IS GIVEN. `GiveWeapon` runs
// `ApplyStoredUpgrades`, which reads `PapLevels` and `RarityTiers` for the prefab — set them
// afterwards and the gun arrives stock, with the right numbers arriving only on the NEXT
// equip. That is the whole reason this class stores data rather than an object.
player.SetPapLevel( prefab, pap );
player.SetRarityTier( prefab, rarity );
var wep = player.GiveWeapon( prefab );
if ( !wep.IsValid() ) return;
var ammo = wep.GameObject.Components
.Get<NZAmmo>( FindMode.EverythingInSelfAndAncestors );
// ⚠️ CLAMPED TO THE LIVE MaxReserve, not written raw. The cap is derived from the clip now
// (ReserveAmmo), so a weapon stored before a clip change could carry a reserve its own
// maximum no longer allows.
if ( ammo.IsValid() )
ammo.Reserve = System.Math.Clamp( reserve, 0, ammo.MaxReserve );
}
void ClearStored()
{
StoredPrefab = "";
StoredPap = 0;
StoredRarity = 0;
StoredReserve = 0;
StoredName = "";
RefreshVisual();
Announce();
}
/// <summary>Empty the table. For a new game, and for `nz_trade_clear`.</summary>
public void Empty() => ClearStored();
// ── helpers ──────────────────────────────────────────────────────────────────────────────
static NZInventory Inventory( NZPlayer player )
=> player.IsValid()
? player.Components.Get<NZInventory>( FindMode.EverythingInSelf )
: null;
/// <summary>
/// The weapon in the ACTIVE slot, or null.
///
/// ⛔ VIA THE INVENTORY, NOT `GetInChildren` — that returns whichever Weapon comes first in the
/// hierarchy, usually the HOLSTERED one. Depositing the wrong gun would be considerably worse
/// here than reporting on it, which is the bug WeaponTuningCommands.Held records.
/// </summary>
static SWB.Base.Weapon HeldWeapon( NZPlayer player )
{
var active = Inventory( player )?.Active;
return active.IsValid()
? active.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf )
: null;
}
}