EasterEgg/ClueCommands.cs

Console command helpers for the game's easter-egg clue system. Provides commands to place a clue at the player, list configured clues, edit clue text, clear all clues, locate the nearest clue and inspect its panels, and search available clue images.

File Access
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// Console access to the easter-egg clues. Every setting in the tool panel has an
/// equivalent here, so one can be placed and written without clicking.
/// </summary>
public static class ClueCommands
{
	static MapEditor Editor
		=> Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();

	static NZPlayer Player
		=> NZPlayer.Local;

	/// <summary>Drop one at your feet: `nz_clue [text...]`.
	///
	/// ⛔ `params string[]`, NOT `string`. A console argument is split on spaces, so
	/// `nz_clue THE CODE IS 4 7 2` bound only "THE" and the clue was placed reading one
	/// word — and because the board auto-sizes to its text, the RESULT looked exactly like
	/// a sizing bug: a small board with clipped writing. The arithmetic was right and the
	/// input was truncated.</summary>
	[ConCmd( "nz_clue" )]
	public static void Place( params string[] words )
	{
		var ed = Editor;
		var p = Player;
		if ( !ed.IsValid() || !p.IsValid() ) { Log.Warning( "[nz-clue] no editor/player" ); return; }

		var text = words is { Length: > 0 } ? string.Join( " ", words ) : "";

		if ( !string.IsNullOrEmpty( text ) ) ed.ClueText = text;

		// ⚠️ At the PLAYER's feet with an UP normal — the command exists so this can be
		// driven headlessly, and a trace needs somewhere to be aiming. A clue placed this
		// way lies flat; use the tool for a wall.
		ed.AddClueAt( p.WorldPosition, Vector3.Up );
	}

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

		if ( list.Count == 0 )
		{
			Log.Info( "[nz-clue] none placed — Q > Easter egg > Interactables > Clue, or nz_clue" );
			return;
		}

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

