Buyables/WallBuyCommands.cs

Console command helpers for wall-buy editor and runtime. Provides ConCmds to place, list, remove, tweak visuals, set rarity, buy and report wallbuy objects and their visuals via WallBuyManager and NZPlayer.

File Access
using System.Linq;
using Sandbox;

namespace NZombies;

/// <summary>
/// Console access to wallbuys.
///
/// ⚠️ Every buyable in this project gets commands, for the same reason the
/// others do: a thing that can only be placed by aiming and only tested by
/// walking up to it is a thing that goes untested. These also make the feature
/// drivable remotely.
/// </summary>
public static class WallBuyCommands
{
	static WallBuyManager Mgr => WallBuyManager.Ensure();

	static NZPlayer Player => NZPlayer.Local;

	/// <summary>
	/// Place one where you are looking. `nz_wallbuy_place galil 1250`.
	///
	/// ⚠️ Accepts a bare name — "galil" becomes prefabs/weapons/nz_galil.prefab,
	/// the same shorthand nz_give takes. Typing full prefab paths at a console
	/// while standing in a map is not a thing anyone does twice.
	/// </summary>
	[ConCmd( "nz_wallbuy_place" )]
	public static void Place( string weapon = "m1911", int price = 500, int rarity = 0 )
	{
		var player = Player;
		if ( player is null || Mgr is null ) { Log.Info( "[wallbuy] no player/manager" ); return; }

		var cam = Game.ActiveScene.Camera;
		var tr = Game.ActiveScene.Trace
			.Ray( cam.WorldPosition, cam.WorldPosition + cam.WorldRotation.Forward * 200f )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();

		// ⚠️ Sit it just off the surface and face it OUTWARD along the normal —
		// placed flush it z-fights the wall, and facing along the trace direction
		// would bury the model inside it.
		//
		// ⚠️ FALLS BACK TO THE PLAYER'S FRONT when the ray misses. Refusing to
		// place unless a wall is under the crosshair makes the command unusable
		// from a console where the view cannot be aimed — and "aim at a surface"
		// gave no clue whether the trace missed or the manager was absent.
		Vector3 pos;
		Rotation rot;
		if ( tr.Hit )
		{
			// ⚠️ 0.25, not 1.5 — the chalk plane should sit ON the wall you clicked, just
				// clear enough not to z-fight with it. The old 1.5 was headroom for a
				// solid weapon model that no longer exists.
				pos = tr.HitPosition + tr.Normal * 0.25f;
			rot = Rotation.LookAt( tr.Normal );
			Log.Info( $"[wallbuy] surface hit at {tr.HitPosition} ({tr.GameObject?.Name})" );
		}
		else
		{
			// ⚠️ FROM THE CAMERA, NOT THE PLAYER ORIGIN. The origin is at the feet,
			// so "+40 up" put the box at waist height — below the crosshair and
			// therefore un-aimable, which looked like the collider was missing.
			pos = cam.WorldPosition + cam.WorldRotation.Forward.WithZ( 0 ).Normal * 60f;
			rot = Rotation.LookAt( -cam.WorldRotation.Forward.WithZ( 0 ).Normal );
			Log.Info( "[wallbuy] no surface under the crosshair — placed in front of the player" );
		}

		var path = weapon.Contains( '/' ) ? weapon
			: $"prefabs/weapons/{(weapon.StartsWith( "nz_" ) ? weapon : "nz_" + weapon)}.prefab";

		var buy = Mgr.Place( pos, rot, path, price, rarity );
		Log.Info( $"[wallbuy] placed {buy.WeaponName} @ {price} (ammo {buy.AmmoPrice})" );
	}

