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