UI/WunderfizzMenu.cs

Static UI state and controller for the Der Wunderfizz menu. Exposes the menu open state, selected perk/augment, pricing and afford checks, commands to open/close/buy, and ensures a Scene GameObject host with a ScreenPanel and WunderfizzPanel exists for the Razor UI to draw.

File Access
using System;
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// Open/close state for the Der Wunderfizz screen.
///
/// ⛔ STATE HERE, DRAWING IN THE RAZOR — the same split ToolPanelState uses. A
/// panel that owns its own open flag cannot be opened by a console command, and
/// every button in this project has to have one; a static that the razor merely
/// reads can be driven from anywhere.
/// </summary>
public static class WunderfizzMenu
{
	/// <summary>The machine being used, or null when the menu is shut.</summary>
	public static Wunderfizz Current { get; private set; }

	public static bool IsOpen => Current.IsValid();

	/// <summary>Which perk is highlighted. The roll lands on this.</summary>
	public static int Index { get; set; }

	/// <summary>The highlighted perk, or null.
	///
	/// ⚠️ CLAMPED, not trusted. Index is public and the perk list is a property
	/// that could shrink on a hotload — an out-of-range read here would throw
	/// inside a razor build, which surfaces as a panel that silently stops
	/// drawing rather than as an error anyone can find.</summary>
	public static PerkRegistry.Perk Selected
	{
		get
		{
			var all = PerkRegistry.All;
			if ( all.Length == 0 ) return null;

			var i = Index.Clamp( 0, all.Length - 1 );
			return all[i];
		}
	}

	/// <summary>The player using the machine.</summary>
	public static NZPlayer User
		=> NZPlayer.Local;

	/// <summary>What the selected perk costs right now.
	///
	/// ⚠️ Asked of the MACHINE per frame rather than cached, because the price
	/// moves the moment a perk is bought — a cached one would show the old cost
	/// until the panel happened to rebuild.</summary>
	public static int SelectedPrice
		=> Current.IsValid() ? Current.PriceFor( User ) : 0;

	/// <summary>Does the using player own this perk id?</summary>
	public static bool Owns( string id )
	{
		var p = User;
		return p.IsValid() && p.HasPerk( id );
	}

	/// <summary>How many perks the player owns. In the panel's BuildHash so the
	/// grid repaints the instant one is bought.</summary>
	public static int OwnedCount => User.IsValid() ? User.Perks.Count : 0;

	/// <summary>True when the selected perk is already owned.</summary>
	public static bool AlreadyOwned
	{
		get
		{
			var p = User;
			var perk = Selected;
			return p.IsValid() && perk is not null && p.HasPerk( perk.Id );
		}
	}

	/// <summary>Can the player afford the selection?</summary>
	public static bool CanAfford
		=> User.IsValid() && User.Points >= SelectedPrice;

	/// <summary>
	/// The line under the panel's two prices: why clicking the selected perk would not buy it, or "" when it would.
	///
	/// ⛔ THE WORDS THE COST LINE USED TO SAY IN PLACE OF ITS NUMBER (moved 2026-10-05). The perk and slot prices are always
	/// shown as numbers now — the user asked for both — and a click that buys nothing must still say why on screen, or it
	/// reads as the machine ignoring the player. In the order `Wunderfizz.Buy` refuses in: the machine itself, owned, no free
	/// slot, then the shortfall.
	/// </summary>
	public static string PerkStatus
	{
		get
		{
			var p = User;
			var machine = Current;
			if ( !p.IsValid() || !machine.IsValid() || Selected is null ) return "";

			var blocked = machine.Unavailable( p );
			if ( !string.IsNullOrEmpty( blocked ) ) return blocked;
			if ( AlreadyOwned ) return "Owned";
			if ( p.PerksFull ) return $"No free slot ({p.Perks.Count}/{p.PerkSlots}) · buy one";
			if ( !CanAfford ) return $"{SelectedPrice - p.Points:N0} more needed";
			return "";
		}
	}

	/// <summary>The status line's colour: "owned" (green) for an owned perk, "poor" (red) for any refusal, none when it buys.</summary>
	public static string PerkStatusClass
		=> AlreadyOwned ? "owned" : string.IsNullOrEmpty( PerkStatus ) ? "" : "poor";

