Static PerkAugments data and helpers for NZombies. Defines augment types, pools of major/minor augments per perk, overrides/renames for descriptions and names, pricing and limits, and utilities to return a corrected pool for a given perk id.
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Perk augments — one MAJOR and two MINOR upgrades bought per owned perk, paid for
/// in salvage.
///
/// ⛔ THE ROSTER IS DATA LIFTED FROM `perks/sh_augments.lua`, NOT WRITTEN HERE. All 162
/// entries (18 perks × 4 major + 5 minor) were parsed out of the lua and emitted, because
/// hand-transcribing 162 names and descriptions is 162 chances to introduce a difference
/// nobody would ever notice — and the descriptions ARE the entire UI.
///
/// ⚠️ 162 IS WHAT WAS IMPORTED, NOT WHAT IS HERE NOW. Tombstone Soda's pool was
/// removed when the perk was, so this file holds 17 × 9 = 153. The import figure is kept
/// because it is the provenance — but read it as history, not as a count of what follows.
///
/// ⛔ NO AUGMENT DOES ANYTHING YET, AND THAT MATCHES THE ORIGINAL. `sh_augments.lua`
/// says so in its own header: *"This file wires the WORKING UI + storage + networking
/// only. Augment EFFECTS are intentionally NOT applied yet."* So this is the same
/// deliberate half: pick, pay, remember, display. Wiring 162 effects behind a UI nobody
/// has clicked through would be the more expensive order to do it in.
///
/// ⚠️ PAID IN SALVAGE, NOT POINTS. That is why <see cref="Salvage"/> was built
/// earn-only ahead of any sink — this is the first one. A player on a map with the
/// salvage setting off has no income and cannot buy augments at all, which is the
/// original's behaviour too and worth knowing before it reads as the menu being broken.
///
/// ⛔ EVERYTHING IS A METHOD OR A REBUILDING PROPERTY — NO `static readonly` COLLECTION.
/// INSTRUCTIONS.md §1: hotload MIGRATES static collections, so a dictionary built in an
/// initialiser can never be corrected in a live session and a newly added key never
/// appears. This project has paid for that lesson seven times.
/// </summary>
public static class PerkAugments
{
/// <summary>Which slot an augment competes for.</summary>
public enum AugmentTier
{
Major,
Minor,
}
/// <summary>One augment: its id, display name and what it claims to do.</summary>
public record Augment( string Id, string Name, string Desc )
{
/// <summary>
/// ⚠️ DERIVED FROM THE ID'S CASE, exactly as the original does
/// (`string.sub(augid,1,1) == "M"`). Storing the tier as a second field would let
/// the two disagree, and every id in the data already carries it: M1-M4 major,
/// m1-m5 minor. The one hazard is a case-INSENSITIVE comparison creeping in, which
/// would silently make every minor a major — hence the explicit ordinal compare.
/// </summary>
public AugmentTier Tier
=> Id.StartsWith( "M", System.StringComparison.Ordinal )
? AugmentTier.Major
: AugmentTier.Minor;
}
/// <summary>The four majors and five minors offered for one perk.</summary>
public record AugmentPool( Augment[] Major, Augment[] Minor );
/// <summary>
/// What one player has equipped on one perk.
///
/// ⚠️ A CLASS, NOT A RECORD STRUCT. It is mutated in place inside the player's
/// dictionary; a value type would be copied out on every lookup and the write would
/// land on the copy — a bug that presents as "buying an augment does nothing".
/// </summary>
public sealed class Loadout
{
/// <summary>
/// ⛔ A LIST, EVEN THOUGH THE LIMIT IS ONE. It was a single string, which is the
/// original's shape — and that shape makes "no limit in Creative" unrepresentable:
/// a second major would silently overwrite the first, so testing all four would be
/// impossible in the one mode built for testing. The limit belongs in
/// <see cref="LimitOf"/>, not in the storage.
/// </summary>
public List<string> Majors { get; set; } = new();
/// <summary>⚠️ Never null. Callers index it without checking.</summary>
public List<string> Minors { get; set; } = new();
/// <summary>
/// Salvage paid for each augment here, by id: what taking it off gives half of back.
///
/// ⚠️ WHAT WAS PAID, NOT THE PRICE NOW. `PriceScale` and the base prices can be retuned
/// mid-game, and half of today's price would pay out on a change nobody paid for. A
/// granted augment is recorded at 0, since it cost nothing.
///
/// ⚠️ MAY BE NULL on a loadout a hotload carried over from before this field existed, so
/// it is read through <see cref="PaidFor"/> and written with `??=`, never used bare.
/// </summary>
public Dictionary<string, int> Paid { get; set; } = new();
}
// ── tuning ───────────────────────────────────────────────
/// <summary>Majors you may equip per perk. The original's `AugmentLimits.major`.</summary>
public static int MajorLimit { get; set; } = 1;
/// <summary>Minors you may equip per perk. The original's `AugmentLimits.minor`.</summary>
public static int MinorLimit { get; set; } = 2;
/// <summary>
/// Creative ignores the slot limits entirely, so every augment can be equipped at
/// once and seen working.
///
/// ⛔ CREATIVE IS THE TESTING MODE AND THE LIMITS ARE THE WHOLE OBSTACLE. Checking
/// nine augments at 1 major + 2 minors means nine reloads of the map, or a
/// clear-and-rebuy cycle per augment — and an augment that only misbehaves ALONGSIDE
/// another is then unreachable by construction.
///
/// ⚠️ NOT A FREE-EVERYTHING SWITCH. Prices still apply; Creative already tops salvage
/// up to 100,000 a frame (`NZPlayer.CreativeSalvage`), so cost was never the obstacle
/// and making augments free here would hide a real pricing mistake.
///
/// ⚠️ Off by default and settable, so a Creative session can put the real limits back
/// and confirm the UI refuses correctly — the refusal states are themselves a thing
/// that has to be tested.
/// </summary>
public static bool UnlimitedInCreative { get; set; } = true;
/// <summary>
/// Are the slot limits lifted right now: in Creative (<see cref="UnlimitedInCreative"/>) — and for everyone, for the rest
/// of the game, once basalt's Easter egg is complete: *"all players become able to have all the augments of all perks, no
/// longer having the limit of 1 major and 2 minor augments"*. The limit lifted, not the augments given, by the user's word
/// — and the prices still stand, as in Creative.
/// </summary>
public static bool Unlimited => LimitsLifted( NZGame.IsCreative, HexPlatforms.EggComplete );
/// <summary>The same, as the rule has it. The mode and the egg apart, for the selftest.</summary>
public static bool LimitsLifted( bool creative, bool egg ) => (UnlimitedInCreative && creative) || egg;
/// <summary>Salvage for a major. The original's `AugmentPrices.major`.</summary>
public static int MajorPrice { get; set; } = 1500;
/// <summary>Salvage for a minor. The original's `AugmentPrices.minor`.</summary>
public static int MinorPrice { get; set; } = 750;
/// <summary>
/// Global price scale — the original's Sinner Mode "Augment Cost Multiplier".
///
/// ⚠️ APPLIED IN ONE PLACE (<see cref="PriceOf"/>) so the shown price and the
/// charged price cannot diverge. The original calls that out in its own comment,
/// which means it has already been got wrong once somewhere.
/// </summary>
public static float PriceScale { get; set; } = 1f;
// ── the roster ──────────────────────────────────────────────
/// <summary>
/// Every perk's pool, keyed by perk id.
///
/// ⛔ A METHOD, NOT A FIELD. See the class note — a static dictionary here would
/// survive hotloads holding yesterday's contents. It rebuilds per call; the roster is
/// read when a menu opens, not per frame.
/// </summary>
static Dictionary<string, AugmentPool> Pools()
{
return new Dictionary<string, AugmentPool>
{
["jugg"] = new AugmentPool(
new Augment[]
{
new( "M1", "Overhealth",
"+100 more max health (total +200 over vanilla)." ),
new( "M2", "Plated Up",
"Refill to full armor at the start of each round." ),
new( "M3", "Bulwark",
"Take 25% less damage (flat damage reduction)." ),
new( "M4", "Bloodthirst",
"Kills heal +10 HP." ),
},
new Augment[]
{
new( "m1", "Hardplate",
"+10 armor per headshot kill (refill without a plate)." ),
// ⚠️ BOTH TEXTS REWRITTEN 2026-10-03: each still described the effect it had before it was changed (Weave used
// to slow the vest's depletion, Dense Plating used to multiply the cap). JuggAugments.cs says what they do now.
// ⚠️ And again the same evening, when both were weakened (Weave from halving to cutting 20%, Dense Plating +5 → +2).
// Weave's text gives the CUT, not a percentage of the hit, because what armor lets through rises with the round.
new( "m2", "Efficient Weave",
"20% less of each hit gets through your armor." ),
new( "m3", "Dense Plating",
"+2 hits per armor bar (a Tier 3 vest holds 3,300 instead of 3,000)." ),
new( "m4", "Adrenal Surge",
"+move speed for 2s when you take damage." ),
new( "m5", "Retaliate",
"Any zombie that melees you is stumbled." ),
}
),
["speed"] = new AugmentPool(
new Augment[]
{
new( "M1", "Fast Hands",
"Reload x0.35 (the fastest raw reloads)." ),
new( "M2", "Auto-Loader",
"Holstered weapons auto-reload over time." ),
new( "M3", "Adrenaline",
"First 20% of each mag deals +20% damage & fire rate." ),
new( "M4", "Conservation",
"20% chance a reload does not consume reserve ammo." ),
},
new Augment[]
{
new( "m1", "Full Clip",
"Shotgun/tube reloads load all shells at once." ),
new( "m2", "Swift Draw",
"Faster weapon swap." ),
new( "m3", "Sleight of Hand",
"Faster aim down sights." ),
new( "m4", "Quick Sip",
"Faster perk drink + faster mystery box spin." ),
new( "m5", "Fluid Motion",
"Move at 1.4x speed while reloading." ),
}
),
["revive"] = new AugmentPool(
new Augment[]
{
new( "M1", "Phoenix",
"+2 self-revives (5 total solo) and much faster self-revive." ),
new( "M2", "Field Medic",
"Much faster co-op revives + revive on the move + revived allies get brief invincibility." ),
new( "M3", "Regenerator",
"Health regen starts much sooner after taking damage." ),
new( "M4", "Guardian Aura",
"Auto-revive downed players just by standing near them." ),
},
new Augment[]
{
new( "m1", "Rapid Recovery",
"Faster health regen." ),
new( "m2", "Combat Medic",
"Take half damage while reviving." ),
new( "m3", "Guardian",
"Extends the post-revive invincibility window." ),
new( "m4", "Full Recovery",
"Instantly recover to full HP after reviving a player." ),
new( "m5", "Lifeblood",
"Kills restore a little health." ),
}
),
["staminup"] = new AugmentPool(
new Augment[]
{
new( "M1", "Marathon",
"Unlimited stamina / infinite sprint." ),
new( "M2", "Lightweight",
"+25% sprint speed." ),
new( "M3", "Fleet Footed",
"+15% base walking speed." ),
new( "M4", "Run & Gun",
"Shoot while sprinting." ),
},
new Augment[]
{
new( "m1", "Steady Aim",
"Full move speed while aiming down sights." ),
// ⚠️ TEXT REWRITTEN 2026-10-03: it still promised the original's longer slides and shorter cooldown, which
// were dropped by request long before (StaminUpAugments.cs: speed only).
new( "m2", "Slide Boost",
"Slides launch 20% faster." ),
new( "m3", "Quick Draw",
"No fire delay out of sprint + faster weapon raise." ),
new( "m4", "Combat Reload",
"Reload while sprinting." ),
new( "m5", "Phase Runner",
"Phase through zombies while sprinting." ),
}
),
["dtap"] = new AugmentPool(
new Augment[]
{
new( "M1", "Double Fire",
"Doubles the projectiles/pellets fired, on ANY weapon." ),
new( "M2", "Rapid Fire",
"+30% fire rate on top of base." ),
new( "M3", "Rev Up",
"Fire rate ramps up to +50% as the magazine empties." ),
new( "M4", "Full Auto",
"Semi-auto weapons fire fully automatic." ),
},
new Augment[]
{
new( "m1", "Rapid Rounds",
"+12% fire rate." ),
new( "m2", "Overpenetration",
"Bullets pierce +2 zombies (any weapon)." ),
new( "m3", "Armor Piercing",
"Penetrating shots keep more damage." ),
new( "m4", "Steady Barrel",
"Half recoil." ),
new( "m5", "Overpressure Round",
"10% chance to fire a x2-damage round that costs 2 rounds from the magazine." ),
}
),
["deadshot"] = new AugmentPool(
new Augment[]
{
new( "M1", "Deadeye",
"+100% headshot damage (x2)." ),
new( "M2", "First Blood",
"Headshots on a full-HP target deal x3." ),
new( "M3", "Cranial Detonation",
"Head-pop meter builds 2x faster; bigger blast, chains further." ),
new( "M4", "Focus",
"+15% damage per consecutive headshot kill (up to +150%)." ),
},
new Augment[]
{
new( "m1", "Steady Hands",
"Reduced sway + faster ADS settle." ),
new( "m2", "Trophy",
"Bonus points per headshot kill." ),
new( "m3", "Concussion",
"~25% chance headshots slow/stagger for ~1s." ),
new( "m4", "Hip Precision",
"Reduced hip-fire spread." ),
new( "m5", "Recycler",
"Headshot kills refund 2 rounds to the magazine." ),
}
),
["phd"] = new AugmentPool(
new Augment[]
{
new( "M1", "Bigger Boom",
"Dive explosion x3 damage, +50% radius." ),
new( "M2", "Double Jump",
"Gain a double jump + softer landings." ),
new( "M3", "Kinetic Burst",
"Hit while sprinting triggers an explosion (10s cd)." ),
new( "M4", "Reactive Blast",
"Taking damage below 30% HP triggers an explosion (~15s cd)." ),
},
new Augment[]
{
new( "m1", "Ground Slam",
"Jump + crouch triggers the dive slam (no double jump needed)." ),
new( "m2", "Trap Immunity",
"Immune to trap damage." ),
new( "m3", "Slide Bomb",
"Sliding creates an explosion." ),
new( "m4", "Long Jump",
"Dive farther/faster + total fall-damage immunity." ),
new( "m5", "Hops",
"Jump slightly higher." ),
}
),
["mulekick"] = new AugmentPool(
new Augment[]
{
new( "M1", "Bandolier",
"3 extra reserve magazines, up to +100 rounds." ),
new( "M2", "Overflow",
"Dealing damage refunds ammo to your magazine." ),
new( "M3", "Hot Swap",
"When your mag hits 0, your holstered weapons fully reload." ),
new( "M4", "Insurance",
"Keep all weapons through down/death + faster weapon swap." ),
},
new Augment[]
{
new( "m1", "Quick Draw",
"Faster weapon swap." ),
new( "m2", "Deep Reserves",
"+10% reserve ammo on all weapons." ),
new( "m3", "Grenadier",
"Carry more lethals & tacticals; recover 1 of each per round." ),
new( "m4", "Trickle Charge",
"Held weapon slowly recovers ammo from your stock." ),
new( "m5", "Fabricator",
"Holstered weapons fabricate +1 reserve ammo per kill." ),
}
),
// ⛔ ALL NINE RE-SPECIFIED BY REQUEST — SEE THE DEVIATIONS TABLE. Two of the ported set
// were unbuildable (m2 Brain Rot needs friendly AI, m3 Shell Shock needs a zombie flee
// state that has never existed) and the rest were re-pointed at the ammo mod system,
// which did not exist when this pool was generated. `PopAugments` implements them.
["pop"] = new AugmentPool(
new Augment[]
{
new( "M1", "Elemental Surge",
"5% chance per hit to trigger a random ammo mod. 5s cooldown." ),
new( "M2", "Overload",
"Doubles the trigger chance of the ammo mod fitted to your weapon." ),
new( "M3", "Overcharge",
"The reload burst gets double radius and double stun time." ),
new( "M4", "Feedback",
"The reload burst kills everything it catches outright." ),
},
new Augment[]
{
new( "m1", "Rapid Discharge",
"Ammo mod cooldowns are 20% shorter." ),
new( "m2", "Conductor",
"Ammo mod trigger chance x1.5." ),
new( "m3", "Wide Arc",
"Reload shock radius +50%." ),
new( "m4", "Amplifier",
"Reload shock damage +50%." ),
new( "m5", "Chain Lightning",
"The reload shock also triggers Dead Wire on a zombie it caught, chaining up to 7." ),
}
),
["vulture"] = new AugmentPool(
new Augment[]
{
new( "M1", "Carrion",
"Greatly increased drop rate and richer drops." ),
new( "M2", "Gas Cloak",
"Gas clouds trigger more often and last longer." ),
new( "M3", "Fortune's Gin",
"+2 perk slots." ),
new( "M4", "Wildcard",
"Empty reloads can swap your weapon for a random Pack-a-Punched gun (tier scales with the round)." ),
},
new Augment[]
{
new( "m1", "Scavenger",
"Bigger loot drops: salvage gives 100 instead of 75, ammo and points drops give 30% more." ),
new( "m2", "Extra Slot",
"+1 perk slot." ),
new( "m3", "Gas Feed",
"Standing in your gas slowly refills ammo." ),
new( "m4", "Deep Pockets",
"Ammo drops refill mag and reserve and give more." ),
new( "m5", "Long Arms",
"Increased drop pickup distance." ),
}
),
["widowswine"] = new AugmentPool(
new Augment[]
{
new( "M1", "Black Widow",
"Snare on any melee hit; bigger radius, DoT; semtex become web-bombs." ),
new( "M2", "Brute Force",
"Massively increased melee damage, launches enemies, gains thunderwall." ),
new( "M3", "Assassin",
"Melee is an instakill on non-boss enemies; kills chain lightning." ),
new( "M4", "Web Shot",
"Your shots have a chance to web the single zombie you hit." ),
},
new Augment[]
{
new( "m1", "Sticky Webs",
"Snares last longer & cover a bigger radius." ),
new( "m2", "Heavy Hands",
"Increased melee damage." ),
new( "m3", "Restock",
"Any kill has a chance to recover a grenade." ),
new( "m4", "Lifedrain",
"Melee kills heal a bit." ),
new( "m5", "Long Reach",
"Double melee range." ),
}
),
["death"] = new AugmentPool(
new Augment[]
{
new( "M1", "Boss Slayer",
"Large increase to damage vs bosses & elites." ),
new( "M2", "Big Game Hunter",
"Reduced damage from bosses + weak points highlighted." ),
new( "M3", "Critical Instinct",
"Chance to deal critical (headshot-tier) damage anywhere on the body." ),
new( "M4", "Bounty Hunter",
"Earn points per boss hit + a large bonus on boss kill." ),
},
new Augment[]
{
new( "m1", "Long-Range X-Ray",
"Greatly increased wallhack range." ),
new( "m2", "Plated Instinct",
"Zombies drop armor plates more often." ),
new( "m3", "Fortune's Sense",
"Power-ups spawn more often." ),
new( "m4", "Boss Bane",
"Increased boss damage (stacks with Boss Slayer)." ),
new( "m5", "Weak Point",
"+10% headshot damage." ),
}
),
["tortoise"] = new AugmentPool(
new Augment[]
{
new( "M1", "Dig In",
"Stand still 3s: x2 damage and 50% less damage taken; ends when you move." ),
new( "M2", "Fortifier",
"Auto-repair nearby barricades, much faster." ),
new( "M3", "Turtle Shell",
"Take 80% less damage from the back." ),
new( "M4", "Rallying Stand",
"Stand still 3s: you and nearby players deal x1.5 damage." ),
},
new Augment[]
{
new( "m1", "Volatile Break",
"Shield or armor-plate break causes a big explosion." ),
new( "m2", "Reflective Plating",
"Enemies hitting your shield are stunned + take damage back." ),
new( "m3", "Handyman",
"Repairing barricades damages nearby zombies and grants points." ),
new( "m4", "Hazmat",
"Immune to hazards/gas/fire/stun/slow; grenades become gas grenades." ),
new( "m5", "Entrench",
"While planted, slowly regenerate armor." ),
}
),
["time"] = new AugmentPool(
new Augment[]
{
new( "M1", "Time Bank",
"Greatly extended power-up duration." ),
new( "M2", "Snail's Pace Slurpee",
"Zombies slow down a lot when near you." ),
new( "M3", "Time Out",
"Using the box, Pack-a-Punch, or a perk makes zombies ignore you briefly." ),
new( "M4", "Chrono Rounds",
"Your shots slow the zombies they hit." ),
},
new Augment[]
{
new( "m1", "Overclock PaP",
"Really fast Pack-a-Punch." ),
new( "m2", "Fault Lines",
"More frequent slow pits." ),
new( "m3", "Fast Forward",
"Faster box spins, trap resets, door buys, and machine use." ),
new( "m4", "Time Warp",
"All cooldowns recharge faster." ),
new( "m5", "Time Dilation",
"Starting a reload briefly slows all nearby zombies." ),
}
),
// ⛔ ALL NINE RE-SPECIFIED BY REQUEST — SEE THE DEVIATIONS TABLE. The ported set was
// nine slide/dive/low-gravity augments needing a dive system that does not exist, on a
// vertical-mobility axis PhD Flopper already owns. Rebuilt around PLACEABLES, the one
// thing nothing else in the project does. `BananaAugments` implements them.
["banana"] = new AugmentPool(
new Augment[]
{
new( "M1", "Slick Bar",
"Place a bar on the floor. Zombies crossing it slip." ),
new( "M2", "One-Way Wall",
"Place a wall you can walk through and zombies have to break." ),
new( "M3", "Banana Stand",
"Place bait. Zombies nearby go for it instead of you." ),
new( "M4", "Springboard",
"Place a pad that flings you when you step on it." ),
},
new Augment[]
{
new( "m1", "Sticky Fingers",
"Kills recharge your placeable 25% faster." ),
new( "m2", "Big Bunch",
"Placeables are 50% bigger." ),
new( "m3", "Tough Peel",
"Placeables have 20% more durability." ),
new( "m4", "Long Shelf Life",
"Placeables last twice as long." ),
new( "m5", "Nothing Wasted",
"Broken early, a placeable refunds its unused time as charge (up to 50%)." ),
}
),
["fire"] = new AugmentPool(
new Augment[]
{
new( "M1", "Wildfire",
"Igniting an enemy spreads fire to nearby enemies." ),
new( "M2", "Demolitionist",
"Massively increased explosive damage; burning kills detonate." ),
new( "M3", "Scorched Earth",
"Shots have a low chance to spawn a burning napalm pit." ),
new( "M4", "Chain Reaction",
"Kills have a chance to detonate, damaging nearby enemies." ),
},
new Augment[]
{
new( "m1", "Accelerant",
"Ignited enemies take even more damage." ),
new( "m2", "Incendiary Rounds",
"Much higher ignite chance." ),
new( "m3", "Ember Trail",
"Kills have a chance to leave a small fire pit." ),
new( "m4", "Powder Keg",
"Increased explosion radius; explode when downed." ),
new( "m5", "Wildspread",
"An ignite has a 10% chance to light 5 nearby zombies instead of 1." ),
}
),
["vigor"] = new AugmentPool(
new Augment[]
{
new( "M1", "Overkill",
"Bullet damage x3.3 total." ),
new( "M2", "Executioner",
"Enemies below 35% HP take an extra x3 (x6 total)." ),
new( "M3", "Point Blank",
"Up to x3 damage the closer the enemy." ),
new( "M4", "Killstreak",
"+3% damage per kill (up to +300%); resets when you take damage." ),
},
new Augment[]
{
new( "m1", "Spoils",
"Extra points per kill." ),
// ⛔ THE NAME AND THE TEXT WERE BOTH STALE, AND THE PLAYER WAS THE ONE PAYING
// FOR IT. This slot has implemented `VigorAugments`' RICOCHET since the rework
// — that file's own table marks it "NEW — replaced Overpenetration" — but the
// catalogue the augment MENU reads still advertised "Increased bullet pierce",
// so anyone picking it bought pierce and got bouncing bullets. It also collided
// by name with Double Tap's m2, which is a real one.
new( "m2", "Ricochet",
"Bullets bounce off walls in a random direction, guaranteed." ),
new( "m3", "Cleave",
"A kill deals 10% of its max HP to the nearest enemy." ),
new( "m4", "Last Round",
"The last bullet of each mag does area damage (160u)." ),
new( "m5", "Opening Shot",
"The first bullet from a full magazine deals +50%." ),
}
),
};
}
/// <summary>
/// Descriptions rewritten because OUR implementation differs from the original's.
///
/// ⛔ A SEPARATE TABLE RATHER THAN EDITING THE GENERATED DATA, and the reason is the
/// generator: `Pools()` is emitted from `sh_augments.lua`, so any correction made in
/// there is silently reverted the next time the roster is regenerated. Keeping the
/// deviations here means a regeneration cannot lose them.
///
/// ⛔ IT ALSO MAKES THE DIVERGENCES ENUMERABLE. This is the complete list of places
/// where this port deliberately does something other than what nZombies does, for
/// augments — `nz_augment_deviations` prints it. Without a list, "did we change this
/// on purpose or is it a porting bug" becomes unanswerable per augment.
///
/// ⚠️ THE DESCRIPTION IS THE ONLY THING A PLAYER EVER SEES. An augment whose behaviour
/// was changed but whose text was not is the same class of lie as the weapon stats
/// panel's three false claims, and as the Banana Colada blurb that promised a
/// "slippery trail" that was never implemented.
///
/// Keyed `perkid/augid`.
/// </summary>
static Dictionary<string, string> Deviations() => new Dictionary<string, string>
{
// ══ DEATH PERCEPTION ══════════════════════════════
//
// ⛔ SIX OF NINE REFERENCED SOMETHING THIS PORT DOES NOT HAVE. Boss Slayer, Big
// Game Hunter, Bounty Hunter and Boss Bane all needed BOSSES; Long-Range X-Ray needed a
// wallhack the base perk never had. `SpecialEnemies.Names` is a one-element array
// holding the hellhound — there are no bosses and no elites.
//
// ⚠️ M1 AND M4 KEEP THEIR BOSS IDENTITY AS CATALOGUE ENTRIES, by request: the
// numbers resolve and print, nothing reads them, and every display says so. The other
// four were respecified.
// Original: "Large increase to damage vs bosses & elites." — kept, quantified.
// ⛔ NO LONGER UNWIRED, AND THE DESCRIPTION SAID "(no bosses yet)" TO THE PLAYER LONG
// AFTER BRUTUS SHIPPED. `Health.Apply` multiplies through `BossScaleAgainst` in two
// places. A tooltip telling the player an augment they own does nothing is the one kind
// of stale comment that costs them points at the machine.
["death/M1"] = "2x damage against bosses.",
// Original M2 was "Big Game Hunter" (boss damage reduction + weak points highlighted).
// Both halves needed bosses. Replaced with an escape, which suits a perk about seeing
// trouble coming.
["death/M2"] = "Going down teleports you to the nearest player still standing.",
// Original M3 was "Critical Instinct" (body shots crit). Replaced by request.
["death/M3"] = "Headshot kills have a 10% chance to make zombies lose you for 3 seconds"
+ " (10s cooldown).",
// Original: "Earn points per boss hit + a large bonus on boss kill." — kept, quantified.
// ⛔ WIRED SINCE BOSSES SHIPPED (`DeathAugments.BossPoints`, added to the award in `ZombieAI`), AND IT STILL SAID "(no bosses
// yet)" AND "which normally pay nothing" UNTIL 2026-10-05: the M1 note's exact fault, and a boss hit or kill pays the usual
// points with this on top. Its numbers are read from `DeathAugments` now.
["death/M4"] = $"+{DeathAugments.BossKillPoints:N0} points per boss kill and +{DeathAugments.BossHitPoints:N0} per boss hit,"
+ " on top of the usual points.",
// Original m1 was "Long-Range X-Ray" (greater wallhack range) — there was no
// wallhack to extend; the base perk's see-through-walls half was never built. The x-ray
// idea moved to m4, which builds it outright.
["death/m1"] = "Armor plates drop 1.2x as often.",
// Original: "Power-ups spawn more often." — kept, quantified.
// ⚠️ THE ROLL, NOT THE PER-ROUND CAP. Power-ups come sooner; no more of them
// exist than the round allows.
["death/m2"] = "Power-ups drop 1.2x as often.",
// Original m5 was "Weak Point" at +10%. Moved to m3 and raised to 12% by request.
// ⚠️ IT MULTIPLIES THE HEAD MULTIPLIER, as the base perk does. On a stock 2.5x
// gun with the base perk that is 3.75x going to 4.2x.
["death/m3"] = "Headshot multiplier x1.12 on top of the perk's own x1.5.",
// NEW. The base perk's own missing half, finally built — `PerkEffects` says
// outright that the see-through-walls effect is "deliberately NOT here".
// ⚠️ The outlines are scene-wide, not per-viewer: `HighlightOutline` is a
// component on the zombie, so in multiplayer everyone would see them.
["death/m4"] = "Zombies within 1200u are outlined through walls.",
// NEW, replacing "Boss Bane" (more boss damage). Pairs with M4's points theme so the
// perk reads as "precision pays" rather than nine unrelated numbers.
["death/m5"] = "Headshot kills pay 25% more points.",
// ══ QUICK REVIVE — WHICH ABSORBED TOMBSTONE SODA ═════════════════════════
//
// ⛔ TWO DEFERRED PERKS BECAME ONE WIRED PERK. Between them only two augments needed
// something that does not exist — friendly AI, and Tombstone's own tombstone — so merging
// them gave nine buildable augments instead of two half-blocked pools. M2 is Tombstone's
// Grave Keeper, M4 its Last Stand, m5 its Phase Shift.
//
// ⛔ AND TWO BASE SYSTEMS HAD TO BE BUILT FIRST: there was no co-op revive at all
// (`Revive()` was reachable only by self-revive and a console command), and being downed
// did not change your weapons (`StripWeapons` existed and `GoDown` never called it).
// Original: "+2 self-revives (5 total solo) and much faster self-revive."
// ⚠ The speed half now applies to reviving OTHERS. Self-revive has a fixed 5s wait and
// making that faster is barely felt; picking a teammate up under fire is where speed pays.
["revive/M1"] = "5 self-revives instead of 3 (solo only), and you revive others 3x faster.",
// Original M2 was "Field Medic" (faster co-op revives + invincibility). The speed is M1's
// job now; this slot took Tombstone's Grave Keeper, which is a bigger deal.
["revive/M2"] = "Keep every perk when you go down.",
// Original M4 was "Guardian Aura". Promoted to M3 — no key needed is a major-tier effect.
["revive/M3"] = "Revive downed players just by standing near them, no key needed.",
// Original M4 replaced by Tombstone's Last Stand, plus the weapon retention that makes it
// possible — a downed player normally holds only an M1911.
// ⚠️ "IT DOESN'T STOP A GAME OVER" (2026-09-27): everybody down ends the run, Last Stand or not. See
// `NZPlayer.CanBeRevived`.
["revive/M4"] = "Keep your real weapons while downed, and killing a zombie stands you"
+ " straight back up. It doesn't stop a game over.",
// Original: "Faster health regen." — that is m2's job; this is the DELAY.
["revive/m1"] = "Health regen starts 20% sooner after taking damage.",
// Original m2 was "Combat Medic" (half damage while reviving). Replaced with the regen
// rate, so m1 and m2 are the two halves of one system rather than two unrelated things.
["revive/m2"] = "Health regenerates 20% faster.",
// Original m3 was "Guardian" (longer post-revive invincibility) — there is no invincibility
// window to extend. Replaced with the reviver's reward.
["revive/m3"] = "Reviving someone heals you to full and gives you 2s of 1.5x speed.",
// Original m4 was "Full Recovery" (full HP after reviving) — that is m3's job now.
["revive/m4"] = "Players you revive come up with full armor.",
// Original m5 was "Lifeblood" (kills restore health) — Widow's m4 already does that for
// melee and Napalm has its own sustain. Replaced with Tombstone's Phase Shift.
["revive/m5"] = "A hit that would kill you leaves you at 1 HP and teleports you to a"
+ " special spawn instead (2 minute cooldown).",
// ══ NAPALM NECTAR ═══════════════════════════════════════════════════
//
// ⛔ THE BASE PERK WAS REBUILT, so several of these describe a different perk than they
// used to. It was two unrelated mechanics — a 1-in-6 double-damage roll with no fire in it,
// and a per-zombie counter of 5 hits then 1-in-3 to ignite. The counter froze while the
// target burned, so the real cost was ~7 hits on ONE zombie and spraying a crowd lit
// nothing. It is now 1-in-6 to ignite with a 6-hit cooldown, burning 5s at x2 damage.
// Original: "Igniting an enemy spreads fire to nearby enemies." — now an exact count.
["fire/M1"] = "Igniting a zombie also lights the 3 nearest within 300u.",
// Original M2 was "Demolitionist": explosive damage plus "burning kills detonate". The
// detonation half is M4's job; what is left is the damage, with a number.
["fire/M2"] = "All your area damage is tripled.",
// Original: "Shots have a low chance to spawn a burning napalm pit." — kept, with numbers.
//
// ⚠ NOT PORTED. Asked to check "Explosive Everclear's napalm pit" — that perk does not
// exist in this project (Napalm Nectar is the only fire perk of the eighteen) and the GMod
// lua is not in this repo. Built on Timeslip's pit primitive instead.
["fire/M3"] = "1% of your hits leave a burning pit: anything standing in it catches fire"
+ " (200u, 8s).",
// Original: "Kills have a chance to detonate, damaging nearby enemies." — the chaining is
// the new part, and it is what makes this a major rather than a minor.
["fire/M4"] = "Kills have a 10% chance to explode — and anything that explosion kills can"
+ " explode too, chaining.",
// Original: "Ignited enemies take even more damage." — now the exact figure.
["fire/m1"] = "Burning zombies take x2.5 damage instead of x2.",
// Original: "Much higher ignite chance." — now the exact roll.
["fire/m2"] = "Ignite chance goes from 1-in-6 to 1-in-4, and the cooldown between"
+ " ignitions from 6 hits to 3.",
// Original m3 was "Ember Trail" (kills leave a fire pit) — that is M3's job now, so this
// became the knife.
["fire/m3"] = "Knifing a zombie sets it on fire.",
// Original m4 was "Powder Keg": explosion radius plus "explode when downed". The radius
// half is kept and made exact; exploding on down is a revive-system effect and Quick
// Revive is not wired yet.
["fire/m4"] = "Every area-damage radius is 20% bigger — grenades, blasts and pits.",
// Original: "An ignite has a 10% chance to light 5 nearby zombies instead of 1." — kept.
["fire/m5"] = "An ignite has a 10% chance to light 5 nearby zombies instead of 1.",
// ══ WIDOW'S WINE ════════════════════════════════════════════════════
//
// ⛔ THREE ORIGINALS NAMED SYSTEMS THAT DO NOT EXIST IN THIS PORT: semtex (there is one
// grenade type, not two), "thunderwall" (nowhere in the codebase), and chain lightning
// (Electric Cherry's shock is a status, and nothing chains). All three were replaced
// rather than left in the text — the PhD dive lesson.
//
// ⛔ AND THE MELEE DAMAGE PILE-UP WAS RESOLVED. M2, M3, m2 and m4 were all knife-damage
// augments, with M3's instakill making the other three pointless. Now M2 owns the
// instakill, m2 is its cheaper minor version, and M3 became a multi-hit — a different axis
// entirely.
// Original: "Snare on any melee hit; bigger radius, DoT; semtex become web-bombs."
// The semtex half is gone (one grenade type) and so is the DoT — the web is a full stop
// already, and stacking damage on it would make M2's instakill redundant twice over.
["widowswine/M1"] = "Knifing a zombie webs it, and the web burst you get from being hit"
+ " covers 50% more ground.",
// Original M3 was "Assassin": instakill plus chain lightning. The instakill moved to M2
// where it is not competing with two damage augments; the lightning has no system.
["widowswine/M2"] = "Your knife always kills in one hit.",
// Original M2 was "Brute Force": melee damage, launching, and "thunderwall". Replaced
// outright — the damage was m2's job and the other two named nothing that exists.
["widowswine/M3"] = "Your melee hits every zombie around you, not just the one in front.",
// Original M4 was "Web Shot" (a chance to web what you shoot). Replaced by the grenade
// economy the perk actually needs — the base effect spends one per save.
//
// ⚠ NOT PORTED FROM THE ORIGINAL. The GMod lua is not in this repo, so the drop chance
// and payload are as requested, not as ported. See `WidowAugments`.
["widowswine/M4"] = "Kills have a 3% chance to drop a spider power-up that gives back a"
+ " grenade.",
// Original: "Snares last longer & cover a bigger radius." — kept, with the number.
["widowswine/m1"] = "Webs last 15% longer and reach 15% further.",
// Original: "Increased melee damage." — kept, with the number.
["widowswine/m2"] = "Knife damage x10.",
// Original m3 was "Restock" (a chance to recover a grenade) — that is M4's job now, so
// this became the ammo economy instead.
["widowswine/m3"] = "A melee kill gives back 2% of your held weapon's max reserve ammo.",
// Original: "Melee kills heal a bit." — kept, with the number.
["widowswine/m4"] = "A melee kill heals you 50 health.",
// Original: "Double melee range." — unchanged.
["widowswine/m5"] = "Double melee range.",
// ══ VICTORIOUS TORTOISE ═════════════════════════════════════════════
//
// ⛔ THE RING IS NEW AND IT IS WHAT HOLDS THE PERK TOGETHER. The originals described M1 and
// M4 as "stand still 3s" personal buffs, which made them the same augment with different
// numbers. They now plant a RING that stays where it was planted, grants its effect to
// anyone standing in it, and dies when its owner steps out — so m2 ("bigger rings") and m5
// ("inside a ring") have something real to modify.
//
// ⛔ AND THE SHIELD IS GONE FROM BOTH PLACES IT APPEARED. m1 was "shield or armor-plate
// break" and m2 was "enemies hitting your shield" — there is no shield system in this port
// and none planned, so m1 keeps the armor half and m2 was replaced outright. Leaving a
// shield in the text would be the same lie as PhD's dive.
// Original: "Stand still 3s: x2 damage and 50% less damage taken; ends when you move."
["tortoise/M1"] = "Stand still 3s to plant a 200u ring. Everyone inside deals x1.5 damage"
+ " and takes half. It stays where you planted it and vanishes when you leave.",
// Original: "Auto-repair nearby barricades, much faster." — the "much faster" half is m4's
// job now, so this is purely the automation.
["tortoise/M2"] = "Barricades within 250u board themselves up, no need to hold Use.",
// Original: "Take 80% less damage from the back." — 90% by request, and it REPLACES the
// base perk's 50% rather than stacking with it.
["tortoise/M3"] = "Take 90% less damage from behind, replacing the perk's usual 50%.",
// Original: "Stand still 3s: you and nearby players deal x1.5 damage." — that was M1 with
// a smaller number. It is a ramp now, and it shares the ring M1 plants.
["tortoise/M4"] = "Your ring banks kills: every zombie killed by anyone inside adds +2%"
+ " damage, up to x3. Leaving the ring loses it.",
// Original m1: "Shield or armor-plate break causes a big explosion." — no shield exists,
// and a stun reads better than a second explosion when PhD already owns those.
["tortoise/m1"] = "Breaking an armor bar stuns every zombie within 400u for half a second.",
// Original m2 was "Reflective Plating", built entirely on a shield that does not exist.
// REPLACED with the augment the ring wanted.
["tortoise/m2"] = "Your rings are 30% bigger.",
// Original: "Repairing barricades damages nearby zombies and grants points." — the points
// already come from repairing, so the damage half became a kill. Since 2026-10-03, by
// request, only the zombies attacking that window; it was everything within 200u of it.
["tortoise/m3"] = "Repairing a board kills every zombie attacking that barricade.",
// Original m4 was "Hazmat": five immunities plus turning grenades into gas grenades. Far
// too much for a minor, and "immune to slow" would cancel Timeslip Tonic outright.
["tortoise/m4"] = "Barricades board up twice as fast.",
// Original: "While planted, slowly regenerate armor." — kept, with a number.
["tortoise/m5"] = "Standing in your ring regenerates 5 armor per second.",
// ══ TIMESLIP TONIC ═══════════════════════════════════════════════════
//
// ⚠ ALL NINE RESTATED WITH NUMBERS. The originals were qualitative — "greatly
// extended", "a lot", "really fast", "more frequent" — and every one of those now has an
// exact figure behind it. A description that says "a lot" cannot be checked against the
// game, so it can never be found to be wrong.
// Original: "Greatly extended power-up duration."
["time/M1"] = "Doubles how long timed power-ups last.",
// Original: "Zombies slow down a lot when near you."
// ⛔ THE RADIUS IS READ FROM `TimeAugments.AuraRadius` (2026-10-05). This line said "400u ... half their current speed"
// for six weeks after the aura became 160u and a one-tier drop (08-22): two copies of one number, and only the code's
// got the change. It reads the code now, and says what the code does.
["time/M2"] = $"Zombies within {TimeAugments.AuraRadius:0}u of you slow down one step: a sprinter runs, a runner walks.",
// Original m2 was "Fault Lines": "More frequent slow pits." — there were no pits to make
// more frequent, so the pit itself is the augment now, and it moved up to a MAJOR.
// ⛔ EVERY NUMBER IS READ FROM `TimeAugments` (2026-10-05). This said "700u ... a sixth speed" for six weeks after the pit
// became 280u and a one-step drop (08-22).
["time/M3"] = $"{TimeAugments.PitChance * 100f:0.#}% of your hits drop a rift: zombies within {TimeAugments.PitRadius:0}u of it"
+ $" slow down one step for {TimeAugments.PitSeconds:0.#}s ({TimeAugments.PitCooldown:0.#}s cooldown).",
// Original: "Your shots slow the zombies they hit."
// ⛔ ONE STEP, FOR GOOD (2026-10-05). This said "10% off per hit, down to half" after 08-22 made every Timeslip slow a
// yes/no one-step drop (`TimeAugments.SpeedScaleFor`), and nothing ever takes a chrono stack away, so the first hit is
// the whole effect and it lasts the zombie's life.
["time/M4"] = "Any zombie you hit slows down one step for the rest of its life.",
// Original: "Really fast Pack-a-Punch."
// ⚠ "20x faster", NOT "instant". The instant version was built and rejected in play —
// see `TimeAugments.PapSpeedup`. The text now says what the code does.
["time/m1"] = "The Pack-a-Punch cycle runs 20x faster.",
// Original m2 was the pit augment; M3 took that. This slot took the original M3's
// "Time Out", which is a better fit for a minor: it is utility, not a damage lever.
["time/m2"] = "Using the box, wunderfizz, arsenal or Pack-a-Punch makes zombies ignore"
+ " you for 15s (1 minute cooldown).",
// Original: "Faster box spins, trap resets, door buys, and machine use." — narrowed to
// what exists. Traps do not exist, and the base perk already speeds up machine use.
["time/m3"] = "The mystery box spin is instant.",
// Original: "All cooldowns recharge faster."
["time/m4"] = "Every cooldown recharges 20% faster.",
// Original: "Starting a reload briefly slows all nearby zombies." — a full STOP now,
// which is what makes half a second worth having.
["time/m5"] = "Reloading stops every zombie within 700u dead for half a second.",
// ══ PhD FLOPPER ════════════════════════════════════════════════════
//
// ⛔ ALL NINE DEVIATE, BECAUSE THE DIVE THEY WERE WRITTEN FOR DOES NOT EXIST HERE. The
// original's dive-slam was never ported, so M1, m1, m3 and m4 all referenced a move the
// player cannot make. The base perk gained a FALL blast instead — you already fall — and
// every augment was retargeted onto it. Leaving the original text would have described a
// control that does nothing, which is the same class of lie as Banana Colada's
// "slippery trail".
// Original: "Dive explosion x3 damage, +50% radius." — same numbers, new trigger.
["phd/M1"] = "Your explosions deal x3 damage over a 50% bigger radius.",
// Original M2 was "Double Jump": "Gain a double jump + softer landings."
// MOVED TO m5, because a second jump is a mobility perk and this slot is the
// perk's damage major. What replaced it chains the explosion instead.
["phd/M2"] = "Every explosion this perk causes goes off 3 times, 0.2s apart.",
// Original: "Hit while sprinting triggers an explosion (10s cd)." — unchanged in
// effect; reworded because "hit" read as "you hit something" rather than "you were hit".
["phd/M3"] = "A zombie hitting you while you sprint detonates you (10s cooldown).",
// Original: "Taking damage below 30% HP triggers an explosion (~15s cd)." — kept, with
// the tilde dropped now that the cooldown is an exact number.
["phd/M4"] = "Taking damage below 30% HP detonates you (15s cooldown).",
// Original: "Jump + crouch triggers the dive slam (no double jump needed)."
// RETARGETED: there is no dive slam. Crouching in mid-air now drops you fast and
// detonates on impact, which is the same fantasy without the missing move.
["phd/m1"] = "Crouch in mid-air to slam down fast and explode on impact.",
// ⚠ MARKED NOT IMPLEMENTED, BY REQUEST, RATHER THAN HIDDEN OR QUIETLY RETUNED. There
// are no traps in the port yet, so there is nothing to be immune to — and an augment
// that silently does nothing is worse than one that says so.
["phd/m2"] = "Immune to trap damage. (not implemented — no traps in the game yet)",
// Original: "Sliding creates an explosion." — unchanged.
["phd/m3"] = "Sliding detonates you.",
// Original m4 was "Long Jump": "Dive farther/faster + total fall-damage immunity."
// REPLACED: the dive is gone, and the base perk already blocks fall damage — so both
// halves were either impossible or already free. It is the jump-height augment now.
["phd/m4"] = "Jump 30% higher.",
// Original m5 was "Hops": "Jump slightly higher." — that is m4's job now, so this slot
// took M2's double jump. A minor is the right weight for it: it is mobility, not damage.
["phd/m5"] = "Gain a second jump in mid-air.",
// Original: "Armor depletes at half rate -- soaks 2x damage before breaking."
// Changed by request — a longer-lasting vest was not the wanted effect.
// ⚠️ THE NUMBER IS READ FROM `JuggAugments.WeaveScale` (2026-10-05): this said "Halve" long after the scale became 0.8,
// and the lobby's info booklet shows it to new players.
["jugg/m2"] = $"Take {(1f - JuggAugments.WeaveScale) * 100f:0}% less of the damage that gets through your armor.",
// Original: "Any zombie that melees you is stumbled." — a 1s stun.
["jugg/m5"] = "Any zombie that melees you is stunned for 5 seconds.",
// Original M3 was "Rev Up": "Fire rate ramps up to +50% as the magazine empties."
// REPLACED OUTRIGHT, not retuned. A rate ramp is invisible while it happens, peaks
// exactly when you are about to be forced to reload, and sat on M2 Rapid Fire's
// axis — which is why the two needed a tie-break.
//
// ⚠️ AND THE REPLACEMENT WAS ITSELF REPLACED. The first Trigger Discipline ramped
// while the trigger was HELD, which rewarded spraying — the opposite of the name.
// It now charges while you hold FIRE, in the other sense of the phrase.
["dtap/M3"] = "Not shooting builds damage, up to x5 after 10 seconds."
+ " Firing spends it five times faster — 2 seconds of sustained fire empties it.",
// ── STAMIN-UP ──────────────────────────────────────────────────────────
//
// Original M2 "Lightweight": "+25% sprint speed." Its hook actually scaled ALL
// movement while the sprint key was held; narrowed to sprint alone so it stays
// distinct from Fleet Footed rather than a strictly bigger version of it.
// 15% rather than the original 25%, by request (2026-10-02).
["staminup/M2"] = "Sprint 15% faster. Walking and aiming are unaffected.",
// Swapped up from minor. Was m5.
//
// Original: "Phase through zombies while sprinting." THE SPRINT CONDITION IS GONE,
// by request. It is also what made the original fragile: its own comment records
// that a body-blocking zombie drops your speed to ~0, which disengaged phasing and
// re-blocked you. With no condition there is no state to get stuck in.
["staminup/M3"] = "Zombies no longer block you. Walk straight through them.",
// Original m1 "Steady Aim": "Full move speed while aiming down sights."
// 80% rather than 100%, by request.
["staminup/m1"] = "Move at 80% of your walking speed while aiming, instead of half.",
// Original m2 "Slide Boost": "Longer, faster slides; reduced slide cooldown."
// Speed only — Banana Colada owns slide duration and chaining.
// ⚠️ THE NUMBER IS READ FROM `StaminUpAugments.SlideSpeedScale` (2026-10-05): this said 50% long after the scale became 1.2.
["staminup/m2"] = $"Slides launch {(StaminUpAugments.SlideSpeedScale - 1f) * 100f:0}% faster.",
// Original m3 was "Quick Draw" (no out-of-sprint fire delay, faster raise) —
// weapon-base plumbing, replaced.
["staminup/m3"] = "Stamina capacity increased by 30%.",
// Original m4 was "Combat Reload" (reload while sprinting), which this project
// already allows — a switch that changed nothing. Replaced.
["staminup/m4"] = "Stamina recovers 30% faster.",
// Swapped down from major. Was M3. 7% rather than the original 15%, by request (2026-10-02).
["staminup/m5"] = "Move 7% faster — walking, sprinting and aiming alike.",
// ── SPEED COLA ─────────────────────────────────────────────────────────
//
// Original: "Holstered weapons auto-reload over time." Widened to EVERY weapon and
// given a clip-relative rate, so a full magazine always takes ten seconds whatever
// its size.
["speed/M2"] = "Every weapon you carry reloads itself from your reserve"
+ " — a full magazine every 10 seconds, held or holstered.",
// The original wired the damage half only and stubbed the fire rate; both work here.
["speed/M3"] = "The first 20% of every magazine deals 20% more damage"
+ " and fires 20% faster.",
// Original m1 was "Full Clip" (whole-magazine shell reloads).
["speed/m1"] = "15% of your reloads happen ten times faster.",
// The original stubbed this outright — no swap-speed lever existed.
["speed/m2"] = "Swap weapons twice as fast.",
// Original m4 was "Quick Sip" (faster perk drink + box spin), which had nothing to
// attach to here — there is no perk-drink animation at all.
["speed/m4"] = "Reloading from empty costs no extra time.",
// ── DEADSHOT DAIQUIRI ──────────────────────────────────────────────────
//
// Original: "+100% headshot damage (x2)." Retuned to x1.5.
["deadshot/M1"] = "Headshots deal 50% more damage.",
// Original: "Headshots on a full-HP target deal x3." The headshot condition is
// dropped — ANY hit on an undamaged zombie triples, which makes this a different
// major from Deadeye rather than a bigger one.
["deadshot/M2"] = "Any hit on an undamaged zombie deals triple damage.",
// Original: "Head-pop meter builds 2x faster; bigger blast, chains further." The
// meter half was unreachable — this project has no head-pop meter to accelerate —
// and the blast now scales off the killing hit rather than a round-health curve.
["deadshot/M3"] = "A headshot kill detonates the head, dealing 10% of the damage"
+ " to everything nearby.",
// Original: "+15% damage per consecutive headshot kill (up to +150%)." Now HEADSHOT
// damage specifically, and the ceiling is x5 rather than +150%.
["deadshot/M4"] = "Each headshot kill in a row adds 15% headshot damage, up to x5."
+ " A body-shot kill loses all of it.",
// Replaced Steady Hands, which duplicated Speed Cola's m3.
["deadshot/m1"] = "10% of your hits count as headshots.",
// ── MULE KICK ──────────────────────────────────────────────────────────
//
// Original M3 was "Hot Swap" (an empty mag reloads your holstered weapons).
// Replaced with a straight fourth slot — the perk's own fantasy, scaled up.
["mulekick/M3"] = "Carry a fourth weapon.",
// Original: "Keep all weapons through down/death + faster weapon swap." The swap
// half is dropped — that is Speed Cola's m2 — and the keep half is narrowed to what
// this project actually destroys: the extra weapon a lost slot takes with it.
["mulekick/M4"] = "The weapons an emptied slot would destroy are held for you,"
+ " and returned when you buy Mule Kick again.",
// Original m1 was "Quick Draw" (faster weapon swap) — Speed Cola's m2, same lever.
["mulekick/m1"] = "Every magazine is 10% bigger.",
// Original m4 was "Trickle Charge" (the held mag refills from reserve), which is
// Speed Cola's M2 Auto-Loader at exactly the same rate.
["mulekick/m4"] = "Grenades are fully restocked at the start of each round.",
// Original: "Holstered weapons fabricate +1 reserve ammo per kill." Moved to the
// HELD weapon, where the ammo appears in the number you are looking at.
["mulekick/m5"] = "Every kill generates a round of reserve ammo for the weapon"
+ " in your hands.",
// ── VIGOR RUSH ─────────────────────────────────────────────────────────
//
// Original: "Bullet damage x3.3 total" (x1.65 stacked on a x2 base). The base is now
// x1.2 and this REPLACES it rather than stacking, so one number is the whole answer.
["vigor/M1"] = "Bullet damage x1.4 instead of x1.2.",
// Original: "Enemies below 35% HP take an extra x3 (x6 total)." Now an outright kill,
// at a tighter threshold.
["vigor/M2"] = "Any hit instantly kills an enemy below 20% health.",
// Original: "Up to x3 damage the closer the enemy" (x1.5 on a x2 base).
["vigor/M3"] = "Up to double damage at point blank, fading to nothing at 500 units.",
// Original m2 was "Overpenetration" — which is Double Tap's m2, same name and nearly
// the same numbers. Replaced.
["vigor/m2"] = "Your bullets bounce off walls instead of stopping.",
// Original m5 was "Opening Shot" (+50% on the first bullet of a full mag), which
// overlapped Speed Cola's M3 Adrenaline. Replaced with its opposite in spirit — a
// reward for being hit rather than for having just reloaded.
["vigor/m5"] = "Taking a hit doubles your damage for 3 seconds.",
};
/// <summary>
/// The pool for one perk, or null when it has none configured.
///
/// ⚠️ APPLIES <see cref="Deviations"/> ON THE WAY OUT, so every reader — the menu, the
/// console listing, `Find` — gets the corrected text without knowing the table exists.
/// Rewriting at one exit point is what stops the UI and the console disagreeing about
/// what an augment claims to do.
/// </summary>
public static AugmentPool PoolFor( string perkId )
{
if ( string.IsNullOrEmpty( perkId ) ) return null;
if ( !Pools().TryGetValue( perkId, out var pool ) ) return null;
var overrides = Deviations();
// ⚠️ `with`, not a mutation. Augment is a record and the array came out of a
// freshly-built dictionary, but rewriting in place would still be a trap the day
// `Pools()` is ever cached.
var renames = Renames();
Augment Fix( Augment a )
{
var key = $"{perkId}/{a.Id}";
if ( renames.TryGetValue( key, out var name ) ) a = a with { Name = name };
if ( overrides.TryGetValue( key, out var desc ) ) a = a with { Desc = desc };
return a;
}
return new AugmentPool(
pool.Major.Select( Fix ).ToArray(),
pool.Minor.Select( Fix ).ToArray() );
}
/// <summary>
/// Augments RENAMED because their behaviour was replaced outright.
///
/// ⛔ A SECOND TABLE RATHER THAN WIDENING THE FIRST, because the two mean different
/// things. A rewritten description says "we do this differently"; a rewritten NAME says
/// "this is not the same augment any more". Double Tap's M3 is not a retuned Rev Up, it
/// is Trigger Discipline — and a player reading "Rev Up: holding fire ramps your
/// damage" would reasonably conclude the game was confused.
///
/// ⚠️ Keyed the same way, and applied at the same exit point, so a rename cannot land
/// in the menu without its description following it.
/// </summary>
static Dictionary<string, string> Renames() => new Dictionary<string, string>
{
["dtap/M3"] = "Trigger Discipline",
// ══ DEATH PERCEPTION ══════════════════════════════
//
// ⚠️ M1 AND M4 KEEP THEIR ORIGINAL NAMES because they keep their original
// effects — they are the two boss slots, catalogued rather than cut. "Weak
// Point" moved from m5 to m3 with its effect; "Plated Instinct" and "Fortune's
// Sense" moved up a slot each for the same reason.
["death/M2"] = "Escape Artist",
["death/M3"] = "Blind Spot",
["death/m1"] = "Plated Instinct",
["death/m2"] = "Fortune's Sense",
["death/m3"] = "Weak Point",
["death/m4"] = "X-Ray Sense",
["death/m5"] = "Executioner's Cut",
// ══ QUICK REVIVE — SIX SLOTS CHANGED EFFECT ═══════════════════════════
//
// ⚠ THREE NAMES CAME ACROSS FROM TOMBSTONE with the augments they describe — Grave Keeper,
// Last Stand and Phase Shift. "Guardian Aura" moved from M4 to M3 with its effect;
// "Field Medic" moved from M2 to m3 because reviving-heals-you is what a medic does.
// "Phoenix" and "Rapid Recovery" keep their slots and their names.
["revive/M2"] = "Grave Keeper",
["revive/M3"] = "Guardian Aura",
["revive/M4"] = "Last Stand",
["revive/m2"] = "Fast Metabolism",
["revive/m3"] = "Field Medic",
["revive/m4"] = "Plate Carrier",
["revive/m5"] = "Phase Shift",
// ══ BANANA COLADA — ALL NINE SLOTS RE-SPECIFIED ═══════════════
//
// ⛔ THE SECOND WHOLE-PERK REMAKE IN THIS TABLE, and the reason is different from Elemental
// Pop's. Nothing here was unbuildable for want of a system — it was unbuildable for want of a
// DIVE, and the vertical-mobility axis those nine sat on is already PhD Flopper's (m4 jump
// height, m5 double jump). Two perks selling one stat is worse than a gap, which is the same
// argument that replaced Deadshot's m1 and three of Mule Kick's.
//
// ⚠ REBUILT AROUND PLACEABLES, which nothing else in the project has: the player putting an
// object into the world that zombies interact with. The four majors are four CATEGORIES —
// debuff, block, redirect, propel — and the five minors all scale one shared spine, so every
// minor works on whichever major was taken.
//
// ⛔ AND THE DESCRIPTIONS HERE ARE AS FRAGILE AS ELEMENTAL POP'S, for the same reason: this
// table carries NAMES only. Nine rewritten texts sit in the generated `Pools()` and a
// regeneration from `sh_augments.lua` reverts them. Two perks now depend on that not happening.
["banana/M1"] = "Slick Bar",
["banana/M2"] = "One-Way Wall",
["banana/M3"] = "Banana Stand",
["banana/M4"] = "Springboard",
["banana/m1"] = "Sticky Fingers",
["banana/m2"] = "Big Bunch",
["banana/m3"] = "Tough Peel",
["banana/m4"] = "Long Shelf Life",
["banana/m5"] = "Nothing Wasted",
// ══ ELEMENTAL POP — ALL NINE SLOTS RE-SPECIFIED ══════════════════════
//
// ⛔ THE WHOLE PERK WAS REMADE, WHICH MAKES IT THE LARGEST DEVIATION IN THIS TABLE. Two of
// the ported nine could never be built here: m2 "Brain Rot" (turn a zombie to fight for
// you) needs friendly AI, the same wall that got the Turned ammo mod cut, and m3 "Shell
// Shock" (kills make nearby zombies flee) needs a `Fleeing` zombie state that has never
// existed. Shipping a perk with two permanently dead slots was the alternative.
//
// ⚠ AND THE OTHER SEVEN WERE RE-POINTED AT THE AMMO MOD SYSTEM, which did not exist when
// this roster was generated from `sh_augments.lua`. Elemental Pop IS the ammo-mod perk, so
// six of the nine are now multipliers on ammo mod chance and cooldown or on the base perk's
// own reload burst. Only M3 keeps its original name AND its original meaning.
//
// ⛔ THE DESCRIPTIONS FOR THIS PERK LIVE IN THE GENERATED POOL AND THIS TABLE CANNOT CARRY
// THEM. `Deviations()` maps a key to a NAME and nothing else, while this file's own note
// says "the description is the only thing a player ever sees" — so the nine rewritten texts
// in `Pools()` are exactly what a regeneration from `sh_augments.lua` would silently revert.
// That is a real gap in this mechanism, not an oversight here, and it is worth closing
// before the next perk is remade.
["pop/M2"] = "Overload",
["pop/m2"] = "Conductor",
["pop/m3"] = "Wide Arc",
["pop/m4"] = "Amplifier",
["pop/m5"] = "Chain Lightning",
// ⚠ M1 "Elemental Surge", M3 "Overcharge", M4 "Feedback" and m1 "Rapid Discharge" KEEP
// THEIR NAMES, so they are absent from this table by design — but three of those four
// changed EFFECT. M4 went from "any zombie that hits you is stunned" to "the reload burst
// kills outright"; m1 from "boosts every chance-based effect" to specifically ammo mod
// cooldowns; M1 from a vague "random element" to a defined 5%/5s roll. A name-only table
// cannot record that, which is the gap noted above.
// ══ NAPALM NECTAR ═══════════════════════════════════════════════════
//
// ⚠ "Ember Trail" NAMED A FIRE PIT ON KILLS, which M3 now owns — so m3 gets a name that
// says what it does. The other eight keep theirs: "Wildfire", "Demolitionist", "Scorched
// Earth", "Chain Reaction", "Accelerant", "Incendiary Rounds", "Powder Keg" and
// "Wildspread" all still describe their effect.
["fire/m3"] = "Hot Blade",
// ══ WIDOW'S WINE — FOUR SLOTS CHANGED EFFECT, SO FOUR NAMES MOVED ════════════
//
// ⚠ "Assassin" MOVED FROM M3 TO M2 with the instakill it names, rather than being left
// on an augment that no longer instakills. "Brute Force" and "Web Shot" describe effects
// that are gone; "Restock" moved to M4 in spirit and is now the spider drop.
["widowswine/M1"] = "Web Blade",
["widowswine/M2"] = "Assassin",
["widowswine/M3"] = "Cleave",
["widowswine/M4"] = "Spider's Gift",
["widowswine/m3"] = "Scavenge",
// ══ VICTORIOUS TORTOISE ═════════════════════════════════════════════
//
// ⚠ "Reflective Plating" and "Hazmat" describe effects that no longer exist, so their
// names go with them. The other seven keep theirs — "Dig In", "Fortifier", "Turtle Shell",
// "Rallying Stand", "Handyman" and "Entrench" all still say what the augment does, and
// renaming a working name is churn.
["tortoise/m1"] = "Shellshock",
["tortoise/m2"] = "Wider Stance",
["tortoise/m4"] = "Quick Hands",
// ══ TIMESLIP TONIC — FOUR RENAMED, TWO SWAPPED SLOTS ═════════════════════
//
// ⛔ M3 AND m2 TRADED EFFECTS, so both names had to move with them — the pit became a
// major and Time Out became a minor. Leaving the names would have put "Fault Lines" on
// the untargetable augment and "Time Out" on the pit, which is the defect PhD's M2
// shipped with an hour ago: a name contradicting its own description.
["time/M3"] = "Fault Lines",
["time/m2"] = "Time Out",
// ⚠ "Snail's Pace Slurpee" shortened. It is a real Black Ops perk NAME, not an
// augment name, and at 21 characters it was the longest in the file by a wide margin.
["time/M2"] = "Snail's Pace",
// ⚠ "Overclock PaP" — the abbreviation read as a placeholder, and the augment is no
// longer "faster", it is instant.
["time/m1"] = "Overclock",
// ══ PhD FLOPPER — THREE NAMES MOVED BECAUSE THREE EFFECTS DID ══════════════════
//
// ⛔ M2 IS NOT "DOUBLE JUMP" ANY MORE, AND LEAVING THE NAME WAS A REAL DEFECT. The
// description was changed to the chain effect and the name was not, so the menu offered
// "Double Jump" and sold a triple explosion. A name that contradicts its own description
// is worse than either being wrong alone.
["phd/M2"] = "Chain Blast",
// ⚠ AND m4/m5 HAD THE SAME PROBLEM, CAUSED BY ME. Their effects were SWAPPED - m4 took
// the jump height, m5 took the double jump - so "Long Jump" and "Hops" ended up attached
// to each other's behaviour. Swapping the names back is the whole fix; the ids stay put
// because the tier is derived from the id's case and `nz_augment_audit` checks it.
["phd/m4"] = "Hops",
["phd/m5"] = "Double Jump",
// ⛔ M3 AND m5 TRADED PLACES, IDS UNCHANGED. Phase Runner was a minor and is now
// the major; Fleet Footed was the major and is now the minor. The IDS could not be
// swapped — the tier is derived from the id's case and `nz_augment_audit`
// cross-checks it — so only the names and effects move.
["staminup/M3"] = "Phase Runner",
["staminup/m5"] = "Fleet Footed",
// Replaced outright; the originals were weapon-base plumbing.
["staminup/m3"] = "Deep Lungs",
["staminup/m4"] = "Second Wind",
// Speed Cola's two replaced minors. Full Clip and Quick Sip are gone.
["speed/m1"] = "Lucky Hands",
["speed/m4"] = "Even Keel",
// Deadshot's m1 was "Steady Hands" (faster ADS settle) — which Speed Cola's m3
// Sleight of Hand already does, on the same multiplier. Two perks selling one stat
// is worse than a gap, so it was replaced rather than duplicated.
["deadshot/m1"] = "Lucky Shot",
// Mule Kick's three replacements. Hot Swap, Quick Draw and Trickle Charge are gone —
// the latter two because they duplicated Speed Cola's m2 and M2 respectively.
["mulekick/M3"] = "Pack Mule",
["mulekick/m1"] = "Wide Mags",
["mulekick/m4"] = "Resupply",
// Vigor Rush's two replaced minors. Overpenetration was Double Tap's m2 under the
// same name; Opening Shot overlapped Speed Cola's M3.
["vigor/m2"] = "Ricochet",
["vigor/m5"] = "Vengeance",
};
/// <summary>Every deviation, as `perkid/augid` to the replacement text.</summary>
public static (string Key, string Desc)[] AllDeviations()
=> Deviations().Select( kv => (kv.Key, kv.Value) ).ToArray();
/// <summary>The four majors for a perk, or empty.</summary>
public static Augment[] MajorsFor( string perkId )
=> PoolFor( perkId )?.Major ?? System.Array.Empty<Augment>();
/// <summary>The five minors for a perk, or empty.</summary>
public static Augment[] MinorsFor( string perkId )
=> PoolFor( perkId )?.Minor ?? System.Array.Empty<Augment>();
/// <summary>One augment by perk and id, or null. The original's GetAugmentData.</summary>
public static Augment Find( string perkId, string augId )
{
if ( string.IsNullOrEmpty( augId ) ) return null;
var pool = PoolFor( perkId );
if ( pool is null ) return null;
// ⚠️ BOTH TIERS ARE SEARCHED rather than picking one from the id's case. The
// case IS the tier, but trusting it here would mean a mistyped id searched the
// wrong list and came back "no such augment" for one that exists.
return pool.Major.FirstOrDefault( a => a.Id == augId )
?? pool.Minor.FirstOrDefault( a => a.Id == augId );
}
/// <summary>
/// How many of this tier may be equipped at once.
///
/// ⛔ EVERY SLOT CHECK GOES THROUGH HERE, which is what makes the Creative override
/// one line instead of nine. `SlotFull`, `Grant`, `TryBuy`, the buy button's label and
/// the UI's PICK-n pips are all readers of this method — the §3 shape, deliberately.
///
/// ⚠️ Returns the POOL SIZE when unlimited, not int.MaxValue. The UI draws one pip per
/// slot; a limit of two billion would try to draw two billion pips.
/// </summary>
public static int LimitOf( AugmentTier tier )
{
if ( !Unlimited )
return tier == AugmentTier.Major ? MajorLimit : MinorLimit;
return tier == AugmentTier.Major ? 4 : 5;
}
/// <summary>What this augment costs in salvage, scale included.</summary>
public static int PriceOf( Augment aug )
{
if ( aug is null ) return 0;
var b = aug.Tier == AugmentTier.Major ? MajorPrice : MinorPrice;
return (int)(b * PriceScale);
}
/// <summary>Perks in the roster that have a pool. For the console listing.</summary>
public static string[] PerksWithAugments()
=> PerkRegistry.All.Where( p => PoolFor( p.Id ) is not null )
.Select( p => p.Id ).ToArray();
// ── what a player owns ──────────────────────────────────────────
/// <summary>
/// This player's loadout for one perk, creating it only if asked.
///
/// ⚠️ READS RETURN NULL RATHER THAN AN EMPTY LOADOUT. Creating on read would put
/// an entry in the dictionary for every perk the UI merely LOOKED at, so "which perks
/// has this player augmented" would answer "all of them".
/// </summary>
public static Loadout LoadoutOf( NZPlayer player, string perkId, bool create = false )
{
if ( !player.IsValid() || string.IsNullOrEmpty( perkId ) ) return null;
if ( player.Augments.TryGetValue( perkId, out var existing ) ) return existing;
if ( !create ) return null;
var fresh = new Loadout();
player.Augments[perkId] = fresh;
return fresh;
}
/// <summary>Is this augment equipped.</summary>
public static bool Has( NZPlayer player, string perkId, string augId )
{
var load = LoadoutOf( player, perkId );
if ( load is null ) return false;
return load.Majors.Contains( augId ) || load.Minors.Contains( augId );
}
/// <summary>How many of this tier are equipped on this perk.</summary>
public static int CountOf( NZPlayer player, string perkId, AugmentTier tier )
{
var load = LoadoutOf( player, perkId );
if ( load is null ) return 0;
return tier == AugmentTier.Major ? load.Majors.Count : load.Minors.Count;
}
/// <summary>Is that tier's slot count already used up on this perk.</summary>
public static bool SlotFull( NZPlayer player, string perkId, AugmentTier tier )
=> CountOf( player, perkId, tier ) >= LimitOf( tier );
/// <summary>Every augment equipped on a perk, major first. For readouts.</summary>
public static string[] EquippedOn( NZPlayer player, string perkId )
{
var load = LoadoutOf( player, perkId );
if ( load is null ) return System.Array.Empty<string>();
var list = new List<string>( load.Majors );
list.AddRange( load.Minors );
return list.ToArray();
}
/// <summary>
/// Equip an augment WITHOUT charging for it. The original's `GiveAugment`.
///
/// ⚠️ STILL ENFORCES OWNERSHIP AND SLOTS — free is not the same as unchecked. The
/// console path comes through here, and a grant that could exceed the limits would
/// produce player state the UI has no way to display.
/// </summary>
public static string Grant( NZPlayer player, string perkId, string augId )
{
if ( !player.IsValid() ) return "no player";
if ( !player.HasPerk( perkId ) ) return $"you do not own {perkId}";
var aug = Find( perkId, augId );
if ( aug is null ) return $"no augment '{augId}' on {perkId}";
if ( Has( player, perkId, augId ) ) return $"{aug.Name} is already equipped";
if ( SlotFull( player, perkId, aug.Tier ) )
return $"{aug.Tier.ToString().ToLower()} slots are full ({LimitOf( aug.Tier )})";
var load = LoadoutOf( player, perkId, create: true );
if ( aug.Tier == AugmentTier.Major ) load.Majors.Add( augId );
else load.Minors.Add( augId );
// ⚠️ PAID NOTHING UNTIL TryBuy SAYS OTHERWISE. A grant costs nothing, so taking it off
// refunds nothing; TryBuy writes the real price over this once it has charged.
(load.Paid ??= new())[augId] = 0;
// ⛔ THE ONE PLACE AN AUGMENT BECOMES EQUIPPED, so it is the one place that can
// tell a stat-based augment to recompute. TryBuy funnels through here and so does
// the console, which means neither can equip something without the refresh
// running — the mistake being avoided is a Max-health augment that works when
// bought but not when granted, or vice versa.
AugmentEffects.OnGained( player, perkId, augId );
return null;
}
/// <summary>
/// Buy an augment with salvage. Returns null on success, else why not.
///
/// ⛔ GRANT FIRST, THEN CHARGE. <see cref="Grant"/> re-runs every check and can
/// still refuse; charging ahead of it would be a chance to take salvage for nothing.
/// The reverse mistake — equipping on a failed payment — cannot happen because the
/// afford check is above and TrySpend cannot fail after it.
/// </summary>
public static string TryBuy( NZPlayer player, string perkId, string augId )
{
if ( !player.IsValid() ) return "no player";
var aug = Find( perkId, augId );
if ( aug is null ) return $"no augment '{augId}' on {perkId}";
if ( !player.HasPerk( perkId ) ) return $"you do not own {perkId}";
if ( Has( player, perkId, augId ) ) return $"{aug.Name} is already equipped";
if ( SlotFull( player, perkId, aug.Tier ) )
return $"{aug.Tier.ToString().ToLower()} slots are full ({LimitOf( aug.Tier )})";
var price = PriceOf( aug );
if ( !Salvage.CanAfford( player, price ) )
return $"need {price:N0} salvage, you have {player.Salvage:N0}";
var refusal = Grant( player, perkId, augId );
if ( refusal is not null ) return refusal;
Salvage.TrySpend( player, price );
// What taking it off will give half of back — see Loadout.Paid.
var load = LoadoutOf( player, perkId );
if ( load is not null ) (load.Paid ??= new())[augId] = price;
return null;
}
/// <summary>What was paid for an equipped augment, in salvage. 0 if it is not equipped.
///
/// ⚠️ AN AUGMENT BOUGHT BEFORE `Paid` EXISTED has no record, and is taken to have cost its
/// price now — which it did, unless the prices were retuned in between.</summary>
public static int PaidFor( NZPlayer player, string perkId, string augId )
{
if ( !Has( player, perkId, augId ) ) return 0;
var load = LoadoutOf( player, perkId );
if ( load?.Paid is not null && load.Paid.TryGetValue( augId, out var paid ) ) return paid;
return PriceOf( Find( perkId, augId ) );
}
/// <summary>What taking an equipped augment off gives back: half what was paid, rounded down.</summary>
public static int RefundFor( NZPlayer player, string perkId, string augId )
=> System.Math.Max( 0, PaidFor( player, perkId, augId ) ) / 2;
/// <summary>
/// Why this augment cannot come off right now, or null if it can.
///
/// ⛔ AN AUGMENT HOLDING A SLOT YOU ARE USING STAYS ON. Mule Kick's Pack Mule is a weapon slot
/// and Vulture Aid's M3 and m2 are perk slots. Taking one off while its slot is full would
/// either destroy what is in it — the rule when Mule Kick itself is lost — or leave the player
/// over the cap with it for half its price back. Neither is a thing one click should do, so the
/// click is refused and the menu says why.
///
/// ⚠️ ASKED BY TAKING IT OFF AND PUTTING IT BACK, at the same place in its list, rather than by
/// naming the slot augments. Every cap here is derived from the loadout when it is read
/// (`NZPlayer.PerkSlots`, `NZInventory.EffectiveMaxSlots`), so this asks the caps themselves,
/// and an augment that grants a slot later is covered without a line here.
/// </summary>
public static string RemoveBlocker( NZPlayer player, string perkId, string augId )
{
if ( !player.IsValid() ) return "no player";
var aug = Find( perkId, augId );
if ( aug is null ) return $"no augment '{augId}' on {perkId}";
var load = LoadoutOf( player, perkId );
var list = aug.Tier == AugmentTier.Major ? load?.Majors : load?.Minors;
var at = list?.IndexOf( augId ) ?? -1;
if ( at < 0 ) return $"{aug.Name} is not equipped";
var inv = player.Inventory;
var perkCap = player.PerkSlots;
var gunCap = inv.IsValid() ? inv.EffectiveMaxSlots : 0;
list.RemoveAt( at );
try
{
if ( player.PerkSlots < perkCap && player.Perks.Count > player.PerkSlots )
return $"{aug.Name} is holding a perk slot you are using"
+ $" ({player.Perks.Count} perks, {player.PerkSlots} slots without it)";
if ( inv.IsValid() && inv.EffectiveMaxSlots < gunCap && inv.Count > inv.EffectiveMaxSlots )
return $"{aug.Name} is holding a weapon slot you are using"
+ $" ({inv.Count} weapons, {inv.EffectiveMaxSlots} slots without it)";
return null;
}
finally
{
list.Insert( at, augId );
}
}
/// <summary>
/// Take an augment off and give back half the salvage paid for it. Returns null on success,
/// else why not; `refund` is what was given back.
///
/// ⛔ ASKED FOR BY THE USER (2026-09-27): "left clicking an augment i have in the wunderfizz
/// removes it and refunds half the salvage it cost". The Wunderfizz's augment rows call this,
/// and so does `nz_augment_remove`. Moved to a RIGHT click on 2026-10-03, also by request —
/// see WunderfizzMenu.RemoveAug.
///
/// ⚠️ REFRESHED THROUGH AugmentEffects.OnLost, the pair of OnGained. Most augments stop the
/// moment they are gone, being asked for when their event fires; the stat-based ones
/// (Overhealth's max health, Mule Kick's grenades, clip and reserve) were computed with the
/// augment and have to be computed again without it.
/// </summary>
public static string TryRemove( NZPlayer player, string perkId, string augId, out int refund )
{
refund = 0;
var blocked = RemoveBlocker( player, perkId, augId );
if ( blocked is not null ) return blocked;
// ⚠️ WORKED OUT WHILE IT IS STILL EQUIPPED: PaidFor reads 0 for one that is not.
refund = RefundFor( player, perkId, augId );
var load = LoadoutOf( player, perkId );
load.Majors.Remove( augId );
load.Minors.Remove( augId );
load.Paid?.Remove( augId );
Salvage.Refund( player, refund );
AugmentEffects.OnLost( player, perkId, augId );
return null;
}
/// <summary>
/// Drop every augment on one perk.
///
/// ⛔ CALLED WHEN A PERK IS LOST, matching the original's OnPlayerLostPerk hook —
/// a re-bought perk starts clean. Without this, going down and re-buying Juggernog
/// would hand back a major and two minors nobody paid for the second time.
/// </summary>
public static void ClearFor( NZPlayer player, string perkId )
{
if ( !player.IsValid() || string.IsNullOrEmpty( perkId ) ) return;
player.Augments.Remove( perkId );
}
/// <summary>Drop every augment on every perk.</summary>
public static void ClearAll( NZPlayer player )
{
if ( !player.IsValid() ) return;
player.Augments.Clear();
}
}