Console command helpers for the augment (perk upgrade) system. Exposes ConCmds to list augments, buy/grant, remove, clear, open the augment UI, adjust prices, and run audits/deviation reports against the generated augment roster.
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>
/// Console access to the augment system.
///
/// ⛔ A SEPARATE FILE FROM <see cref="PerkAugments"/> because that one is 760 lines of
/// generated data and this is hand-written. Regenerating the roster from the lua must not
/// mean re-pasting the commands back in afterwards — which is exactly what would happen
/// if they shared a file.
///
/// ⚠️ EVERY BUTTON IN THE AUGMENT UI HAS A COMMAND HERE, per the standing rule: a screen
/// you can only reach by walking into a machine and clicking cannot be tested remotely,
/// and the augment screen is three clicks deep (walk in → click an owned perk → click a
/// row).
/// </summary>
public static class AugmentCommands
{
static NZPlayer Me()
=> NZPlayer.Local;
/// <summary>
/// `nz_augments [perk]` — list a perk's pool with what is equipped, or summarise
/// every perk you own when given nothing.
///
/// ⚠️ THE SUMMARY SKIPS PERKS YOU DO NOT OWN. All 18 always have a pool, so listing
/// them all would be 162 lines of things you cannot buy; the point of the no-argument
/// form is "what can I augment right now".
/// </summary>
[ConCmd( "nz_augments" )]
public static void ListCmd( string perk = "" )
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
if ( string.IsNullOrEmpty( perk ) )
{
Log.Info( $"[nz-aug] {p.Salvage:N0} salvage"
+ $" · major {PerkAugments.MajorPrice:N0}"
+ $" / minor {PerkAugments.MinorPrice:N0}"
+ $" (×{PerkAugments.PriceScale:0.##})"
+ $" · pick {PerkAugments.LimitOf( PerkAugments.AugmentTier.Major )} major"
+ $" + {PerkAugments.LimitOf( PerkAugments.AugmentTier.Minor )} minor"
+ (PerkAugments.Unlimited ? " (the limit lifted)" : "") );
if ( p.Perks.Count == 0 )
{
Log.Info( "[nz-aug] you own no perks — nz_perk <id> to grant one" );
return;
}
foreach ( var id in p.Perks )
{
var equipped = PerkAugments.EquippedOn( p, id );
var pool = PerkAugments.PoolFor( id );
Log.Info( $"[nz-aug] {id,-11} {(pool is null ? "no pool" : $"{pool.Major.Length}M/{pool.Minor.Length}m")}"
+ $" · equipped {(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}" );
}
Log.Info( "[nz-aug] nz_augments <perk> for the full list" );
return;
}
var target = PerkRegistry.Find( perk );
if ( target is null ) { Log.Warning( $"[nz-aug] no perk '{perk}'" ); return; }
Log.Info( $"[nz-aug] {target.Name} ({target.Id})"
+ $" · {(p.HasPerk( target.Id ) ? "OWNED" : "not owned — cannot buy")}"
+ $" · {p.Salvage:N0} salvage" );
Dump( p, target.Id, PerkAugments.AugmentTier.Major );
Dump( p, target.Id, PerkAugments.AugmentTier.Minor );
}
static void Dump( NZPlayer p, string perkId, PerkAugments.AugmentTier tier )
{
var list = tier == PerkAugments.AugmentTier.Major
? PerkAugments.MajorsFor( perkId )
: PerkAugments.MinorsFor( perkId );
Log.Info( $"[nz-aug] {tier.ToString().ToUpper()}"
+ $" — {PerkAugments.CountOf( p, perkId, tier )}/{PerkAugments.LimitOf( tier )} used" );
foreach ( var a in list )
{
var mark = PerkAugments.Has( p, perkId, a.Id ) ? "*" : " ";
Log.Info( $"[nz-aug] {mark} {a.Id,-3} {a.Name,-22} {PerkAugments.PriceOf( a ),6:N0} {a.Desc}" );
}
}
/// <summary>
/// `nz_augment <perk> <id> [free]` — buy an augment, or grant it without paying.
///
/// ⚠️ PAID BY DEFAULT, free only when asked. The whole reason salvage exists is to
/// feel the cost, and a command that quietly skipped it would make every price test
/// meaningless.
/// </summary>
[ConCmd( "nz_augment" )]
public static void BuyCmd( string perk = "", string id = "", string free = "" )
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
if ( string.IsNullOrEmpty( perk ) || string.IsNullOrEmpty( id ) )
{
Log.Info( "[nz-aug] nz_augment <perk> <M1|m1|...> [free]" );
return;
}
var isFree = free == "free" || free == "1";
var aug = PerkAugments.Find( perk, id );
var price = PerkAugments.PriceOf( aug );
var msg = isFree
? PerkAugments.Grant( p, perk, id )
: PerkAugments.TryBuy( p, perk, id );
if ( msg is not null ) { Log.Warning( $"[nz-aug] refused: {msg}" ); return; }
Log.Info( $"[nz-aug] {perk}/{id} \"{aug.Name}\" equipped"
+ (isFree ? " — FREE" : $" — {price:N0} salvage, {p.Salvage:N0} left") );
}
/// <summary>`nz_augment_clear [perk]` — drop one perk's augments, or all of them.</summary>
[ConCmd( "nz_augment_clear" )]
public static void ClearCmd( string perk = "" )
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
if ( string.IsNullOrEmpty( perk ) )
{
var n = p.Augments.Count;
PerkAugments.ClearAll( p );
Log.Info( $"[nz-aug] cleared augments on {n} perk(s)" );
return;
}
PerkAugments.ClearFor( p, perk );
Log.Info( $"[nz-aug] cleared {perk}" );
}
/// <summary>
/// `nz_augment_remove <perk> <id>` — take one augment off for half its salvage back, as a
/// right click on its row in the Wunderfizz does (a left click until 2026-10-03).
///
/// ⚠️ NOT nz_augment_clear. That one drops augments with no refund and no refresh, for
/// resetting a test; this is the player's action, through the same TryRemove the row uses.
/// </summary>
[ConCmd( "nz_augment_remove" )]
public static void RemoveCmd( string perk = "", string id = "" )
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
if ( string.IsNullOrEmpty( perk ) || string.IsNullOrEmpty( id ) )
{
Log.Info( "[nz-aug] nz_augment_remove <perk> <M1|m1|...> — nz_augments lists what you have" );
return;
}
var aug = PerkAugments.Find( perk, id );
var msg = PerkAugments.TryRemove( p, perk, id, out var refund );
if ( msg is not null ) { Log.Warning( $"[nz-aug] not removed: {msg}" ); return; }
Log.Info( $"[nz-aug] {perk}/{id} \"{aug?.Name}\" removed — {refund:N0} salvage back,"
+ $" {p.Salvage:N0} now" );
}
/// <summary>
/// `nz_augment_menu [perk]` — open the augment screen.
///
/// ⛔ THE SCREEN IS A MODE ON THE WUNDERFIZZ MENU, so this has to open that menu
/// first, which needs a machine on the map. It falls back to the nearest machine
/// anywhere rather than one in use range, matching nz_fizz_menu — the command is for
/// looking at the UI, not for playing.
///
/// ⚠️ Defaults to the FIRST PERK YOU OWN rather than to a fixed id, because the
/// screen refuses to open on a perk you do not have and a default of "jugg" would
/// therefore do nothing on most test players.
/// </summary>
[ConCmd( "nz_augment_menu" )]
public static void MenuCmd( string perk = "" )
{
var p = Me();
if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
if ( string.IsNullOrEmpty( perk ) ) perk = p.Perks.FirstOrDefault();
if ( string.IsNullOrEmpty( perk ) )
{
Log.Warning( "[nz-aug] you own no perks — nz_perk jugg, then try again" );
return;
}
if ( !p.HasPerk( perk ) )
{
Log.Warning( $"[nz-aug] you do not own '{perk}' — the screen only opens on"
+ " owned perks, same as the machine" );
return;
}
if ( !WunderfizzMenu.IsOpen ) WunderfizzMenu.Cmd();
if ( !WunderfizzMenu.IsOpen ) return; // Cmd already logged why
WunderfizzMenu.OpenAugments( perk );
}
/// <summary>
/// `nz_augment_set [major] [minor] [scale]` — retune prices and the global scale.
///
/// ⚠️ Limits are deliberately NOT settable here. Raising the minor limit past 2 gives
/// the UI more rows than its "PICK n" pips are laid out for, and a menu that draws
/// four pips into space meant for two is a worse outcome than a fixed cap.
/// </summary>
[ConCmd( "nz_augment_set" )]
public static void SetCmd( int major = -1, int minor = -1, float scale = -1f )
{
if ( major >= 0 ) PerkAugments.MajorPrice = major;
if ( minor >= 0 ) PerkAugments.MinorPrice = minor;
if ( scale >= 0f ) PerkAugments.PriceScale = scale;
Log.Info( $"[nz-aug] major {PerkAugments.MajorPrice:N0}"
+ $" · minor {PerkAugments.MinorPrice:N0}"
+ $" · ×{PerkAugments.PriceScale:0.##}"
+ $" → charged {(int)(PerkAugments.MajorPrice * PerkAugments.PriceScale):N0}"
+ $" / {(int)(PerkAugments.MinorPrice * PerkAugments.PriceScale):N0}" );
}
/// <summary>
/// `nz_augment_deviations` — every augment whose behaviour intentionally differs from
/// the original's.
///
/// ⛔ EXISTS SO "PORTING BUG OR DELIBERATE CHANGE" IS ANSWERABLE. The roster is
/// generated from the lua, so anything that does NOT match the lua is either a
/// decision someone made or a mistake — and after a few weeks those are
/// indistinguishable from the code. This is the list of decisions.
/// </summary>
[ConCmd( "nz_augment_deviations" )]
public static void DeviationsCmd()
{
var all = PerkAugments.AllDeviations();
Log.Info( $"[nz-aug] {all.Length} deliberate deviation(s) from nZombies:" );
foreach ( var (key, desc) in all )
{
var slash = key.IndexOf( '/' );
var perkId = slash > 0 ? key.Substring( 0, slash ) : key;
var augId = slash > 0 ? key.Substring( slash + 1 ) : "";
var aug = PerkAugments.Find( perkId, augId );
Log.Info( $"[nz-aug] {key,-14} {aug?.Name ?? "?"}" );
Log.Info( $"[nz-aug] now: {desc}" );
}
Log.Info( "[nz-aug] the DESCRIPTION is rewritten with the behaviour — an augment"
+ " whose text still describes the original is the same lie as a weapon stat"
+ " panel claiming an effect that is not wired" );
}
/// <summary>
/// `nz_augment_audit` — prove the roster is intact.
///
/// ⛔ EXISTS BECAUSE THE ROSTER IS GENERATED. 162 entries parsed out of lua and
/// emitted as C# is exactly the kind of thing that loses a perk to a regex change and
/// reports it as "that perk has no augments yet", which looks intentional. This
/// counts what is actually there rather than trusting the number in a comment
/// (INSTRUCTIONS.md §6).
/// </summary>
[ConCmd( "nz_augment_audit" )]
public static void AuditCmd()
{
var perks = PerkRegistry.All;
int withPool = 0, majors = 0, minors = 0, bad = 0;
foreach ( var perk in perks )
{
var pool = PerkAugments.PoolFor( perk.Id );
if ( pool is null )
{
Log.Warning( $"[nz-aug-audit] {perk.Id} has NO POOL" );
bad++;
continue;
}
withPool++;
majors += pool.Major.Length;
minors += pool.Minor.Length;
// ⚠️ The tier is derived from the id's case, so a lowercase id in the major
// list would silently become a minor and quietly break both slot limits.
foreach ( var a in pool.Major.Where( a => a.Tier != PerkAugments.AugmentTier.Major ) )
{
Log.Warning( $"[nz-aug-audit] {perk.Id}/{a.Id} is in MAJOR but reads as minor" );
bad++;
}
foreach ( var a in pool.Minor.Where( a => a.Tier != PerkAugments.AugmentTier.Minor ) )
{
Log.Warning( $"[nz-aug-audit] {perk.Id}/{a.Id} is in MINOR but reads as major" );
bad++;
}
foreach ( var a in pool.Major.Concat( pool.Minor )
.Where( a => string.IsNullOrWhiteSpace( a.Name ) || string.IsNullOrWhiteSpace( a.Desc ) ) )
{
Log.Warning( $"[nz-aug-audit] {perk.Id}/{a.Id} has an empty name or description" );
bad++;
}
}
Log.Info( $"[nz-aug-audit] {withPool}/{perks.Length} perks have a pool"
+ $" · {majors} major + {minors} minor = {majors + minors} augments"
+ $" · {bad} problem(s)" );
// ⚠️ THIS LINE USED TO SAY "NONE OF THEM DO ANYTHING YET" and it was true for
// about twenty minutes. A hardcoded claim about how much is wired is a claim that
// goes stale the moment anything is wired — INSTRUCTIONS.md §6 — so it now
// enumerates, and the count comes from the same list the dispatch uses.
var wired = AugmentEffects.WiredPerks();
var unwired = perks.Where( p => !AugmentEffects.IsWired( p.Id ) ).ToArray();
Log.Info( $"[nz-aug-audit] EFFECTS WIRED for {wired.Length}/{perks.Length} perk(s):"
+ $" {string.Join( ", ", wired )}"
+ $" · effects layer {(AugmentEffects.Enabled ? "on" : "OFF")}" );
Log.Info( $"[nz-aug-audit] no effects yet ({unwired.Length}):"
+ $" {string.Join( ", ", unwired.Select( p => p.Id ) )}"
+ " — their menus still take salvage, which the screen says out loud" );
}
}