Buyables/PackAPunch.cs

Component for a Pack-a-Punch machine. Manages placement, visuals, sound cues, player interaction (inserting weapon, paying, upgrade timing, collecting or expiring), and per-frame animation of the displayed weapon.

File AccessNetworking
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// PACK-A-PUNCH — feed it your gun, get it back hitting harder.
///
/// Ported from the original's `pap` machine (perk_machine + pap_weapon_fly).
///
/// ⛔ IT IS REGISTERED AS A PERK IN THE ORIGINAL AND IT IS NOT ONE. `sh_perks.lua`
/// declares it with `specialmachine = true` and `nobuy = true`, which skips the
/// entire own-a-perk path — no bottle, no icon slot, nothing persists on the
/// player. It reuses the perk machine only because that entity already knew how to
/// stand somewhere, gate on power and take money. So this is its own component
/// rather than the first perk: modelling it as a perk would mean building the perk
/// system around the one thing that is not one.
///
/// ⚠️ The machine barely animates — the model carries a single `idle` sequence.
/// The gun going in and coming out is a SEPARATE entity there (`pap_weapon_fly`),
/// and here it is the same floating-offer object the mystery box already uses.
/// </summary>
public sealed class PackAPunch : Component
{
	/// <summary>Every live machine, for the use trace and the prompt.</summary>
	public static readonly List<PackAPunch> All = new();

	// ⛔ `Costs` REMOVED. It was a `[Property] List<int>` on this component and is now
	// `MapConfig.Pap.Costs`, edited from the settings panel. Two places holding the price ladder
	// meant the panel could disagree with the machine standing in front of you, and nothing
	// serialized this one anyway (checked: no "Costs" in Assets) because machines are built at
	// runtime by PackAPunchManager. §3: one author per value.

	/// <summary>How close you must stand. Matches the box and the barricade.</summary>
	[Property] public float Reach { get; set; } = 90f;

	/// <summary>The machine. BO2 vending PaP, same pack and author as the crate.</summary>
	[Property] public string MachineModel { get; set; } = "models/nz/pap/vending_pap.vmdl";

	/// <summary>
	/// How long the machine works before handing the gun back.
	///
	/// The original's `PerkTime = CurTime() + 3.5` (perk_machine:1166).
	/// </summary>
	[Property] public float WorkTime { get; set; } = 3.5f;

	/// <summary>How long the finished gun waits to be collected.</summary>
	[Property] public float ReadyTime { get; set; } = 20f;

	/// <summary>
	/// Where the weapon floats, above the machine's base.
	///
	/// ⚠️ 42 IS THE THROAT OPENING, found with `nz_pap_gun` against the real model
	/// rather than guessed — 52 was a first estimate made before there was a machine
	/// to look at, and hung the gun up near the top edge. 37 was also tried and is
	/// too low: it rests the weapon on the bottom lip, which reads as a gun left
	/// lying on the machine rather than one being presented by it.
	/// </summary>
	[Property] public float GunHeight { get; set; } = 42f;

	/// <summary>
	/// How far IN FRONT of the machine the weapon sits when presented.
	///
	/// ⚠️ The machine's local +x points at whoever placed it (`nz_pap` sets the
	/// spot's yaw from the player), so forward is out towards the player and this is
	/// simply a positive offset — no sign juggling per placement.
	///
	/// ⚠️ 28.8, tuned by eye in two passes: 40 hung the weapon clear of the machine
	/// and read as a gun floating NEAR a Pack-a-Punch, 14.4 overcorrected and tucked
	/// it almost against the throat. This sits between them — offered by the machine,
	/// with enough travel that the sink-back still reads as reclaiming it.
	/// </summary>
	[Property] public float GunOutDistance { get; set; } = 28.8f;

	/// <summary>How deep into the roller throat it travels while being worked on.</summary>
	[Property] public float GunInDepth { get; set; } = 2f;

	/// <summary>How long the weapon takes to travel in, and to come back out.</summary>
	[Property] public float TravelTime { get; set; } = 0.7f;

	/// <summary>
	/// `TravelTime` after Timeslip m1 Overclock.
	///
	/// ⛔ ONE PROPERTY, FOUR READERS. `TravelTime` is read by the two `_next` deadlines AND by
	/// the two animation lerps that slide the gun in and out. Scaling only the deadlines would
	/// run the animation at normal speed against a deadline 20x shorter — the gun would jump
	/// rather than travel, which looks like a broken machine rather than a fast one.
	///
	/// ⚠ Resolved from `_owner`, the player who started the cycle, exactly as
	/// `MachineCycleMultiplier` is. Null-safe: no owner resolves to the unscaled time.
	/// </summary>
	float EffectiveTravel
		=> MathF.Max( 0.01f, TravelTime * TimeAugments.PapCycleScale( _owner ) );

	/// <summary>How the presented weapon is angled. Same broadside idea as the box.</summary>
	[Property] public Angles GunAngles { get; set; } = new( 0f, 90f, 0f );

