EasterEgg/ShootableCommands.cs

Console command helpers for the Easter Egg shootable interactables. Exposes commands to place, list, simulate hits, inspect nearest shootable, set properties, and clear configured shootables, using the MapEditor, player, and ActiveConfig systems.

Reflection
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// Console access to the easter-egg shootables.
///
/// ⛔ `nz_shoot_hit` IS NOT A CONVENIENCE, IT IS THE ONLY HEADLESS TEST. A pressable can be
/// driven by calling its use method; a shootable is triggered by a real bullet arriving at a
/// real collider, which nothing can fake from a console. This runs the same `Unavailable` and
/// `Bank` a bullet does, so the count, the overshoot rule, the step clock and every gate are on
/// the path being exercised — everything except the trace itself.
/// </summary>
public static class ShootableCommands
{
	static MapEditor Editor
		=> Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();

	static NZPlayer Player => PlayerCharacters.Local();

	/// <summary>
	/// Drop one at your feet: `nz_shoot [door flag] [required] [reward] [step]`.
	///
	/// ⚠️ The same four arguments as `nz_press`, in the same order, because they set the same
	/// shared `Egg*` fields — a step built from a button and a target is typed once.
	/// Comma-separated lists, no spaces: a console argument splits on the space.
	/// </summary>
	[ConCmd( "nz_shoot" )]
	public static void Place( string flag = null, string required = null, string reward = null,
		int step = -1 )
	{
		var ed = Editor;
		var p = Player;
		if ( !ed.IsValid() || !p.IsValid() ) { Log.Warning( "[nz-shoot] no editor/player" ); return; }

		if ( flag is not null ) ed.SpawnLink = flag;
		if ( required is not null ) ed.EggRequired = required;
		if ( reward is not null ) ed.EggReward = reward;
		if ( step >= 0 ) ed.EggStepNumber = step;

		ed.AddShootableAt( p.WorldPosition, Vector3.Up );
	}

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

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

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

