Player/SalvageDrop.cs

Static helper that implements a player salvage drop action. It validates the player, clamps spend to what they have, spends salvage, asks the host to create a shared pickup if called from a client, spawns a SalvageGift pickup on the host, refunds on spawn failure, and exposes console commands to drop and change the drop amount.

NetworkingFile Access
using Sandbox;
using System;

namespace NZombies;

/// <summary>
/// DROP SALVAGE FOR SOMEONE ELSE TO PICK UP — `6`, or `nz_drop_salvage` (2026-09-29): *"pressing "6" should allow me to drop
/// 100 salvage any player can pick up, kind of what we did with "5" for money sharing"*.
///
/// The player spends up to <see cref="Amount"/> salvage and a pile worth exactly that lands in front of them
/// (`PickupKind.SalvageGift`, outlined in gold). Anyone can take it, the dropper included: the first to walk over it.
///
/// ⛔ THE SALVAGE IS MOVED, NEVER CREATED OR DESTROYED, the rule `PointsDrop` keeps. The pile carries the amount actually spent
/// (`Pickup.Amount`, sent to every machine with it) and pays exactly that — not a kill's roll with its augments on top.
///
/// ⛔ NOT A KILL'S SALVAGE, WHICH IS ITS KILLER'S ALONE. That kind lands on one machine and nobody else ever sees it
/// (`Pickup.DropFor`). This one is shared, so it takes the shared path: the host decides who stood on it first and offers it
/// to them, and their machine pays them (`Pickup.Offer`, `Pickup.TryTakeLocal`).
///
/// ⚠️ SHORT OF THE FULL AMOUNT, IT DROPS WHAT YOU HAVE, as `5` does: "give a teammate everything I've got" is the case this is for.
///
/// ⚠️ IT WAITS FIVE MINUTES (the kind's `Lifetime`), where a kill's pile waits two — then it is gone, like a dropped powerup.
/// </summary>
public static class SalvageDrop
{
	/// <summary>
	/// What a full drop costs and pays — 100, as asked. Nullable-backed, so a changed default reaches a running editor
	/// (INSTRUCTIONS §1).
	/// </summary>
	public static int Amount
	{
		get => _amount ??= 100;
		set => _amount = Math.Max( 1, value );
	}

	static int? _amount;

	/// <summary>Why this player cannot drop salvage right now, or null if they can — `6 did nothing` must always be answerable.</summary>
	public static string WhyCannot( NZPlayer player )
	{
		if ( !player.IsValid() ) return "no player";
		if ( player.IsDown ) return "you are down";
		if ( player.Salvage <= 0 ) return "you have no salvage";
		return null;
	}

	/// <summary>Spend and drop. True when the salvage left this player — dropped here, or asked of the host.</summary>
	public static bool Drop( NZPlayer player )
	{
		var why = WhyCannot( player );
		if ( why is not null )
		{
			Log.Info( $"[nz-drop] cannot drop salvage — {why}" );
			return false;
		}

		// ⚠️ CLAMPED TO WHAT THEY HAVE, so the spend cannot refuse
		var amount = Math.Min( Amount, player.Salvage );

		if ( !Salvage.TrySpend( player, amount ) )
		{
			Log.Warning( "[nz-drop] salvage spend refused unexpectedly — nothing dropped" );
			return false;
		}

		// the same spot `5` drops on: in front, on the floor, short of a wall
		var at = PointsDrop.DropSpot( player );

		// ⛔ A CLIENT ASKS THE HOST TO MAKE IT, for `PointsDrop`'s reason: made here, the pile would be in this world only and
		// nobody could take it. The spend above stays local — salvage lives on the owner's body.
		if ( NZGame.IsClient )
		{
			NZNet.SalvageDropAsk( at, amount );
			Log.Info( $"[nz-drop] asked the host to drop {amount} salvage at {at:0}" );
			return true;
		}

		var pile = Pickup.Spawn( at, PickupKind.SalvageGift, null, player.GameObject, amount: amount );

		if ( !pile.IsValid() )
		{
			// ⛔ REFUNDED: spent with nothing on the floor is the one outcome that must not happen
			Salvage.Refund( player, amount );
			Log.Warning( $"[nz-drop] the salvage pile failed to spawn — {amount} refunded" );
			return false;
		}

		Log.Info( $"[nz-drop] dropped {amount} salvage at {at:0}" + ( amount < Amount ? " (everything they had)" : "" ) );
		return true;
	}

	/// <summary>`nz_drop_salvage` — the same drop the key does (every button gets a command).</summary>
	[ConCmd( "nz_drop_salvage" )]
	public static void DropCmd()
	{
		var player = NZPlayer.Local;
		if ( !player.IsValid() ) { Log.Warning( "[nz-drop] no player" ); return; }

		Drop( player );
	}

	/// <summary>`nz_drop_salvage_amount [salvage]` — what a full drop costs. No argument reports it.</summary>
	[ConCmd( "nz_drop_salvage_amount" )]
	public static void AmountCmd( int salvage = -1 )
	{
		if ( salvage > 0 ) Amount = salvage;

		Log.Info( $"[nz-drop] a full salvage drop costs and pays {Amount}, landing {PointsDrop.DropDistance:0}u in front"
			+ ( salvage > 0 ? "" : " (nz_drop_salvage_amount <salvage> to change)" ) );
	}
}