	/// <summary>List every wallbuy in the map. `nz_wallbuy_list`.</summary>
	/// <summary>
	/// `nz_wallbuy_rarity &lt;index&gt; &lt;tier&gt;` — retag a placed wall buy. No args lists them.
	///
	/// ⛔ WRITES THE CONFIG ENTRY AND THE LIVE OBJECT, THEN REBUILDS THE CHALK. Setting only
	/// the component leaves the colour right until the next save/load and then reverts; setting
	/// only the config leaves the wall on screen in its old colour. §13: changing the data is not
	/// changing the world.
	/// </summary>
	[ConCmd( "nz_wallbuy_rarity" )]
	public static void SetRarity( int index = -1, int tier = -1 )
	{
		var list = ActiveConfig.Current?.WallBuys;
		if ( list is null || list.Count == 0 ) { Log.Info( "[wallbuy] none placed" ); return; }

		if ( index < 0 || tier < 0 )
		{
			Log.Info( $"[wallbuy] {list.Count} placed — nz_wallbuy_rarity <index> <0-{Rarity.LegendaryTier}>" );
			for ( var i = 0; i < list.Count; i++ )
				Log.Info( $"[wallbuy]   [{i}] {Rarity.NameFor( list[i].Rarity )}"
					+ $"  {System.IO.Path.GetFileNameWithoutExtension( list[i].WeaponPrefab )}"
					+ $" @ {list[i].Price}" );
			return;
		}

		if ( index >= list.Count )
		{
			Log.Warning( $"[wallbuy] no wall buy #{index} (have {list.Count})" );
			return;
		}

		// ⚠️ TO LEGENDARY: Godly is basalt's Easter egg's, and no wall sells it
		var t = System.Math.Clamp( tier, 0, Rarity.LegendaryTier );
		list[index].Rarity = t;

		// ⚠️ The live object too, and its chalk — see the summary above.
		// ⚠️ `Mgr`, NOT `WallBuyManager.All` — All is an instance member and Mgr is the
		// create-on-demand accessor every other command in this file already goes through.
		var live = Mgr?.All?.ElementAtOrDefault( index );
		if ( live.IsValid() )
		{
			live.Rarity = t;
			WallBuyManager.SpawnChalk( live );
		}

		Log.Info( $"[wallbuy] #{index} -> {Rarity.NameFor( t )}"
			+ $" (damage x{Rarity.Mult( t ):0.##})  (unsaved — nz_save to keep it)" );
	}

	[ConCmd( "nz_wallbuy_list" )]
	public static void List()
	{
		if ( Mgr is null ) { Log.Info( "[wallbuy] no manager" ); return; }
		var all = Mgr.All;
		Log.Info( $"[wallbuy] {all.Count} placed" );
		foreach ( var b in all )
			Log.Info( $"   {b.WeaponName,-10} {b.Price,5} / ammo {b.AmmoPrice,5}   {b.WorldPosition}" );
	}

	/// <summary>
	/// Remove every wall buy selling this weapon — matched anywhere in its prefab's path, as `nz_hex_rings_from_wallbuy`
	/// matches — from the config, and rebuild. `nz_wallbuy_remove deagle`. In memory, like every edit: nz_save keeps it.
	///
	/// ⚠️ A WEAPON IS NEEDED. An empty match would take every wall buy, and `nz_wallbuy_clear all` is the command for that.
	/// ⚠️ THE CONFIG, NOT THE OBJECTS: a wall buy's object is rebuilt from its config entry, so destroying only the object
	/// leaves it to come back at the next rebuild.
	/// </summary>
	[ConCmd( "nz_wallbuy_remove" )]
	public static void RemoveWeapon( string weapon = "" )
	{
		if ( NZGame.IsClient ) { Log.Warning( "[wallbuy] the config is the host's" ); return; }

		var match = weapon?.Trim() ?? "";
		if ( match == "" ) { Log.Warning( "[wallbuy] nz_wallbuy_remove <weapon> — which weapon's wall buys to take away" ); return; }

		var cfg = ActiveConfig.Current;
		if ( cfg?.WallBuys is null ) { Log.Warning( "[wallbuy] no config here" ); return; }

		var gone = cfg.WallBuys
			.Where( b => b.WeaponPrefab is not null && b.WeaponPrefab.Contains( match, System.StringComparison.OrdinalIgnoreCase ) )
			.ToList();
		foreach ( var b in gone ) cfg.WallBuys.Remove( b );
		if ( gone.Count > 0 ) Mgr?.Rebuild();

		Log.Info( $"[wallbuy] removed {gone.Count} selling '{match}' — {cfg.WallBuys.Count} left (unsaved — nz_save to keep it)" );
	}