	/// <summary>
	/// Select a perk and then either buy it or open its augments.
	///
	/// ⚠️ SELECTS FIRST, THEN ACTS — so if the purchase is refused the panel is
	/// left showing the thing that was refused, with its name, price and reason.
	/// Buying without selecting would refuse something the player is not looking
	/// at, which reads as the machine doing nothing.
	///
	/// ⛔ A PERK YOU ALREADY OWN OPENS ITS AUGMENT SCREEN INSTEAD OF RE-BUYING, which
	/// is what the original does (`sh_fizzmenu.lua`'s DoClick, guarded by HasPerk). The
	/// old behaviour was to refuse the click and show "OWNED" on the cost line — a dead
	/// click on the one thing a returning player is most likely to press.
	/// </summary>
	public static void ClickPerk( int index )
	{
		Index = index;

		var perk = Selected;
		if ( perk is not null && Owns( perk.Id ) )
		{
			OpenAugments( perk.Id );
			return;
		}

		BuySelected();
	}

	// ── AUGMENT SCREEN ───────────────────────────────────────────────

	/// <summary>
	/// The perk whose augment screen is showing, or null for the perk grid.
	///
	/// ⛔ A MODE ON THE SAME MENU, NOT A SECOND PANEL. The original replaces the fizz
	/// menu with a new VGUI window; here one razor switches on this string. A second
	/// PanelComponent would need its own host object, its own ScreenPanel and its own
	/// EnsureHost — and two screen panels both claiming the cursor is a fight nobody
	/// wins. The note on EnsureHost records how much that machinery already cost once.
	/// </summary>
	public static string AugmentPerk { get; private set; }

	public static bool InAugments => !string.IsNullOrEmpty( AugmentPerk );

	/// <summary>The perk being augmented, or null.</summary>
	public static PerkRegistry.Perk AugmentPerkData => PerkRegistry.Find( AugmentPerk );

	/// <summary>Its signature colour, for the header tint.</summary>
	public static string AugmentAccent => PerkRegistry.AccentHex( AugmentPerk );

	/// <summary>
	/// Which augment row is highlighted, or null for none.
	///
	/// ⚠️ SEPARATE FROM <see cref="Index"/> on purpose. Index is the perk grid's
	/// cursor and the augment screen needs its own; sharing one would mean opening an
	/// augment screen scrambled which perk you came back to.
	/// </summary>
	public static string SelectedAug { get; set; }

	/// <summary>The highlighted augment's data, or null.</summary>
	public static PerkAugments.Augment SelectedAugData
		=> PerkAugments.Find( AugmentPerk, SelectedAug );

	/// <summary>Open the augment screen for a perk.</summary>
	public static void OpenAugments( string perkId )
	{
		if ( string.IsNullOrEmpty( perkId ) ) return;

		AugmentPerk = perkId;

		// ⚠️ NO ROW PRESELECTED, matching the original's "Select an augment / to see
		// what it does" empty state. Preselecting M1 would put a live BUY button under
		// the cursor on open, one click from 1,500 salvage on an unread choice.
		SelectedAug = null;

		var pool = PerkAugments.PoolFor( perkId );
		Log.Info( $"[nz-aug] {perkId} — {pool?.Major.Length ?? 0} major,"
			+ $" {pool?.Minor.Length ?? 0} minor"
			+ $" · equipped [{string.Join( "+", PerkAugments.EquippedOn( User, perkId ) )}]" );
	}

	/// <summary>Back to the perk grid.</summary>
	public static void CloseAugments()
	{
		AugmentPerk = null;
		SelectedAug = null;
	}

	/// <summary>
	/// A row was LEFT-clicked: highlight it. Does NOT buy — the BUY button does — and since
	/// 2026-10-03 does not remove either; see <see cref="RemoveAug"/>.
	/// </summary>
	public static void SelectAug( string augId ) => SelectedAug = augId;