	protected override void OnEnabled()
	{
		if ( !All.Contains( this ) ) All.Add( this );
		BuildVisual();

		// ⛔ SEEDED HERE, BECAUSE A DEFAULT `TimeUntil` HAS ALREADY ELAPSED. Left unset,
		// `_nextJingle` reads as due on the machine's very first tick — so every Pack-a-Punch on
		// the map jingled simultaneously the moment the power came on, and again in unison
		// whenever their random gaps happened to line up. That is half of "the jingle plays
		// multiple times at once", and it was not the sound system's fault.
		_nextJingle = Game.Random.Float( JingleGap.x, JingleGap.y );
	}

	protected override void OnDisabled()
	{
		All.Remove( this );
		ClearGun();
		StopLoop();
	}

	// ── ambience ─────────────────────────────────────────────────────────────

	SoundHandle _loop;
	TimeUntil _nextJingle;

	// ══ ONE PACK-A-PUNCH CUE AT A TIME, ACROSS EVERY MACHINE ══════════════════════
	//
	// ⛔ STATIC ON PURPOSE, and it is the one thing on this component that should be. Per-machine
	// state cannot stop TWO machines singing over each other, which is exactly what was reported.
	// A map with three Pack-a-Punches had three independent jingle timers and nothing between
	// them.
	//
	// ⚠ A HANDLE, NOT A GUESSED DURATION. `NZSound.Play` returns a `SoundHandle`, so the engine
	// tells us when the cue is done — no table of sample lengths to keep in step with the assets.
	// `_loop` above is managed the same way and its note says why: checking IsValid is cheaper
	// than tracking a duration, and it self-heals if the handle is lost.

	static SoundHandle _cue;

	/// <summary>
	/// Upper bound on how long a cue can block the jingle.
	///
	/// ⛔ A FAILSAFE FOR THE STATIC ABOVE. A static SoundHandle survives a hotload (§1), so a
	/// leaked or stale handle that keeps reading valid would silence every jingle on the map
	/// FOREVER — and silence is the one bug that never reports itself. A `TimeUntil` always
	/// elapses, so the worst case is one jingle skipped rather than all of them.
	/// </summary>
	static TimeUntil _cueCap;

	/// <summary>Seconds after which a cue stops counting as playing. Generous on purpose.</summary>
	public static float CueCapSeconds { get; set; } = 15f;

	/// <summary>Is any Pack-a-Punch cue currently sounding, on any machine.</summary>
	static bool CuePlaying => _cue.IsValid() && !_cueCap;

	/// <summary>
	/// Play a Pack-a-Punch cue and record it, so the idle jingle knows to stay quiet.
	///
	/// ⚠ EVERY PaP CUE GOES THROUGH HERE. A cue played with a bare `NZSound.Play` is invisible
	/// to `CuePlaying`, and the jingle would talk over it — which is the other half of what was
	/// reported.
	/// </summary>
	static void PlayCue( string cue, Vector3 at )
	{
		_cue = NZSound.Play( cue, at );
		_cueCap = CueCapSeconds;
	}

	/// <summary>
	/// Seconds between jingles, picked fresh each time within this range.
	///
	/// ⚠️ LONG AND RANDOM, as the original does it (`NextJingle = CurTime() +
	/// math.random(0,600)`). The jingle is meant to be something you occasionally
	/// notice from across the map, not a soundtrack — on a short fixed timer it
	/// stops being an event and starts being wallpaper.
	/// </summary>
	[Property] public Vector2 JingleGap { get; set; } = new( 90f, 300f );

	void StopLoop()
	{
		if ( _loop.IsValid() ) _loop.Stop();
		_loop = default;
	}

	/// <summary>
	/// Keep the hum running while there is power, and drop a jingle now and then.
	///
	/// ⛔ GATED ON POWER, like every machine in the original. A Pack-a-Punch humming
	/// away before the power is on is a promise the map cannot keep — and it is the
	/// sound, not the model, that makes a player walk over to try.
	///
	/// ⚠️ The loop is RESTARTED rather than left running, because a 1.23s sample
	/// ends. Checking IsValid each tick and replaying is cheaper than tracking its
	/// duration, and it self-heals if the handle is ever lost.
	/// </summary>
	void TickAmbience()
	{
		if ( !Power.IsOn )
		{
			StopLoop();
			return;
		}

		// ⛔ PlayAmbient, NOT Play — it drops cues emitted beyond their own falloff.
		// The sample is 1.23s, so "keep it running" means restarting it about once a
		// second, forever, per machine. Unculled that is a stream of silent sounds
		// for anyone not standing there, and with the audio trace on it buries every
		// other cue in the console — which is how it was noticed.
		if ( !_loop.IsValid() )
			_loop = NZSound.PlayAmbient( NZSound.PapLoop, WorldPosition );

		// ⚠ THE TIMER IS ONLY RESET WHEN THE JINGLE ACTUALLY PLAYS. Resetting it on a blocked
		// attempt would push the next one a further 90-300s away for no reason the player could
		// see; leaving it due means the jingle happens as soon as the machine is quiet again.
		if ( _nextJingle && !CuePlaying )
		{
			_nextJingle = Game.Random.Float( JingleGap.x, JingleGap.y );
			PlayCue( NZSound.PapJingle, WorldPosition );
		}
	}