	/// <summary>Remove the one you are aiming at, or all of them. `nz_wallbuy_clear [all]`.</summary>
	[ConCmd( "nz_wallbuy_clear" )]
	public static void Clear( string what = "" )
	{
		if ( Mgr is null ) return;

		if ( what == "all" )
		{
			var n = Mgr.All.Count;
			foreach ( var b in Mgr.All ) b.GameObject.Destroy();
			Log.Info( $"[wallbuy] removed {n}" );
			return;
		}

		var aimed = Mgr.Aimed( Player );
		if ( aimed is null ) { Log.Info( "[wallbuy] not aiming at one" ); return; }
		Log.Info( $"[wallbuy] removed {aimed.WeaponName}" );
		aimed.GameObject.Destroy();
	}

	/// <summary>
	/// Resize / spin the chalk drawings. `nz_wallbuy_chalk 64 0`.
	///
	/// ⚠️ Respawns every chalk quad rather than only the aimed one — the setting is
	/// global, so leaving the others at the old value would make the next
	/// adjustment impossible to judge.
	/// </summary>
	[ConCmd( "nz_wallbuy_chalk" )]
	public static void Chalk( float scale = 64f, float spin = 270f )
	{
		if ( Mgr is null ) return;
		WallBuyManager.ChalkScale = scale;
		WallBuyManager.ChalkSpin = spin;
		WallBuyManager.ClearCentreCache();   // recompute, do not reuse a stale centre

		// ⚠️ SpawnVisual, not SpawnChalk — it also puts the marker box back for any
		// weapon whose chalk is missing. Calling SpawnChalk alone would leave those
		// wallbuys invisible, since nothing else renders them any more.
		var all = Mgr.All;
		foreach ( var b in all ) WallBuyManager.SpawnVisual( b );
		Log.Info( $"[wallbuy] chalk scale {scale} spin {spin}° on {all.Count} wallbuy(s)" );

		// ⚠️ REPORT THE CHILD, NOT A MATERIAL. This used to probe for
		// materials/chalk/<name>.vmat, which was how chalk worked when it was traced
		// from pack icons. Chalk is generated from the weapon's geometry now, so that
		// probe always said MISSING — a status line that is always wrong is worse
		// than none, because it sends you looking for an asset that should not exist.
		foreach ( var b in all )
		{
			var chalk = b.GameObject.Children.FirstOrDefault( c => c.Name == "chalk" );
			var model = chalk.IsValid() ? chalk.Components.Get<ModelRenderer>()?.Model : null;
			Log.Info( $"[wallbuy]   {b.WeaponName,-10} " +
				$"chalk={(chalk.IsValid() ? "spawned" : "NONE (marker box)")} " +
				$"from={model?.Name ?? "-"}" );
		}
	}

	/// <summary>
	/// Force the weapon model visible on every wallbuy. `nz_wallbuy_reveal 1`.
	///
	/// ⚠️ Exists because the reveal is normally driven by where the player is
	/// LOOKING, and an aim-gated visual cannot be inspected from a console or
	/// screenshotted from the editor camera. Also the fastest way to judge whether
	/// the model actually fits its outline.
	/// </summary>
	[ConCmd( "nz_wallbuy_reveal" )]
	public static void Reveal( int on = 1 )
	{
		if ( Mgr is null ) return;
		WallBuyManager.ForceReveal = on != 0;

		var n = 0;
		foreach ( var b in Mgr.All )
		{
			var go = b.GameObject.Children.FirstOrDefault( c => c.Name == "weapon" );
			if ( !go.IsValid() ) { Log.Info( $"[wallbuy] {b.WeaponName}: no weapon model" ); continue; }
			go.Enabled = on != 0;
			n++;
			Log.Info( $"[wallbuy] {b.WeaponName} model scale {go.LocalScale.x:0.000} " +
				$"local {go.LocalPosition}" );
		}
		Log.Info( $"[wallbuy] reveal={on != 0} on {n} model(s)" );
	}

