Component that implements floor pickups (salvage, armor plate, vulture points/ammo and dropped salvage) for the game. It defines per-kind data (model, radius, lifetime, award logic, outline), spawning, ground placement, host/client networking rules for shared vs per-player drops, offer/collect protocol, dev console spawn/list commands, and awarding behaviour.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>What a pickup gives when you walk over it.</summary>
public enum PickupKind
{
/// <summary>Crafting currency. Drops from any kill.</summary>
Salvage,
/// <summary>Armor plate, carried and applied later. Drops from any kill.</summary>
ArmorPlate,
/// <summary>Vulture Aid — points. Only drops for a player holding the perk.</summary>
VulturePoints,
/// <summary>Vulture Aid — reserve ammo. Only drops for a player holding the perk.</summary>
VultureAmmo,
/// <summary>
/// Salvage a player DROPPED for anyone to take — `6` (`SalvageDrop`). It pays exactly what was spent
/// (<see cref="Pickup.Amount"/>).
///
/// ⚠️ LAST IN THE LIST ON PURPOSE: the kind travels over the network as its number, so a new one goes on the end and every
/// existing kind keeps its value.
/// </summary>
SalvageGift,
}
/// <summary>
/// A thing on the floor you walk over to collect.
///
/// ⚠️ DELIBERATELY NOT A POWERUP. Powerup.cs is 594 lines and nearly all of the
/// difference is powerup-specific: a screen banner, an announcer voice, a global
/// timed effect, an ambient hum, blink-before-expiry, and a per-round drop cap. A
/// salvage chunk needs none of it — in the original, salvage and plates are plain
/// models you touch. This is a smaller thing, not a copy of a bigger one.
///
/// ⚠️ DO NOT EXTRACT A SHARED BASE CLASS YET. If this ever needs blink/hover/glow,
/// that is the moment to lift the machinery out of Powerup. Doing it now means
/// refactoring 594 lines of play-tested code for a benefit nothing has asked for.
///
/// ⚠️ THE KIND TABLE IS THE POINT. Every behavioural difference between the kinds is
/// data in <see cref="Info"/>, mirroring the original's
/// `nzPerks:AddVultureDrop( id, data )` registry. Vulture's gas cloud and its armor
/// drop are rows waiting for an asset, not new components.
/// </summary>
public sealed class Pickup : Component
{
/// <summary>Everything that differs between kinds.</summary>
public readonly struct Def
{
public string Model { get; init; }
public string Label { get; init; }
/// <summary>Seconds before it vanishes.</summary>
public float Lifetime { get; init; }
/// <summary>How close the player must get, in units.</summary>
public float Radius { get; init; }
/// <summary>Award it. Returns FALSE to refuse the pickup and leave it on the
/// floor — the ammo drop needs this when the gun is already full.</summary>
public Func<NZPlayer, bool> Award { get; init; }
/// <summary>
/// Award the amount THIS drop carries (<see cref="Pickup.Amount"/>), for a kind whose value is set when it drops rather
/// than rolled when it is taken — a player's dropped salvage. Used instead of <see cref="Award"/> when set.
///
/// ⛔ THE AMOUNT TRAVELS WITH THE DROP, so every machine pays the same figure — the one that was actually spent. A kind
/// that rolled its own value here would create or destroy currency on every hand-over (`PointsDrop`'s rule).
/// </summary>
public Func<NZPlayer, int, bool> AwardAmount { get; init; }
/// <summary>
/// The cue on collection. Null falls back to the powerup pickup, which is what
/// every kind used before salvage got its own.
///
/// ⚠️ ON THE DEF, NOT A SWITCH AT THE PLAY SITE. Everything else that differs per
/// kind — model, radius, lifetime, award — already lives here, and a second place
/// that answers "which kind is this" is the divergence §3 warns about.
/// </summary>
public string Sound { get; init; }
/// <summary>Counts against the owner's Vulture live-drop cap.</summary>
public bool IsVulture { get; init; }
/// <summary>
/// ⛔ THE KILLER'S OWN, SINCE 2026-09-27 — *"only that player can see and pick up the salvage, so salvage becomes
/// individual per player"*. The host rolls the drop and it lands on the KILLER'S machine alone (`DropFor`): nobody else
/// sees it, nobody else can take it, and a death with no player behind it drops none.
///
/// ⚠️ THE NOTES BELOW ARE THE MODEL BEFORE THAT — one copy per player, each collected independently — and still say
/// why a per-player kind needs no arbitration: its one copy is on its one owner's machine, which collects it for its
/// own player (`TickPerPlayer`) and tells nobody.
///
/// ⛔ IT IS A DIFFERENT NETWORK MODEL, NOT A RULE ON TOP OF THE SHARED ONE. The shared path
/// exists to stop two machines both deciding "collected" and paying two players for one
/// pile: the host arbitrates, offers to the owner, and announces the result so every copy
/// dies. None of that applies when the entitlement is per player — there is nothing to
/// contend over, so the arbitration, the offer and the announcement are all skipped and
/// each machine simply collects its own copy for its own player.
///
/// ⚠️ WHICH MAKES IT THE SIMPLER PATH, and that is worth saying because it looks like the
/// special case. The shared kinds need three network messages; this one needs none beyond
/// the spawn that every machine already gets.
///
/// ⚠️ THE DROP IS STILL ONE ROLL ON THE HOST. `PickupDropped` is what gives every machine a
/// copy at the same place with the same id; per-player changes who may TAKE a copy, not how
/// many drops exist. Rolling locally would give each player a different map.
///
/// ⚠️ SALVAGE ONLY, BY REQUEST. Armour plates and Vulture drops stay first-come — plates in
/// particular are a scarce resource players compete for, and the Vulture drops already carry
/// an `Owner` and a live cap that assume one taker.
/// </summary>
public bool PerPlayer { get; init; }
/// <summary>
/// Outline colour, so loot can be spotted on a cluttered floor.
///
/// ⚠️ DEFAULT IS TRANSPARENT = NO OUTLINE, which is what `default` gives, so
/// a kind that wants none simply omits it rather than passing a sentinel.
/// </summary>
public Color Outline { get; init; }
/// <summary>Show the outline THROUGH walls. Off unless a kind asks.</summary>
public bool OutlineThroughWalls { get; init; }
}
/// <summary>
/// Per-kind data.
///
/// ⛔ A SWITCH, NOT A `static readonly` DICTIONARY. A collection built in a static
/// initialiser cannot be corrected in a live session — INSTRUCTIONS.md §1, seven
/// occurrences and the most expensive pattern in this project. PerkRegistry made
/// the same call for the same reason.
///
/// ⚠️ Radii differ ON PURPOSE. Salvage and plates use the original's 32u sweep;
/// Vulture drops use 48u to match powerups, because they are worth more and one
/// you walked past without collecting reads as a bug.
/// </summary>
public static Def Info( PickupKind kind ) => kind switch
{
PickupKind.Salvage => new Def
{
Model = "models/nz/pickups/loot_salvage.vmdl",
Label = "Salvage",
Lifetime = 120f,
Radius = 32f,
Award = p => Salvage.AwardPickup( p ) > 0,
// ⚠️ EVERYONE GETS THEIR OWN PILE. Requested outright: *"when a salvage drops and I
// pick it up, for the other players it's still on the floor and they can pick it up."*
PerPlayer = true,
// ⚠️ THE ORIGINAL'S OWN CUE, four variants picked at random —
// `nz_moo/effects/pickup_salvage/pickup_00–03`. Salvage was using the generic
// powerup pickup, which is a fanfare for something you grab every few kills.
Sound = NZSound.PickupSalvage,
// ⚠️ GREEN, AND VISIBLE THROUGH GEOMETRY. Salvage is a 15,811-triangle
// junk pile that reads as scenery on a cluttered floor — without the
// outline it is genuinely easy to walk past, which is the whole reason
// the original gives its drops a glow.
Outline = new Color( 0.2f, 1f, 0.3f, 1f ),
OutlineThroughWalls = true,
},
PickupKind.ArmorPlate => new Def
{
Model = "models/nz/pickups/armor_plate.vmdl",
Label = "Armor Plate",
Lifetime = 120f,
Radius = 32f,
// ⚠️ Refuses at the carry cap, so the plate stays on the floor for later
// instead of evaporating for nothing.
Award = Armor.AddPlate,
},
PickupKind.VulturePoints => new Def
{
Model = "models/nz/pickups/vulture_points.vmdl",
Label = "Points",
Lifetime = 30f,
Radius = 48f,
IsVulture = true,
Award = AwardVulturePoints,
},
PickupKind.VultureAmmo => new Def
{
Model = "models/nz/pickups/vulture_ammo.vmdl",
Label = "Ammo",
Lifetime = 30f,
Radius = 48f,
IsVulture = true,
Award = AwardVultureAmmo,
},
// ⚠️ A PLAYER'S DROPPED SALVAGE — `6` (`SalvageDrop`). The kill's own salvage pile, but SHARED: first to walk over it
// takes it, the dropper included, through the host's offer like a plate. Not `PerPlayer` — that kind is its killer's
// alone and nobody else ever sees it.
PickupKind.SalvageGift => new Def
{
Model = "models/nz/pickups/loot_salvage.vmdl",
Label = "Dropped salvage",
// ⚠️ LONGER THAN A KILL'S PILE (120s): somebody paid for this, and it has to wait for a teammate to come for it
Lifetime = 300f,
Radius = 32f,
// ⛔ `Gift`, NOT `AwardPickup`: exactly the amount dropped, with no kill's roll or augment on top, and not gated on
// the map paying salvage for kills — it is somebody's own salvage changing hands
AwardAmount = ( p, amount ) => Salvage.Gift( p, amount ) > 0,
Sound = NZSound.PickupSalvage,
// ⚠️ GOLD, NOT THE KILL PILE'S GREEN, so a dropped gift reads as somebody's on a floor with your own salvage on it
Outline = new Color( 1f, 0.78f, 0.25f, 1f ),
OutlineThroughWalls = true,
},
_ => default,
};
// ── vulture awards ───────────────────────────────────────────────────────
/// <summary>
/// 100-200 points.
///
/// ⚠️ IN STEPS OF TEN, not a smooth range — the original rolls
/// `math.random(10,20) * 10`. Kept, because round numbers are what makes the
/// points popup readable at a glance.
///
/// ⚠️ NO UPGRADED TIER. The original doubles this with an upgraded perk, but
/// s&box has no perk-upgrade concept at all yet (nothing reads HasUpgrade), so
/// there is deliberately no branch here pretending to.
/// </summary>
static bool AwardVulturePoints( NZPlayer player )
{
if ( !player.IsValid() ) return false;
// ⚠️ SCAVENGER SCALES THE ROLLED VALUE, so the steps-of-ten shape survives as steps
// of thirteen rather than being replaced by a flat number.
player.AddPoints( VultureAugments.PointsAward( player, Game.Random.Int( 10, 20 ) * 10 ) );
return true;
}
/// <summary>
/// 5-10% of the held weapon's reserve.
///
/// ⛔ RETURNS FALSE WHEN THE GUN IS ALREADY FULL, and that is load-bearing: the
/// original returns false in the same case so the drop is NOT consumed. Without
/// it, a player at full ammo walking over one destroys it for nothing.
///
/// ⚠️ THE HELD WEAPON ONLY, matching the original's `ply:GetActiveWeapon()`. A
/// Max Ammo powerup fills every gun; this tops up the one in your hands, which
/// is what keeps it a small reward rather than a free powerup.
/// </summary>
static bool AwardVultureAmmo( NZPlayer player )
{
if ( !player.IsValid() ) return false;
var inv = player.Components.Get<NZInventory>( FindMode.EverythingInSelf );
if ( !inv.IsValid() || !inv.Active.IsValid() ) return false;
// ⚠️ `EverythingInSelf` — the same reason PowerupEffects.MaxAmmo uses it: a
// holstered weapon's components are DISABLED and the plain lookup skips them.
var ammo = inv.Active.Components.Get<NZAmmo>( FindMode.EverythingInSelf );
if ( !ammo.IsValid() ) return false;
if ( ammo.Reserve >= ammo.MaxReserve ) return false;
// ⚠️ THE BASE IS PASSED IN, NOT RESTATED. It lives on PickupDrops beside the perk's other
// authored numbers, and the augment is only ever a modifier ON it. This line used to spell
// 0.05f/0.10f out and nz_aug_vulture spelled the same pair out again, so the number existed
// twice and only one copy would ever have been tuned (§3).
var give = (int)MathF.Ceiling( ammo.MaxReserve * VultureAugments.AmmoFraction(
player, PickupDrops.VultureAmmoLow, PickupDrops.VultureAmmoHigh ) );
// ⛔ A FLAT CEILING ON THE AWARD, ABOVE THE PERCENTAGE AND ABOVE BOTH AUGMENTS.
// `give` is `MaxReserve × fraction`, so one roll is worth 3 rounds on a pistol and
// 156 on a weapon at the 600 reserve cap — a quarter of a full reserve for walking
// over a single pickup. 10 rounds, or 15 with m4 Deep Pockets.
//
// ⚠️ `Math.Min`, SO IT IS A LIMIT AND NOT AN AWARD. A small gun whose percentage
// works out to 3 rounds still gets 3.
give = Math.Min( give, VultureAugments.AmmoCapFor( player ) );
var before = ammo.Reserve;
ammo.Reserve = Math.Min( ammo.Reserve + give, ammo.MaxReserve );
return ammo.Reserve > before;
}
// ── instance ─────────────────────────────────────────────────────────────
[Property] public PickupKind Kind { get; set; } = PickupKind.Salvage;
/// <summary>
/// Who this drop belongs to, for the Vulture live-drop cap.
///
/// ⚠️ Null for salvage and plates — those are unowned and anyone may take them.
/// Only Vulture drops are owned, because the original's cap is per-player.
/// </summary>
public NZPlayer Owner { get; set; }
/// <summary>Which drop this is, shared across machines. See <see cref="SpawnRemote"/>.</summary>
public Guid NetId { get; set; }
/// <summary>
/// What this drop pays, for a kind that carries its value (<see cref="Def.AwardAmount"/>) — a player's dropped salvage. 0
/// for every other kind, which rolls its own. Sent with the drop (`NZNet.PickupDropped`).
/// </summary>
public int Amount { get; set; }
/// <summary>Can this kind pay anybody at all.</summary>
static bool CanAward( Def def ) => def.Award is not null || def.AwardAmount is not null;
/// <summary>
/// Pay this drop to a player. False means refused, and the drop stays on the floor.
///
/// ⚠️ THE ONE PLACE A DROP PAYS, for all four paths that collect one — the host's, the offer's, the per-player one and the
/// remote award — so a kind that carries its amount cannot be paid its kind's default by one of them.
/// </summary>
bool AwardTo( NZPlayer player )
{
var def = Info( Kind );
if ( def.AwardAmount is not null )
return Amount > 0 && def.AwardAmount( player, Amount );
return def.Award is not null && def.Award( player );
}
/// <summary>Find a drop by its shared id, on any machine.</summary>
public static Pickup ById( Guid id )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() || id == default ) return null;
foreach ( var p in scene.GetAllComponents<Pickup>() )
if ( p.IsValid() && p.NetId == id ) return p;
return null;
}
/// <summary>
/// The host says a pickup dropped. Clients only — build the same scenery and nothing else.
///
/// ⛔ SALVAGE IS A `Pickup`, AND A `Pickup` IS A PLAIN LOCAL GameObject. Drops come from
/// kills, which happen on the host, so a client never had one to see or walk into — and with
/// no salvage, the HUD block (which only draws when you have some) never appeared either.
/// User: *"clients cannot see salvage, do not have salvage on hud and have no storage for it."*
/// The storage was always there; nothing had ever put anything in it.
/// </summary>
public static void SpawnRemote( Guid id, Vector3 pos, PickupKind kind, int amount = 0 )
{
if ( ById( id ).IsValid() ) return;
var p = Spawn( pos, kind, amount: amount );
if ( p.IsValid() ) p.NetId = id;
}
/// <summary>
/// The host says this one was taken, and by whom. Clients only.
///
/// ⚠️ THE AWARD RUNS ONLY ON THE COLLECTOR'S MACHINE. Salvage and armour plates are
/// PERSONAL — unlike a powerup, which is a team event — so every machine applying the award
/// to its own player would pay everybody for one pile of junk.
///
/// ⚠️ AND `def.Award` IS RUN ON THIS MACHINE'S OWN PLAYER rather than the amount being
/// sent, because the award is a delegate with augments folded into it — Vulture's per-pickup
/// bonus among them — and those live on the machine that owns the player.
/// </summary>
public static void CollectRemote( Guid id, Guid collector )
{
var p = ById( id );
var mine = Connection.Local is not null && Connection.Local.Id == collector;
if ( mine )
{
var def = Info( p.IsValid() ? p.Kind : PickupKind.Salvage );
// ⚠️ THROUGH THE DROP WHEN IT IS STILL HERE, so a dropped gift pays what it carries; without it, what it always did
if ( p.IsValid() ) p.AwardTo( NZPlayer.Local );
else def.Award?.Invoke( NZPlayer.Local );
if ( p.IsValid() )
NZSound.Play( def.Sound ?? NZSound.PowerupPickup, p.WorldPosition );
}
if ( !p.IsValid() ) return;
p.Release();
p._taken = true;
p.GameObject.Destroy();
}
/// <summary>How often one pickup may be offered to the same remote player.</summary>
public static float OfferInterval { get; set; } = 0.35f;
TimeSince _sinceOffer = 999f;
/// <summary>
/// Ask a remote player whether they can take this. Host only.
///
/// ⚠️ THROTTLED, because standing on a pickup you cannot take would otherwise send a message
/// every frame for as long as you stand there. A third of a second is imperceptible to a player
/// walking over it and is two orders of magnitude less traffic.
/// </summary>
void Offer( NZPlayer player )
{
if ( _sinceOffer < OfferInterval ) return;
_sinceOffer = 0f;
if ( !Guid.TryParse( NZPlayers.OwnerOf( player.GameObject ), out var who ) ) return;
NZNet.PickupOffer( NetId, who );
}
/// <summary>
/// The host says I may take this. Try it against MY real inventory. Collector only.
///
/// ⚠️ THE REFUSAL LIVES HERE AND NOWHERE ELSE, which is the point of the offer: only this
/// machine knows how many plates I am carrying or whether that gun is already full.
///
/// ⚠️ IT ANNOUNCES THE PICKUP GONE ONLY ON SUCCESS. A refused offer leaves the object
/// standing on every machine and the host will offer it again in a third of a second.
/// </summary>
public static void TryTakeLocal( Guid id )
{
var p = ById( id );
if ( !p.IsValid() || p._taken ) return;
var def = Info( p.Kind );
if ( !p.AwardTo( NZPlayer.Local ) ) return;
NZSound.Play( def.Sound ?? NZSound.PowerupPickup, p.WorldPosition );
Log.Info( $"[nz-pickup] {def.Label} collected{( p.Amount > 0 ? $" — {p.Amount}" : "" )}" );
NZNet.PickupGone( id );
p.Release();
p._taken = true;
p.GameObject.Destroy();
}
/// <summary>
/// Somebody took it. Destroy my copy and award NOTHING.
///
/// ⛔ DELIBERATELY NOT `CollectRemote`. That method awards when it believes it is the
/// collector, and the collector has already awarded itself in `TryTakeLocal` — routing this
/// through it would pay them twice.
/// </summary>
public static void Vanish( Guid id )
{
var p = ById( id );
if ( !p.IsValid() ) return;
// ⛔ A PER-PLAYER KIND IS NEVER SOMEBODY ELSE'S TO END. Nothing in the per-player path
// sends `PickupGone` or `PickupTaken`, so this cannot fire for salvage from a machine
// running this build — but it is one message away from deleting every player's copy of a
// drop, and a mixed-version session is exactly how that message arrives. Cheap insurance
// on the one handler that is purely destructive.
if ( Info( p.Kind ).PerPlayer )
{
Log.Warning( $"[nz-pickup] ignored a remote 'gone' for {Info( p.Kind ).Label}"
+ " — per-player drops are not shared, so this would have taken it from everyone" );
return;
}
p.Release();
p._taken = true;
p.GameObject.Destroy();
}
/// <summary>
/// Refuse collection for a moment after spawning.
///
/// ⚠️ Needed because a drop lands ON the zombie that just died, which is usually
/// inside the player who killed it — with no delay the pickup fires on the frame
/// it appears and nobody ever sees it.
/// </summary>
[Property] public float ArmDelay { get; set; } = 0.4f;
TimeSince _alive;
bool _taken;
/// <summary>Seconds left before it expires.</summary>
public float Remaining => MathF.Max( 0f, Info( Kind ).Lifetime - _alive );
protected override void OnStart() => _alive = 0f;
protected override void OnUpdate()
{
if ( _taken ) return;
var def = Info( Kind );
if ( _alive >= def.Lifetime )
{
Release();
_taken = true;
GameObject.Destroy();
return;
}
if ( _alive < ArmDelay ) return;
// ⛔ A PER-PLAYER KIND COLLECTS ITSELF, ON EVERY MACHINE, AND MUST BE ABOVE THE HOST GATE
// BELOW. That gate returns on every client, so a per-player branch underneath it would be
// dead on exactly the machines it exists for — a client would watch its salvage lie there
// forever. INSTRUCTIONS §4, and the fourth time this shape has come up today.
if ( def.PerPlayer ) { TickPerPlayer( def ); return; }
// ⛔ THE HOST DECIDES WHO PICKED IT UP. Every machine runs this component, so two
// machines both deciding "collected" pays two players for one pile. The host collects and
// says who got it; a client's copy is scenery until then. Same rule as `Powerup`.
if ( NZGame.IsClient ) return;
foreach ( var player in Scene.GetAllComponents<NZPlayer>() )
{
if ( !player.IsValid() ) continue;
// ⛔ MEASURED TO THE PLAYER'S WHOLE BODY, NOT TO A SINGLE POINT. This was
// the bug that stopped salvage being collectable at all: the check used
// the player's MIDDLE (copied from Powerup.cs, which is right for a
// pickup that HOVERS at 40u) while keeping the original's 32u radius,
// which is measured from the FEET — `ents.FindInSphere(ply:GetPos(), 32)`,
// and GetPos is feet in Source.
//
// A drop lying on the floor is already ~32u below your middle, so the two
// conventions together made a 32u radius unreachable before you had moved
// a single unit horizontally. Vulture drops hid it by using 48u.
//
// ⚠️ Inflating the radius would have been the wrong fix — it would make
// floor pickups grabbable from further away HORIZONTALLY too, and the 32u
// reach is the original's deliberate "stand on it" feel.
// ⚠️ THE REACH IS RESOLVED PER PLAYER rather than read straight off the Def,
// because Vulture Aid's m5 Long Arms triples it. One chokepoint, so a new pickup
// kind cannot forget to honour the augment.
if ( DistanceToBody( player ) > VultureAugments.ReachFor( player, def.Radius ) ) continue;
if ( !CanAward( def ) ) continue;
// ⛔ THE HOST MUST NOT AWARD SOMEBODY ELSE'S PICKUP, AND THIS WAS THE WHOLE BUG.
//
// Collection is decided here, on the host, over `GetAllComponents<NZPlayer>()` — which
// for a client means the host's PROXY COPY of them. `def.Award` then ran against that
// copy: `Armor.AddPlate` incremented a ghost's plate count, while the real award
// happened separately and correctly on the client through `CollectRemote`.
//
// The ghost never SPENDS a plate, because the client spends its own. So after exactly
// `MaxPlates` pickups — three — the host's copy is full, `AddPlate` returns false, this
// line `continue`s, and **no plate can ever be collected by that player again.**
// User: *"it seems like it can at the start but stopped being able to."* Three is the
// start.
//
// ⚠️ AND THE HOST CANNOT SIMPLY SKIP THE TEST. A refusal is meaningful — a full-ammo
// Vulture drop and plates at the cap both return false, and consuming them anyway
// deletes the thing the player came back for. The host has no way to know either
// answer for a body it does not own.
//
// ⚠️ SO IT OFFERS RATHER THAN DECIDES. The owner tries it against their real
// inventory and announces the pickup gone only if they took it — the same shape as
// `PlayerSpawner`, where *"the host DECIDES the placement and the OWNER PERFORMS it."*
// A refused offer simply repeats later, which is correct: spend a plate and the next
// one is takeable.
if ( Networking.IsActive && PlayerPresence.Theirs( player.GameObject ) )
{
Offer( player );
continue;
}
// ⛔ A REFUSED AWARD LEAVES THE PICKUP ALONE. Full-ammo Vulture drops and
// plates at the carry cap both return false, and consuming them anyway
// would delete the thing the player came back for.
if ( !AwardTo( player ) ) continue;
Release();
_taken = true;
NZSound.Play( def.Sound ?? NZSound.PowerupPickup, WorldPosition );
Log.Info( $"[nz-pickup] {def.Label} collected{( Amount > 0 ? $" — {Amount}" : "" )}" );
// ⚠️ AFTER `def.Award` HAS SUCCEEDED, never before. A refused award leaves the
// pickup standing (a full-ammo Vulture drop, plates at the cap), and announcing a
// collection that did not happen would delete it on every other machine.
if ( Networking.IsActive && NZGame.IsHost )
NZNet.PickupTaken( NetId, NZPlayers.OwnerOf( player.GameObject ) is { } o
&& Guid.TryParse( o, out var g ) ? g : Connection.Local?.Id ?? default );
GameObject.Destroy();
return;
}
}
/// <summary>
/// Collect a per-player kind: MY player, MY copy, nobody told.
///
/// ⛔ `NZPlayer.Local`, NOT A SWEEP OF `GetAllComponents<NZPlayer>()`. On the host that sweep
/// returns PROXY copies of every client's body as well as its own, and awarding against a proxy
/// is the exact bug the shared path's long note describes — it paid a ghost while the real
/// player got nothing. There is no arbitration to do here, so the right body is simply the one
/// this machine owns, and `PlayerPresence.Find()` is the settled answer to that question.
///
/// ⚠️ NO `PickupTaken`, NO `PickupGone`, NO `Offer`. Those three exist to make one collection
/// agree across machines. Here every machine has its own entitlement to the same drop, so
/// announcing would do the one thing the request rules out — delete everybody else's copy.
///
/// ⚠️ NO `PlayerPresence.Theirs` BRANCH EITHER, for the same reason the offer is gone: the
/// refusal it protects (plates at the cap, a full gun) can only be answered by the machine that
/// owns the body, and that machine is this one by construction.
///
/// ⚠️ A REFUSED AWARD STILL LEAVES IT STANDING, exactly as in the shared path — `AwardPickup`
/// returning 0 means the pile is untouched and stays takeable.
///
/// ⚠️ `Release()` IS STILL CALLED so the Vulture live-drop cap balances if a per-player kind
/// ever carries an `Owner`. Salvage does not, and `Release` returns immediately on a null owner
/// — but the increment and decrement are deliberately one invariant in this file and skipping
/// half of it here would be how they drift.
/// </summary>
void TickPerPlayer( Def def )
{
var player = NZPlayer.Local;
if ( !player.IsValid() ) return;
if ( DistanceToBody( player ) > VultureAugments.ReachFor( player, def.Radius ) ) return;
if ( !AwardTo( player ) ) return;
Release();
_taken = true;
NZSound.Play( def.Sound ?? NZSound.PowerupPickup, WorldPosition );
Log.Info( $"[nz-pickup] {def.Label} collected (per-player — still there for everyone else)" );
GameObject.Destroy();
}
/// <summary>
/// Shortest distance from this pickup to the player's body.
///
/// ⚠️ TO A VERTICAL SEGMENT, not to a point. The player is ~64u tall, so
/// clamping to the segment from their feet to their head means a drop on the
/// floor, at waist height on a crate, or on a step one stair up are all judged by
/// how far away they are HORIZONTALLY — which is what the reach is supposed to
/// mean. Measuring to any single point on the body makes the answer depend on
/// where that point happens to be relative to the drop.
/// </summary>
float DistanceToBody( NZPlayer player )
{
var feet = player.WorldPosition;
var head = feet + Vector3.Up * PlayerHeight;
var pos = WorldPosition;
// Clamp the pickup's height into the body's span, then measure to that.
var z = MathX.Clamp( pos.z, feet.z, head.z );
return pos.Distance( new Vector3( feet.x, feet.y, z ) );
}
/// <summary>Roughly how tall a standing player is, for the reach check.</summary>
const float PlayerHeight = 64f;
/// <summary>
/// Hand the owner's Vulture slot back.
///
/// ⛔ CALLED ON BOTH PATHS — collected AND expired. The original hangs this on
/// CallOnRemove for exactly that reason: release only on pickup and an expired
/// drop leaks a slot, so after four the perk silently stops producing anything
/// with nothing on screen to explain why.
/// </summary>
void Release()
{
if ( !Info( Kind ).IsVulture ) return;
if ( !Owner.IsValid() ) return;
Owner.VultureDrops = Math.Max( 0, Owner.VultureDrops - 1 );
}
// ── spawning ─────────────────────────────────────────────────────────────
/// <summary>Put one in the world. Null if the kind has no model.</summary>
/// <summary>
/// ⚠️ `announce`: the host tells every machine of a drop it spawns (`NZNet.PickupDropped`) — except a drop that is one
/// player's own (`DropFor`), which nobody else may see.
/// </summary>
public static Pickup Spawn( Vector3 pos, PickupKind kind, NZPlayer owner = null,
GameObject ignore = null, bool announce = true, int amount = 0 )
{
var scene = Game.ActiveScene;
if ( scene is null ) return null;
var def = Info( kind );
if ( string.IsNullOrEmpty( def.Model ) ) return null;
var model = Model.Load( def.Model );
if ( model is null ) return null;
// ⛔ EVERY KIND LANDS ON THE FLOOR. This was briefly a per-kind flag, on the
// theory that Vulture drops should float like powerups — the user's call is
// that they all lie down, and a bool every row sets to the same value is the
// dead configuration INSTRUCTIONS.md §12 is about. So it is unconditional.
//
// ⚠️ IF SOMETHING EVER NEEDS TO FLOAT, add a hover OFFSET, not a bool. The
// original's gas cloud traces to the ground and THEN lifts 32u
// (sh_vultures.lua, the gas drop's initialize), so even the one kind that
// hovers is ground-relative — an on/off switch could not express it.
//
// ⚠️ TRACED FROM ABOVE THE SPAWN POINT, DOWNWARD. Starting the ray at `pos`
// itself begins inside the floor for anything already resting on it and
// reports no hit at all.
//
// ⚠️ IGNORES THE CORPSE AND THE PLAYER. NZOMBIES_REFERENCE §9.8 records that
// the original's ground probe is world-only for exactly this reason — a body
// lying where the drop spawns would otherwise BE the floor, and the drop would
// sit on the dead zombie's chest.
pos = Ground( scene, pos, ignore );
var go = scene.CreateObject();
go.Name = $"pickup_{kind}";
go.WorldPosition = pos;
// A random facing, so a pile of them does not look stamped from one mould.
go.WorldRotation = Rotation.FromYaw( Game.Random.Float( 0f, 360f ) );
// ⚠️ NotSaved, or a play session bakes these into the scene file and they come
// back permanently on the next load — the trap the wall buys hit, see the
// 2026-08-19 (10h) changelog entry.
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never;
var renderer = go.Components.Create<ModelRenderer>();
renderer.Model = model;
// ⛔ NOTHING DRAWS WITHOUT A Highlight ON THE CAMERA. HighlightOutline only
// marks a renderer as a target; the camera component does the drawing. Reusing
// WallBuyManager's helper rather than writing a second one — see its note.
if ( def.Outline.a > 0f )
{
WallBuyManager.EnsureHighlight( scene );
var outline = go.Components.Create<HighlightOutline>();
outline.Color = def.Outline;
// ⚠️ Transparent INSIDE, so it reads as an outline around the model
// rather than a coloured wash over it — the model's own texture stays
// visible, unlike the wall-buy chalk which hides its mesh entirely.
outline.InsideColor = Color.Transparent;
outline.ObscuredColor = def.OutlineThroughWalls
? def.Outline.WithAlpha( 0.55f )
: Color.Transparent;
outline.InsideObscuredColor = Color.Transparent;
outline.Width = 0.35f;
}
var pickup = go.Components.Create<Pickup>( startEnabled: false );
pickup.Kind = kind;
pickup.Owner = owner;
pickup.Amount = Math.Max( 0, amount );
// ⚠️ THE HOST GIVES IT AN IDENTITY AND TELLS EVERYONE. Clients reach this method through
// `SpawnRemote`, which sets the id itself and must not announce it again.
if ( announce && Networking.IsActive && NZGame.IsHost )
{
pickup.NetId = Guid.NewGuid();
NZNet.PickupDropped( pickup.NetId, pickup.WorldPosition, (int)kind, pickup.Amount );
}
// ⛔ THE INCREMENT LIVES HERE, WITH THE DECREMENT IN Release(). It used to
// sit in PickupDrops.RollVulture while only the decrement was here, which
// split one invariant across two files — and the dev spawn command promptly
// proved why: it assigned an owner without incrementing, so collecting a
// hand-spawned drop refunded a slot it had never taken. One place owns both
// halves now, so they cannot disagree.
//
// ⚠️ DELIBERATELY NOT CAPPED HERE. The 4-live limit is a RULE ABOUT DROP
// RATE and belongs in the roll; a dev command asking for five should get five.
if ( def.IsVulture && owner.IsValid() )
owner.VultureDrops++;
// ⛔ KIND AND OWNER BEFORE Enabled. Anything reading Kind during OnStart would
// otherwise race the assignment — the pattern INSTRUCTIONS.md §11 records
// twice, and the reason a hellhound once spawned as a walker.
pickup.Enabled = true;
return pickup;
}
/// <summary>Where a drop comes to rest: traced down onto the world from just above `pos`, past the corpse and players.</summary>
static Vector3 Ground( Scene scene, Vector3 pos, GameObject ignore )
{
var tr = scene.Trace.Ray( pos + Vector3.Up * 8f, pos + Vector3.Down * 160f )
.WithoutTags( "player", "trigger" )
.IgnoreGameObjectHierarchy( ignore )
.Run();
return tr.Hit ? tr.HitPosition : pos;
}
/// <summary>
/// A DROP THAT IS ONE PLAYER'S OWN — salvage, the killer's (2026-09-27). HOST (or solo), from the drop roll.
///
/// ⛔ IT EXISTS ON THE OWNER'S MACHINE AND NOWHERE ELSE. The host's own player's: made here, announced to nobody. A client's:
/// not made here at all — the host's player can neither see it nor take it — but sent to that client alone
/// (`Rpc.FilterInclude`), whose machine builds it (`SpawnRemote`) and collects it for its own player (`TickPerPlayer`).
/// A joiner is never told of one (`NZNet.SendGame` skips them): it is nobody else's.
///
/// ⚠️ THE HOST STILL FINDS THE FLOOR, past the corpse, which only it can ignore — the same trace every drop takes.
/// </summary>
public static void DropFor( NZPlayer owner, Vector3 at, PickupKind kind, GameObject ignore = null )
{
if ( !owner.IsValid() ) return;
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
if ( !Networking.IsActive || PlayerPresence.Mine( owner.GameObject ) )
{
Spawn( at, kind, null, ignore, announce: false );
return;
}
var id = NZPlayers.OwnerOf( owner.GameObject );
var to = Connection.All.FirstOrDefault( c => c is not null && c.Id.ToString() == id );
if ( to is null ) return;
using ( Rpc.FilterInclude( to ) )
NZNet.PickupDropped( Guid.NewGuid(), Ground( scene, at, ignore ), (int)kind, 0 );
}
// ── commands ─────────────────────────────────────────────────────────────
/// <summary>Spawn one ahead of you: nz_pickup [kind] [distance]</summary>
[ConCmd( "nz_pickup" )]
public static void Cmd( string kind = "salvage", float distance = 80f )
{
var player = NZPlayer.Local;
if ( !player.IsValid() ) { Log.Warning( "[nz-pickup] no player" ); return; }
if ( !Enum.TryParse<PickupKind>( kind, ignoreCase: true, out var parsed ) )
{
Log.Info( $"[nz-pickup] unknown kind '{kind}' — try "
+ string.Join( ", ", Enum.GetNames<PickupKind>().Select( n => n.ToLower() ) ) );
return;
}
var pos = player.WorldPosition + Vector3.Up * 40f
+ player.EyeAngles.Forward.WithZ( 0f ).Normal * distance;
// ⚠️ A PER-PLAYER KIND (salvage) IS THE ASKER'S OWN, AS A KILL'S IS: on this machine alone.
if ( Info( parsed ).PerPlayer )
{
Spawn( pos, parsed, null, null, announce: false );
Log.Info( $"[nz-pickup] spawned {parsed} {distance:0}u ahead — yours alone" );
return;
}
// Only Vulture drops are owned, so only they get an owner here.
var owner = Info( parsed ).IsVulture ? player : null;
// ⚠️ A KIND THAT CARRIES ITS AMOUNT IS GIVEN A DROP'S WORTH, or it would pay nothing and lie there until it expired
if ( Spawn( pos, parsed, owner, amount: DevAmount( parsed ) ) is null )
{
Log.Warning( $"[nz-pickup] {parsed} did not spawn —"
+ $" model '{Info( parsed ).Model}' missing?" );
return;
}
Log.Info( $"[nz-pickup] spawned {parsed} {distance:0}u ahead" );
}
/// <summary>
/// One of every drop, in a row: nz_pickup_all
///
/// ⚠️ TRACED DOWN TO THE FLOOR rather than dropped at eye height, copying
/// nz_powerup_all. Without the trace they hang in mid-air on any map with a step
/// or a slope, and a pickup you cannot walk over tests nothing.
///
/// ⚠️ IGNORES THE 4-LIVE VULTURE CAP on purpose — Spawn does the counting but
/// not the limiting, so this always produces all four. The cap is a rule about
/// drop RATE and lives in PickupDrops; a dev command asking for one of each
/// should get one of each.
/// </summary>
[ConCmd( "nz_pickup_all" )]
public static void All()
{
var player = NZPlayer.Local;
if ( !player.IsValid() ) { Log.Warning( "[nz-pickup] no player" ); return; }
var controller = player.Components.Get<PlayerController>();
var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
var rot = controller?.EyeAngles.ToRotation() ?? player.WorldRotation;
var kinds = Enum.GetValues<PickupKind>();
var spawned = 0;
// Centred on where you are looking, 90u apart — same spacing as the powerup
// row, so the two dev commands lay out consistently.
for ( var i = 0; i < kinds.Length; i++ )
{
var offset = (i - (kinds.Length - 1) / 2f) * 90f;
var from = eye + rot.Forward * 160f + rot.Right * offset;
var def = Info( kinds[i] );
var owner = def.IsVulture ? player : null;
// ⚠️ NO PRE-TRACE HERE ANY MORE — Spawn drops everything to the floor
// itself, and tracing twice would just find the same surface. `player` is
// handed over as the thing to ignore so the row cannot land on your head.
if ( Spawn( from, kinds[i], owner, player.GameObject, amount: DevAmount( kinds[i] ) ) is null )
{
Log.Warning( $"[nz-pickup] {kinds[i]} did not spawn —"
+ $" model '{def.Model}' missing?" );
continue;
}
spawned++;
}
Log.Info( $"[nz-pickup] spawned {spawned}/{kinds.Length} — "
+ string.Join( ", ", kinds.Select( k => Info( k ).Label ) ) );
if ( spawned > 0 )
Log.Info( "[nz-pickup] walk across them; ammo refuses at full reserve"
+ " and plates refuse at the carry cap, by design" );
}
/// <summary>What a dev command's copy of a kind carries: a player's drop for one that carries its amount, else nothing.</summary>
static int DevAmount( PickupKind kind ) => Info( kind ).AwardAmount is not null ? SalvageDrop.Amount : 0;
/// <summary>List what is on the floor: nz_pickups</summary>
[ConCmd( "nz_pickups" )]
public static void List()
{
var all = Game.ActiveScene?.GetAllComponents<Pickup>().ToList();
if ( all is null || all.Count == 0 ) { Log.Info( "[nz-pickup] none on the floor" ); return; }
Log.Info( $"[nz-pickup] {all.Count} on the floor:" );
var me = NZPlayer.Local;
foreach ( var p in all )
{
var def = Info( p.Kind );
// ⚠️ REPORTS THE REACH TEST, not just the lifetime. "Why did that not
// get picked up" was answerable only by reading the source before this;
// printing the distance against the radius makes it a one-command answer.
var reach = me.IsValid()
? $" {p.DistanceToBody( me ):0}u away (reach {def.Radius:0})"
+ (p.DistanceToBody( me ) <= def.Radius ? " IN RANGE" : "")
: "";
Log.Info( $"[nz-pickup] {def.Label,-12} {p.Remaining:0.#}s left{reach}"
+ $"{(def.IsVulture ? " (vulture)" : "")}{(p.Amount > 0 ? $" worth {p.Amount}" : "")}" );
}
}
}