UI/UsePrompt.cs

UI helper that composes the contextual "Press E" prompt shown under the crosshair. It inspects nearby game objects (revive targets, machines, doors, boxes, etc.) and returns the appropriate prompt text or refusal reason, plus a helper to detect if a prompt is a refusal.

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

namespace NZombies;

/// <summary>
/// The "Press E - …" line under the crosshair.
///
/// Ported from the original's cl_target.lua. The wording matters more than it
/// looks: the same door produces four different sentences depending on price,
/// power and whether you are authoring or playing, and each one is the only
/// feedback the player gets about why a thing will not open.
///
/// ⚠️ THE TEXT LIVES HERE, NOT IN THE RAZOR. It is the part with rules in it —
/// so it can be unit-driven from the console (nz_prompt) instead of only being
/// checkable by standing in front of something.
/// </summary>
public static class UsePrompt
{
	/// <summary>
	/// How close you must stand for the prompt to appear.
	///
	/// ⚠️ NOT A DECISION MADE HERE. The managers' own reach is what gates both
	/// the prompt and the action, so this only reports it. If the prompt owned a
	/// separate number it would drift from the buy range and start offering
	/// things the use key refuses.
	/// </summary>
	public static float Range => DebrisManager.Instance?.Reach ?? 80f;

	/// <summary>The key hint. Matches the original's `usekey` — "E - ".</summary>
	const string Use = "Press E - ";

	/// <summary>
	/// A teammate on the floor within reach.
	///
	/// ⛔ NOBODY HAS EVER BEEN TOLD THEY COULD REVIVE ANYONE. `DownedHud` shows YOUR OWN bleedout
	/// and nothing else, so the co-op revive — which has existed since the Quick Revive augments —
	/// was an undocumented hold on an unmarked key over a player you had to guess was reachable.
	/// Solo that was invisible because there was no one to revive; it is the first thing a second
	/// player runs into.
	///
	/// ⚠️ IT ASKS `ReviveAugments.TargetFor`, WHICH IS WHAT THE HOLD ITSELF USES. A separate
	/// reach test here would drift from the one that decides whether the hold works, and this file
	/// records that exact failure four times over for the buy machines.
	///
	/// ⚠️ "HOLD", NOT "PRESS" — every other line on this panel is a tap and this one is not, so
	/// it does not borrow their wording. M3 Guardian Aura needs no key at all and says so.
	/// </summary>
	static string ForRevive( NZPlayer player )
	{
		if ( !player.IsValid() || player.IsDown ) return "";

		var target = ReviveAugments.TargetFor( player );
		if ( !target.IsValid() ) return "";

		var total = ReviveAugments.SecondsFor( player );
		var left = MathF.Max( 0f, total - player.ReviveProgress );

		// ⛔ THE ACCOUNT'S NAME, NOT THE CHARACTER'S. This read the CHARACTER and defended it on
		// the grounds that *"Revive Dempsey" says which of two crawling teammates you are about to
		// spend four seconds on* — which is exactly backwards the moment both of them picked
		// Dempsey, and nothing stops them. A costume is not an identity. Requested: *"i dont want
		// the player names to become the character names, they keep their profile names"*.
		//
		// ⚠️ STILL ONLY IF THERE IS ONE. The roster HUD deliberately shows no names — *"the icon
		// is enough"* — but that is a list you glance at; this is an instruction about one
		// specific body at your feet, so it names whoever it can and says "your teammate" when
		// this machine does not know whose body it is.
		var who = NZPlayers.NameOf( target );
		var name = string.IsNullOrWhiteSpace( who ) ? "your teammate" : who;

		// ⚠️ THE COUNTDOWN ONLY APPEARS ONCE THE HOLD IS UNDER WAY, so the line does not flicker
		// a full timer at somebody who is simply walking past a downed player.
		if ( player.RevivingWho == target && player.ReviveProgress > 0f )
			return $"Reviving {name}…  {left:0.0}s";

		return ReviveAugments.RevivesPassively( player )
			? $"Reviving {name}…  {total:0.0}s"
			: $"Hold E - Revive {name}";
	}