	GameObject _visual;
	GameObject _gunGO;
	ModelRenderer _gunRenderer;

	void BuildVisual()
	{
		_visual?.Destroy();

		var go = Scene.CreateObject();
		go.Name = "machine";
		go.SetParent( GameObject );
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)

		// ⚠️ Local AFTER parenting. SetParent preserves WORLD position, so a child
		// made by Scene.CreateObject stays at the origin and merely acquires a
		// compensating offset — the bug that made the mystery box invisible for two
		// rounds of diagnosis.
		go.LocalPosition = Vector3.Zero;
		go.LocalRotation = Rotation.Identity;

		var model = Model.Load( MachineModel );

		var r = go.Components.Create<ModelRenderer>();
		r.Model = model;

		// ⛔ IT HAD NO COLLIDER AND YOU WALKED STRAIGHT THROUGH IT. Found only
		// because someone asked for a sound when they ran into it — a machine you
		// can stand inside is worse than one that makes no noise.
		//
		// ⛔ A BOX, NOT A ModelCollider. Our vmdl is a mesh-only OBJ import with no
		// PhysicsShapeList, so a ModelCollider has no hulls to build from. A box is
		// also the better answer on its own merits: a vending machine IS a box, and
		// a mesh collider off this model would snag the player on the sign bracket
		// and the trestle legs for no gain.
		//
		// ⚠️ Sized from the model's own bounds, so it stays correct if the machine
		// model is ever swapped for one of the other PaP variants.
		if ( model is not null )
		{
			var box = go.Components.Create<BoxCollider>();
			box.Scale = model.Bounds.Size;
			box.Center = model.Bounds.Center;
			box.Static = true;
		}

