Static utility for resolving powerup effects in the NZombies game. It maps collected powerup kinds to concrete actions (MaxAmmo, BonusPoints, Carpenter, Nuke, Spider via augment), exposes team-effect classification, tunable point values, console commands for testing/tweaking, and exposes read-only properties used by timed powerup systems (Double Points, Insta-Kill, Fire Sale).
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>
/// What each powerup actually DOES.
///
/// ⛔ ONE METHOD PER POWERUP, and that is the whole payoff of building the shell and
/// the registry first. Spawning, hovering, expiry, pickup, the banner, the announcer
/// and the countdown are all handled elsewhere and identically — everything specific
/// to a powerup lives here and nowhere else.
///
/// Point awards are the original's own, from `sh_powerups.lua`:
/// Nuke 400 · Carpenter 200 · Bonus `math.random(1,6)*50`
/// </summary>
public static class PowerupEffects
{
/// <summary>
/// Fire the effect for a collected powerup.
///
/// ⚠️ The TIMED ones do nothing here — their behaviour is read live from
/// `ActivePowerups.IsActive` by the systems they affect, rather than pushed at
/// pickup. A Double Points that flipped a flag on would need a matching flip-off
/// on a timer, and the timer already exists in one place.
/// </summary>
public static void Apply( PowerupKind kind, NZPlayer player, int pointsOverride = -1 )
{
// ⛔ SIX SITUATIONS FROM ONE LINE, because this is the single place every power-up resolves.
// Hanging the voice off each individual effect method would be six call sites that can drift
// apart, and `BonusPoints` and `Spider` would each need remembering to leave alone.
//
// ⚠️ AN UNMAPPED KIND PASSES null, WHICH `Say` IGNORES. `Spider`, `BonusPoints` and
// `DeathMachine` have no recorded line, and that is not an oversight to fix — the crew never
// recorded one.
CharacterVoice.Say( kind switch
{
PowerupKind.Nuke => "nuke",
PowerupKind.MaxAmmo => "maxammo",
PowerupKind.InstaKill => "instakill",
PowerupKind.DoublePoints => "doublepoints",
PowerupKind.FireSale => "firesale",
PowerupKind.Carpenter => "carpenter",
_ => null,
}, player );
switch ( kind )
{
// ⚠ Widow's Wine M4. The effect lives in `WidowAugments` rather than here because it
// is the augment's payload, not a power-up the game otherwise has — and that keeps the
// grenade cap in one place.
case PowerupKind.Spider: WidowAugments.CollectSpider( player ); break;
case PowerupKind.MaxAmmo: MaxAmmo( player ); break;
case PowerupKind.BonusPoints: BonusPoints( player, pointsOverride ); break;
case PowerupKind.Carpenter: Carpenter( player ); break;
case PowerupKind.Nuke: Nuke( player ); break;
// Read live from ActivePowerups — see the properties below.
case PowerupKind.DoublePoints:
case PowerupKind.InstaKill:
case PowerupKind.FireSale:
break;
}
}
/// <summary>
/// Does this powerup happen to EVERYONE, or only to whoever picked it up?
///
/// ⛔ ONE AUTHOR, BECAUSE TWO COST A BUG THIS MORNING. `Powerup.Collect` and
/// `Powerup.CollectRemote` each carried their own hand-written exception for Max Ammo, and only
/// `CollectRemote` ever got it — so the host went without whenever a CLIENT picked one up, and
/// it read as intermittent because it depended on who walked over it. Reported as *"max ammo
/// só deu a uma pessoa"*. A second membership test would have been a second chance to make the
/// same mistake with Nuke and Carpenter.
///
/// ⚠️ "TEAM" MEANS THE PER-PLAYER HALF REACHES EVERY PLAYER — the refill, the points. It does
/// NOT mean the world half runs on every machine: `Nuke` kills the zombies and `Carpenter`
/// reboards the barricades exactly once, guarded inside those methods. Both halves live in one
/// call precisely so neither can be forgotten.
///
/// ⚠️ INSTA-KILL, DOUBLE POINTS AND FIRE SALE ARE NOT HERE and must not be. They are TIMED,
/// not instant — `ActivePowerups.Activate` already starts everybody's clock, and whatever they
/// affect reads the registry live. Adding them would apply a second, per-player effect on top of
/// a global one that already works.
/// </summary>
public static bool IsTeamEffect( PowerupKind kind )
=> kind is PowerupKind.MaxAmmo or PowerupKind.Nuke or PowerupKind.Carpenter;
/// <summary>Points a Nuke pays each player. 400.</summary>
public static int NukePoints { get; set; } = 400;
/// <summary>Points a Carpenter pays each player. 200.</summary>
public static int CarpenterPoints { get; set; } = 200;
// ── instant effects ──────────────────────────────────────────────────────
/// <summary>
/// Every weapon's reserve AND magazine back to full, and grenades topped up.
///
/// ⛔ THE CLIP IS FILLED TOO NOW, REVERSING A STATED DESIGN DECISION. This method used to
/// carry a ⛔ block saying "THE RESERVE ONLY, NOT THE CLIP — Max Ammo has never reloaded your
/// weapon", on two arguments: that a player mid-reload should keep reloading, and that filling
/// the clip hands a free instant reload to anyone who grabs it dry. Requested changed: the
/// powerup refills the magazine as well. The free instant reload IS the feature now; the
/// mid-reload case is handled rather than avoided — see the cancel below.
///
/// ⚠️ EVERY weapon, not the held one. Both slots, both fire modes.
/// </summary>
public static void MaxAmmo( NZPlayer player )
{
if ( !player.IsValid() ) return;
int guns = 0, clips = 0;
var inv = player.Components.Get<NZInventory>( FindMode.EverythingInSelf );
if ( inv.IsValid() )
{
foreach ( var go in inv.Weapons )
{
// ⚠️ `EverythingInSelf` — a holstered weapon is DISABLED and the plain
// lookup skips disabled components, which would refill only the gun in
// your hands.
var ammo = go.Components.Get<NZAmmo>( FindMode.EverythingInSelf );
if ( ammo.IsValid() )
{
ammo.Reserve = ammo.MaxReserve;
guns++;
}
// ⚠️ THE SAME `EverythingInSelf` REASON APPLIES TO THE WEAPON ITSELF, and getting
// it wrong here would be invisible: the reserve would refill on every gun while
// only the held one got its magazine, which reads as the powerup working.
var wep = go.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInSelf );
if ( !wep.IsValid() ) continue;
// ⛔ THE RELOAD IN PROGRESS MUST BE CANCELLED, AND THIS IS THE HALF THE OLD DESIGN
// NOTE WAS RIGHT ABOUT. A reload that completes after the magazine is already full
// runs `Take()` against the reserve and pours rounds into a clip with no room — so
// the player pays for a reload they did not need, out of a reserve that was just
// filled. Worse, the animation plays to the end over a full gun, which reads as the
// powerup not having worked.
//
// ⚠️ `CancelAnyReload`, NOT `CancelReload`. Two cancels exist and a caller outside
// Weapon.Reload cannot know which applies — `ShellReloading` is the discriminator,
// and a tube-fed rifle shell-reloads while some shotguns do not. That method's own
// header says it exists for exactly this kind of caller.
//
// ⚠️ AND IT IS SAFE ON A HOLSTERED GUN BY CONSTRUCTION: it early-returns unless
// `IsReloading`, and a disabled weapon is not reloading, so nothing touches a null
// ViewModelRenderer.
wep.CancelAnyReload();
if ( FillClip( wep.Primary ) ) clips++;
if ( FillClip( wep.Secondary ) ) clips++;
}
}
// ⛔ AND THE GRENADES. This is what the round-end +1 was standing in for, and
// the reason `Refill()` was written with nothing able to call it.
var nades = player.Components.Get<Grenade>( FindMode.EverythingInSelf );
int added = nades.IsValid() ? nades.Refill() : 0;
Log.Info( $"[nz] MAX AMMO — {guns} weapon(s) refilled, {clips} magazine(s) topped up,"
+ $" +{added} grenade(s)" );
}
/// <summary>
/// Top one fire mode's magazine to full. Returns whether it actually needed filling.
///
/// ⛔ `ClipSize <= 0` IS "FEEDS STRAIGHT FROM THE RESERVE", NOT "AN EMPTY MAGAZINE". SWB
/// spells a magazineless weapon as a clip size of -1, and `ShouldAutoReload` already tests it
/// that way — "there is no magazine to fill". Writing `Ammo = ClipSize` on one of those would
/// set the round count NEGATIVE, and every fire gate reads `Ammo > 0`.
///
/// ⚠️ IT REPORTS WHETHER ANYTHING CHANGED so the log line counts magazines that were
/// actually short. "3 magazines topped up" on a player who was already full is the kind of
/// number that makes a report agree with itself while the game does something else.
///
/// ⚠️ THE ROUNDS ARE FREE — nothing is taken from the reserve. The reserve is set to its
/// own maximum in the same pass, so charging the clip against it would be charging a number
/// that is about to be overwritten anyway.
/// </summary>
static bool FillClip( SWB.Base.ShootInfo si )
{
if ( si is null || si.ClipSize <= 0 || si.Ammo >= si.ClipSize ) return false;
si.Ammo = si.ClipSize;
return true;
}
/// <summary>Bonus Points — the low end of the roll. 500.</summary>
public static int BonusPointsMin { get; set; } = 500;
/// <summary>Bonus Points — the high end. 1500.</summary>
public static int BonusPointsMax { get; set; } = 1500;
/// <summary>
/// A points windfall, paid to EVERY player.
///
/// ⛔ IT USED TO PAY 50-300 TO THE COLLECTOR ALONE, which is why it was reported as
/// "doing nothing". Both halves were wrong for this game: a wall buy costs 950 and a
/// Pack-a-Punch 5,000, so a 50-point award at round 20 is invisible — and a team powerup
/// that only pays whoever walked over it is not a team powerup. `math.random(1,6)*50` is
/// the original's roll and the original's economy; this one is not that.
///
/// ⚠️ RANDOM 500-1500, ROLLED ONCE FOR THE WHOLE TEAM. Rolling per player would hand one
/// player 1500 and another 500 off the same pickup, which reads as a bug from both ends.
///
/// ⚠️ VARIANCE IS STILL THE POINT — a bonus that always paid the same would just be a
/// slower Double Points.
/// </summary>
public static void BonusPoints( NZPlayer player, int amountOverride = -1 )
{
// ⚠️ A DROPPED ONE PAYS EXACTLY WHAT WAS PUT IN, TO THE COLLECTOR ONLY, and that is the
// whole contract of the drop: the points are MOVED, not created. Rolling on a powerup
// someone paid 1000 for would make dropping a way to destroy points, and paying the
// whole team what one player dropped would make it a way to print them.
//
// ⛔ -1 IS "NOT A DROP", AND ONLY -1. `Powerup.Spawn` defaulted its override to 0, which this
// line reads as a drop worth 0 — so a natural Bonus Points paid the collector nothing and
// returned before the roll. The default is -1 now, matching `Powerup.PointsOverride`.
if ( amountOverride >= 0 )
{
if ( !player.IsValid() ) return;
player.AddPoints( amountOverride );
Log.Info( $"[nz] BONUS POINTS — +{amountOverride} (dropped)" );
return;
}
var scene = Game.ActiveScene;
if ( scene is null ) return;
var amount = Game.Random.Int( BonusPointsMin, BonusPointsMax );
// ⛔ EVERY PLAYER, NOT `NZPlayer.Local` AND NOT THE COLLECTOR. This runs on ONE machine, the
// collector's — `Powerup.Collect` applies it for the host's own pickup and `CollectRemote` for a
// client's — so the local player here is one of the team, not the team.
//
// ⚠️ PAYING A PROXY IS SAFE BECAUSE `AddPoints` RELAYS, from a client as well as from the host.
// It tests `PlayerPresence.Theirs` and forwards through `NZNet.AwardPoints`, a broadcast only
// the body's owner acts on. Without that this loop would raise a number on four copies and
// pay one person.
var paid = 0;
foreach ( var p in scene.GetAllComponents<NZPlayer>() )
{
if ( !p.IsValid() ) continue;
p.AddPoints( amount );
paid++;
}
Log.Info( $"[nz] BONUS POINTS — +{amount} to {paid} player(s)" );
}
/// <summary>
/// `nz_bonuspoints [min] [max]` — the roll, and a live payout to prove it.
///
/// ⚠️ PAYING IS THE TEST. "It is not giving points" was the original report, and a command
/// that only printed the range could not have distinguished a wrong number from a broken
/// award path.
/// </summary>
[ConCmd( "nz_bonuspoints" )]
public static void BonusPointsCmd( int min = -1, int max = -1 )
{
if ( min >= 0 ) BonusPointsMin = min;
if ( max >= 0 ) BonusPointsMax = max;
if ( BonusPointsMax < BonusPointsMin ) BonusPointsMax = BonusPointsMin;
Log.Info( $"[nz] Bonus Points rolls {BonusPointsMin}-{BonusPointsMax}, paid to every player" );
BonusPoints( null );
}
/// <summary>
/// Every barricade back to full boards.
///
/// ⚠️ Pays 200 ONCE, not per barricade. The original awards a flat 200 however
/// many boards went back up — paying per plank would make Carpenter worth more on
/// a map you had let fall apart, which rewards playing badly.
/// </summary>
public static void Carpenter( NZPlayer player )
{
var scene = Game.ActiveScene;
if ( scene is null ) return;
int fixedUp = 0;
// ⛔ THE BOARDS GO BACK ON ONCE, ON THE HOST. This method now runs on EVERY machine so
// that everyone gets paid — see `IsTeamEffect` — and barricade state is the host's. A client
// calling `SetBoards` would be writing a value the next replication overwrites, and doing it
// on four machines is four writes for one outcome.
if ( !Networking.IsActive || NZGame.IsHost )
{
foreach ( var b in scene.GetAllComponents<Barricade>() )
{
if ( !b.IsValid() || b.IsFull ) continue;
b.SetBoards( Barricade.MaxPlanks );
fixedUp++;
}
}
// ⚠️ PAID TO THIS MACHINE'S OWN PLAYER, whoever collected it. `player` is `NZPlayer.Local`
// on every machine for a team effect, so each person is paid once, locally, with their own
// perks and augments in the calculation — the same reasoning `Powerup.Collect` gives for
// deferring a remote collector's payout rather than paying it on the host.
if ( player.IsValid() ) player.AddPoints( CarpenterPoints );
Log.Info( $"[nz] CARPENTER — {fixedUp} barricade(s) reboarded, +{CarpenterPoints}"
+ " to each player" );
}
/// <summary>
/// Kill everything on the map that a nuke is allowed to kill.
///
/// ⛔ IT NO LONGER KILLS EVERYTHING, AND `ZombieVariant.NukeDamageFraction` DECIDES WHO. The
/// horde, the hellhound and the pest die as they always did; the napalm, the shrieker and
/// Brutus take 30% of their maximum health, and Oberon is untouched. The rule is authored per
/// variant rather than listed here — see the property for why no flag we already had could
/// express that set.
///
/// ⛔ PAYS A FLAT 400, NOT PER ZOMBIE. The original is explicit about this
/// (`GivePoints(400)`), and it is what stops a Nuke on a full round being worth
/// more than the round itself.
///
/// ⚠️ Damage rather than a silent despawn, so the death path runs — ragdolls,
/// death sounds and the round's alive-count all hang off it. Removing them
/// outright would leave the wave believing they were still coming.
/// </summary>
public static void Nuke( NZPlayer player )
{
var scene = Game.ActiveScene;
if ( scene is null ) return;
int killed = 0, hurt = 0, spared = 0;
// ⛔ THE ZOMBIES DIE ONCE, ON THE HOST. This method now runs on EVERY machine so that
// everyone gets paid — see `IsTeamEffect` — and without this guard each client would run the
// sweep too. That is not harmless: `Health.OnDamage` RELAYS a client's damage to the host
// rather than refusing it, so a four-player nuke would send four full sets of kill messages
// for one set of zombies, and every kill would be attributed on four machines.
//
// ⚠️ Materialised with ToList FIRST — killing a zombie mutates the scene's
// component list, and iterating it live while it changes throws.
if ( !Networking.IsActive || NZGame.IsHost )
{
foreach ( var z in scene.GetAllComponents<ZombieAI>().ToList() )
{
if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() ) continue;
var share = NukeShare( z.Variant );
// ⛔ IMMUNE MEANS NOTHING HAPPENS, NOT THAT ZERO DAMAGE IS DEALT. Oberon is
// authored at 0 and was asked for as *"do nothing to oberon"* — so no flinch, no
// hit sound, and no `OnDamaged` for anything downstream to react to.
if ( share <= 0f ) { spared++; continue; }
if ( share >= 1f )
{
hp.OnDamage( new DamageInfo
{
Damage = hp.Max * 10f,
Position = z.WorldPosition,
} );
killed++;
continue;
}
// ⛔ `Apply` RATHER THAN `OnDamage`, AND ON BRUTUS THAT IS THE DIFFERENCE BETWEEN
// 30% AND 4.5%. `OnDamage` runs `BrutusHelmet.ScaleOn`, whose body scale is 0.15:
// a helmeted boss reads an untagged body hit as a fifteenth of itself, which is
// right for a bullet and wrong for a bomb going off over the whole map. `Apply` is
// the raw sink both routes end in — `OnDamage` finishes by calling it — so the
// death path, the ragdoll, the drops and the round's alive-count all still run.
//
// ⚠️ NO ATTACKER, WHICH IS THE SAME null THE KILL BRANCH PASSES. So no damage
// points and no kill credit: a nuke that paid per-hit on a boss's health bar would
// be a farm sitting on top of the flat 400.
hp.Apply( hp.Max * share, false, null );
if ( hp.IsDead ) killed++;
else hurt++;
}
}
// ⚠️ PAID LOCALLY, ONCE PER PLAYER. See the note on Carpenter's own payout.
if ( player.IsValid() ) player.AddPoints( NukePoints );
Log.Info( $"[nz] NUKE — {killed} zombie(s) killed, {hurt} hurt, {spared} immune,"
+ $" +{NukePoints} to each player" );
}
/// <summary>
/// What a Nuke does to one variant: the share of its MAX health it takes off.
/// 1 = killed outright, 0 = untouched. `nz_nuke_resist` prints the whole table.
/// </summary>
///
/// ⚠️ A NULL VARIANT IS THE WALKER, AND THE WALKER DIES. Almost every zombie in the game
/// carries no variant at all, so the default has to be the killing one — a 0 default would
/// have made the power-up do nothing to the horde it exists for.
public static float NukeShare( ZombieVariant variant )
=> MathX.Clamp( variant?.NukeDamageFraction ?? 1f, 0f, 1f );
/// <summary>
/// `nz_nuke_resist [variant] [share]` — who survives a nuke, and a knob to try another answer.
/// </summary>
///
/// ⚠️ IT READS THE ASSETS RATHER THAN A LIST IN THIS FILE. A printed table written out here
/// would agree with the game exactly until the first time a `.zvar` changed, and then be the
/// most convincing wrong answer in the project.
///
/// ⚠️ THE WRITE IS THIS SESSION ONLY AND SAYS SO. It sets the property on the LOADED resource,
/// which is the object the sweep reads, so a value tried here is live on the next nuke and
/// gone on the next hotload. The keeper goes in the `.zvar`.
[ConCmd( "nz_nuke_resist" )]
public static void NukeResistCmd( string variant = "", float share = -1f )
{
if ( !string.IsNullOrWhiteSpace( variant ) )
{
var v = SpecialEnemies.VariantFor( variant );
if ( v is null )
{
Log.Info( $"[nz] no special called '{variant}' —"
+ $" try one of {string.Join( ", ", SpecialEnemies.Names )}" );
return;
}
if ( share >= 0f )
{
v.NukeDamageFraction = MathX.Clamp( share, 0f, 1f );
Log.Info( $"[nz] {variant} now takes {v.NukeDamageFraction:P0} of its max health"
+ " from a nuke — THIS SESSION ONLY, the .zvar still says what it said" );
}
}
// ⚠️ THE LABEL IS A VARIABLE because a string literal nested inside an interpolation hole
// is a C# 11 feature and this project does not assume one.
var walker = "(walker)";
Log.Info( "[nz] NUKE — share of MAX health taken off:" );
Log.Info( $"[nz] {walker,-10} {NukeShare( null ),6:P0} killed outright" );
foreach ( var name in SpecialEnemies.Names )
{
var s = NukeShare( SpecialEnemies.VariantFor( name ) );
var says = s <= 0f ? "untouched"
: s >= 1f ? "killed outright"
: $"{System.MathF.Ceiling( 1f / s ):0} nukes from full health";
Log.Info( $"[nz] {name,-10} {s,6:P0} {says}" );
}
}
// ── timed effects, read live ─────────────────────────────────────────────
/// <summary>Points multiplier — 2 while Double Points runs, else 1.</summary>
public static int PointsMultiplier
=> ActivePowerups.IsActive( PowerupKind.DoublePoints ) ? 2 : 1;
/// <summary>Is Insta-Kill running?</summary>
public static bool InstaKill => ActivePowerups.IsActive( PowerupKind.InstaKill );
/// <summary>
/// What Insta-Kill does to one variant: null = killed by any hit, otherwise the damage multiplier
/// it takes instead. `nz_instakill_resist` prints the table.
/// </summary>
///
/// ⚠️ A NULL VARIANT IS THE WALKER, AND THE WALKER DIES — the same default `NukeShare` needs, for
/// the same reason: almost every zombie in the game carries no variant at all.
public static float? InstaKillScale( ZombieVariant variant )
=> variant?.InstaKillMultiplier is float m ? System.MathF.Max( 0f, m ) : null;
/// <summary>
/// `nz_instakill_resist [variant] [multiplier]` — who survives Insta-Kill, and by how much.
/// </summary>
///
/// ⚠️ A MULTIPLIER OF 0 OR BELOW CLEARS IT, putting the variant back on "killed outright". There
/// is no other way to type "empty" into a console argument, and a variant that took zero damage
/// under Insta-Kill would be a stranger rule than either of the two the game actually has.
///
/// ⚠️ THE WRITE IS THIS SESSION ONLY, like `nz_nuke_resist`'s: it sets the loaded resource, which
/// is what `Health.OnDamage` reads, and the keeper goes in the `.zvar`.
[ConCmd( "nz_instakill_resist" )]
public static void InstaKillResistCmd( string variant = "", float multiplier = float.NaN )
{
if ( !string.IsNullOrWhiteSpace( variant ) )
{
var v = SpecialEnemies.VariantFor( variant );
if ( v is null )
{
Log.Info( $"[nz] no special called '{variant}' —"
+ $" try one of {string.Join( ", ", SpecialEnemies.Names )}" );
return;
}
if ( !float.IsNaN( multiplier ) )
{
v.InstaKillMultiplier = multiplier > 0f ? multiplier : null;
Log.Info( $"[nz] {variant} under Insta-Kill: "
+ (v.InstaKillMultiplier is float m ? $"{m:0.##}x damage" : "killed outright")
+ " — THIS SESSION ONLY, the .zvar still says what it said" );
}
}
var walker = "(walker)";
Log.Info( "[nz] INSTA-KILL — what a hit does while it runs:" );
Log.Info( $"[nz] {walker,-10} killed outright" );
foreach ( var name in SpecialEnemies.Names )
{
var s = InstaKillScale( SpecialEnemies.VariantFor( name ) );
Log.Info( $"[nz] {name,-10} {(s is float m ? $"{m:0.##}x damage, not killed" : "killed outright")}" );
}
Log.Info( $"[nz] Insta-Kill is {(InstaKill ? "RUNNING" : "not running")}"
+ " — nz_powerup instakill to drop one" );
}
/// <summary>
/// Is a Fire Sale on? Three separate things read this.
///
/// ⛔ THIS COMMENT USED TO SAY ONLY THE PRICE WAS WIRED, and that the project "has one box that
/// relocates rather than a set of fixed spots, so all boxes open has nothing yet to mean". The
/// second half was already untrue when it was written — MysteryBoxSpot is a LIST in the config
/// and MysteryBoxManager picks one to start at — so the note read as a design limitation when it
/// was really an unfinished feature. All three behaviours are now wired:
///
/// • PRICE — MysteryBox.Price returns FireSaleCost (10) instead of the spot's cost.
/// • EVERY LOCATION — MysteryBoxManager builds a box at every spot for the duration.
/// • NO BEAR — MysteryBox.RollTeddy refuses, so a sale cannot end itself by moving the box.
/// </summary>
public static bool FireSale => ActivePowerups.IsActive( PowerupKind.FireSale );
}