	/// <summary>
	/// "Press E - Pick up the cursed flame", while this player looks at basalt's cursed flame floating over tile 1 — Torch
	/// Carry. Asked for as *"when the player is looking at it a hud message appears to press E to pick up the cursed
	/// flame"*. ⚠️ THE KEY ASKS THE SAME QUESTION, `HexPlatforms.CursedFlameAimed`, so the screen and E cannot disagree.
	/// </summary>
	static string ForCursedFlame( NZPlayer player )
		=> HexPlatforms.CursedFlameAimed( player ) ? Use + "Pick up the cursed flame" : "";

	/// <summary>
	/// "Press E - Place the cursed flame", while this player carries basalt's cursed flame and looks at the altar in the
	/// shield's column, the shield lock open — the Altar, step 5. ⚠️ THE KEY ASKS THE SAME QUESTION,
	/// `HexPlatforms.AltarAimed`, and the host decides whether it is set there.
	/// </summary>
	static string ForAltar( NZPlayer player )
		=> HexPlatforms.AltarAimed( player ) ? Use + "Place the cursed flame" : "";

	/// <summary>
	/// "Press E - Take down the shield", while this player carries basalt's light blue flame and looks at the twin shield at
	/// teleporter #0's far pad. ⚠️ THE KEY ASKS THE SAME QUESTION, `HexPlatforms.TwinAimed`, and the host decides.
	/// </summary>
	static string ForTwin( NZPlayer player )
		=> HexPlatforms.TwinAimed( player ) ? Use + "Take down the shield" : "";

	/// <summary>
	/// "Press E - Enter the code", while this player looks at basalt's shield lock, shut and within reach — or, once a wrong
	/// code has jammed it, "Locked until the next round", and E does nothing. ⚠️ THE KEY ASKS THE SAME QUESTION,
	/// `HexPlatforms.LockAimed`, and opens the keypad unless the lock is jammed.
	/// </summary>
	static string ForShieldLock( NZPlayer player )
		=> !HexPlatforms.LockAimed( player ) ? ""
			: HexPlatforms.LockJammedShown ? "Locked until the next round" : Use + "Enter the code";

	/// <summary>
	/// "Press E - Activate the teleporter", while this player looks at basalt's blue altar, standing in the teleporter's
	/// middle once its destination is set — or, while it charges, "The teleporter is charging". E sends every player to the
	/// boss arena. ⚠️ THE KEY ASKS THE SAME QUESTION, `HexPlatforms.BlueAltarAimed`, and the host decides.
	/// </summary>
	static string ForBlueAltar( NZPlayer player )
		=> !HexPlatforms.BlueAltarAimed( player ) ? ""
			: HexPlatforms.ArenaSendingShown ? "The teleporter is charging" : Use + "Activate the teleporter";

	/// <summary>
	/// "Press E - Take the orb", while this player looks at the green orb floating over basalt's boss spawn once the beast is
	/// dead — taking it sends everyone home and completes the Easter egg. ⚠️ THE KEY ASKS THE SAME QUESTION,
	/// `HexPlatforms.CoreAimed`, and the host decides.
	/// </summary>
	static string ForCore( NZPlayer player )
		=> HexPlatforms.CoreAimed( player ) ? Use + "Take the orb" : "";

