Perk and augment logic for a Napalm Nectar fire perk and its augments. Defines ignite chance/cooldown, spreading (Wildfire/Wildspread), pit spawning and ticking, area/chain damage, diagnostics and console commands, plus a NapalmPit component that applies periodic damage.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Napalm Nectar — the base ignite AND its augments.
///
/// | | effect |
/// |---|---|
/// | base | **1-in-6** hits ignite, then a **6-hit cooldown**. Burning: **5s**, takes **×2** damage |
/// | M1 **Wildfire** | an ignite also lights the **3 nearest** zombies within 300u |
/// | M2 **Demolitionist** | **×3** area damage |
/// | M3 **Scorched Earth** | shots have a **1%** chance to leave a **burning pit** |
/// | M4 **Chain Reaction** | kills have a **10%** chance to explode — and that explosion's kills can chain |
/// | m1 **Accelerant** | burning zombies take **×2.5** instead of ×2 |
/// | m2 **Incendiary Rounds** | ignite **1-in-4** instead of 1-in-6, and the cooldown **3 hits** instead of 6 |
/// | m3 **Hot Blade** | knifing a zombie ignites it |
/// | m4 **Powder Keg** | every AoE radius **+20%** |
/// | m5 **Wildspread** | an ignite has a **10%** chance to light **5** nearby instead of 1 |
///
/// ⛔ THE BASE PERK WAS REBUILT, NOT JUST RETUNED. It used to be two unrelated mechanics: a
/// 1-in-6 roll for DOUBLE DAMAGE (no fire at all), and a separate per-zombie counter of 5 hits
/// followed by a 1-in-3 roll to ignite. The counter froze while the target burned, so the real
/// cost was ~7 hits on one zombie and spraying a crowd built five separate counters and lit
/// nothing. It is now one mechanic: **1-in-6 to ignite, then a 6-hit cooldown**.
///
/// ⚠️ THE COOLDOWN IS COUNTED IN HITS, NOT SHOTS, and the difference is real: a miss never
/// reaches `Health.OnDamage`, so it cannot tick the cooldown down. Requested as "6 shot
/// cooldown"; hits are what the damage path can actually see, and counting shots would need a
/// hook in the weapon rather than the victim.
///
/// ⚠️ THE COOLDOWN IS PER PLAYER, NOT PER ZOMBIE. "In between each ignition" reads as a limit on
/// how often YOU can set something alight, which is also what stops a crowd from all igniting
/// at once. The old design was per zombie and that is exactly why it felt dead.
///
/// ⚠️ THE BURN STILL DOES ITS OWN TICK DAMAGE (8 per 0.5s, from the `burn` rule). Not mentioned
/// in the request, and removing it would be a silent nerf to a number nobody asked to change.
/// </summary>
public static class FireAugments
{
const string Perk = "fire";
/// <summary>The name every napalm pit GameObject carries.</summary>
public const string PitName = "nz_napalm_pit";
// ══ tuning ════════════════════════════════════════════════════════════════
static float? _wildfireRadius;
/// <summary>M1 Wildfire — the "medium radius" it spreads within. 300u.</summary>
public static float WildfireRadius { get => _wildfireRadius ?? 300f; set => _wildfireRadius = value; }
static int? _wildfireCount;
/// <summary>M1 Wildfire — how many extra zombies catch. 3.</summary>
public static int WildfireCount { get => _wildfireCount ?? 3; set => _wildfireCount = value; }
static float? _areaDamage;
/// <summary>M2 Demolitionist — multiplier on all area damage. ×3.</summary>
public static float AreaDamage { get => _areaDamage ?? 3f; set => _areaDamage = value; }
static float? _pitChance;
/// <summary>M3 Scorched Earth — chance per hit to leave a pit. 1%.</summary>
public static float PitChance { get => _pitChance ?? 0.01f; set => _pitChance = value; }
static float? _pitRadius;
/// <summary>M3 Scorched Earth — the pit's radius. 200u.</summary>
public static float PitRadius { get => _pitRadius ?? 200f; set => _pitRadius = value; }
static float? _pitSeconds;
/// <summary>M3 Scorched Earth — how long a pit burns. 8s.</summary>
public static float PitSeconds { get => _pitSeconds ?? 8f; set => _pitSeconds = value; }
static float? _pitTickInterval;
/// <summary>M3 Scorched Earth — how often a pit damages what is inside it. 0.5s.</summary>
public static float PitTickInterval { get => _pitTickInterval ?? 0.5f; set => _pitTickInterval = value; }
static float? _pitDpsPerTick;
/// <summary>
/// M3 Scorched Earth — how many SECONDS of your weapon's DPS each tick deals. 1.0.
///
/// ⛔ THE REQUEST IS AMBIGUOUS BY A FACTOR OF TWO AND THIS IS THE KNOB THAT SETTLES IT.
/// "Dealing the calculated DPS of your held weapon every half a second" reads literally as one
/// full second of DPS per half-second tick — so the pit's throughput is TWICE your gun's DPS.
/// The other reading, "the pit does as much damage per second as your gun", is 0.5 here.
///
/// Built literally, because that is what the words say. `nz_fire_set pitDps 0.5` is the other
/// reading, and it is one command.
/// </summary>
public static float PitDpsPerTick { get => _pitDpsPerTick ?? 1f; set => _pitDpsPerTick = value; }
static float? _noWeaponDps;
/// <summary>
/// M3 Scorched Earth — DPS assumed when nothing is held. 50.
///
/// ⚠ NOT ZERO. An empty-handed player is reachable — mid-swap, a fresh Creative spawn — and a
/// pit that silently does nothing reads as the augment being broken rather than as an edge
/// case. Same reasoning as PhD's `NoWeaponDamage`.
/// </summary>
public static float NoWeaponDps { get => _noWeaponDps ?? 50f; set => _noWeaponDps = value; }
static float? _chainChance;
/// <summary>M4 Chain Reaction — chance a kill explodes. 10%, and it applies to chained kills too.</summary>
public static float ChainChance { get => _chainChance ?? 0.10f; set => _chainChance = value; }
static int? _chainDepth;
/// <summary>
/// M4 Chain Reaction — how many links deep a chain may run. 8.
///
/// ⛔ A HARD STOP, NOT A BALANCE KNOB. Each explosion can kill zombies whose deaths roll for
/// another explosion, so the recursion is genuinely unbounded — a dense horde with a lucky
/// streak would recurse until the stack gave out. 10% makes eight links vanishingly rare and
/// the cap costs nothing in practice.
/// </summary>
public static int ChainDepth { get => _chainDepth ?? 8; set => _chainDepth = value; }
static float? _burnVulnerability;
/// <summary>
/// m1 Accelerant — what a burning zombie's damage multiplier BECOMES. 2.5.
///
/// ⚠️ AN ABSOLUTE, NOT A BONUS, so the number here is the number in the augment text. The
/// delta against the `burn` rule's own 2 is computed at the read site, which means retuning
/// the rule cannot silently change what this augment is worth.
/// </summary>
public static float BurnVulnerability { get => _burnVulnerability ?? 2.5f; set => _burnVulnerability = value; }
static int? _igniteOneIn;
/// <summary>m2 Incendiary Rounds — the ignite roll BECOMES 1-in-this. 4.</summary>
public static int IgniteOneIn { get => _igniteOneIn ?? 4; set => _igniteOneIn = value; }
static int? _igniteCooldownHits;
/// <summary>
/// m2 Incendiary Rounds — the cooldown BECOMES this many hits. 3.
///
/// ⚠ m2 MOVES BOTH HALVES OF THE BASE ROLL, not just the chance. Halving the cooldown is the
/// bigger effect of the two: at 1-in-4 with a 6-hit wait the cooldown is what limits you, so
/// improving only the chance would have been most of a wasted augment.
/// </summary>
public static int IgniteCooldownHits { get => _igniteCooldownHits ?? 3; set => _igniteCooldownHits = value; }
static float? _areaRadius;
/// <summary>m4 Powder Keg — multiplier on every AoE radius. +20%.</summary>
public static float AreaRadius { get => _areaRadius ?? 1.2f; set => _areaRadius = value; }
static float? _spreadChance;
/// <summary>m5 Wildspread — chance an ignite lights a crowd instead of one. 10%.</summary>
public static float SpreadChance { get => _spreadChance ?? 0.10f; set => _spreadChance = value; }
static int? _spreadCount;
/// <summary>m5 Wildspread — how many catch when it does. 5.</summary>
public static int SpreadCount { get => _spreadCount ?? 5; set => _spreadCount = value; }
static float? _spreadRadius;
/// <summary>m5 Wildspread — how far it reaches. 400u.</summary>
public static float SpreadRadius { get => _spreadRadius ?? 400f; set => _spreadRadius = value; }
static bool Has( NZPlayer p, string augId )
=> p.IsValid() && p.HasPerk( Perk ) && PerkAugments.Has( p, Perk, augId );
static NZPlayer PlayerOf( GameObject go )
=> go.IsValid()
? go.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
: null;
// ══ the base ignite ═══════════════════════════════════════════════════════
/// <summary>
/// Hits until this player can ignite again. 6, or 3 with m2.
///
/// ⚠ READ WHEREVER THE COOLDOWN IS SET, never cached, so buying m2 shortens the NEXT wait
/// rather than only the one after that.
/// </summary>
public static int CooldownFor( NZPlayer player )
=> Has( player, "m2" )
? Math.Max( 0, IgniteCooldownHits )
: Math.Max( 0, PerkEffects.IgniteCooldownHits );
/// <summary>The ignite roll denominator. 1-in-6, or 1-in-4 with m2.</summary>
public static int ChanceFor( NZPlayer player )
=> Has( player, "m2" )
? Math.Max( 1, IgniteOneIn )
: Math.Max( 1, PerkEffects.IgniteOneIn );
/// <summary>
/// A non-melee hit landed on something. THE base perk.
///
/// ⛔ THE COOLDOWN IS SPENT BEFORE THE ROLL, so a hit during the cooldown ticks it down and
/// does not roll. Rolling first and discarding would burn 1-in-6 chances the player cannot
/// see, making the real rate quietly worse than the number advertised.
///
/// ⚠️ ALREADY-BURNING TARGETS ARE SKIPPED ENTIRELY — no roll, no cooldown tick. Re-igniting
/// something that is alight would spend the cooldown for no effect, and refreshing the burn
/// was never part of the design.
/// </summary>
public static void TryIgnite( GameObject attacker, GameObject victim )
{
var p = PlayerOf( attacker );
if ( !p.IsValid() || !victim.IsValid() ) return;
if ( StatusEffects.Has( victim, "burn" ) ) return;
if ( victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ) is not { } ai
|| !ai.IsValid() ) return;
if ( p.NapalmCooldown > 0 )
{
p.NapalmCooldown--;
return;
}
if ( Game.Random.Int( 1, ChanceFor( p ) ) != 1 ) return;
Ignite( p, victim, ai.WorldPosition );
}
/// <summary>
/// Light a zombie, spend the cooldown, and run M1 and m5's spread.
///
/// ⚠️ THE COOLDOWN IS SET HERE, so every path that ignites pays it — including m3's knife.
/// Setting it at the call sites would mean the knife could ignite for free.
///
/// ⚠️ THE SPREAD DOES NOT SPREAD AGAIN. Lighting the neighbours calls `StatusEffects.Apply`
/// directly rather than recursing through here, or one ignite would chain across the whole
/// map and the cooldown would never bind.
/// </summary>
public static void Ignite( NZPlayer player, GameObject victim, Vector3 at )
{
if ( !player.IsValid() || !victim.IsValid() ) return;
StatusEffects.Apply( victim, "burn", player.GameObject );
player.NapalmCooldown = CooldownFor( player );
player.FlashNapalm();
// ── m5 Wildspread: a chance at a crowd, checked first because it is the bigger effect ──
var spread = Has( player, "m5" )
&& Game.Random.Float() < MathF.Max( 0f, SpreadChance );
var count = spread ? Math.Max( 1, SpreadCount ) : 0;
var radius = spread ? SpreadRadius : 0f;
// ── M1 Wildfire: always the 3 nearest, and it takes the wider of the two ──
if ( Has( player, "M1" ) && WildfireCount > count )
{
count = Math.Max( 1, WildfireCount );
radius = MathF.Max( radius, WildfireRadius );
}
else if ( Has( player, "M1" ) )
{
radius = MathF.Max( radius, WildfireRadius );
}
if ( count <= 0 ) return;
var lit = SpreadTo( player, victim, at, count, radius );
if ( lit > 0 )
Log.Info( $"[nz-aug] fire spread — lit {lit} more within {radius:0}u"
+ (spread ? " (m5 Wildspread)" : " (M1 Wildfire)") );
}
/// <summary>
/// Light the nearest <paramref name="count"/> zombies, excluding the one already alight.
///
/// ⚠️ NEAREST FIRST, not "any within radius". "The 3 nearest ones" is an ordering, and taking
/// an arbitrary three from a crowd of twenty would look random rather than like fire
/// spreading outward from what you shot.
/// </summary>
static int SpreadTo( NZPlayer player, GameObject already, Vector3 at, int count, float radius )
{
var lit = 0;
foreach ( var z in ZombieAI.All
.Where( z => z.IsValid() && z.GameObject.IsValid() )
.Where( z => z.GameObject != already )
.Where( z => !StatusEffects.Has( z.GameObject, "burn" ) )
.Where( z => at.Distance( z.WorldPosition ) <= radius )
.OrderBy( z => at.Distance( z.WorldPosition ) )
.Take( count ) )
{
StatusEffects.Apply( z.GameObject, "burn", player.GameObject );
lit++;
}
return lit;
}
/// <summary>
/// m3 Hot Blade — a knife hit ignites.
///
/// ⚠️ IT GOES THROUGH `Ignite`, so it pays the same cooldown a bullet does. A free ignite on
/// a weapon with no ammo cost would make the whole roll irrelevant.
/// </summary>
public static void OnKnifeHit( NZPlayer player, GameObject victim, Vector3 at )
{
if ( !Has( player, "m3" ) ) return;
if ( !victim.IsValid() || StatusEffects.Has( victim, "burn" ) ) return;
if ( player.NapalmCooldown > 0 ) return;
Ignite( player, victim, at );
}
// ══ m1 — the burn's own multiplier ════════════════════════════════════════
/// <summary>
/// Extra factor on a BURNING target's damage, on top of the `burn` rule's own vulnerability.
///
/// ⛔ A DELTA AGAINST THE RULE, not a replacement. `StatusEffects.VulnerabilityOf` has already
/// applied the rule's 2 by the time this is read, so returning 2.5 would give ×5. This returns
/// 2.5/2 = 1.25 — computed from the rule rather than hardcoded, so retuning the rule cannot
/// silently change what the augment is worth.
///
/// ⚠️ READ OFF THE ATTACKER, applied to the VICTIM's burn. The augment belongs to the shooter;
/// the fire belongs to the zombie.
/// </summary>
public static float BurnDamageBonus( GameObject attacker, GameObject victim )
{
var p = PlayerOf( attacker );
if ( !Has( p, "m1" ) ) return 1f;
if ( !victim.IsValid() || !StatusEffects.Has( victim, "burn" ) ) return 1f;
var ruleBase = StatusEffects.Rules.TryGetValue( "burn", out var rule ) && rule.Vulnerability > 0f
? rule.Vulnerability
: 2f;
return MathF.Max( 0f, BurnVulnerability ) / ruleBase;
}
// ══ M2 / m4 — area damage and radius ══════════════════════════════════════
/// <summary>
/// Multiplier on AREA damage — M2's ×3.
///
/// ⛔ "AREA DAMAGE" IS A LIST OF CALL SITES, NOT A DAMAGE TYPE. This project has no explosive
/// tag; `Health.IsMelee` checks `"melee"`/`"knife"` and that is the only damage typing there
/// is. So M2 and m4 are maintained by the six sites that scale themselves:
///
/// | site | radius |
/// |------|--------|
/// | `Grenade` | 220u |
/// | `PhdAugments` blasts | 260u |
/// | `DeadshotAugments` Cranial Detonation | 260u |
/// | `VigorAugments` Last Round | 160u |
/// | `PerkEffects` Elemental Pop burst | 150u |
/// | `FireAugments` pits and chain explosions | 200u |
///
/// ⚠️ `TechBlast.Radius` IS A `const` AND IS NOT COVERED. Scaling it would mean turning that
/// into a read-through, which changes a weapon-tech node rather than a perk — recorded here
/// instead of done quietly.
///
/// ⚠️ DELIBERATELY EXCLUDES NON-DAMAGE RADII: Widow's webs, Tortoise's ring and stun,
/// Timeslip's aura and pit, Vulture's gas. A fire perk widening a web would be a surprise.
/// </summary>
public static float AreaDamageScale( GameObject attacker )
{
var p = PlayerOf( attacker );
return Has( p, "M2" ) ? MathF.Max( 0f, AreaDamage ) : 1f;
}
/// <summary>Multiplier on every AoE radius — m4's +20%. Same call-site register as above.</summary>
public static float AreaRadiusScale( GameObject attacker )
{
var p = PlayerOf( attacker );
return Has( p, "m4" ) ? MathF.Max( 0.1f, AreaRadius ) : 1f;
}
// ══ M3 — the pit ══════════════════════════════════════════════════════════
static float _pitStamp = -1f;
static (Vector3 At, NZPlayer Owner)[] _pitCache = Array.Empty<(Vector3, NZPlayer)>();
/// <summary>Live pits, resolved once per frame. Same cache shape as Timeslip's.</summary>
static (Vector3 At, NZPlayer Owner)[] Pits()
{
if ( _pitStamp == Time.Now ) return _pitCache;
_pitStamp = Time.Now;
var scene = Game.ActiveScene;
_pitCache = scene.IsValid()
? scene.Directory.FindByName( PitName )
.Where( g => g.IsValid() )
.Select( g => (g.WorldPosition,
g.Components.Get<NapalmPit>()?.Owner) )
.ToArray()
: Array.Empty<(Vector3, NZPlayer)>();
return _pitCache;
}
/// <summary>
/// M3 Scorched Earth — roll for a pit where a shot landed.
///
/// ⚠️ NOT PORTED FROM ANYTHING. 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, and NOT
/// claimed to match any original.
/// </summary>
public static void TryPit( GameObject attacker, Vector3 at )
{
var p = PlayerOf( attacker );
if ( !Has( p, "M3" ) ) return;
if ( Game.Random.Float() > MathF.Max( 0f, PitChance ) ) return;
SpawnPitShared( p, at );
}
/// <summary>
/// Drop a pit on EVERY machine, and tell them whose it is.
///
/// ⛔ A PIT IS `NetworkMode.Never`, SO NOBODY HAS EVER SEEN ANYBODY ELSE'S — INCLUDING THE
/// HOST'S. It is created with `scene.CreateObject()` and never announced, so a burning ring
/// eight seconds long existed on exactly one screen. That was true before any of this session's
/// changes: a host's pit was already invisible to every client.
///
/// ⚠️ EVERY MACHINE BUILDS ITS OWN, rather than one machine networking an object. The pit is
/// a position, a radius and a clock — three numbers — and `PitVisual` builds the same ring from
/// them anywhere. Replicating the object would mean a networked prefab for something that lives
/// eight seconds.
///
/// ⚠️ AND ONLY THE OWNER'S COPY DAMAGES — see `NapalmPit.OnUpdate`. Every copy ticking would
/// hurt each zombie once per machine.
/// </summary>
public static void SpawnPitShared( NZPlayer owner, Vector3 at )
{
SpawnPit( owner, at );
if ( !Networking.IsActive || Connection.Local is null ) return;
NZNet.NapalmPitDropped( Connection.Local.Id.ToString(),
NZPlayers.OwnerOf( owner.IsValid() ? owner.GameObject : null ), at );
}
/// <summary>Build a pit that arrived from another machine, owned by whoever made it.</summary>
public static void SpawnPitFromNetwork( string ownerId, Vector3 at )
{
var owner = PlayerSpawner.AllBodies()
.Select( go => go.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) )
.FirstOrDefault( p => p.IsValid() && NZPlayers.OwnerOf( p.GameObject ) == ownerId );
SpawnPit( owner, at );
}
/// <summary>
/// Drop a burning pit.
///
/// ⚠️ THE PIT IGNITES RATHER THAN DAMAGING. Fire is already a damage-over-time with a
/// vulnerability rider, so applying `burn` gives the pit its damage, its tint, its light and
/// its expiry for free — and m1's multiplier applies to it without knowing pits exist.
///
/// ⚠️ THE PLACEHOLDER CUBE IS GONE. It was a flattened `models/dev/box.vmdl` scaled to the
/// radius; the look now comes from `PitVisual` in its `Fire` style, whose light is a straight
/// port of the `DynamicLight` all three of GMod's fire-pit entities create.
/// </summary>
public static GameObject SpawnPit( NZPlayer owner, Vector3 at )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var radius = PitRadius * AreaRadiusScale( owner?.GameObject );
var go = scene.CreateObject();
go.Name = PitName;
go.WorldPosition = at;
go.NetworkMode = NetworkMode.Never;
// ⛔ THE VISUAL BEFORE THE PIT COMPONENT, BECAUSE `Attach` MOVES THE OBJECT TO THE FLOOR and
// `NapalmPit` ignites a sphere around `WorldPosition`. Attaching after would burn a volume
// centred where the ring is not — the same ordering `RadioactiveDecay` documents at its own
// call site, and the reason that ordering is worth a comment in both places.
//
// ⚠️ THE VISUAL IS GIVEN THE SCALED RADIUS, not `PitRadius`, so a player carrying an
// area-size augment sees the ring they actually burn inside.
PitVisual.Attach( go, radius, PitVisual.Style.Fire, PitSeconds );
var pit = go.Components.Create<NapalmPit>();
pit.Owner = owner;
pit.Radius = radius;
SWB.Shared.GameObjectExtensions.DestroyAsync( go, MathF.Max( 0.1f, PitSeconds ) );
Log.Info( $"[nz-aug] fire M3 Scorched Earth — pit at {at}"
+ $" · {radius:0}u for {PitSeconds:0.#}s" );
return go;
}
/// <summary>
/// The held weapon's damage per second, computed the way the game computes it.
///
/// ⛔ `GetRealRPM` RETURNS AN INTERVAL, NOT AN RPM — seconds between shots. The stats card
/// makes the same reading (`perkedRPM = 60 / interval`), so DPS is simply damage divided by
/// that interval. Treating the return value as an RPM would be wrong by a factor of
/// `RPM²/3600`, which at 600 RPM is a hundredfold.
///
/// ⚠ IT GOES THROUGH `GetRealRPM` AND `DamageFor`, not the authored `si.RPM` and `si.Damage`.
/// Those two are the chokepoints every perk, augment and tech node folds into — so a pit
/// scales with Pack-a-Punch, Double Tap's fire rate and Vigor's damage without knowing any of
/// them exist. Reading the authored values would make the pit ignore the whole upgrade tree.
///
/// ⚠ `× Bullets` FOR SHOTGUNS, matching the stats card's Damage row. Per-pellet damage would
/// make a KS23 pit a tenth of what its card claims.
/// </summary>
public static float WeaponDps( NZPlayer player )
{
var wep = VultureAugments.HeldWeapon( player );
var si = wep.IsValid() ? wep.Primary : null;
if ( si is null ) return MathF.Max( 0f, NoWeaponDps );
var interval = wep.GetRealRPM( si.RPM );
if ( interval <= 0.0001f ) return MathF.Max( 0f, NoWeaponDps );
var perShot = si.DamageFor( 0f, null ) * Math.Max( 1, si.Bullets );
return perShot / interval;
}
/// <summary>What one pit tick deals, including M2's area-damage multiplier.</summary>
public static float PitTickDamage( NZPlayer player )
=> WeaponDps( player )
* MathF.Max( 0f, PitDpsPerTick )
* AreaDamageScale( player?.GameObject );
// ══ M4 — the chain ════════════════════════════════════════════════════════
/// <summary>
/// A zombie died. M4 rolls for an explosion, and that explosion's kills roll again.
///
/// ⚠️ `depth` IS THE RECURSION GUARD and it is not optional. An explosion can kill zombies
/// whose deaths roll for another explosion, so the chain is genuinely unbounded — see
/// <see cref="ChainDepth"/>.
/// </summary>
public static void OnZombieKilled( NZPlayer killer, Vector3 position, int depth = 0 )
{
if ( !Has( killer, "M4" ) ) return;
if ( depth >= Math.Max( 1, ChainDepth ) ) return;
if ( Game.Random.Float() > MathF.Max( 0f, ChainChance ) ) return;
Detonate( killer, position, depth + 1 );
}
/// <summary>
/// One link of the chain: damage everything nearby, and roll again on whatever it kills.
///
/// ⚠️ THE KILLS ARE COLLECTED BEFORE RECURSING, not while iterating. Recursing inside the loop
/// would mutate `ZombieAI.All` underneath it, and the chain would visit some zombies twice and
/// others never.
/// </summary>
static void Detonate( NZPlayer player, Vector3 at, int depth )
{
var radius = PitRadius * AreaRadiusScale( player.GameObject );
var damage = ChainDamage( player );
var killed = new System.Collections.Generic.List<Vector3>();
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
if ( at.Distance( z.WorldPosition ) > radius ) continue;
var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() || hp.IsDead ) continue;
var wasAlive = hp.Current;
hp.OnDamage( new DamageInfo
{
Damage = damage,
Attacker = player.GameObject,
Position = z.WorldPosition + Vector3.Up * 32f,
Tags = new TagSet(),
} );
if ( wasAlive > 0f && hp.IsDead ) killed.Add( z.WorldPosition );
}
BlastEffect.Spawn( at, radius );
Log.Info( $"[nz-aug] fire M4 Chain Reaction [link {depth}] — {damage:0} within {radius:0}u"
+ $", killed {killed.Count}" );
foreach ( var k in killed )
OnZombieKilled( player, k, depth );
}
/// <summary>
/// What one chain explosion deals.
///
/// ⚠️ SCALED BY M2, because a chain explosion IS area damage. That is the whole reason M2 is
/// expressed as a call-site register rather than a damage type — this is one of the sites.
/// </summary>
public static float ChainDamage( NZPlayer player )
{
var round = RoundManager.Instance?.Round ?? 1;
// ⚠️ SCALED OFF THE ROUND CURVE so it stays relevant, the same way the knife is. A flat
// figure would be a one-shot at round 5 and a tickle at round 40.
var baseDamage = ZombieStats.HealthForRound( round ) * 0.5f;
return baseDamage * AreaDamageScale( player?.GameObject );
}
// ══ per-frame: the pits burn ══════════════════════════════════════════════
// ⛔ `TickPits` REMOVED. It applied the `burn` status to everything inside the pit every
// frame, and it did not work: a 75 HP zombie survived 8 seconds in a pit that should have
// dealt ~128. Re-applying a status at frame rate appears to keep resetting its tick timer, so
// the 0.5s damage tick never fired — the pit was permanently about to hurt something.
//
// The pit now deals damage ITSELF, on its own timer, in `NapalmPit.OnUpdate`. See there.
// ══ diagnostics ═══════════════════════════════════════════════════════════
public static void Report( NZPlayer player )
{
if ( !player.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
var has = player.HasPerk( Perk );
var equipped = PerkAugments.EquippedOn( player, Perk );
Log.Info( $"[nz-aug] NAPALM NECTAR {(has ? "owned" : "NOT OWNED — every line below is inert")}"
+ $" · equipped [{(equipped.Length == 0 ? "none" : string.Join( "+", equipped ))}]" );
var rule = StatusEffects.Rules.TryGetValue( "burn", out var r ) ? r : null;
Log.Info( $"[nz-aug] base ignite 1-in-{ChanceFor( player )} per hit"
// ⚠ BOTH HALVES READ THE RESOLVED VALUE. Printing `ChanceFor` beside the RAW
// cooldown made one number reflect m2 and the other not, on the same line.
+ $" · {CooldownFor( player )}-hit cooldown"
+ $" · cooldown now {player.NapalmCooldown}"
+ $" · burn {rule?.Seconds ?? 0f:0.#}s at x{rule?.Vulnerability ?? 0f:0.##}" );
Log.Info( $"[nz-aug] M1 Wildfire {(Has( player, "M1" ) ? $"lights {WildfireCount} nearest within {WildfireRadius:0}u" : "-")}" );
Log.Info( $"[nz-aug] M2 Demolitionist {(Has( player, "M2" ) ? $"area damage x{AreaDamage:0.##}" : "-")}" );
Log.Info( $"[nz-aug] M3 Scorched {(Has( player, "M3" ) ? $"{PitChance * 100f:0.#}% per hit -> {PitRadius * AreaRadiusScale( player.GameObject ):0}u pit for {PitSeconds:0.#}s" : "-")}"
+ $" {Pits().Length} pit(s) live" );
Log.Info( $"[nz-aug] M4 Chain {(Has( player, "M4" ) ? $"{ChainChance * 100f:0.#}% per kill -> {ChainDamage( player ):0} in {PitRadius * AreaRadiusScale( player.GameObject ):0}u, up to {ChainDepth} links" : "-")}" );
Log.Info( $"[nz-aug] m1 Accelerant {(Has( player, "m1" ) ? $"burning take x{BurnVulnerability:0.##} (rule x{rule?.Vulnerability ?? 0f:0.##} x{BurnVulnerability / MathF.Max( 0.01f, rule?.Vulnerability ?? 2f ):0.##})" : "-")}" );
Log.Info( $"[nz-aug] m2 Incendiary {(Has( player, "m2" ) ? $"1-in-{IgniteOneIn} (was 1-in-{PerkEffects.IgniteOneIn}) · {IgniteCooldownHits}-hit cooldown (was {PerkEffects.IgniteCooldownHits})" : "-")}" );
Log.Info( $"[nz-aug] m3 Hot Blade {(Has( player, "m3" ) ? "knife ignites" : "-")}" );
Log.Info( $"[nz-aug] m4 Powder Keg {(Has( player, "m4" ) ? $"every AoE radius x{AreaRadius:0.##}" : "-")}"
+ $" grenade {220f * AreaRadiusScale( player.GameObject ):0}u"
+ $" · phd {PhdAugments.Radius * AreaRadiusScale( player.GameObject ):0}u" );
Log.Info( $"[nz-aug] m5 Wildspread {(Has( player, "m5" ) ? $"{SpreadChance * 100f:0.#}% -> {SpreadCount} within {SpreadRadius:0}u" : "-")}" );
}
/// <summary>`nz_aug_fire` — the resolved state of the base and all nine.</summary>
[ConCmd( "nz_aug_fire" )]
public static void ReportCmd()
=> Report( NZPlayer.Local );
/// <summary>`nz_fire_pit [distance]` — drop a napalm pit, ignoring the 1%.</summary>
[ConCmd( "nz_fire_pit" )]
public static void PitCmd( float distance = 200f )
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
var rot = p.Components.Get<PlayerController>()?.EyeAngles.ToRotation() ?? p.WorldRotation;
SpawnPit( p, p.WorldPosition + rot.Forward.WithZ( 0f ).Normal * distance );
}
/// <summary>
/// `nz_fire_chain` — force a chain explosion at the nearest zombie, ignoring the 10%.
///
/// ⚠️ 10% OF KILLS, CHAINED, IS UNTESTABLE BY PLAYING. Forcing link one and letting the rest
/// roll naturally is the only way to see whether the chain actually chains.
/// </summary>
[ConCmd( "nz_fire_chain" )]
public static void ChainCmd()
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-aug] no player" ); return; }
if ( !Has( p, "M4" ) )
{
Log.Warning( "[nz-aug] M4 Chain Reaction not equipped" );
return;
}
var z = ZombieAI.All
.Where( a => a.IsValid() )
.OrderBy( a => a.WorldPosition.Distance( p.WorldPosition ) )
.FirstOrDefault();
Detonate( p, z.IsValid() ? z.WorldPosition : p.WorldPosition, 1 );
}
/// <summary>`nz_fire_set` — retune live. Negative or omitted leaves a value alone.</summary>
[ConCmd( "nz_fire_set" )]
public static void SetCmd( float wildfireRadius = -1f, int wildfireCount = -1,
float areaDamage = -1f, float pitChance = -1f, float pitRadius = -1f, float pitSeconds = -1f,
float chainChance = -1f, int chainDepth = -1, float burnVuln = -1f, int igniteOneIn = -1,
float areaRadius = -1f, float spreadChance = -1f, int spreadCount = -1,
float spreadRadius = -1f, int cooldownHits = -1 )
{
if ( wildfireRadius >= 0f ) WildfireRadius = wildfireRadius;
if ( wildfireCount >= 0 ) WildfireCount = wildfireCount;
if ( areaDamage >= 0f ) AreaDamage = areaDamage;
if ( pitChance >= 0f ) PitChance = pitChance;
if ( pitRadius >= 0f ) PitRadius = pitRadius;
if ( pitSeconds >= 0f ) PitSeconds = pitSeconds;
if ( chainChance >= 0f ) ChainChance = chainChance;
if ( chainDepth >= 1 ) ChainDepth = chainDepth;
if ( burnVuln >= 0f ) BurnVulnerability = burnVuln;
if ( igniteOneIn >= 1 ) IgniteOneIn = igniteOneIn;
if ( cooldownHits >= 0 ) IgniteCooldownHits = cooldownHits;
if ( areaRadius >= 0f ) AreaRadius = areaRadius;
if ( spreadChance >= 0f ) SpreadChance = spreadChance;
if ( spreadCount >= 1 ) SpreadCount = spreadCount;
if ( spreadRadius >= 0f ) SpreadRadius = spreadRadius;
ReportCmd();
}
}
/// <summary>
/// A burning napalm pit. Carries its owner so the burn it applies is credited correctly.
///
/// ⚠️ THE OWNER MATTERS FOR MORE THAN CREDIT: m1's vulnerability and m4's radius are read off the
/// attacker, so a pit that forgot who dropped it would apply the base burn instead of the
/// augmented one.
/// </summary>
public sealed class NapalmPit : Component
{
public NZPlayer Owner { get; set; }
public float Radius { get; set; } = 200f;
/// <summary>When this pit next damages what is standing in it.</summary>
TimeUntil _nextTick;
/// <summary>
/// Damage everything inside, on the pit's own clock.
///
/// ⛔ THE PIT OWNS ITS TIMER, WHICH IS WHY THIS IS HERE AND NOT IN `NZPlayer.OnUpdate`. Driven
/// from the player, two players standing near one pit would tick it twice as fast — and the
/// previous version was driven that way and applied a status every frame instead of damaging
/// on a cadence, which is exactly why it never hurt anything.
///
/// ⚠ A RUNTIME-CREATED COMPONENT DOES NOT SURVIVE A HOTLOAD, and that is acceptable here
/// where it would not be elsewhere: a pit lives eight seconds. `Slide` carries the warning for
/// the case that matters, and `DestroyAsync`'s own timer component is created the same way.
///
/// ⚠ DAMAGE, NOT `burn`. Requested outright — "the pit must damage zombies inside the radius".
/// A consequence worth knowing: pits no longer IGNITE, so M1 Wildfire and m5 Wildspread do not
/// spread from a pit, and m1 Accelerant does not scale pit damage. One line adds the burn back
/// alongside if that is wanted.
/// </summary>
protected override void OnUpdate()
{
if ( !_nextTick ) return;
_nextTick = MathF.Max( 0.05f, FireAugments.PitTickInterval );
// ⛔ ONE MACHINE DAMAGES, EVERY MACHINE DRAWS. The pit now exists on all of them so the
// ring is visible to everybody — but each copy ticking would hurt every zombie inside it
// once PER MACHINE, and on a client each of those hits would be relayed to the host as a
// separate one. Two players means double damage from one pit.
//
// ⚠❗ THE OWNER'S MACHINE, NOT THE HOST'S, so the damage carries the owner's own perks
// through `Health.AttackerScale` — which is the whole reason a client's fire is worth
// anything now.
if ( Networking.IsActive
&& (!Owner.IsValid() || !PlayerPresence.Mine( Owner.GameObject )) ) return;
var damage = FireAugments.PitTickDamage( Owner );
if ( damage <= 0f ) return;
var at = WorldPosition;
var hit = 0;
foreach ( var z in ZombieAI.All )
{
if ( !z.IsValid() || !z.GameObject.IsValid() ) continue;
if ( at.Distance( z.WorldPosition ) > Radius ) continue;
var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() || hp.IsDead ) continue;
hp.OnDamage( new DamageInfo
{
Damage = damage,
Attacker = Owner.IsValid() ? Owner.GameObject : null,
Position = z.WorldPosition + Vector3.Up * 32f,
Tags = new TagSet(),
} );
hit++;
}
if ( hit > 0 )
Log.Info( $"[nz-aug] fire pit tick — {damage:0} to {hit} zombie(s) within {Radius:0}u" );
}
}