Buyables/BuyableEndingCommands.cs

Console command helpers for the buyable ending system. Provides commands to place an ending at the player, list configured endings, clear them, change prices, test nearest ending availability, and tweak model rotation, and logs relevant info.

File Access
using Sandbox;
using System.Linq;

namespace NZombies;

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

	static NZPlayer Player
		=> NZPlayer.Local;

	/// <summary>Drop one at your feet: `nz_ending [price]`.</summary>
	[ConCmd( "nz_ending" )]
	public static void Place( int price = -1 )
	{
		var ed = Editor;
		var p = Player;
		if ( !ed.IsValid() || !p.IsValid() ) { Log.Warning( "[nz-end] no editor/player" ); return; }

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

		// ⚠️ At the PLAYER's feet with an UP normal, not a crosshair trace. The command
		// exists so this can be driven headlessly, and a trace needs somewhere to be aiming.
		ed.AddEndingAt( p.WorldPosition, Vector3.Up );
	}

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

		if ( list.Count == 0 )
		{
			Log.Info( "[nz-end] none placed — Q > Placeables > Buyable ending, or nz_ending" );
			return;
		}

		for ( int i = 0; i < list.Count; i++ )
		{
			var e = list[i];

			Log.Info( $"[nz-end] [{i}] {e.Price,7} points"
				+ ( e.KeepPlaying ? "  keep-playing" : "" )
				+ ( e.RewardPerks ? "  reward-perks" : "" )
				+ ( e.PermaPerks ? "  perma-perks" : "" )
				+ ( e.StartRound > 1 ? $"  from round {e.StartRound}" : "" )
				+ ( e.RequiresPower ? "  ⚡power" : "" )
				+ $"  flag {DoorLinks.Display( e.Link )}" );

			// ⚠️ THE MODEL PATH IS PRINTED IN FULL, on its own line. It is the one field a
			// mapper types by hand, so it is the one that will have a typo in it — and a
			// wrong path draws the error model, which looks like a deliberately odd prop
			// rather than a mistake.
			Log.Info( $"[nz-end]      model '{e.Model}'   prompt '{e.Hint}'"
				+ ( string.IsNullOrWhiteSpace( e.CustomText ) ? "" : $"   text '{e.CustomText}'" ) );
		}

		var mgr = BuyableEndingManager.Instance;

		// ⚠️ CONFIGURED vs STANDING, said separately. An ending whose model failed to load
		// leaves a spot in the config and nothing usable in the world, and those two numbers
		// disagreeing is the only cheap way to notice.
		Log.Info( $"[nz-end] {list.Count} configured, "
			+ $"{( mgr.IsValid() ? mgr.Built.ToString() : "?" )} standing" );
	}

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

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

	/// <summary>
	/// `nz_ending_price [points]` — retune every placed ending, and the tool default.
	///
	/// ⚠️ BOTH, DELIBERATELY. The config is what the endings already standing charge; the
	/// `MapEditor` field is what the NEXT one placed is stamped with. Setting one alone
	/// means the price changes now and silently reverts later — the same trap
	/// `nz_fizz_slotprice` documents.
	/// </summary>
	[ConCmd( "nz_ending_price" )]
	public static void Price( int points = -1 )
	{
		if ( points >= 0 )
		{
			foreach ( var e in ActiveConfig.Current.Endings ) e.Price = points;

			var ed = Editor;
			if ( ed.IsValid() ) ed.EndingPrice = points;
			else Log.Warning( "[nz-end] no MapEditor — the endings already placed were retuned, "
				+ "but the NEXT one placed will use the old price" );
		}

		List();
	}

	/// <summary>
	/// `nz_ending_test` — what the nearest ending would do if you pressed E right now.
	///
	/// ⛔ IT DOES NOT BUY. `Unavailable` is the same method the prompt and the use key both
	/// ask, so this reports exactly what they would — without ending the run, which is the
	/// one interaction you cannot take back to try again.
	/// </summary>
	[ConCmd( "nz_ending_test" )]
	public static void Test()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-end] no player" ); return; }

		var end = BuyableEnding.Near( p.WorldPosition );

		if ( end is null )
		{
			var nearest = BuyableEnding.All
				.Where( e => e.IsValid() )
				.OrderBy( e => e.WorldPosition.Distance( p.WorldPosition ) )
				.FirstOrDefault();

			// ⚠️ SAYS HOW FAR THE NEAREST ONE IS, rather than just "none in range". "Nothing
			// here" and "it is 40 units past the reach" look identical when you are stood
			// next to it wondering why the prompt is missing.
			Log.Info( nearest is null
				? "[nz-end] none placed"
				: $"[nz-end] nearest is {nearest.WorldPosition.Distance( p.WorldPosition ):0}u away"
					+ $" — the use range is {BuyableEnding.UseRange:0}u" );
			return;
		}

		var blocked = end.Unavailable( p );

		Log.Info( $"[nz-end] '{end.Hint}' · {end.Price:N0} points · you have {p.Points:N0}" );
		Log.Info( string.IsNullOrEmpty( blocked )
			? "[nz-end]   READY — pressing E would end the run"
			: $"[nz-end]   BLOCKED — {blocked}" );
	}

	/// <summary>
	/// `nz_ending_rotate [pitch] [yaw] [roll]` — retune the model correction and rebuild.
	/// No arguments just prints the current one.
	///
	/// ⛔ A ROTATION DESCRIBED IN WORDS IS A GUESS UNTIL IT IS SEEN. "180 around the
	/// vertical, then 90 left-to-right" has an obvious reading and three plausible ones, and
	/// the difference is a bear face-down instead of upright. Iterating on this without a
	/// live knob costs a compile per attempt.
	///
	/// ⚠️ Vertical axis = YAW. Left-to-right = PITCH. Front-to-back = ROLL.
	/// </summary>
	[ConCmd( "nz_ending_rotate" )]
	public static void Rotate( float pitch = float.NaN, float yaw = float.NaN, float roll = float.NaN )
	{
		var cur = BuyableEndingManager.ModelTweak.Angles();

		if ( !float.IsNaN( pitch ) || !float.IsNaN( yaw ) || !float.IsNaN( roll ) )
		{
			// ⚠️ NaN means "leave this one alone", so one axis can be nudged without
			// retyping the other two and accidentally resetting them to 0.
			BuyableEndingManager.ModelTweak = Rotation.From(
				float.IsNaN( pitch ) ? cur.pitch : pitch,
				float.IsNaN( yaw ) ? cur.yaw : yaw,
				float.IsNaN( roll ) ? cur.roll : roll );

			// ⚠️ REBUILT, because the tweak is baked into WorldRotation at Build time rather
			// than read every frame. Without this the number changes and nothing moves, which
			// reads as the command not working.
			BuyableEndingManager.Ensure( Game.ActiveScene )?.Rebuild();
		}

		var a = BuyableEndingManager.ModelTweak.Angles();

		Log.Info( $"[nz-end] model tweak — pitch {a.pitch:0.#} (left-to-right)"
			+ $" · yaw {a.yaw:0.#} (vertical)"
			+ $" · roll {a.roll:0.#} (front-to-back)" );
	}
}