			Log.Info( $"[nz-shoot] [{i}] flag {DoorLinks.Display( s.Link )}"
				+ ( step.StepNumber > 0 ? $"  STEP {step.StepNumber}" : "" )
				+ $"  needs [{string.Join( ",", step.Required )}]"
				+ $"  gives [{string.Join( ",", step.Reward )}]"
				+ ( s.RepeatCount > 1 ? $"  x{s.RepeatCount}" : "" )
				+ ( s.RequiresPaP ? "  PaP only" : "" )
				+ ( string.IsNullOrWhiteSpace( s.RequiredWeapon ) ? "" : $"  weapon '{s.RequiredWeapon}'" )
				+ ( s.TimeWindow > 0f ? $"  limit {s.TimeWindow:0.#}s" : "" )
				+ ( step.Completed ? "  DONE" : step.Satisfied ? "  satisfied, waiting" : "" ) );
		}

		var mgr = ShootableManager.Instance;

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

	/// <summary>
	/// `nz_shoot_hit [times] [index]` — trigger one from the console. Index -1 = the nearest.
	///
	/// ⚠️ THE WEAPON REQUIREMENT IS JUDGED AGAINST THE GUN IN HAND, because there is no bullet
	/// to ask. That is the same gun a real shot would have come from, so the test is honest
	/// about everything except which barrel it left.
	/// </summary>
	[ConCmd( "nz_shoot_hit" )]
	public static void Hit( int times = 1, int index = -1 )
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-shoot] no player" ); return; }

		Shootable target;

		if ( index >= 0 )
		{
			var list = ActiveConfig.Current.Shootables;

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

			var row = list[index];
			target = Shootable.All.FirstOrDefault( t => t.IsValid() && t.Spot == row );

			if ( target is null )
			{
				Log.Warning( $"[nz-shoot] target [{index}] is configured but not standing" );
				return;
			}
		}
		else
		{
			target = Shootable.Near( p.WorldPosition );

			if ( target is null )
			{
				Log.Info( $"[nz-shoot] none within {Shootable.FindRange:0}u — "
					+ $"{ActiveConfig.Current.Shootables.Count} placed" );
				return;
			}
		}

		for ( int i = 0; i < times.Clamp( 1, 50 ); i++ )
			Log.Info( $"[nz-shoot] {target.Simulate( p )}" );
	}

	/// <summary>
	/// `nz_shoot_where` — the nearest target, and which gate is holding it.
	///
	/// ⛔ EVERY GATE SEPARATELY, not just the first refusal — a mapper debugging a target that
	/// will not trigger needs to see that the door is shut AND two required flags are missing.
	/// </summary>
	[ConCmd( "nz_shoot_where" )]
	public static void Where()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-shoot] no player" ); return; }

		var t = Shootable.NearAny( p.WorldPosition );

		if ( t is null )
		{
			var n = ActiveConfig.Current.Shootables.Count;

			Log.Info( n == 0
				? "[nz-shoot] none placed"
				: $"[nz-shoot] none within {Shootable.FindRange:0}u — {n} placed" );
			return;
		}

		var s = t.Spot;
		if ( s is null ) { Log.Warning( "[nz-shoot] nearest target has no config row" ); return; }

		var step = s.Step;

		Log.Info( $"[nz-shoot] nearest is {t.WorldPosition.Distance( p.WorldPosition ):0}u away" );

		if ( t.Visual.IsValid() )
			Log.Info( $"[nz-shoot]   body {( t.Visual.Enabled ? "drawn and shootable" : "HIDDEN — its step is done" )}" );

		// ⛔ THE EXACT LOOKUP A BULLET DOES, RUN WITHOUT FIRING ONE. `BulletInfo.HitScan`
		// finds its victim with `hitObj.Components.GetInAncestorsOrSelf<IDamageable>()` and needs
		// a collider to have hit in the first place. Both are things a build can silently lose —
		// a manager that forgot the BoxCollider, a component that ended up on the wrong object —
		// and the symptom of either is identical to a gate refusing: nothing happens. Asking the
		// same two questions here separates "the bullet never reached it" from "it said no".
		var collider = t.Visual.IsValid()
			? t.Visual.Components.Get<Collider>( FindMode.EverythingInSelfAndDescendants )
			: null;

		var damageable = t.Visual.IsValid()
			? t.Visual.Components.GetInAncestorsOrSelf<Component.IDamageable>()
			: null;

		Log.Info( $"[nz-shoot]   bullet path: collider {( collider.IsValid() ? "yes" : "MISSING" )}"
			+ $" · IDamageable {( ReferenceEquals( damageable, t ) ? "this target"
				: damageable is null ? "NONE — a bullet would pass through" : damageable.GetType().Name )}" );

		Log.Info( $"[nz-shoot]   door flag {DoorLinks.Display( s.Link )}"
			+ ( DoorLinks.IsOpen( s.Link ) ? " — open" : " — SHUT, the target is inert" ) );

		foreach ( var f in step.Required )
			Log.Info( $"[nz-shoot]   requires {f} = {( EggFlags.IsSet( f ) ? "SET" : "MISSING" )}" );

		foreach ( var f in step.Excluded )
			Log.Info( $"[nz-shoot]   excluded by {f} = {( EggFlags.IsSet( f ) ? "SET — LOCKED OUT" : "unset" )}" );

		Log.Info( $"[nz-shoot]   rewards [{string.Join( ",", step.Reward )}]"
			+ ( string.IsNullOrWhiteSpace( step.GeneralReward ) ? "" : $" · opens door {step.GeneralReward}" )
			+ ( step.StepNumber > 0 ? $" · step {step.StepNumber}" : "" ) );

		if ( step.StepNumber > 0 )
		{
			var members = EggGroups.InGroup( step.StepNumber ).ToList();

			Log.Info( $"[nz-shoot]   STEP {step.StepNumber} — "
				+ $"{members.Count( m => m.Step.Satisfied )}/{members.Count} satisfied" );

			foreach ( var m in members )
				Log.Info( $"[nz-shoot]     {( m.Step.Satisfied ? "done " : "OPEN " )}{m.Describe()}" );
		}

		Log.Info( $"[nz-shoot]   hits {t.Hits}/{System.Math.Max( 1, s.RepeatCount )}"
			+ ( s.RequiresPaP ? " · needs a PACKED weapon" : "" )
			+ ( string.IsNullOrWhiteSpace( s.RequiredWeapon ) ? "" : $" · needs '{s.RequiredWeapon}'" )
			+ ( string.IsNullOrWhiteSpace( s.RequiredPerk ) ? "" : $" · needs perk '{s.RequiredPerk}'" ) );

		var left = EggGroups.Remaining( t );

		if ( s.TimeWindow > 0f || left >= 0f )
			Log.Info( $"[nz-shoot]   step time limit {s.TimeWindow:0.#}s"
				+ ( left >= 0f ? $" · RUNNING, {left:0.0}s left" : " · not started" ) );

		Log.Info( $"[nz-shoot]   retry: "
			+ ( s.Retry == EggRetry.NextRound
				? "next round"
				: s.CooldownSeconds > 0f ? $"{s.CooldownSeconds:0.#}s" : "at once" )
			+ ( t.LockedUntilRound ? " · LOCKED until the round turns" : "" )
			+ ( t.Cooldown > 0f ? $" · LOCKED for {t.Cooldown:0.0}s more" : "" ) );

		var blocked = t.Unavailable( p, null );

		Log.Info( step.Completed
			? "[nz-shoot]   VERDICT: already completed"
			: string.IsNullOrEmpty( blocked )
				? "[nz-shoot]   VERDICT: shoot it"
				: $"[nz-shoot]   VERDICT: '{blocked}'" );
	}

	/// <summary>
	/// `nz_shoot_set [count] [pap 0/1] [step time limit]` — retune every placed target AND the
	/// tool default. −1 leaves a value alone.
	///
	/// ⚠️ BOTH, DELIBERATELY — the trap `nz_dmgwall_set` documents. The config is what the
	/// targets already standing do; the `MapEditor` fields are what the NEXT one placed is
	/// stamped with.
	/// </summary>
	[ConCmd( "nz_shoot_set" )]
	public static void Set( int count = -1, int pap = -1, float window = -1f )
	{
		foreach ( var s in ActiveConfig.Current.Shootables )
		{
			if ( count >= 0 ) s.RepeatCount = count;
			if ( pap >= 0 ) s.RequiresPaP = pap != 0;
			if ( window >= 0f ) s.TimeWindow = window;
		}

		var ed = Editor;

		if ( ed.IsValid() )
		{
			if ( count >= 0 ) ed.EggRepeat = count;
			if ( pap >= 0 ) ed.ShootRequiresPaP = pap != 0;
			if ( window >= 0f ) ed.EggWindow = window;
		}
		else
		{
			Log.Warning( "[nz-shoot] no MapEditor — the targets already placed were retuned, "
				+ "but the NEXT one placed will use the old numbers" );
		}

		List();
	}

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

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