	/// <summary>
	/// A row, or the detail pane's button, was RIGHT-clicked. An augment you have comes off, for
	/// half its salvage back; any other is only highlighted.
	///
	/// ⛔ RIGHT CLICK, BY REQUEST (user, 2026-10-03): "removing perk augments, right now is done
	/// by left clicking, change to right clicking". It was a left click from 2026-09-27 ("make it
	/// so left clicking an augment i have in the wunderfizz removes it and refunds half the
	/// salvage it cost"), which made the click that reads an augment's text the same click that
	/// throws it away. It still selects, so the pane then shows the augment just taken off, with
	/// its price to buy it back.
	///
	/// ⚠️ A LEFT CLICK ON THE BUY BUTTON DOES NOT REMOVE. An owned augment's button says EQUIPPED
	/// and what a right click would refund, and a left click does nothing: a button that bought on
	/// one click and removed on the next would make a double click cost half the price. A RIGHT
	/// click on it lands here, as on the row — its label tells the player to right click, and that
	/// button is where they will try it.
	/// </summary>
	public static void RemoveAug( string augId )
	{
		// ⚠️ The button passes the selection, which is null until a row is picked.
		if ( string.IsNullOrEmpty( augId ) ) return;

		SelectedAug = augId;
		if ( !AugOwned( augId ) ) return;

		var msg = PerkAugments.TryRemove( User, AugmentPerk, augId, out var refund );

		Log.Info( msg is null
			? $"[nz-aug] removed {AugmentPerk}/{augId} — {refund:N0} salvage back,"
				+ $" {PlayerSalvage:N0} now"
			: $"[nz-aug] not removed: {msg}" );
	}

	/// <summary>
	/// The augment screen's footnote.
	///
	/// ⛔ IT SAID "Augment effects are not wired yet" FOR EVERY PERK, true when written and false
	/// once most were wired (AugmentEffects.WiredPerks lists them). Now it says so only for a perk
	/// whose augments still do nothing, and otherwise how to take one off, which a right click on
	/// its row does (a left click until 2026-10-03) and nothing on screen said.
	/// </summary>
	public static string AugmentNote
		=> AugmentEffects.IsWired( AugmentPerk )
			? "Right-click an augment you have to take it off: half its salvage back."
			: "This perk's augments do nothing yet. Right-click one you have to take it off: half its salvage back.";

	/// <summary>Salvage the using player holds, for the header readout.</summary>
	public static int PlayerSalvage => User.IsValid() ? User.Salvage : 0;

	/// <summary>What the highlighted augment costs.</summary>
	public static int SelectedAugPrice => PerkAugments.PriceOf( SelectedAugData );

	/// <summary>Is the highlighted augment already equipped.</summary>
	public static bool SelectedAugOwned
		=> SelectedAug is not null && PerkAugments.Has( User, AugmentPerk, SelectedAug );

	/// <summary>What taking the highlighted augment off would give back.</summary>
	public static int SelectedAugRefund
		=> SelectedAugOwned ? PerkAugments.RefundFor( User, AugmentPerk, SelectedAug ) : 0;

	/// <summary>Is the highlighted augment holding a slot the player is using, so it cannot come off.</summary>
	public static bool SelectedAugBlocked
		=> SelectedAugOwned && PerkAugments.RemoveBlocker( User, AugmentPerk, SelectedAug ) is not null;

	/// <summary>Is the highlighted augment's tier already full.</summary>
	public static bool SelectedAugSlotFull
	{
		get
		{
			var aug = SelectedAugData;
			return aug is not null && PerkAugments.SlotFull( User, AugmentPerk, aug.Tier );
		}
	}

	/// <summary>Can the player afford the highlighted augment.</summary>
	public static bool CanAffordAug
		=> SelectedAugData is not null && Salvage.CanAfford( User, SelectedAugPrice );

	/// <summary>Is an augment equipped. For the row styling.</summary>
	public static bool AugOwned( string augId )
		=> PerkAugments.Has( User, AugmentPerk, augId );

	/// <summary>Equipped count in a tier, for the "PICK n" pips.</summary>
	public static int AugCount( PerkAugments.AugmentTier tier )
		=> PerkAugments.CountOf( User, AugmentPerk, tier );

	/// <summary>
	/// What the buy button says right now.
	///
	/// ⚠️ ONE PLACE DECIDES THE LABEL, ANOTHER DECIDES THE OUTCOME, and they must not
	/// drift. The razor reads this and <see cref="BuyAugClass"/>; the click calls
	/// <see cref="BuyAug"/>, which re-derives its refusal from PerkAugments. A button
	/// that says EQUIPPED and still charges is exactly the duplicated-lookup divergence
	/// INSTRUCTIONS.md §3 is about — so both sides ask PerkAugments, never each other.
	/// </summary>
	public static string BuyAugLabel
	{
		get
		{
			var aug = SelectedAugData;
			if ( aug is null ) return "SELECT AN AUGMENT";
			// ⚠️ SAYS WHAT A RIGHT CLICK ON ITS ROW WOULD DO: the refund, or that its slot is in use.
			if ( SelectedAugOwned )
				return SelectedAugBlocked
					? "EQUIPPED  ·  ITS SLOT IS IN USE"
					: $"EQUIPPED  ·  RIGHT-CLICK REFUNDS {SelectedAugRefund:N0}";
			if ( SelectedAugSlotFull ) return $"{aug.Tier.ToString().ToUpper()} SLOT FULL";

			return $"BUY  -  {SelectedAugPrice:N0}";
		}
	}