	/// <summary>What should be on screen right now, or "" for nothing.</summary>
	public static string Text( NZPlayer player )
	{
		if ( !player.IsValid() ) return "";

		// ⛔ BEFORE THE BARRICADE, WHICH WAS PREVIOUSLY FIRST, AND `TickUse` REFUSES EVERYTHING
		// ELSE WHILE THIS IS SHOWING. A teammate on the floor is the most urgent thing that can be
		// within reach of you, and the alternative is worse than a wrong prompt: E is also the buy
		// key, so a player crouched over a downed friend beside a wallbuy would spend three
		// thousand points trying to pick them up.
		var reviveText = ForRevive( player );
		if ( !string.IsNullOrEmpty( reviveText ) ) return reviveText;

		// ⚠️ BASALT'S CURSED FLAME NEXT, AND TickUse MATCHES. It asks to be LOOKED AT — a small thing floating in the open over
		// tile 1 — so a player looking straight at it means it, whatever machine may stand behind it.
		var flameText = ForCursedFlame( player );
		if ( !string.IsNullOrEmpty( flameText ) ) return flameText;

		// ⚠️ THEN THE ALTAR, AND TickUse MATCHES: its carrier, looking at it, the shield lock open. It stands where the shield
		// stood, so nothing else is at it; before the lock, which is shut whenever this could not show.
		var altarText = ForAltar( player );
		if ( !string.IsNullOrEmpty( altarText ) ) return altarText;

		// ⚠️ AND THE TWIN SHIELD, AND TickUse MATCHES: the light blue flame's carrier, looking at it
		var twinText = ForTwin( player );
		if ( !string.IsNullOrEmpty( twinText ) ) return twinText;

		// ⚠️ BASALT'S SHIELD LOCK NEXT, AND TickUse MATCHES — also looked at, and on the shield where a wall buy stood, so it
		// must win over one still there.
		var lockText = ForShieldLock( player );
		if ( !string.IsNullOrEmpty( lockText ) ) return lockText;

		// ⚠️ AND BASALT'S TELEPORTER BUTTONS, AND TickUse MATCHES — looked at, they show NOTHING, by the user's word (*"pressing
		// E, but not hud message"*), and nothing below them either, since E presses the button.
		if ( HexPlatforms.HexButtonAimed( player ) >= 0 ) return "";

		// ⚠️ AND BASALT'S BLUE ALTAR, AND TickUse MATCHES — looked at in the teleporter's middle, its destination set
		var blueAltarText = ForBlueAltar( player );
		if ( !string.IsNullOrEmpty( blueAltarText ) ) return blueAltarText;

		// ⚠️ AND THE BEAST'S CORE, AND TickUse MATCHES — once he is dead, over the middle platform
		var coreText = ForCore( player );
		if ( !string.IsNullOrEmpty( coreText ) ) return coreText;

		// ⚠️ FIRST OF THE REST. Everything below is aim-gated, and a barricade is not — you
		// can be next to one while looking at a wallbuy across the room. When
		// both apply the barricade wins the prompt, because it is the one the
		// player is standing in and the one a horde is coming through.
		var repairText = ForBarricade( player );
		if ( !string.IsNullOrEmpty( repairText ) ) return repairText;

		// ⚠️ BEFORE the box, matching TickUse exactly. The prompt and the key must
		// agree about who wins, or the screen offers one thing and E does another.
		var papText = ForPap( player );
		if ( !string.IsNullOrEmpty( papText ) ) return papText;

		var boxText = ForBox( player );
		if ( !string.IsNullOrEmpty( boxText ) ) return boxText;

		// ⚠️ AFTER the box and BEFORE the switch, and TickUse must match. The
		// prompt and the key have to agree about who wins or the screen offers one
		// thing and E does another — a rule this file already records twice.
		var fizzText = ForWunderfizz( player );
		if ( !string.IsNullOrEmpty( fizzText ) ) return fizzText;

		// ⚠️ IMMEDIATELY AFTER THE WUNDERFIZZ, and TickUse matches. Both are perk
		// machines a mapper will stand side by side, so whichever wins has to win
		// in BOTH places or the screen offers one and E buys the other.
		var perkText = ForPerkMachine( player );
		if ( !string.IsNullOrEmpty( perkText ) ) return perkText;

		// ⚠️ AFTER the machines, and TickUse matches. A pad is a big flat thing a mapper will put
		// a machine on top of, so the machine has to win — you can always step off the pad to
		// reach it, but you cannot step off a machine to reach the pad under it.
		var teleText = ForTeleporter( player );
		if ( !string.IsNullOrEmpty( teleText ) ) return teleText;

		// ⚠️ LAST OF THE PROXIMITY READOUTS, because it is the only one that is NOT an offer — a
		// soul box has no key to press. Anything that can actually be used must win the line.
		var soulText = ForSoulBox( player );
		if ( !string.IsNullOrEmpty( soulText ) ) return soulText;

		// ⚠️ IMMEDIATELY AFTER THE WUNDERFIZZ, and TickUse matches. The two
		// machines are the same size and a mapper can stand them side by side, so
		// whichever wins has to win in BOTH places or the screen offers one and E
		// does the other — the rule this file already records three times.
		var arsenalText = ForArsenal( player );
		if ( !string.IsNullOrEmpty( arsenalText ) ) return arsenalText;

		// ⛔ THE ENDING WINS EVERY TIE, AND TickUse MATCHES. It is the one interaction that
		// cannot be undone; see the note there.
		var endText = ForEnding( player );
		if ( !string.IsNullOrEmpty( endText ) ) return endText;

		// ⚠️ AFTER THE ENDING, AND TickUse MATCHES — see the note there.
		var miseryText = ForMisery( player );
		if ( !string.IsNullOrEmpty( miseryText ) ) return miseryText;

		// ⚠️ AND TickUse MATCHES — see the note there.
		var pressText = ForPressable( player );
		if ( !string.IsNullOrEmpty( pressText ) ) return pressText;

		// ⚠️ AFTER THE ARSENAL, AND TickUse MATCHES. An ammo refill is the cheapest and most
		// repeatable thing on this list, so it loses every tie — the same prompt-and-key-must-agree
		// rule recorded above.
		var ammoText = ForAmmoBox( player );
		if ( !string.IsNullOrEmpty( ammoText ) ) return ammoText;

		// ⚠️ BEFORE THE TABLES, AND TickUse MATCHES — see the note there.
		var partText = ForBuildPart( player );
		if ( !string.IsNullOrEmpty( partText ) ) return partText;

		var buildText = ForBuildTable( player );
		if ( !string.IsNullOrEmpty( buildText ) ) return buildText;

		// ⚠️ AFTER THE AMMO BOX, AND TickUse MATCHES — see the note there.
		var tradeText = ForTradeTable( player );
		if ( !string.IsNullOrEmpty( tradeText ) ) return tradeText;

		var switchText = ForSwitch( player );
		if ( !string.IsNullOrEmpty( switchText ) ) return switchText;

		// ⚠️ SAME ORDER AS TickUse — wallbuy before debris. See NZPlayer.TickUse.
		var wallText = ForWallBuy( player );
		if ( !string.IsNullOrEmpty( wallText ) ) return wallText;

		return ForDebris( player );
	}

