Player/AugmentCommands.cs

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.

NetworkingFile Access
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" );
	}
}