Pickups/Pickup.cs

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.

NetworkingFile Access
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&amp;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&lt;NZPlayer&gt;()`. 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}" : "")}" );
		}
	}
}