	/// <summary>
	/// "Press E - Pack-a-Punch to MK1 [Cost: 5000]", or what it costs to go again.
	///
	/// ⚠️ The price shown is the one for the NEXT level, not a flat number — 5000,
	/// 15000, 30000. A machine advertising 5000 to someone holding an MK2 would be
	/// quoting a price it will not honour.
	/// </summary>
	/// <summary>
	/// "Press E - Der Wunderfizz", or why it will not serve.
	///
	/// ⛔ NO PRICE IN THE PROMPT. Opening the machine is FREE — the cost belongs to
	/// what you take out of it, and that is shown per perk inside the menu. A
	/// bracketed cost here would read as a door charge and make a player think
	/// twice about pressing E, which is precisely the wrong hesitation for a
	/// machine whose whole job is to be browsed.
	///
	/// ⚠️ SHOWS THE BLOCKED REASON RATHER THAN NOTHING. A machine that is off
	/// because the power is out, or because it is round 2, looks identical to a
	/// machine that is broken — and "nothing happens when I press E" is the worst
	/// possible answer. Unavailable() returns the sentence for exactly this.
	/// </summary>
	/// <summary>
	/// "Press E - Arsenal: Armor Tier 2 [700 salvage]", or why it will not serve.
	///
	/// ⚠️ STATES THE CURRENCY. Every other prompt in this file quotes POINTS, so
	/// an unlabelled number here would read as points and make a 5,000 armor tier
	/// look mispriced against a 950 mystery box.
	/// </summary>
	static string ForArsenal( NZPlayer player )
	{
		var arsenal = Arsenal.Near( player.WorldPosition );
		if ( arsenal is null ) return "";

		var blocked = arsenal.Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		// ⚠️ NO PRICE ON THE PROMPT ANY MORE. E opens a four-tab menu, so quoting
		// one tab's next purchase would promise something E does not do — the same
		// prompt-and-key-must-agree rule this file records for every other machine.
		return Use + "Arsenal";
	}