	/// <summary>The buy button's state class: "none", "owned", "full", "poor" or "".</summary>
	public static string BuyAugClass
	{
		get
		{
			if ( SelectedAugData is null ) return "none";
			if ( SelectedAugOwned ) return SelectedAugBlocked ? "full" : "owned";
			if ( SelectedAugSlotFull ) return "full";
			return CanAffordAug ? "" : "poor";
		}
	}

	/// <summary>Buy the highlighted augment. The refusal is logged either way.</summary>
	public static void BuyAug()
	{
		if ( SelectedAug is null ) return;

		// ⚠️ The price is read BEFORE the purchase, because a successful buy spends it
		// and the log line would otherwise report what the player has left as what they
		// paid.
		var price = SelectedAugPrice;
		var msg = PerkAugments.TryBuy( User, AugmentPerk, SelectedAug );

		Log.Info( msg is null
			? $"[nz-aug] bought {AugmentPerk}/{SelectedAug} — {price:N0} salvage,"
				+ $" {PlayerSalvage:N0} left"
			: $"[nz-aug] refused: {msg}" );
	}

	/// <summary>Buy the selection. The message is logged either way.</summary>
	public static void BuySelected()
	{
		var machine = Current;
		var player = User;
		var perk = Selected;

		if ( !machine.IsValid() || !player.IsValid() || perk is null ) return;

		var msg = machine.Buy( player, perk );
		if ( !string.IsNullOrEmpty( msg ) ) Log.Info( $"[nz-fizz] {msg}" );

		// ⚠️ The menu STAYS OPEN. Buying one perk and being ejected would make
		// buying three a chore of walking back into the machine, and the price
		// readout updating in place is the clearest way to show it went up.
	}

	/// <summary>Slots used, for the menu footer.</summary>
	public static string SlotText
	{
		get
		{
			var p = User;
			return p.IsValid() ? $"{p.Perks.Count}/{p.PerkSlots}" : "-";
		}
	}

	/// <summary>Price of one more perk slot at this machine.</summary>
	public static int SlotPrice
		// ⛔ THROUGH `SlotPriceFor`, NOT OFF `Spot.PerkSlotPrice`. The spot's field is only
		// the price of the FIRST slot now — reading it directly would print the base
		// forever while `BuySlot` charged the escalated figure, and the button would look
		// like it was overcharging.
		=> Current.IsValid() ? Current.SlotPriceFor( User ) : 0;

	/// <summary>Can this player afford another slot.</summary>
	public static bool CanAffordSlot
	{
		get
		{
			var p = User;
			return p.IsValid() && p.Points >= SlotPrice;
		}
	}

	/// <summary>Buy a slot from the menu footer.</summary>
	public static void ClickSlot()
	{
		var machine = Current;
		var player = User;
		if ( !machine.IsValid() || !player.IsValid() ) return;

		var msg = machine.BuySlot( player );
		if ( !string.IsNullOrEmpty( msg ) ) Log.Info( $"[nz-fizz] {msg}" );
	}

	/// <summary>Buy a perk slot from the console: `nz_fizz_slot`.</summary>
	[ConCmd( "nz_fizz_slot" )]
	public static void SlotCmd()
	{
		var p = User;
		if ( !p.IsValid() ) { Log.Warning( "[nz-fizz] no player" ); return; }

		// ⚠️ Falls back to raising the cap directly when the player is not stood
		// at a machine, so the cap can be tested without one placed on the map.
		if ( !Current.IsValid() )
		{
			p.BonusPerkSlots++;
			Log.Info( $"[nz-fizz] no machine — slot granted free, now {p.Perks.Count}/{p.PerkSlots}" );
			return;
		}

		ClickSlot();
	}

