Player/PointsDrop.cs

Static utility for dropping player points as a world powerup. Validates the player can drop, spends up to a configured Amount, computes a traced drop spot in front of the player, requests the host to spawn the BonusPoints powerup on clients, and refunds the spend if spawn fails.

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

namespace NZombies;

/// <summary>
/// DROP POINTS FOR SOMEONE ELSE TO PICK UP — `5`, or `nz_drop_points`.
///
/// The player spends up to <see cref="Amount"/> and a Bonus Points powerup worth exactly that much
/// lands in front of them. Anyone can take it, including the dropper.
///
/// ⛔ THE POINTS ARE MOVED, NEVER CREATED OR DESTROYED. The powerup is stamped with the amount that
/// was actually spent (`Powerup.PointsOverride`) rather than rolling the usual 50-300. Left to roll,
/// dropping 1000 would return an average of 175 — a points shredder — and dropping 40 would return
/// the same 175, a printer. Either one turns a sharing tool into an economy exploit.
///
/// ⚠️ SHORT OF THE FULL AMOUNT, IT DROPS WHAT YOU HAVE rather than refusing. "Give a teammate
/// everything I've got" is the case this exists for, and a hard 1000 floor would refuse exactly the
/// player who most wants to hand over their last points.
/// </summary>
public static class PointsDrop
{
	/// <summary>
	/// What a full drop costs and pays.
	///
	/// ⚠️ A settable static so it can be dialled in play without a rebuild, like the box and boss
	/// numbers. Nullable-backed for the hotload reason INSTRUCTIONS.md gives: a static's VALUE
	/// survives a hotload but its initialiser does not re-run, so changing the default here would
	/// not reach a running editor.
	/// </summary>
	public static int Amount
	{
		get => _amount ??= 1000;
		set => _amount = value;
	}

	static int? _amount;

	/// <summary>How far in front of the player it lands.</summary>
	public static float DropDistance
	{
		get => _dropDistance ??= 64f;
		set => _dropDistance = value;
	}

	static float? _dropDistance;

	/// <summary>
	/// Why this player cannot drop right now, or null if they can.
	///
	/// ⚠️ A REASON STRING, THE SAME CONTRACT `Armor.WhyCannotPlate` HAS. "5 did nothing" must always
	/// be answerable, and the answers here are genuinely different problems: broke, downed, or not
	/// in a round at all.
	/// </summary>
	public static string WhyCannot( NZPlayer player )
	{
		if ( !player.IsValid() ) return "no player";
		if ( player.IsDown ) return "you are down";
		if ( player.Points <= 0 ) return "you have no points";
		return null;
	}

	/// <summary>
	/// Spend and drop. Returns the powerup, or null if it refused.
	/// </summary>
	public static Powerup Drop( NZPlayer player )
	{
		var why = WhyCannot( player );
		if ( why is not null )
		{
			Log.Info( $"[nz-drop] cannot drop — {why}" );
			return null;
		}

		// ⚠️ CLAMPED TO WHAT THEY HAVE, so `TrySpend` can never refuse here. Calling it with the
		// full Amount and letting it fail would play the deny sound and drop nothing, which is the
		// opposite of the "give away my last points" case this is for.
		var amount = Math.Min( Amount, player.Points );

		if ( !player.TrySpend( amount ) )
		{
			// Unreachable by the clamp above, and checked anyway: TrySpend owns the refusal sound
			// and the points, and a drop that spawned a powerup without paying would print points.
			Log.Warning( "[nz-drop] spend refused unexpectedly — nothing dropped" );
			return null;
		}

		var at = DropSpot( player );

		// ⛔ A CLIENT ASKS THE HOST TO MAKE IT. Spawning locally puts the drop in one machine's
		// world only — invisible to everyone else, and since collection is the host's, invisible to
		// the host means nobody can pick it up at all, including the player who just paid for it.
		//
		// ⚠️ THE SPEND HAS ALREADY HAPPENED, ABOVE, AND STAYS LOCAL. Points are per-player by
		// design; only the object in the world is the host's to create.
		//
		// ⚠️ WHICH MEANS THE REFUND BELOW CANNOT COVER A CLIENT. If the host fails to spawn it,
		// the client has paid for nothing — `NZNet.PointsDropAsk` logs that loudly rather than
		// letting it pass. Making it recoverable needs a reply, which is a bigger change than this.
		if ( NZGame.IsClient )
		{
			NZNet.PointsDropAsk( at, amount );

			Log.Info( $"[nz-drop] asked the host to drop {amount} point(s) at {at:0}" );
			return null;
		}

		var p = Powerup.Spawn( at, PowerupKind.BonusPoints, amount );

		if ( !p.IsValid() )
		{
			// ⛔ REFUNDED. The spend already happened, and a powerup that failed to spawn would
			// otherwise take the points with nothing to show for it — the one outcome that must not
			// be possible.
			player.AddPoints( amount );
			Log.Warning( $"[nz-drop] powerup failed to spawn — {amount} refunded" );
			return null;
		}

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

		return p;
	}

	/// <summary>
	/// Where the powerup lands: in front, on the floor.
	///
	/// ⛔ TRACED DOWN, NOT PLACED AT EYE HEIGHT. `Powerup.Spawn` adds its own `Hover` to whatever it
	/// is given, so handing it an eye-height point puts the powerup above head height where the
	/// pickup sphere never meets anyone. The same trace-to-floor shape `HoundCheck.Spawn` uses.
	///
	/// ⚠️ AND TRACED FORWARD FIRST, so dropping while facing a wall does not put the powerup on the
	/// far side of it. Dropping at your feet is a worse outcome than dropping in front, but it is a
	/// far better one than dropping into the next room.
	///
	/// ⚠️ SHARED WITH `SalvageDrop`, `6`, rather than copied: two drops that land in two different places would read as a bug.
	/// </summary>
	internal static Vector3 DropSpot( NZPlayer player )
	{
		var scene = player.Scene;
		var controller = player.Components.Get<PlayerController>();
		var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;

		var forward = (controller?.EyeAngles.ToRotation() ?? player.WorldRotation).Forward
			.WithZ( 0f ).Normal;

		var ahead = scene.Trace.Ray( eye, eye + forward * DropDistance )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();

		var from = ahead.Hit ? ahead.EndPosition - forward * 8f : ahead.EndPosition;

		var down = scene.Trace.Ray( from, from + Vector3.Down * 200f )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();

		return down.Hit ? down.HitPosition : player.WorldPosition;
	}

	/// <summary>
	/// `nz_drop_points` — the same drop the key does.
	///
	/// ⛔ EVERY BUTTON GETS A COMMAND, the standing rule `LobbyCommands` states: nobody can press a
	/// key over MCP, so a key-only feature cannot be tested or demonstrated remotely.
	/// </summary>
	[ConCmd( "nz_drop_points" )]
	public static void DropCmd()
	{
		var player = NZPlayer.Local;

		if ( !player.IsValid() ) { Log.Warning( "[nz-drop] no player" ); return; }

		Drop( player );
	}

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

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