	/// <summary>
	/// "Press E - Ammo (1,500)", or why it will not serve.
	///
	/// ⚠️ THE PRICE IS ON THE PROMPT, unlike the Arsenal's. E does exactly one thing here and its
	/// cost changes every time you use it, so the number is the single most useful thing the prompt
	/// can carry — the whole mechanic is that you decide whether this refill is still worth it.
	/// </summary>
	/// <summary>
	/// "Press E - Leave weapon" / "Swap for MUSTANG MK3", or why it will not serve.
	///
	/// ⚠️ IT SAYS WHICH WEAPON, AND WHICH DIRECTION. E does three different things at this table
	/// depending on what is on it and what you hold — deposit, collect, or swap — and a bare
	/// "Trading Table" would leave the player guessing which one they are about to trigger.
	///
	/// ⚠️ NO PRICE, because there is not one. The table is free.
	/// </summary>
	/// <summary>"Press E - Pick up the core", or why not.</summary>
	static string ForBuildPart( NZPlayer player )
	{
		var part = BuildPart.Near( player );
		if ( part is null ) return "";

		var blocked = part.Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		return Use + $"Pick up the {BuildParts.Name( part.Spot.Part )}";
	}

	/// <summary>
	/// "Hold E - Build the Prisma", the progress while holding, or what is still missing.
	/// </summary>
	///
	/// ⛔ THE MISSING-PARTS MESSAGE NAMES THEM. "You are missing pieces" alone is true and
	/// useless — a player who has walked to the table wants to know what to go and look for, and
	/// the parts are named after what they look like for exactly this sentence.
	///
	/// ⚠️ THE PROGRESS READS OFF `player.BuildHold`, the same field the hold itself counts, so the
	/// bar cannot claim a second the build did not credit.
	static string ForBuildTable( NZPlayer player )
	{
		var table = BuildTable.Near( player.WorldPosition );
		if ( table is null ) return "";

		var blocked = table.Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		// ⚠️ THE FINISHED WEAPON OUTRANKS EVERYTHING, including the missing-pieces message. A
		// player who has just built one and started collecting the next set must not be told they
		// are short while the gun they made is sitting in front of them.
		if ( table.Built ) return Use + $"Take the {BuildParts.WeaponName}";

		// ⚠️ A SPENT BENCH IS SILENT UNLESS YOU ARE CARRYING A FULL SET. It is finished furniture
		// and a permanent "already used" label on something you walk past for forty rounds is
		// noise — but a player holding all three parts is about to wonder why nothing happens, and
		// that is the one moment the answer is worth saying.
		if ( table.Spent )
			return BuildParts.HasAll() ? "This one has already been built" : "";

		if ( !BuildParts.HasAll() )
			return $"You are missing pieces — {BuildParts.Missing()}";

		if ( player.BuildHold > 0f )
		{
			var pct = MathX.Clamp( player.BuildHold / MathF.Max( BuildTable.HoldSeconds, 0.01f ),
				0f, 1f );

			return $"Building... {pct * 100f:0}%";
		}

		return "Hold E - Build the Prisma";
	}

	static string ForTradeTable( NZPlayer player )
	{
		var table = TradeTable.Near( player.WorldPosition );
		if ( table is null ) return "";

		var blocked = table.Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		if ( !table.HasWeapon ) return Use + "Leave weapon";

		// ⚠️ "Swap" RATHER THAN "Take" WHENEVER YOU ARE HOLDING SOMETHING, because that is what
		// happens — your gun goes onto the table. Calling it "Take" would hide half the transaction.
		return Use + (table.StoredName is { Length: > 0 } name
			? $"Swap for {name}"
			: "Take weapon");
	}