			// ⚠️ TEXT AND IMAGE BOTH PRINTED IN FULL. They are the two fields typed by hand,
			// and a blank panel in the world is the same sight whether the text was left
			// empty or the image path was wrong.
			Log.Info( $"[nz-clue] [{i}] {c.Size.x:0}x{c.Size.y:0}u  font {c.FontSize:0}"
				+ $"  text '{c.Text}'"
				+ ( string.IsNullOrWhiteSpace( c.Image ) ? "" : $"  image '{c.Image}'" ) );
		}

		var mgr = ClueManager.Instance;

		Log.Info( $"[nz-clue] {list.Count} configured, "
			+ $"{( mgr.IsValid() ? mgr.Built.ToString() : "?" )} standing" );
	}

	/// <summary>
	/// `nz_clue_text &lt;index&gt; &lt;text...&gt;` — rewrite one clue in place.
	///
	/// ⚠️ REBUILDS. `CluePanel`'s BuildHash covers the text, so an edit redraws on its own
	/// once the object exists — but the config row is a different object after a rebuild
	/// elsewhere, and rebuilding here keeps "what the console says" and "what is on the
	/// wall" the same thing.
	/// </summary>
	[ConCmd( "nz_clue_text" )]
	public static void SetText( int index, params string[] words )
	{
		var text = words is { Length: > 0 } ? string.Join( " ", words ) : "";
		var list = ActiveConfig.Current.Clues;

		if ( index < 0 || index >= list.Count )
		{
			Log.Warning( $"[nz-clue] no clue [{index}] — there are {list.Count}" );
			return;
		}

		list[index].Text = text;
		ClueManager.Ensure( Game.ActiveScene )?.Rebuild();

		Log.Info( $"[nz-clue] [{index}] now reads '{text}'" );
	}

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

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

	/// <summary>
	/// `nz_clue_where` — the nearest clue, what it holds, and whether a panel exists.
	///
	/// ⛔ IT REPORTS WHETHER THE `WorldPanel` AND `CluePanel` ARE ACTUALLY THERE. This is
	/// the project's first world-space UI, so "the clue draws nothing" has one more
	/// possible cause than usual: the panel components failing to attach. A blank wall
	/// looks identical whether the text is empty, the image path is wrong, or no panel was
	/// ever created — and only this separates them.
	/// </summary>
	[ConCmd( "nz_clue_where" )]
	public static void Where()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-clue] no player" ); return; }

		var clue = Clue.Near( p.WorldPosition );

		if ( clue is null ) { Log.Info( "[nz-clue] none placed" ); return; }

		var wp = clue.Components.Get<WorldPanel>( FindMode.EverythingInSelf );
		var cp = clue.Components.Get<CluePanel>( FindMode.EverythingInSelf );
		var s = clue.Spot;

		Log.Info( $"[nz-clue] nearest is {clue.WorldPosition.Distance( p.WorldPosition ):0}u away" );
		Log.Info( $"[nz-clue]   text '{s?.Text}'"
			+ ( string.IsNullOrWhiteSpace( s?.Image ) ? "  (no image)" : $"  image '{s.Image}'" ) );
		Log.Info( $"[nz-clue]   WorldPanel {( wp.IsValid() ? $"ok, {wp.PanelSize}" : "MISSING" )}"
			+ $" · CluePanel {( cp.IsValid() ? "ok" : "MISSING" )}" );

		if ( s is not null && string.IsNullOrWhiteSpace( s.Text ) && string.IsNullOrWhiteSpace( s.Image ) )
			Log.Warning( "[nz-clue]   this clue has NO text and NO image — it will draw nothing" );
	}

	/// <summary>
	/// `nz_clue_images [filter]` — what the picker offers, and search the rest of the project.
	///
	/// ⛔ THE PICKER ONLY LISTS `materials/clues/`, and this is how anything else is found.
	/// Scanning the whole project returns 6,515 PNGs — wall and weapon textures, almost none
	/// of them a clue — so the tool panel offers a curated folder and the console offers the
	/// search. With a filter this looks EVERYWHERE; without one it shows what the picker has.
	///
	/// ⚠️ Running it drops the cache, so art added while the game is running appears without
	/// a restart.
	/// </summary>
	[ConCmd( "nz_clue_images" )]
	public static void Images( string filter = "" )
	{
		ToolSettings.ForgetClueImages();

		if ( string.IsNullOrWhiteSpace( filter ) )
		{
			var picker = ToolSettings.ClueImages().Where( p => !string.IsNullOrEmpty( p ) ).ToList();

			Log.Info( $"[nz-clue] the picker offers {picker.Count} image(s) "
				+ $"from {ToolSettings.ClueImageFolder}/" );

			foreach ( var p in picker.Take( 40 ) )
				Log.Info( $"[nz-clue]   {p}" );

			if ( picker.Count > 40 )
				Log.Info( $"[nz-clue]   … and {picker.Count - 40} more" );

			if ( picker.Count == 0 )
				Log.Info( $"[nz-clue] nothing there yet — drop PNGs in Assets/{ToolSettings.ClueImageFolder}/ "
					+ "and run this again. To use art already in the project, pass a filter: "
					+ "nz_clue_images skull" );
			return;
		}

		// ⚠️ SEARCHES THE SOURCE PNGs, not the compiled textures. Every texture in this
		// project is a `*_png_<hash>.generated.vtex` produced by material compilation, so a
		// search over compiled names would return machine-generated strings a mapper has
		// never seen and cannot recognise.
		var hits = new List<string>();

		try
		{
			foreach ( var f in FileSystem.Mounted.FindFile( "materials", "*.png", true ) )
			{
				if ( f.Contains( filter, System.StringComparison.OrdinalIgnoreCase ) )
					hits.Add( $"materials/{f}" );
			}
		}
		catch ( System.Exception e )
		{
			Log.Warning( $"[nz-clue] search failed: {e.Message}" );
			return;
		}

		Log.Info( $"[nz-clue] {hits.Count} image(s) matching '{filter}'" );

		foreach ( var h in hits.Take( 30 ) )
			Log.Info( $"[nz-clue]   {h}" );

		if ( hits.Count > 30 )
			Log.Info( $"[nz-clue]   … and {hits.Count - 30} more — narrow the filter" );

		if ( hits.Count > 0 )
			Log.Info( "[nz-clue] set one with the Image row, or copy it into "
				+ $"Assets/{ToolSettings.ClueImageFolder}/ to have it in the picker" );
	}
}