	/// <summary>Buy from the console: `nz_fizz_buy`.</summary>
	[ConCmd( "nz_fizz_buy" )]
	public static void BuyCmd()
	{
		if ( !IsOpen ) { Log.Warning( "[nz-fizz] menu is not open" ); return; }
		BuySelected();
	}

	/// <summary>Points the using player has, for the footer readout.</summary>
	public static int PlayerPoints
	{
		get
		{
			var p = NZPlayer.Local;
			return p.IsValid() ? p.Points : 0;
		}
	}

	/// <summary>The runtime-created host for the razor panel.</summary>
	static GameObject _host;

	/// <summary>
	/// Make sure something is actually drawing the menu.
	///
	/// ⛔ THE PANEL HAS NO HOME IN THE SCENE. Every other HUD here is a scene
	/// object with a ScreenPanel on it (Survival HUD, Dev Menu, Lobby), and the
	/// scene file must not be rewritten from a script — so this one has to build
	/// its own. Without it the razor exists, compiles, and never renders: the menu
	/// "opens" in state and nothing appears.
	///
	/// ⚠️ Rebuilt whenever the object is gone, not once. A GameObject created from
	/// code does not survive a hotload, and a menu that silently stops appearing
	/// after a code edit is the PowerupMusic failure all over again.
	/// </summary>
	static void EnsureHost()
	{
		if ( _host.IsValid() ) return;

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		_host = scene.CreateObject();
		_host.Name = "Wunderfizz UI";
		_host.Flags |= GameObjectFlags.NotSaved;

		_host.Components.Create<ScreenPanel>();
		_host.Components.Create<WunderfizzPanel>();

		Log.Info( "[nz-fizz] created the menu's screen panel" );
	}

	public static void Open( NZPlayer player, Wunderfizz fizz )
	{
		if ( !fizz.IsValid() ) return;

		EnsureHost();

		Current = fizz;
		Index = 0;
		CloseAugments();

		// ⚠️ The cursor is what makes a menu usable, and the LOBBY already sets it
		// — Noclip keys off Mouse.Visibility for exactly this reason, so a menu
		// that does not set it leaves V toggling noclip under the player while
		// they click.
		Mouse.Visibility = MouseVisibility.Visible;

		Log.Info( $"[nz-fizz] menu open — {fizz.Price} points, "
			+ $"{PerkRegistry.Names.Length} perks" );
	}

	/// <summary>
	/// How far you can get from the machine before the menu shuts itself.
	///
	/// ⛔ WIDER THAN `Wunderfizz.UseRange` (96), ON PURPOSE. Closing at exactly the range that opens
	/// it means standing on the boundary flickers the menu — and with it the CURSOR — open and shut
	/// every frame you shift your weight. The gap between 96 and this is hysteresis: you must
	/// actually walk away, not merely stop being in range.
	///
	/// ⚠️ 1.5× rather than a bare number so the two stay related if UseRange is ever tuned.
	/// </summary>
	public static float CloseRange { get; set; } = Wunderfizz.UseRange * 1.5f;

	/// <summary>
	/// Shut the menu when the player walks away from the machine they opened.
	///
	/// ⛔ MEASURED TO `Current`, NOT TO THE NEAREST MACHINE. On a map with two Wunderfizzes,
	/// nearest-machine would keep the menu alive as you walked from one to the other — browsing
	/// machine A's screen while stood at machine B, buying from the wrong one.
	///
	/// ⚠️ A DESTROYED MACHINE ALSO CLOSES IT. `Current` is a component reference and the object can
	/// go — a config reload or `nz_fizz_clear` — leaving a menu open with `SelectedPrice` reading 0
	/// and BUY doing nothing, which looks like the menu breaking rather than the machine leaving.
	///
	/// ⚠️ CALLED FROM NZPlayer's TICK, beside the ESC handler, for the reason written there: the way
	/// out of a modal must not depend on the modal working. A walk-away check living in the razor
	/// would go down with the panel.
	/// </summary>
	public static void TickRange( NZPlayer player )
	{
		if ( !IsOpen ) return;

		if ( !Current.IsValid() )
		{
			Log.Info( "[nz-fizz] machine gone — menu closed" );
			Close();
			return;
		}

		// ⚠️ No player means no distance to measure, so LEAVE IT OPEN. Closing on a null player
		// would shut the menu during the frame a respawn swaps the object out.
		if ( !player.IsValid() ) return;

		var dist = player.WorldPosition.Distance( Current.WorldPosition );
		if ( dist <= CloseRange ) return;

		Log.Info( $"[nz-fizz] walked away — {dist:0} > {CloseRange:0} units, menu closed" );
		Close();
	}