	static string ForPressable( NZPlayer player )
	{
		var p = Pressable.Near( player.WorldPosition );
		if ( p is null || p.Spot is null ) return "";

		if ( p.Spot.Step.Completed ) return "";

		var blocked = p.Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		// ⚠️ SHOWS THE PROGRESS WHEN THERE IS ANY. A repeat-count button with no counter is
		// one you have to keep your own tally for, and overshooting FAILS — so the count is
		// not decoration, it is the only way to play it correctly.
		var need = System.Math.Max( 1, p.Spot.RepeatCount );

		// ⛔ THE COUNTDOWN GOES HERE, NOT IN `Unavailable`. That method's return value is a
		// REFUSAL — anything non-empty stops the press — so putting a running clock in it would
		// make a timed step impossible to finish. The prompt is the only place a value that is
		// merely informative can live.
		var left = EggGroups.Remaining( p );
		var clock = left >= 0f ? $" · {left:0.0}s" : "";

		if ( p.Spot.HoldSeconds > 0f )
			return Use + $"Hold ({p.Spot.HoldSeconds:0.#}s)" + clock;

		return ( need > 1
			? Use + $"Press ({p.Presses}/{need})"
			: Use + "Press" ) + clock;
	}

	static string ForMisery( NZPlayer player )
	{
		var dev = MiseryDevice.Near( player.WorldPosition );
		if ( dev is null ) return "";

		var blocked = dev.Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		// ⚠️ SAYS WHICH WAY IT WILL GO, not what it is. A toggle labelled with its current
		// state reads as a status light and leaves you guessing what the key does.
		return Use + ( MiseryDevice.Running ? "Stop the misery" : "Accelerate the misery" );
	}

	static string ForEnding( NZPlayer player )
	{
		var end = BuyableEnding.Near( player.WorldPosition );
		if ( end is null ) return "";

		var blocked = end.Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		// ⚠️ A FREE ENDING SHOWS NO PRICE. "End game (0)" reads as a bug rather than as a
		// gift, and 0 is a legitimate configuration here — see EndingSpot.Price.
		return Use + ( end.Price > 0 ? $"{end.Hint} ({end.Price:N0})" : end.Hint );
	}

	static string ForAmmoBox( NZPlayer player )
	{
		var box = AmmoBox.Near( player.WorldPosition );
		if ( box is null ) return "";

		var blocked = box.Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		return Use + $"Ammo ({AmmoBox.PriceFor( player ):N0})";
	}

	static string ForWunderfizz( NZPlayer player )
	{
		var fizz = Wunderfizz.Near( player.WorldPosition );
		if ( fizz is null ) return "";

		var blocked = fizz.Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		return Use + "Der Wunderfizz";
	}

	/// <summary>
	/// A perk machine's offer.
	///
	/// ⚠️ CARRIES THE PRICE, unlike the Wunderfizz. Browsing a Wunderfizz is free and the charge
	/// happens inside its menu, so quoting a price on the way in would be a lie; here E IS the
	/// purchase, so the number has to be on screen before the key is pressed.
	/// </summary>
	static string ForPerkMachine( NZPlayer player )
	{
		var machine = PerkMachine.Near( player.WorldPosition );
		if ( machine is null ) return "";

		var perk = machine.Perk;
		if ( perk is null ) return "";

		var blocked = machine.Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		// Already yours — say so rather than offering a purchase that will be refused.
		if ( player.HasPerk( perk.Id ) ) return $"{perk.Name} — already owned";

		var price = machine.PriceFor( player );
		return price == 0
			? Use + perk.Name
			: Use + $"{perk.Name} [Cost: {price:N0}]";
	}

	/// <summary>
	/// A teleporter's offer.
	///
	/// ⚠️ SAYS HOW MANY ARE RIDING when it is more than one. The pad taking everyone standing on
	/// it is its whole point and is invisible otherwise — a player would have no way to know their
	/// team came along until they arrived.
	/// </summary>
	static string ForTeleporter( NZPlayer player )
	{
		var tp = Teleporter.Near( player.WorldPosition );
		if ( tp is null ) return "";

		var blocked = tp.Unavailable( player );
		if ( !string.IsNullOrEmpty( blocked ) ) return blocked;

		var riders = tp.Riders().Count;
		var extra = riders > 1 ? $"  ·  {riders} riding" : "";

		return tp.Price > 0
			? Use + $"Teleport [Cost: {tp.Price:N0}]{extra}"
			: Use + $"Teleport{extra}";
	}