	/// <summary>
	/// Nudge the revealed model's depth off the wall. `nz_wallbuy_depth 2`.
	/// </summary>
	[ConCmd( "nz_wallbuy_depth" )]
	public static void Depth( float depth = 2f )
	{
		if ( Mgr is null ) return;
		WallBuyManager.ModelDepth = depth;
		foreach ( var b in Mgr.All ) WallBuyManager.SpawnVisual( b );
		if ( WallBuyManager.ForceReveal ) Reveal( 1 );
		Log.Info( $"[wallbuy] model depth {depth}" );
	}

	/// <summary>
	/// Buy from the first wallbuy in the map. `nz_wallbuy_buy`.
	///
	/// ⚠️ Does not require aiming — the buy path is what spawns the weapon on the
	/// player, and testing it should not also depend on the camera trace, which
	/// cannot be driven from a console.
	/// </summary>
	[ConCmd( "nz_wallbuy_buy" )]
	public static void Buy()
	{
		var player = Player;
		if ( player is null || Mgr is null ) return;

		var buy = Mgr.Aimed( player ) ?? Mgr.All.FirstOrDefault();
		if ( buy is null ) { Log.Info( "[wallbuy] none placed" ); return; }

		var spent = buy.TryBuy( player );
		Log.Info( $"[wallbuy] buy {buy.WeaponName} -> spent {spent}, points now {player.Points}" );

		// ⚠️ Report what the wallbuy is left holding. A duplicate weapon model is
		// only visible as a COUNT, and eyeballing the wall cannot tell you whether
		// the second one belongs to the wallbuy or to the player.
		foreach ( var c in buy.GameObject.Children )
			Log.Info( $"[wallbuy]   child '{c.Name}' enabled={c.Enabled}" );

		var held = player.GameObject.Components
			.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants ).ToList();
		Log.Info( $"[wallbuy]   player holds {held.Count} weapon(s): " +
			string.Join( ", ", held.Select( w => w.ClassName ) ) );
	}

	/// <summary>
	/// Rotate the weapon model within its outline. `nz_wallbuy_model 0 0 180`.
	///
	/// ⚠️ Logs the resulting WORLD angles next to the wallbuy's own, because
	/// "perpendicular" is judged from a screenshot and confirmed from the numbers:
	/// the model's yaw should sit 90° off the wallbuy's for a gun lying along the
	/// wall, and its roll says which way up it is.
	/// </summary>
	[ConCmd( "nz_wallbuy_model" )]
	public static void ModelTweak( float yaw = 0f, float pitch = 0f, float roll = 180f )
	{
		if ( Mgr is null ) return;
		WallBuyManager.ModelTweak = Rotation.From( pitch, yaw, roll );
		WallBuyManager.RefitModels();

		foreach ( var b in Mgr.All )
		{
			var go = b.GameObject.Children.FirstOrDefault( c => c.Name == "weapon" );
			if ( !go.IsValid() ) continue;
			Log.Info( $"[wallbuy] {b.WeaponName} wallbuy={b.WorldRotation.Angles()} " +
				$"model={go.WorldRotation.Angles()}" );
		}
		Log.Info( $"[wallbuy] model tweak yaw={yaw} pitch={pitch} roll={roll}" );
	}

	/// <summary>
	/// Rotate a pack's weapon model BEFORE it is flattened into the chalk. Unlike
	/// nz_wallbuy_model — which spins the finished wafer and cannot change its shape —
	/// this turns the MESH, so a DIFFERENT face becomes the drawing. This is the fix for
	/// a pack that comes out end-on ("forwards"). Per pack: `nz_wallbuy_premodel 0 90 0 simers`.
	///
	/// ⚠️ Use CARDINAL turns (0/90/180/270). The flatten axis follows this rotation and
	/// can squash only a whole model axis, so an off-axis value flattens approximately.
	/// Dial until the gun reads in profile, then bake it into WallBuyManager.PreTweaks.
	/// </summary>
	[ConCmd( "nz_wallbuy_premodel" )]
	public static void PreModelTweak( float yaw = 0f, float pitch = 0f, float roll = 0f, string pack = "" )
	{
		// ⛔ WAS A SILENT `return`, WHICH IS THE WORST OF THE THREE WAYS THIS COMMAND CAN DO
		// NOTHING. No manager, no matching weapons and a wrong angle all looked identical: you type
		// it, nothing happens, and there is no way to tell which. The other two now report; this
		// one says the play session is not running.
		if ( Mgr is null )
		{
			Log.Info( "[wallbuy] no wall-buy manager — start play mode first; the chalk only exists"
				+ " in a running scene" );
			return;
		}

		if ( string.IsNullOrWhiteSpace( pack ) )
		{
			Log.Info( "[wallbuy] nz_wallbuy_premodel needs a pack tag — e.g. `nz_wallbuy_premodel 0 90 0 simers`" );
			return;
		}

		WallBuyManager.PreTweaks[pack] = Rotation.From( pitch, yaw, roll );

		// ⚠️ DROP THE DERIVED ROSTERS TOO. A manifest-identified pack (`historical`) builds its
		// weapon list lazily and caches it in a static — so dialing a NEW tag would otherwise be
		// resolved against a roster computed before that tag existed.
		WallBuyManager.InvalidateRosters();
		WallBuyManager.RefitModels();

		Log.Info( $"[wallbuy] PRE-rotation for '{pack}' = yaw={yaw} pitch={pitch} roll={roll}"
			+ " — mesh turned before flatten" );
		Log.Info( $"[wallbuy]   matches {WallBuyManager.MatchCount( pack )} weapon(s)"
			+ " — 0 means the tag matches nothing and the dial will appear to do nothing" );
	}

	/// <summary>Slide the model within its outline. `nz_wallbuy_nudge 0 -2`.</summary>
	[ConCmd( "nz_wallbuy_nudge" )]
	public static void Nudge( float along = 0f, float up = 0f )
	{
		if ( Mgr is null ) return;
		WallBuyManager.ModelNudge = new Vector3( 0f, along, up );
		WallBuyManager.RefitModels();
		Log.Info( $"[wallbuy] model nudge along={along} up={up}" );
	}

	/// <summary>What is under the crosshair, and what it would cost. `nz_wallbuy_report`.</summary>
	[ConCmd( "nz_wallbuy_report" )]
	public static void Report()
	{
		var player = Player;
		// ⚠️ Report the TRACE, not just the conclusion. "nothing aimed" is three
		// different failures wearing one message: the ray missed, it hit
		// something else, or it hit the right object and the component lookup
		// failed. Saying which is the whole value of the command.
		var cam = Game.ActiveScene.Camera;
		var tr = Game.ActiveScene.Trace
			.Ray( cam.WorldPosition, cam.WorldPosition + cam.WorldRotation.Forward * 200f )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();

		Log.Info( $"[wallbuy] ray hit={tr.Hit} obj={tr.GameObject?.Name ?? "-"} " +
			$"dist={(tr.Hit ? tr.Distance : 0):0}" );

		foreach ( var b in Mgr.All )
			Log.Info( $"[wallbuy]   placed '{b.GameObject.Name}' at {b.WorldPosition} " +
				$"collider={b.GameObject.Components.Get<BoxCollider>()?.Scale.ToString() ?? "NONE"} " +
				$"dist={Vector3.DistanceBetween( b.WorldPosition, player.WorldPosition ):0}" );

		var buy = Mgr?.Aimed( player );
		if ( buy is null ) { Log.Info( "[wallbuy] Aimed() returned nothing" ); return; }

		Log.Info( $"[wallbuy] {buy.WeaponName}  price {buy.Price}  ammo {buy.AmmoPrice}" );
		Log.Info( $"[wallbuy]   prompt: {buy.UseText( player )}" );
		Log.Info( $"[wallbuy]   points: {player?.Points}   creative(free): {NZGame.IsCreative}" );
	}
}