EasterEgg/PressableCommands.cs

Console command helpers for the Easter-egg pressable interactables. Provides commands to place, list, inspect flags, find what blocks a nearby pressable, simulate uses, adjust settings, and clear configured pressables.

NetworkingFile Access
using Sandbox;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// Console access to the easter-egg pressables. Every setting in the tool panel has an
/// equivalent here, so a button can be placed, inspected and PRESSED without clicking —
/// the dev menu has been promising `nz_press`, `nz_press_list` and `nz_press_flags` in its
/// tooltip since the tool was wired.
///
/// ⛔ THE FLAG DUMP IS THE POINT OF THIS FILE. A button reads TWO separate pools — the door
/// pool for whether it exists, the egg pool for whether its step is current — and from in
/// front of an unresponsive button both look the same. `nz_press_where` names which one is
/// holding it shut.
/// </summary>
public static class PressableCommands
{
	static MapEditor Editor
		=> Game.ActiveScene?.GetAllComponents<MapEditor>().FirstOrDefault();

	// ⛔ `PlayerCharacters.Local()`, NOT `GetAllComponents<NZPlayer>().FirstOrDefault()` — and
	// TODAY THEY RETURN THE SAME THING. `Local()` resolves through `PlayerPresence.Find()`, which
	// is still a `FirstOrDefault`. Calling it anyway is deliberate: it is the one accessor
	// multiplayer will make genuinely local, so all 20 of its call sites become correct in a
	// single edit instead of 20. A raw FirstOrDefault here would have to be found again.
	//
	// ⚠️ It is already better in one respect: `Find()` falls back to disabled objects, which
	// `GetAllComponents` skips — so it works in the lobby, where the player body is switched off.
	static NZPlayer Player => PlayerCharacters.Local();

