Transport/TeleporterCommands.cs

Console command helpers for teleporter tooling. Exposes concommands to place teleporters, place one ahead of the player, list configured teleporters, clear them, use a teleporter, check the teleporter model asset, and diagnose why a pad is not usable.

File AccessNetworking
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// Console access to the teleporters. Every button in the tool panel has an equivalent here, so a
/// pair can be placed, inspected and ridden without clicking.
/// </summary>
public static class TeleporterCommands
{
	static MapEditor Editor
		=> Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();

	static NZPlayer Player
		=> NZPlayer.Local;

	/// <summary>
	/// Place a whole teleporter in one call: `nz_teleporter <bx> <by> <bz> [price]`.
	///
	/// ⛔ THE PAD IS AT YOUR FEET AND B IS AN EXPLICIT COORDINATE. Nobody can click twice over
	/// MCP, so a two-click tool has no console route at all unless one of the two points is given
	/// outright — and a command that placed only half a teleporter would leave a pending corner
	/// behind for the next click to consume.
	/// </summary>
	[ConCmd( "nz_teleporter" )]
	public static void Place( float bx, float by, float bz, int price = -1 )
	{
		var ed = Editor;
		var p = Player;
		if ( !ed.IsValid() || !p.IsValid() ) { Log.Warning( "[nz-tp] no editor/player" ); return; }

		if ( price >= 0 ) ed.TeleporterPrice = price;

		ed.ResetCorners();
		ed.AddTeleporterPointAt( p.WorldPosition );
		ed.AddTeleporterPointAt( new Vector3( bx, by, bz ) );
	}