	/// <summary>
	/// `nz_fizz_range [units]` — read or set the walk-away distance.
	///
	/// ⚠️ REFUSES TO GO BELOW UseRange. A close range under the open range is a menu that shuts the
	/// instant it opens, which reads as the machine being broken rather than as a bad setting.
	/// </summary>
	[ConCmd( "nz_fizz_range" )]
	public static void RangeCmd( float units = -1f )
	{
		if ( units >= 0f )
			CloseRange = MathF.Max( units, Wunderfizz.UseRange );

		Log.Info( $"[nz-fizz] walk-away range {CloseRange:0} units"
			+ $" (opens within {Wunderfizz.UseRange:0})"
			+ (units >= 0f && units < Wunderfizz.UseRange
				? $" — {units:0} was raised to the open range" : "") );
	}

	public static void Close()
	{
		if ( !IsOpen ) return;

		// ⚠️ The augment screen is a MODE, so shutting the menu has to leave it —
		// otherwise the next player to walk into the machine opens straight onto the
		// last one's augment screen, for a perk they may not even own.
		CloseAugments();

		Current = null;
		Mouse.Visibility = MouseVisibility.Hidden;

		Log.Info( "[nz-fizz] menu closed" );
	}

	/// <summary>
	/// What the menu's UI is actually made of: `nz_fizz_ui`.
	///
	/// ⚠️ Reports each link SEPARATELY — host object, ScreenPanel, the panel
	/// component itself, the open flag and the cursor. "No UI appears" has five
	/// causes that look identical on screen, and tonight has already shown that
	/// guessing between them costs more than printing them.
	/// </summary>
	[ConCmd( "nz_fizz_ui" )]
	public static void UiState()
	{
		var scene = Game.ActiveScene;

		var host = _host.IsValid() ? _host : scene?.Directory
			.FindByName( "Wunderfizz UI" ).FirstOrDefault();

		Log.Info( $"[nz-fizz-ui] host {(host.IsValid() ? "alive" : "MISSING")}"
			+ $" · open {IsOpen}"
			+ $" · cursor {Mouse.Visibility}" );

		if ( !host.IsValid() )
		{
			Log.Warning( "[nz-fizz-ui] no host object — EnsureHost never ran or its "
				+ "object was destroyed. nz_fizz_menu builds it." );
			return;
		}

		var screen = host.Components.Get<ScreenPanel>( FindMode.EverythingInSelf );
		var panel = host.Components.Get<WunderfizzPanel>( FindMode.EverythingInSelf );

		Log.Info( $"[nz-fizz-ui] ScreenPanel {(screen.IsValid() ? (screen.Enabled ? "on" : "OFF") : "MISSING")}"
			+ $" · WunderfizzPanel {(panel.IsValid() ? (panel.Enabled ? "on" : "OFF") : "MISSING")}"
			+ $" · host enabled {host.Enabled}" );

		if ( !panel.IsValid() )
			Log.Warning( "[nz-fizz-ui] the razor component is not on the host — it did "
				+ "not construct. That is a razor/compile problem, not a state one." );
	}

	/// <summary>Open the menu at the nearest machine: `nz_fizz_menu`.
	///
	/// ⚠️ Exists because a UI that can only be reached by standing in the right
	/// spot and pressing E cannot be tested remotely — the rule every other
	/// feature here follows.</summary>
	[ConCmd( "nz_fizz_menu" )]
	public static void Cmd()
	{
		if ( IsOpen ) { Close(); return; }

		var p = NZPlayer.Local;
		if ( !p.IsValid() ) { Log.Warning( "[nz-fizz] no player" ); return; }

		// Nearest machine ANYWHERE, not just in use range — the command is for
		// looking at the menu, not for playing.
		var fizz = Wunderfizz.All.Where( w => w.IsValid() )
			.OrderBy( w => w.WorldPosition.Distance( p.WorldPosition ) )
			.FirstOrDefault();

		if ( !fizz.IsValid() )
		{
			Log.Warning( "[nz-fizz] no machine placed — nz_fizz to drop one" );
			return;
		}

		Open( p, fizz );
	}
}