	/// <summary>
	/// Drop one at your feet: `nz_press [door flag] [required] [reward] [step]`.
	///
	/// ⛔ THE FIRST ARGUMENT IS THE DOOR FLAG, THE OTHER TWO ARE EGG FLAGS — the same order
	/// as the tool panel's rows, and the same two separate pools. `nz_press mine gate opened`
	/// makes a button that only exists once the door flag `mine` is bought, is only press-able
	/// once the EGG flag `gate` is set, and sets the EGG flag `opened` when pressed.
	///
	/// ⚠️ COMMA-SEPARATED, NO SPACES. A console argument splits on the space — that is how
	/// `nz_clue` shipped placing a clue reading one word — so a list is `a,b,c` and each
	/// argument stays one token.
	/// </summary>
	[ConCmd( "nz_press" )]
	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-press] 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;

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

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

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

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

			Log.Info( $"[nz-press] [{i}] flag {DoorLinks.Display( s.Link )}"
				+ ( step.StepNumber > 0 ? $"  STEP {step.StepNumber}" : "" )
				+ $"  needs [{Join( step.Required )}]"
				+ $"  gives [{Join( step.Reward )}]"
				+ ( step.Excluded.Count > 0 ? $"  locked by [{Join( step.Excluded )}]" : "" )
				+ ( string.IsNullOrWhiteSpace( step.GeneralReward ) ? "" : $"  opens {step.GeneralReward}" )
				+ ( s.RepeatCount > 1 ? $"  x{s.RepeatCount}" : "" )
				+ ( s.HoldSeconds > 0f ? $"  hold {s.HoldSeconds:0.#}s" : "" )
				+ ( s.TimeWindow > 0f ? $"  limit {s.TimeWindow:0.#}s" : "" )
				+ ( s.Retry == EggRetry.NextRound
					? "  retry next round"
					: s.CooldownSeconds > 0f ? $"  retry {s.CooldownSeconds:0.#}s" : "" )
				+ ( step.Completed ? "  DONE" : step.Satisfied ? "  satisfied, waiting" : "" ) );
		}

		var mgr = PressableManager.Instance;

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

	/// <summary>
	/// `nz_press_flags` — both pools, side by side.
	///
	/// ⛔ TWO POOLS, PRINTED TOGETHER AND LABELLED. `Docs/EASTER_EGG_TOOLSET.md`: a General
	/// flag "2" and an egg flag "2" are unrelated values in separate namespaces. Printing
	/// only one of them is how someone concludes a flag "is set" while the button is reading
	/// the other pool and disagreeing.
	/// </summary>
	[ConCmd( "nz_press_flags" )]
	public static void Flags()
	{
		var egg = EggFlags.Snapshot().ToList();

		Log.Info( $"[nz-press] EGG pool — {egg.Count( f => f.Value )} of {egg.Count} set" );

		foreach ( var f in egg.OrderBy( f => f.Index ) )
			Log.Info( $"[nz-press]   [{f.Index}] {f.Flag} = {( f.Value ? "SET" : "unset" )}" );

		if ( egg.Count == 0 )
			Log.Info( "[nz-press]   (none registered — flags register when the pressables build)" );

		var doors = DoorLinks.Opened;

		Log.Info( $"[nz-press] DOOR pool — {doors.Count} open: {DoorLinks.Summary}" );
	}

	/// <summary>
	/// `nz_press_where` — the nearest button, and WHICH gate is holding it.
	///
	/// ⛔ EVERY GATE IS PRINTED SEPARATELY, not just the first refusal. `Unavailable` returns
	/// one string because a prompt can only show one line, but a mapper debugging a button
	/// that will not press needs to see that the door is shut AND two required flags are
	/// missing — fixing the first and finding it still dead is the slow way to learn that.
	/// </summary>
	[ConCmd( "nz_press_where" )]
	public static void Where()
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-press] no player" ); return; }

		// ⚠️ `NearAny`, so a step that has already been done can still be inspected —
		// "why did nothing happen" is asked most often about a button that is finished.
		var press = Pressable.NearAny( p.WorldPosition );

		if ( press is null )
		{
			var n = ActiveConfig.Current.Pressables.Count;

			Log.Info( n == 0
				? "[nz-press] none placed"
				: $"[nz-press] none within {Pressable.UseRange:0}u — {n} placed, walk up to one" );
			return;
		}

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

		var step = s.Step;

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

		// ⚠️ WHETHER IT IS ACTUALLY THERE, said first. A completed step removes its parts
		// from the world, and creative keeps them — so "the button is still standing in front of
		// me" and "the button exists" are different questions, and only this answers the second.
		if ( press.Visual.IsValid() )
			Log.Info( $"[nz-press]   body {( press.Visual.Enabled ? "drawn" : "HIDDEN — its step is done" )}"
				+ ( press.Step is not null && press.Step.Completed && press.Visual.Enabled
					? " (kept because creative shows authoring visuals)" : "" ) );

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

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

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

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

		// ── the group ────────────────────────────────────────────────────────
		// ⛔ NAMED, NOT COUNTED. "3 of 4" tells a mapper the group is stuck and nothing
		// about WHICH member is holding it — and the missing one is usually the one placed
		// somewhere nobody walks past.
		if ( step.StepNumber > 0 )
		{
			var members = EggGroups.InGroup( step.StepNumber ).ToList();

			Log.Info( $"[nz-press]   STEP {step.StepNumber} — "
				+ $"{members.Count( m => m.Step.Satisfied )}/{members.Count} satisfied"
				+ ( members.All( m => m.Step.Completed ) ? " · COMPLETE" : "" ) );

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

		// ── the conditions ───────────────────────────────────────────────────
		var need = System.Math.Max( 1, s.RepeatCount );

		Log.Info( $"[nz-press]   presses {press.Presses}/{need}"
			+ ( s.HoldSeconds > 0f ? $" · hold {press.HeldFor:0.0}/{s.HoldSeconds:0.#}s" : "" )
			+ ( s.TimedDelay > 0f ? $" · unlock delay {s.TimedDelay:0.#}s" : "" )
			+ ( s.ResetOnRound ? " · resets each round" : "" ) );

		// ⚠️ THE RULE AND THE CURRENT STATE ON ONE LINE. "Retry: next round" says how it is
		// set; "LOCKED" says whether it is serving one right now — and a button that will not
		// press asks the second question, not the first.
		// ⛔ THE LIMIT AND WHAT IS LEFT OF IT ARE DIFFERENT FACTS. "limit 10s" is how it was
		// authored; "4.2s left" only exists while somebody is mid-attempt, and a step that keeps
		// failing is diagnosed by watching the second number, not the first.
		var left = EggGroups.Remaining( press );

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

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

		if ( !string.IsNullOrWhiteSpace( s.RequiredWeapon ) )
			Log.Info( $"[nz-press]   needs weapon '{s.RequiredWeapon}' in hand" );

		if ( !string.IsNullOrWhiteSpace( s.RequiredPerk ) )
			Log.Info( $"[nz-press]   needs perk '{s.RequiredPerk}'" );

		// ── the verdict the prompt would show ────────────────────────────────
		var blocked = press.Unavailable( p );

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

	/// <summary>
	/// `nz_press_use [times] [index]` — press a button from the console. Index -1 = the nearest.
	///
	/// ⚠️ IT GOES THROUGH `Press`, not through the flags. Setting the reward flag by hand
	/// would test nothing: the repeat count, the cooldown, the overshoot rule and the door
	/// gate all live on the path the use key takes, and this exists to exercise exactly that
	/// path without needing to walk up and mash E through a hold timer.
	///
	/// ⛔ THE INDEX EXISTS BECAUSE A STEP GROUP CANNOT BE TESTED WITHOUT IT. Driving this
	/// headlessly places every button at the player's feet, and `Near` can only ever return one
	/// of a stack — so a four-member group would have three members no command could reach.
	/// It resolves through the config ROW, not through list order, so it still names the right
	/// button after a rebuild has recreated every component.
	/// </summary>
	[ConCmd( "nz_press_use" )]
	public static void Use( int times = 1, int index = -1 )
	{
		var p = Player;
		if ( !p.IsValid() ) { Log.Warning( "[nz-press] no player" ); return; }

		Pressable press;

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

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

			var row = list[index];
			press = Pressable.All.FirstOrDefault( b => b.IsValid() && b.Spot == row );

			if ( press is null )
			{
				Log.Warning( $"[nz-press] button [{index}] is configured but not standing" );
				return;
			}
		}
		else
		{
			press = Pressable.Near( p.WorldPosition );
			if ( press is null ) { Log.Info( "[nz-press] nothing within reach" ); return; }
		}

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

	/// <summary>
	/// `nz_egg_groups` — every step number in the map and how far along it is.
	///
	/// ⚠️ IT LISTS THE MEMBERS, NOT JUST THE TALLY. A step that will not finish is almost
	/// always one member the mapper forgot they placed, or placed twice — both of which read as
	/// "4 of 5" and neither of which a count can tell apart.
	/// </summary>
	[ConCmd( "nz_egg_groups" )]
	public static void Groups()
	{
		var any = false;

		foreach ( var g in EggGroups.Snapshot() )
		{
			any = true;

			Log.Info( $"[nz-press] step {g.Number} — {g.Satisfied}/{g.Total} satisfied"
				+ ( g.Complete ? " · COMPLETE" : "" ) );

			foreach ( var m in EggGroups.InGroup( g.Number ) )
				Log.Info( $"[nz-press]   {( m.Step.Satisfied ? "done " : "OPEN " )}{m.Describe()}" );
		}

		var loners = EggGroups.All.Count( m => m.Step.StepNumber <= 0 );

		if ( !any )
			Log.Info( "[nz-press] no step groups — every placed interactable is on its own "
				+ "(Easter egg step = 0)" );

		Log.Info( $"[nz-press] {loners} ungrouped interactable(s)" );
	}

	/// <summary>
	/// `nz_press_set [count] [hold] [retry seconds] [step time limit]` — retune every placed
	/// button AND the tool default. −1 (or omitted) leaves a value alone.
	///
	/// ⚠️ BOTH, DELIBERATELY — the trap `nz_dmgwall_set` and `nz_fizz_slotprice` both
	/// document. The config is what the buttons already standing do; the `MapEditor` fields are
	/// what the NEXT one placed is stamped with. Setting one alone means the behaviour changes
	/// now and silently reverts later.
	/// </summary>
	[ConCmd( "nz_press_set" )]
	public static void Set( int count = -1, float hold = -1f, float retrySeconds = -1f,
		float window = -1f )
	{
		foreach ( var p in ActiveConfig.Current.Pressables )
		{
			if ( count >= 0 ) p.RepeatCount = count;
			if ( hold >= 0f ) p.HoldSeconds = hold;
			if ( retrySeconds >= 0f ) p.CooldownSeconds = retrySeconds;

			// ⚠️ SET ON EVERY MEMBER, which is what `EggGroups.WindowFor` wants: it warns
			// when a step's members disagree, and a command that changed only some of them
			// would be the thing producing that warning.
			if ( window >= 0f ) p.TimeWindow = window;
		}

		var ed = Editor;

		if ( ed.IsValid() )
		{
			if ( count >= 0 ) ed.EggRepeat = count;
			if ( hold >= 0f ) ed.PressHold = hold;
			if ( retrySeconds >= 0f ) ed.EggCooldown = retrySeconds;
			if ( window >= 0f ) ed.EggWindow = window;
		}
		else
		{
			Log.Warning( "[nz-press] no MapEditor — the buttons already placed were retuned, "
				+ "but the NEXT one placed will use the old numbers" );
		}

		List();
	}

	/// <summary>
	/// `nz_press_retry &lt;seconds|round&gt;` — how a FAILED attempt is locked out, on every placed
	/// button and the tool default.
	///
	/// ⛔ THE TWO ARE MUTUALLY EXCLUSIVE, so this takes a word rather than a number. Passing
	/// "round" does not also clear the seconds value: it is simply not read, and a mapper who
	/// switches back gets their old number rather than a zero they have to retype.
	/// </summary>
	[ConCmd( "nz_press_retry" )]
	public static void Retry( string mode = "" )
	{
		if ( string.IsNullOrWhiteSpace( mode ) )
		{
			Log.Info( "[nz-press] usage: nz_press_retry seconds | nz_press_retry round" );
			return;
		}

		var round = mode.StartsWith( "r", System.StringComparison.OrdinalIgnoreCase );
		var value = round ? EggRetry.NextRound : EggRetry.Seconds;

		foreach ( var p in ActiveConfig.Current.Pressables )
			p.Retry = value;

		var ed = Editor;

		if ( ed.IsValid() )
			ed.EggRetryMode = ToolSettings.RetryModes[round ? 1 : 0];
		else
			Log.Warning( "[nz-press] no MapEditor — placed buttons were changed, the next one "
				+ "placed will not be" );

		Log.Info( $"[nz-press] retry after failing: {( round ? "next round" : "a clock" )}" );
		List();
	}

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

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

	static string Join( List<string> flags ) => flags.Count == 0 ? "" : string.Join( ",", flags );
}