	/// <summary>
	/// Place one that sends you a set distance ahead: `nz_teleporter_ahead [distance] [price]`.
	///
	/// A convenience over nz_teleporter for testing, where the exact destination does not matter
	/// and typing three coordinates does.
	/// </summary>
	[ConCmd( "nz_teleporter_ahead" )]
	public static void PlaceAhead( float distance = 400f, int price = -1 )
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-tp] no player" ); return; }

		var ahead = p.WorldPosition + p.WorldRotation.Forward.WithZ( 0 ).Normal * distance;
		Place( ahead.x, ahead.y, ahead.z, price );
	}

	/// <summary>What is placed: `nz_teleporter_list`.</summary>
	[ConCmd( "nz_teleporter_list" )]
	public static void List()
	{
		var list = ActiveConfig.Current.Teleporters;

		if ( list.Count == 0 )
		{
			Log.Info( "[nz-tp] none placed — Q > Placeables > Transportation > "
				+ "Teleporter, or nz_teleporter_ahead" );
			return;
		}

		for ( int i = 0; i < list.Count; i++ )
		{
			var t = list[i];
			Log.Info( $"[nz-tp] [{i}] {t.A} -> {t.B}  {t.A.Distance( t.B ):0,6}u"
				+ (t.Price > 0 ? $"  {t.Price} points" : "  free")
				+ $"  {t.WarmupTime:0.#}+{t.TransitTime:0.#}s"
				+ (t.Cooldown > 0f ? $"  {t.Cooldown:0}s cd" : "")
				+ (t.RequiresPower ? "  ⚡power" : "")
				+ $"  pad {t.PadSize:0}  flag {DoorLinks.Display( t.Link )}" );
		}

		var mgr = TeleporterManager.Instance;

		// ⚠️ CONFIGURED vs STANDING, said separately — a pad whose mesh failed to build leaves a
		// spot in the config and nothing in the world.
		Log.Info( $"[nz-tp] {list.Count} configured, "
			+ $"{(mgr.IsValid() ? mgr.Built.ToString() : "?")} standing" );
	}

	/// <summary>Remove them all: `nz_teleporter_clear`.</summary>
	[ConCmd( "nz_teleporter_clear" )]
	public static void Clear()
	{
		var n = ActiveConfig.Current.Teleporters.Count;
		ActiveConfig.Current.Teleporters.Clear();
		TeleporterManager.Ensure( Game.ActiveScene )?.Rebuild();

		Log.Info( $"[nz-tp] removed {n}" );
	}

	/// <summary>
	/// Ride the pad you are standing on: `nz_teleporter_use`.
	///
	/// ⚠️ Goes through the same Use the key does, so a refusal here is the refusal a player would
	/// get — this is not a shortcut that skips the gates.
	/// </summary>
	[ConCmd( "nz_teleporter_use" )]
	public static void Use()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-tp] no player" ); return; }

		var tp = Teleporter.Near( p.WorldPosition );
		if ( tp is null ) { Log.Info( "[nz-tp] not standing at a teleporter pad" ); return; }

		Log.Info( $"[nz-tp] {tp.Use( p )}" );
	}

	/// <summary>
	/// Is the pad MODEL usable? `nz_teleporter_model`.
	///
	/// ⚠️ Asks the asset system, not the config. A pad standing as a plain box has two causes —
	/// the model deliberately blanked, or the vmdl missing — and only this tells them apart.
	/// </summary>
	[ConCmd( "nz_teleporter_model" )]
	public static void ModelCheck()
	{
		var path = new TeleporterSpot().Model;
		var m = Model.Load( path );

		if ( m is null ) { Log.Warning( $"[nz-tp] {path} — NOT FOUND" ); return; }
		if ( m.IsError ) { Log.Warning( $"[nz-tp] {path} — ERROR MODEL (did not compile)" ); return; }

		Log.Info( $"[nz-tp] {path} ok — bounds {m.Bounds.Size}, meshes {m.MeshCount}" );
	}

	/// <summary>
	/// Why is the pad under me not offering? `nz_teleporter_why`.
	///
	/// ⛔ EVERY GATE IN ONE LINE. "Pressing E does nothing" has four unrelated causes here — no
	/// pad in range, the power off, the flag shut, or standing beside the pad rather than on it —
	/// and from inside the game they all look identical.
	/// </summary>
	[ConCmd( "nz_teleporter_why" )]
	public static void Why()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-tp] no player" ); return; }

		var built = TeleporterManager.Instance;
		Log.Info( $"[nz-tp] {ActiveConfig.Current.Teleporters.Count} configured, "
			+ $"{(built.IsValid() ? built.Built.ToString() : "no manager")} standing, "
			+ $"power {(Power.IsOn ? "ON" : "off")}" );

		var tp = Teleporter.Near( p.WorldPosition );
		if ( tp is null )
		{
			var nearest = Teleporter.All.Where( t => t.IsValid() && t.Spot is not null )
				.OrderBy( t => p.WorldPosition.Distance( t.Spot.A ) ).FirstOrDefault();

			Log.Info( nearest is null
				? "[nz-tp] no pads standing at all"
				: $"[nz-tp] none in range — nearest is #{nearest.Index} at "
					+ $"{p.WorldPosition.Distance( nearest.Spot.A ):0}u, "
					+ $"needs {nearest.UseRange:0}u" );
			return;
		}

		var blocked = tp.Unavailable( p );
		Log.Info( $"[nz-tp] #{tp.Index}: {(string.IsNullOrEmpty( blocked ) ? "USABLE" : blocked)}"
			+ $"  ·  on the pad: {tp.IsOn( p )}"
			+ $"  ·  riders {tp.Riders().Count}"
			+ $"  ·  price {tp.Price}, you have {p.Points}"
			+ (tp.CoolingFor > 0f ? $"  ·  recharging {tp.CoolingFor:0.0}s" : "") );

		// ⚠️ THE SEQUENCE STATE IS ITS OWN LINE. "Nothing happened when I pressed E" now has a
		// sixth cause — it DID happen and is mid-warmup — and that one looks identical to the
		// others from inside the game.
		Log.Info( $"[nz-tp] sequence: {(tp.Busy ? $"BUSY, {tp.PhaseLeft:0.0}s left, {tp.Aboard} aboard" : "idle")}"
			+ $"  ·  overlay {(TeleportOverlayState.Active ? $"frame {TeleportOverlayState.Frame}" : "off")}" );
	}
}