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.
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 <seconds|round>` — 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 );
}