	/// <summary>
	/// A soul box's fill, as a readout.
	///
	/// ⛔ NOT AN OFFER, AND IT CARRIES NO KEY HINT. There is nothing to press — a box fills by
	/// zombies dying near it. Prefixing this with "Press E" would promise an interaction that does
	/// not exist, which is the false-display problem the weapon stats panel already had.
	///
	/// ⚠️ A SHORTER RANGE THAN THE BOX'S OWN. A box reaches 500 units for KILLS; showing its
	/// counter from that far would put a readout on screen across most of a room, and several at
	/// once where boxes overlap.
	/// </summary>
	static string ForSoulBox( NZPlayer player )
	{
		const float readRange = 140f;

		SoulBox best = null;
		var bestDist = readRange;

		foreach ( var b in SoulBoxManager.All() )
		{
			var d = player.WorldPosition.Distance( b.Spot.Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = b;
		}

		return best?.Readout() ?? "";
	}

	static string ForPap( NZPlayer player )
	{
		var pap = PackAPunch.Near( player.WorldPosition );
		if ( pap is null ) return "";

		if ( pap.HasFinishedGun )
			return pap.Owner == player ? Use + "Take your weapon" : "";

		// Mid-cycle: the machine will refuse, so it must not offer.
		if ( pap.IsBusy ) return "";

		if ( string.IsNullOrWhiteSpace( player.StartingWeapon ) ) return "";

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

		// ⛔ NO "Press E" ON A LOCKED TIER — this file's own rule, stated at every other
		// machine: a prompt that offers what E refuses is worse than no prompt. It still says
		// WHAT is locked and WHEN, because a silent machine reads as broken.
		//
		// ⚠️ A DIFFERENT SENTENCE FROM "Fully upgraded", deliberately. Telling a round-12
		// player their MK1 rifle is finished is both wrong and unrecoverable advice.
		if ( PackAPunch.IsRoundLocked( player ) )
		{
			int next = player.PapLevelFor( player.StartingWeapon ) + 1;
			int at = ActiveConfig.Pap?.UnlockRoundFor( next - 1 ) ?? 0;
			return $"MK{next} unlocks at round {at}";
		}

		int price = pap.PriceFor( player );
		int mk = player.PapLevelFor( player.StartingWeapon ) + 1;

		return Use + $"Pack-a-Punch to MK{mk} [Cost: {price}]";
	}

	/// <summary>
	/// "Press E - Open Mystery Box [Cost: 950]".
	///
	/// ⚠️ Wording and bracket format taken from the original's own prompt
	/// (display/cl_target.lua:82) rather than invented, so a player who knows
	/// nZombies reads the same sentence here.
	/// </summary>
	static string ForBox( NZPlayer player )
	{
		var box = MysteryBox.Near( player.WorldPosition );
		if ( box is null ) return "";

		// ⚠️ The prompt has to follow the box's STATE, or it offers to sell a roll
		// while a weapon is sitting on the lid waiting to be taken.
		if ( box.HasOffer )
			return Use + $"Take {box.OfferName}";

		// ⛔ NOTHING WHILE THE WEAPON IS RISING. Buy refuses mid-sequence, so a
		// prompt here is an offer the box will not honour — the player presses E
		// against a cycling gun, is charged nothing, told nothing, and reads the
		// whole box as broken.
		if ( box.IsBusy ) return "";

		return Use + $"Open Mystery Box [Cost: {box.Price}]";
	}

	// ── barricades ───────────────────────────────────────────────────────────

	/// <summary>
	/// "Hold E - Rebuild Barricade (4/6)".
	///
	/// ⚠️ Says HOLD, not press, and shows the COUNT. Both are load-bearing: the
	/// key has to be held for six boards, and without the count there is no
	/// feedback that holding is achieving anything until the last plank lands.
	/// </summary>
	static string ForBarricade( NZPlayer player )
	{
		var b = Barricade.RepairableNear( player.WorldPosition );
		if ( b is null ) return "";

		return $"Hold E - Rebuild Barricade ({b.Planks}/{Barricade.MaxPlanks})";
	}

	// ── the power switch ─────────────────────────────────────────────────────

	static string ForSwitch( NZPlayer player )
	{
		var mgr = PowerManager.Instance;
		if ( mgr is null || mgr.Aimed( player ) < 0 ) return "";

		if ( Power.IsOn ) return "The power is already on";

		return Use + "Turn on the Power";
	}

	// ── wallbuys ─────────────────────────────────────────────────────────────

	static string ForWallBuy( NZPlayer player )
	{
		var buy = WallBuyManager.Ensure()?.Aimed( player );
		if ( buy is null ) return "";

		// ⚠️ The entity words this, not the HUD: whether you are buying the gun
		// or just ammo is the SAME question TryBuy has to answer, and asking it
		// twice in two places is how the prompt starts lying about the price.
		return Use + buy.UseText( player ).Replace( "Hold F for ", "" );
	}

	// ── doors and debris ─────────────────────────────────────────────────────

	static string ForDebris( NZPlayer player )
	{
		var mgr = DebrisManager.Instance;
		if ( mgr is null ) return "";

		var index = mgr.AimedBuyable( player );
		if ( index < 0 ) return "";

		var list = ActiveConfig.Current.Debris;
		if ( index >= list.Count ) return "";

		return ForDebris( list[index] );
	}

	/// <summary>
	/// What a given barrier would say — a PURE function of its settings and the
	/// world state, with no trace involved.
	///
	/// ⚠️ SPLIT OUT ON PURPOSE. One door has six possible wordings across price,
	/// sign, power and creative mode, and checking them by walking up to things
	/// means only ever seeing the case you happened to build. nz_prompt_door
	/// asks any of them directly.
	/// </summary>
	public static string ForDebris( Debris d )
	{
		if ( d is null ) return "";

		// Authoring shows the SETTINGS, not the offer — you need to see the flag
		// and whether it is gated, which a player never should. (The original
		// switches on ROUND_CREATE for exactly this.)
		if ( NZGame.Mode == GameMode.Creative )
		{
			// ⚠️ A non-buyable barrier has no price worth quoting. Showing one
			// would read as the toggle not having taken — the author needs to
			// see what the barrier DOES, which is wait for its flag.
			if ( !d.Buyable )
				return $"Not buyable  ·  Opens with flag {d.Link}";

			return d.RequiresPower && d.Price == 0
				? $"Opens on power  ·  Flag {d.Link}"
				: $"Price: {d.Price:N0}  ·  Flag {d.Link}"
					+ (d.RequiresPower ? "  ·  Requires Power" : "");
		}

		// ⛔ NO OFFER, NO PROMPT. To the player this barrier is scenery; it
		// opens with its link group, bought from somewhere else entirely.
		if ( !d.Buyable ) return "";

		if ( DoorLinks.IsOpen( d.Link ) ) return "";

		// ⚠️ ORDER IS THE MESSAGE. Power is checked before price because a
		// player standing at an unpowered door needs to be told to find the
		// switch, not quoted a price they cannot pay yet.
		if ( d.RequiresPower && !Power.IsOn )
		{
			return d.Price == 0
				? "This door will open when the electricity is turned on"
				: "You must turn on the electricity first!";
		}

		// Price 0 with no power requirement is simply a free door.
		if ( d.Price == 0 ) return Use + "Clear Debris";

		// The original supports a NEGATIVE price — salvage, which pays you.
		if ( d.Price < 0 ) return Use + $"Salvage [+{-d.Price:N0}]";

		return Use + $"Clear Debris [Cost: {d.Price:N0}]";
	}

	/// <summary>Is the prompt an offer the player can act on, or a refusal?
	/// The refusals are drawn in a warning colour.</summary>
	public static bool IsRefusal( string text )
		=> !string.IsNullOrEmpty( text ) && !text.StartsWith( Use );
}