		_visual = go;
	}

	// ── walking into it ──────────────────────────────────────────────────────

	/// <summary>How close counts as bumping it. Machine half-width plus a body.</summary>
	[Property] public float BumpReach { get; set; } = 62f;

	/// <summary>
	/// How fast you must be closing to make it clang.
	///
	/// ⚠️ SPEED TOWARD THE MACHINE, not raw speed. Standing at the machine to use it
	/// means being inside BumpReach for as long as you are there — without the
	/// approach test it would clang while you stood still deciding, and again on
	/// every strafe.
	/// </summary>
	[Property] public float BumpSpeed { get; set; } = 60f;

	/// <summary>Quietest gap between two clangs, so a wall-hug does not rattle.</summary>
	[Property] public float BumpCooldown { get; set; } = 0.7f;

	TimeUntil _bumpReady;

	void TickBump()
	{
		if ( !_bumpReady ) return;

		foreach ( var player in Scene.GetAllComponents<NZPlayer>() )
		{
			if ( !player.IsValid() ) continue;

			var toMachine = WorldPosition.WithZ( player.WorldPosition.z ) - player.WorldPosition;
			if ( toMachine.Length > BumpReach ) continue;

			// ⚠️ Velocity PROJECTED onto the approach direction. A player running
			// past the machine at speed is not running into it, and would otherwise
			// set it off every time they rounded the corner.
			var controller = player.Components.Get<PlayerController>();
			var vel = controller?.Velocity ?? Vector3.Zero;

			if ( vel.Dot( toMachine.Normal ) < BumpSpeed ) continue;

			_bumpReady = BumpCooldown;
			NZSound.Play( NZSound.MachineBump, WorldPosition );
			return;
		}
	}

	// ── state ────────────────────────────────────────────────────────────────

	/// <summary>
	/// What the machine is doing. Idle is the only state that takes a gun.
	///
	/// ⚠️ Inserting and Ejecting are TRAVEL states, and they exist so the weapon is
	/// seen going in and coming out rather than teleporting. The original splits
	/// them the same way — `take_gun` and `eject_gun` are separate sequences either
	/// side of the work, not one animation.
	/// </summary>
	public enum PapState { Idle, Inserting, Working, Ejecting, Ready }

	[Property, ReadOnly] public PapState State { get; private set; } = PapState.Idle;

	TimeUntil _next;
	NZPlayer _owner;
	string _weapon = "";
	int _newLevel;

	/// <summary>Is there a finished gun sitting here?</summary>
	public bool HasFinishedGun => State == PapState.Ready;

	/// <summary>Mid-cycle — no new gun can go in.</summary>
	public bool IsBusy => State != PapState.Idle;

	/// <summary>Whose gun is in there. Nobody else may take it.</summary>
	public NZPlayer Owner => _owner;

	/// <summary>
	/// How far out in front the gun's MESH currently is, or null.
	///
	/// ⚠️ Measured along the machine's own FORWARD, not world x — the machine is
	/// placed facing whoever put it down, so a world-axis reading changes meaning
	/// with every placement. Same lesson the mystery box's watch learned about
	/// measuring a child in its parent's frame.
	/// </summary>
	public float? GunOut
	{
		get
		{
			if ( !_gunGO.IsValid() || !_gunRenderer.IsValid() ) return null;

			var model = _gunRenderer.Model;
			if ( model is null ) return null;

			var centre = _gunGO.WorldPosition + _gunGO.WorldRotation * model.Bounds.Center;
			return (centre - WorldPosition).Dot( WorldRotation.Forward );
		}
	}

	/// <summary>What the next pack would cost this player, or 0 if they cannot.</summary>
	public int PriceFor( NZPlayer player )
	{
		if ( !player.IsValid() || string.IsNullOrWhiteSpace( player.StartingWeapon ) ) return 0;

		// ⚠️ `CostToReach` RETURNS 0 BOTH FOR "fully packed" AND for a malformed array, so callers
		// must check `IsMaxed` first — 0 reads as free rather than as refused. `Insert` does.
		return ActiveConfig.Pap?.CostToReach( player.PapLevelFor( player.StartingWeapon ), NZPlayer.PapMaxLevel ) ?? 0;
	}

	/// <summary>Is this weapon already fully packed?</summary>
	public static bool IsMaxed( NZPlayer player )
		=> player.IsValid()
			&& player.PapLevelFor( player.StartingWeapon ) >= NZPlayer.PapMaxLevel;

	/// <summary>The highest tier the CURRENT round allows to be bought.</summary>
	public static int TierCapForNow()
	{
		var pap = ActiveConfig.Pap;
		if ( pap is null ) return NZPlayer.PapMaxLevel;

		// ⛔ CREATIVE IGNORES THE ROUND GATE ENTIRELY. The gate is a PACING rule — it exists so a
		// run has something left to unlock at round 45 — and creative has no run and no rounds to
		// wait through. Left gated, the round reads 0, `MaxTierForRound` clamps to MK1, and the
		// mode built for testing the other four tiers cannot reach them.
		//
		// ⚠️ THE SAME REASON WALLBUYS ARE FREE IN CREATIVE (`WallBuy.cs`: `var free =
		// NZGame.IsCreative`). An economy rule and a pacing rule are both rules about a RUN, and
		// creative is not one.
		if ( NZGame.IsCreative ) return NZPlayer.PapMaxLevel;

		var round = RoundManager.Instance.IsValid() ? RoundManager.Instance.Round : 0;
		// ⚠️ ON THE LADDER AS IT STANDS — six with basalt's Easter egg complete. The config's own Tiers would stop the gate at
		// MK5, and MK6 could never be bought.
		return Math.Min( NZPlayer.PapMaxLevel, pap.MaxTierForRound( round, NZPlayer.PapMaxLevel ) );
	}

	/// <summary>
	/// Is the next tier locked behind a round this player has not reached?
	///
	/// ⛔ SEPARATE FROM `IsMaxed`, AND THE DIFFERENCE IS THE WHOLE FEATURE. "Fully upgraded"
	/// and "not yet" are different answers and must read differently at the machine —
	/// collapsing them into one refusal would tell a round-12 player their MK1 rifle is
	/// finished, which is both wrong and unrecoverable advice.
	/// </summary>
	public static bool IsRoundLocked( NZPlayer player )
	{
		if ( !player.IsValid() || IsMaxed( player ) || NZGame.IsCreative ) return false;

		// ⛔ THE NEXT TIER'S OWN GATE, NOT THE CAP FOR NOW. MK6 has no round — basalt's Easter egg is its only gate, and
		// `IsMaxed` has said whether that is open — so a gun that came by an MK5 early (the Wildcard, the trade table) packs to
		// MK6 at once, where the cap for now, counted up the ladder, stops at the first locked round below it.
		var pap = ActiveConfig.Pap;
		if ( pap is null ) return false;

		var round = RoundManager.Instance.IsValid() ? RoundManager.Instance.Round : 0;
		return !pap.TierOpenAt( player.PapLevelFor( player.StartingWeapon ) + 1, round );
	}

	/// <summary>
	/// Put the held weapon in. Returns what happened, for the log and the prompt.
	///
	/// ⚠️ THE WEAPON IS TAKEN OFF THE PLAYER IMMEDIATELY. That is the cost of using
	/// the machine and it is the original's behaviour too (`StripWeapon(class)`) —
	/// standing at a PaP with a horde inbound should be a decision, not a freebie.
	/// </summary>
	public string Insert( NZPlayer player )
	{
		if ( !player.IsValid() ) return "no player";
		if ( State != PapState.Idle ) return "";

		if ( !Power.IsOn ) return "the machine has no power";

		var prefab = player.StartingWeapon;
		if ( string.IsNullOrWhiteSpace( prefab ) ) return "you have nothing to pack";

		if ( IsMaxed( player ) )
			return $"already fully upgraded (MK{NZPlayer.PapMaxLevel})";

		// ⛔ CHECKED BEFORE THE PRICE, so a locked tier never takes points. `TrySpend` fuses
		// the check with the deduction, so any refusal reached after it has already charged.
		//
		// ⚠️ IT NAMES THE ROUND. "Not available yet" tells the player nothing they can act on;
		// the round number turns a refusal into a goal.
		if ( IsRoundLocked( player ) )
		{
			var next = player.PapLevelFor( player.StartingWeapon ) + 1;
			var at = ActiveConfig.Pap?.UnlockRoundFor( next - 1 ) ?? 0;
			return $"MK{next} unlocks at round {at}";
		}

		int price = PriceFor( player );
		if ( price <= 0 ) return "nothing to upgrade";

		// ⚠️ TrySpend fuses the check with the deduction, as everywhere else —
		// asking CanAfford separately is how you get free packs.
		if ( !player.TrySpend( price ) )
			return $"not enough points — MK{player.PapLevelFor( prefab ) + 1} costs {price}";

		// ⚠ TIMESLIP m2 TIME OUT — AFTER THE SPEND SUCCEEDED, never before. A refused purchase
		// must not buy 15 seconds of invisibility; that is the same reasoning the box's own
		// `Uses++` comment gives two lines down.
		TimeAugments.OnMachineUsed( player, "pack-a-punch" );
		_owner = player;
		_weapon = prefab;
		_newLevel = player.PapLevelFor( prefab ) + 1;

		ShowGun( prefab );

		// ⛔ STRIPPED, NOT HIDDEN. Leaving the weapon equipped while its model sits
		// in the machine lets you keep shooting the gun you just handed over.
		StripWeapon( player );

		// ⚠️ WHEN THE GUN GOES IN, NOT WHEN IT COMES OUT. `Finish()` is the machine returning to Idle
		// — several seconds later, after the player has already walked off with the weapon. The line
		// is a reaction to handing it over.
		CharacterVoice.Say( "upgrading", player );

		State = PapState.Inserting;
		_next = EffectiveTravel;
		_sinceStage = 0f;

		// ⚠️ Sting AND work loop together — the sting is the machine acknowledging
		// the keypress, the work sound is what it does afterwards. The original
		// plays them as separate cues for the same reason.
		// ⚠ THE WORK LOOP IS THE ONE RECORDED, and the sting rides along unrecorded. They start
		// together and there is one cue slot, so the pair is bounded by whichever is recorded —
		// the work sound, because it is the one that lasts for the cycle. Recording the sting
		// instead would let the jingle cut in over the tail of the work loop.
		NZSound.Play( NZSound.PapSting, WorldPosition );
		PlayCue( NZSound.PapWork, WorldPosition );
		return $"packing {WeaponName( prefab )} -> MK{_newLevel} for {price}";
	}

	/// <summary>
	/// Apply the pack level and put the gun in the player's hands. Returns its new name.
	///
	/// ⛔ SHARED BY `Collect` AND TIMESLIP m1's INSTANT PATH, and that sharing is the point.
	/// m1 skips the machine entirely, so without one implementation there would be two answers
	/// to "what does packing a gun do" — and the one that drifted would be the augment's, because
	/// the machine's is the one that gets played every round. §3 with a purchase attached.
	///
	/// ⚠ `GiveWeapon`, not StartingWeapon + re-equip. On the machine path the weapon was
	/// stripped on insert so the slot is free; on m1's path `GiveOrReplace` swaps the same prefab
	/// in place. Either way the OTHER weapon the player kept is untouched.
	///
	/// ⚠ THE LEVEL IS READ BACK FROM `AddPapLevel` rather than computed here. That method owns
	/// the ceiling, so a gun already at MK3 reports MK3 instead of an invented MK4.
	/// </summary>
	string Upgrade( NZPlayer player, string prefab )
	{
		int level = player.AddPapLevel( prefab );

		player.GiveWeapon( prefab );

		return $"{WeaponName( prefab )} MK{level}";
	}

	/// <summary>
	/// Collect the finished weapon.
	///
	/// ⛔ ONLY THE OWNER. `pap_weapon_fly:Use` checks `ply == self:GetPaPOwner()`,
	/// and it matters: without it a teammate walks off with the gun you paid
	/// 30,000 points to upgrade.
	/// </summary>
	public string Collect( NZPlayer player )
	{
		if ( !player.IsValid() || State != PapState.Ready ) return "";
		if ( player != _owner ) return "that is not your weapon";

		var name = Upgrade( player, _weapon );

		Finish();
		NZSound.Play( NZSound.Purchase, WorldPosition );

		return $"took the {name}";
	}

	/// <summary>
	/// Nobody collected it. The weapon is returned UNUPGRADED and the points stay
	/// spent.
	///
	/// ⚠️ RETURNED, NOT DESTROYED — the original leaves you a backup weapon and
	/// removes the fly entity, which amounts to the same thing: you are not left
	/// unarmed by walking away. Losing the money is the punishment; losing the gun
	/// as well would be a trap rather than a cost.
	/// </summary>
	void Expire()
	{
		if ( _owner.IsValid() && !string.IsNullOrEmpty( _weapon ) )
		{
			_owner.GiveWeapon( _weapon );
			Log.Info( $"[nz] nobody collected the {WeaponName( _weapon )} — "
				+ "returned unupgraded" );
		}

		Finish();
	}

	/// <summary>
	/// Back to idle, holding nothing.
	///
	/// ⛔ NOT CALLED `Reset` — that HIDES `Component.Reset()`, and this is the THIRD
	/// time in this project: `DamageOverlay.Enabled` shadowed `Component.Enabled`,
	/// `Barricade.Reset` shadowed this same method, and here it is again. The
	/// compiler only warns, so the shadowed version compiles and runs and the
	/// engine's own call goes somewhere unexpected. Check the base class before
	/// naming any public member on a Component.
	/// </summary>
	void Finish()
	{
		ClearGun();
		State = PapState.Idle;
		_owner = null;
		_weapon = "";
		_newLevel = 0;
	}

	protected override void OnUpdate()
	{
		TickGun();
		TickAmbience();
		TickBump();

		if ( State == PapState.Idle ) return;
		if ( !_next ) return;

		switch ( State )
		{
			case PapState.Inserting:
				State = PapState.Working;
				// Timeslip Tonic halves the upgrade. Resolved from _owner, set by
				// Insert() before this state is ever reached, so it is the player who
				// put the gun in — not whoever is standing at the machine now.
				//
				// ⚠️ ReadyTime is deliberately NOT scaled: that is the window to
				// COLLECT the weapon, and shortening it would make the perk a
				// downgrade. See PerkEffects.MachineCycleMultiplier.
				// ⚠ m1 OVERCLOCK SCALES THE SAME PRODUCT as the base perk. `MathF.Max` keeps a
				// zero work time from stalling the state machine on a deadline that has already
				// elapsed.
				_next = MathF.Max( 0.01f,
					WorkTime
						* PerkEffects.MachineCycleMultiplier( _owner )
						* TimeAugments.PapCycleScale( _owner ) );
				_sinceStage = 0f;
				break;

			case PapState.Working:
				State = PapState.Ejecting;

				// ⛔ `EffectiveTravel`, NOT `TravelTime` — THE ONE READER THAT WAS MISSED. Its own
				// header says "one property, four readers"; this was the fourth and it took the raw
				// value. So under Timeslip m1 Overclock the eject DEADLINE stayed 0.7s while the
				// eject ANIMATION (`_sinceStage / EffectiveTravel`, twice below) ran on the
				// shortened one — the weapon finished sliding out and then sat there, or the state
				// advanced mid-slide, depending on which way the augment scaled.
				_next = EffectiveTravel;
				_sinceStage = 0f;

				// ⛔ THE CAMO GOES ON AS IT STARTS COMING OUT, not on arrival. This is the frame
				// the weapon stops being the one you handed over and becomes the upgraded one, so
				// it must emerge ALREADY packed — painting it at Ready instead makes the gun
				// travel out plain and then flip mid-air, which reads as a bug rather than a
				// reveal. It was previously never painted at all: the machine's display gun is a
				// plain ModelRenderer that no code touched, so the one moment the player gets a
				// clear, close look at their upgrade showed them an un-upgraded gun.
				PaintGun();

				// ⚠️ The ready cue fires as it STARTS coming out, not when it
				// arrives. The sound is what makes you look up, and by the time you
				// do the weapon should already be moving toward you.
				NZSound.Play( NZSound.PapReady, WorldPosition );
				break;

			case PapState.Ejecting:
				State = PapState.Ready;
				_next = ReadyTime;

				// ⚠️ Reset, because Ready is now a TRAVEL stage too — the weapon
				// sinks back in across the whole grab window and needs its own clock.
				_sinceStage = 0f;
				break;

			case PapState.Ready:
				Expire();
				break;
		}
	}

	// ── the weapon in the machine ────────────────────────────────────────────

	void ShowGun( string prefab )
	{
		ClearGun();

		var model = ModelForPrefab( prefab );

		var go = Scene.CreateObject();
		go.Name = "pap gun";
		go.SetParent( GameObject );
		go.Flags |= GameObjectFlags.NotSaved;
		go.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
		go.LocalRotation = GunAngles.ToRotation();

		_gunRenderer = go.Components.Create<ModelRenderer>();
		_gunGO = go;

		// ⛔ MODEL FIRST, THEN POSITION — GunLocalPos reads the renderer's current
		// model to centre the mesh, so placing first would align this gun using
		// whatever came before it. Three separate paths in the mystery box had this
		// bug; it is the same shape here and worth not re-learning.
		// ⚠️ Born at the OUT position — the travel starts from where the player
		// handed it over, not from inside the machine. Spawning it already in the
		// throat makes the insert look like it began before you pressed the key.
		if ( model is not null ) _gunRenderer.Model = model;
		go.LocalPosition = GunLocalPos( OutPos );

		_gunRenderer.Enabled = model is not null;
		_sinceStage = 0f;
	}

	/// <summary>
	/// Put the camo for the level being bought onto the machine's display gun.
	///
	/// ⚠️ `_newLevel`, NOT the weapon's current level. Insert() sets it to
	/// `PapLevelFor( prefab ) + 1` — the tier the player is paying FOR — and that is what should
	/// come out of the machine. Using the current level would show MK1's camo on the gun that is
	/// about to become MK2, i.e. always exactly one tier stale.
	///
	/// ⚠️ HONOURS `PapCamo.CamoEnabled` like the held weapon does. `nz_camo 0` turning the gun in
	/// your hands plain while the machine still hands you a painted one would make the toggle look
	/// broken; the two paths must agree.
	///
	/// ⚠️ MaterialOverride REPLACES EVERY SURFACE, which is the same trade PapCamo.Repaint makes on
	/// the view and world models — a scope's lens gets painted too. Matching the held weapon's
	/// appearance is the point, so the display gun takes the identical compromise rather than a
	/// per-submaterial treatment that would look different from what you receive.
	/// </summary>
	void PaintGun()
	{
		if ( !_gunRenderer.IsValid() ) return;

		_gunRenderer.MaterialOverride = NZombies.PapCamo.CamoEnabled
			? NZombies.PapCamo.MaterialFor( _newLevel )
			: null;
	}

	void ClearGun()
	{
		_gunGO?.Destroy();
		_gunGO = null;
		_gunRenderer = null;
	}

	TimeSince _sinceStage;

	/// <summary>Presented: out in front of the machine, at working height.</summary>
	Vector3 OutPos => Vector3.Up * GunHeight + Vector3.Forward * GunOutDistance;

	/// <summary>Being worked on: back in the roller throat.</summary>
	Vector3 InPos => Vector3.Up * GunHeight - Vector3.Forward * GunInDepth;

	/// <summary>
	/// Move and turn the weapon for whatever stage the machine is in.
	///
	/// ⛔ RUNS OUTSIDE THE STAGE GATE, like every other per-frame motion in this
	/// project. The gate in OnUpdate fires only on the frame a stage ENDS — a
	/// travel driven from inside it is a weapon that teleports once.
	///
	/// ⚠️ The insert also swings the weapon through 90 degrees of yaw, which is the
	/// original's own motion (`pap_weapon_fly` lerps a -90 -> 0 yaw offset in, and
	/// back out on eject). It reads as the machine taking the gun rather than the
	/// gun sliding on rails.
	/// </summary>
	void TickGun()
	{
		if ( !_gunGO.IsValid() ) return;

		var pose = GunAngles.ToRotation();

		switch ( State )
		{
			case PapState.Inserting:
			{
				float t = Smooth( _sinceStage / EffectiveTravel );
				Place( Vector3.Lerp( OutPos, InPos, t ),
					pose * Rotation.FromYaw( MathX.Lerp( -90f, 0f, t ) ) );
				break;
			}

			case PapState.Working:
				// ⛔ IT DOES NOT SPIN. It used to turn at 220 deg/s while the machine
				// worked, which reads as a prize on a pedestal — the mystery box
				// already learned the same lesson about its offer. The machine is
				// doing the work; the weapon sits in the throat and waits.
				Place( InPos, pose );
				break;

			case PapState.Ejecting:
			{
				float t = Smooth( _sinceStage / EffectiveTravel );
				Place( Vector3.Lerp( InPos, OutPos, t ),
					pose * Rotation.FromYaw( MathX.Lerp( 90f, 0f, t ) ) );
				break;
			}

			case PapState.Ready:
			{
				// ⛔ THE GRAB WINDOW **IS** THE WEAPON SINKING BACK IN, exactly as the
				// mystery box works. It used to hang motionless at the presented
				// position for the full 20s and then vanish, so there was no way to
				// tell four seconds left from eighteen — the deadline existed only as
				// a hidden timer. Watching it go is the timer.
				//
				// ⚠️ LINEAR, unlike the insert and eject travels. Those are flourishes
				// and get smoothstep; this is a clock, and a clock that speeds up or
				// slows down misreports the one thing it exists to communicate.
				float t = MathX.Clamp( _sinceStage / MathF.Max( ReadyTime, 0.01f ), 0f, 1f );
				Place( Vector3.Lerp( OutPos, InPos, t ), pose );
				break;
			}
		}
	}

	/// <summary>Smoothstep. The original eases its rotation the same way.</summary>
	static float Smooth( float t )
	{
		t = MathX.Clamp( t, 0f, 1f );
		return t * t * (3f - 2f * t);
	}

	/// <summary>
	/// Set the pose, compensating the mesh offset LAST.
	///
	/// ⚠️ Rotation first, because the mesh-centre correction is rotated by whatever
	/// pose the object is wearing — computing the offset against the previous
	/// rotation puts the gun a little further out of place the faster it is turning.
	/// </summary>
	void Place( Vector3 at, Rotation rot )
	{
		_gunGO.LocalRotation = rot;
		_gunGO.LocalPosition = GunLocalPos( at, rot );
	}

	/// <summary>
	/// Local position that puts the gun's MESH at <see cref="GunHeight"/>.
	///
	/// ⚠️ Viewmodels are authored around a camera and a pair of hands, so their
	/// geometry sits anywhere relative to their origin — measured across our pool,
	/// 29 of 31 fall in a 5.4u band and the Uzi and ASP are ~50 units up. Placing
	/// by origin would hang those two through the machine's ceiling.
	/// </summary>
	Vector3 GunLocalPos( Vector3 at ) => GunLocalPos( at, GunAngles.ToRotation() );

	Vector3 GunLocalPos( Vector3 at, Rotation rot )
	{
		var model = _gunRenderer.IsValid() ? _gunRenderer.Model : null;
		if ( model is null ) return at;

		return at - rot * model.Bounds.Center;
	}

	/// <summary>
	/// A weapon prefab's viewmodel.
	///
	/// ⚠️ SAME TWO-CANDIDATE LOOKUP AS THE BOX, and for the same reason: 31 of the
	/// 32 folders drop the `nz_` prefix and the MPL keeps it
	/// (`weapons/nz_mpl/v_nz_mpl.vmdl`). Stripping unconditionally makes exactly
	/// one weapon invisible, which nobody notices until it is the one they packed.
	/// </summary>
	public static Model ModelForPrefab( string prefab )
	{
		if ( string.IsNullOrWhiteSpace( prefab ) ) return null;

		var raw = prefab;
		var slash = raw.LastIndexOf( '/' );
		if ( slash >= 0 ) raw = raw[(slash + 1)..];
		raw = raw.Replace( ".prefab", "" );

		var bare = raw.StartsWith( "nz_" ) ? raw[3..] : raw;

		foreach ( var stem in new[] { bare, raw } )
		{
			// ⚠️ ITS DISPLAY MODEL WHEN IT HAS ONE (`WeaponDisplay`): an MW viewmodel's bind pose floats in pieces
			var m = WeaponDisplay.Load( $"weapons/{stem}/v_{stem}.vmdl" );
			if ( m is not null && !m.IsError ) return m;
		}

		return null;
	}

	/// <summary>A weapon's display name, from the library.</summary>
	public static string WeaponName( string prefab )
	{
		var entry = WeaponLibrary.All.FirstOrDefault( e => e.Prefab == prefab );

		// ⚠️ THE PRISMA IS NOT IN THE LIBRARY (WeaponLibrary leaves the egg's weapon out), so it
		// names itself here rather than printing its prefab path.
		return entry?.Name ?? (BuildParts.IsWonderWeapon( prefab ) ? BuildParts.WeaponName : prefab);
	}

	/// <summary>
	/// Take the weapon off the player.
	///
	/// ⚠️ THE SAME disable-unparent-destroy SEQUENCE WallBuy AND THE BOX USE. A
	/// Destroy is deferred to end of frame, so the equip guard would otherwise
	/// still find the old weapon and silently skip spawning the replacement.
	/// </summary>
	static void StripWeapon( NZPlayer player )
	{
		// ⛔ ONLY THE WEAPON BEING PACKED, NOT EVERY WEAPON. This destroyed all of
		// them, which was right with one slot and now throws away the gun you did
		// not put in the machine — insert your pistol and your Galil vanished.
		//
		// ⚠️ Through the inventory so `Items` is updated too. Destroying the object
		// behind the inventory's back leaves a dead entry in a slot, which reads as
		// a phantom weapon you cannot switch to.
		var inv = player.Inventory;
		if ( inv.Active.IsValid() ) inv.Remove( inv.Active );
	}

	/// <summary>The nearest machine a point could use, or null.</summary>
	public static PackAPunch Near( Vector3 point )
	{
		PackAPunch best = null;
		float bestDist = float.MaxValue;

		foreach ( var m in All )
		{
			if ( !m.IsValid() ) continue;

			float d = m.WorldPosition.Distance( point );
			if ( d > m.Reach || d >= bestDist ) continue;

			bestDist = d;
			best = m;
		}

		return best;
	}
}