Static utility implementing the Blast Furnace ammo mod. It handles passive on-kill splash damage to nearest zombies, V-tier Meltdown triggered on bullet hits with cooldown and visuals, flame spawn on corpses, and console commands for diagnostics and tuning.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// Blast Furnace — killing a zombie damages the ones nearest it.
///
/// | | value |
/// |---|---|
/// | proc | **passive** — no roll, no cooldown |
/// | trigger | **any kill** while it is fitted |
/// | damage | **300% of your weapon's damage**, to each of them; **500%** at II Hotter Blast, **800%** at IV White Heat |
/// | splash | the **3 nearest**, gathered within **600u**; **5** at I Wider Blast, **7** at III Wildfire |
/// | tell | one large flame on the corpse |
/// | V Meltdown | **10% of bullet hits**, **3s** cooldown: **every** zombie within **600u** of the one hit, that one included, takes the level's share (800%) |
///
/// ⚠️ THE UPGRADES (2026-10-05, `Sbox nzombies/Docs/AMMO_MODS.md` "Upgrades") are the KILLER'S level, read on the killer's
/// machine where the blast already runs (`VictimsFor`, `FractionFor`). The user (20:37): *"Make III the 7 nearest"*, in place
/// of a chain reaction, so nothing chains and `_detonating` stays exactly as it is.
///
/// ⚠️ TIERS IV AND V (2026-10-06, `AMMO_MODS.md` "Tiers IV and V"). IV White Heat is a bigger share (`FractionFor`). V Meltdown is
/// the user's: *"make it so on V 10% of hits trigger it, even without killing"* — the whole-crowd blast, set off by a bullet hit
/// from this gun, on the SHOOTER'S machine (`TryMeltdown`, from `AmmoMods.OnZombieHit`). The 3s cooldown is mine, proposed with
/// it and not contradicted: without one a 600 rpm gun sets it off about once a second. Every kill still blasts the nearest few.
///
/// ⛔ NEITHER BLAST CHAINS, AND THEY SHARE ONE PATH TO KEEP IT SO (`Detonate`): a Meltdown's kills set off no kill blast, as a kill
/// blast's never did — the host's `_detonating`, a client's `_splashed` — and their hits roll no mod (`AmmoMods.WithoutProcs`).
///
/// ⛔ IT DOES NOT IGNITE, AND THE FIRST VERSION OF THIS FILE DID. Upstream's AAT still calls
/// `ent:Ignite(3)` on every hit, marks the zombie, and pays out only if the kill lands while that
/// 3s mark is live. Removed by request, and the request is the better read of the effect: what
/// Phase 7 actually changed was replacing the shared status's execute (`Health() + 666`) with this
/// damage-the-neighbours payload. The payload is the identity; the fire was flavour on top.
///
/// ⛔ SO THERE IS NO MARK AND NO BURN WINDOW, and dropping them removed most of this file: no
/// dictionary of lit zombies, no expiry to keep in step with a status rule, no pruning pass, and no
/// second applier of `burn` to reconcile against Napalm Nectar. It also takes away the light and
/// tint that `burn` was applying — which is what made the mod read as "sets things on fire" rather
/// than "detonates".
///
/// ⛔ AND SO IT NOW CHECKS THE HELD MOD AT KILL TIME, WHICH THE MARKED VERSION MUST NOT HAVE DONE.
/// With a mark, the payout was earned when the zombie was shot and had to be honoured however the
/// player's loadout had changed since; `AmmoMods.OnZombieKilled` still carries a note saying it
/// must not check, and that note is now wrong for this mod. With nothing remembered, "is Blast
/// Furnace fitted right now" IS the condition.
///
/// ⚠️ THE 600u GATHER WITH A 3-VICTIM CAP IS UPSTREAM'S PHASE 7 SHAPE, not the original's. It used
/// to hit everything inside 200u; the rebalance widened the search and capped the count, so it
/// reaches further but never hits more than three.
/// </summary>
public static class BlastFurnace
{
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
// re-run. INSTRUCTIONS.md §1.
static float? _fraction;
/// <summary>
/// Share of the KILLER'S WEAPON DAMAGE each of the three takes. 3.0 — 300%.
/// </summary>
///
/// ⛔ A SHARE OF THE WEAPON, NOT OF THE CORPSE, SINCE 2026-09-24 — asked for as *"make it deal
/// 300% of the weapon's damage to the 3 nearest zombies"*. It was 10% of the dead zombie's max
/// health, a fixed slice of the round's health whatever you held; it now scales with the gun like
/// every other damaging mod (`AmmoMods.WeaponDamage`).
public static float Fraction { get => _fraction ?? 3f; set => _fraction = value; }
static float? _gather;
/// <summary>How far to look for splash victims. 600u.</summary>
public static float Gather { get => _gather ?? 600f; set => _gather = value; }
static int? _victims;
/// <summary>
/// How many of the nearest get hit. 3.
///
/// ⚠️ A CAP ON A WIDE SEARCH, not a radius. Upstream's rebalance note is explicit that this
/// replaced "everything within 200u" — so a lone zombie 500u away IS reachable, but a pack of
/// eight around the corpse still only loses three.
/// </summary>
public static int Victims { get => _victims ?? 3; set => _victims = value; }
static bool? _flame;
/// <summary>Show the flame on the corpse. On.</summary>
public static bool Flame { get => _flame ?? true; set => _flame = value; }
static float? _flameScale;
/// <summary>
/// How much bigger than the burn flame the detonation flame is. 3.5×.
///
/// ⚠️ THE SAME PREFAB THE `burn` STATUS PUTS ON A ZOMBIE'S HEAD, scaled up. Reusing it is what
/// makes the two read as related, and the SIZE is what tells a detonation apart from something
/// merely on fire. A separate fireball asset would have said neither — and we do not have one:
/// upstream's `bo4_alistairs_fireball_kill` is not in this project.
/// </summary>
public static float FlameScale { get => _flameScale ?? 3.5f; set => _flameScale = value; }
static float? _flameSeconds;
/// <summary>How long the flame lives. 1.1s — one flash, not a fire.</summary>
public static float FlameSeconds { get => _flameSeconds ?? 1.1f; set => _flameSeconds = value; }
// ══ upgrades (2026-10-05) ════════════════════════════════════════════════
//
// ⚠️ EACH UPGRADED NUMBER IS ITS OWN TUNABLE BESIDE THE BASE, resolved in one helper each (§3).
static int? _widerVictims;
/// <summary>I WIDER BLAST: how many of the nearest get hit. 5 (`Victims`, 3).</summary>
public static int WiderVictims { get => _widerVictims ?? 5; set => _widerVictims = value; }
static float? _hotterFraction;
/// <summary>II HOTTER BLAST: share of the killer's weapon damage each takes. 5.0 — 500% (`Fraction`, 300%).</summary>
public static float HotterFraction { get => _hotterFraction ?? 5f; set => _hotterFraction = value; }
static int? _wildfireVictims;
/// <summary>III WILDFIRE: how many of the nearest get hit. 7.</summary>
public static int WildfireVictims { get => _wildfireVictims ?? 7; set => _wildfireVictims = value; }
// ══ tiers IV and V (2026-10-06) ══════════════════════════════════════════
//
// ⚠️ `AMMO_MODS.md` "Tiers IV and V". Each its own tunable beside the others, nullable-backed (§1), read through one helper (§3).
static float? _whiteHeatFraction;
/// <summary>IV WHITE HEAT: share of the killer's weapon damage each takes. 8.0 — 800% (II's 500%). The Meltdown's share too.</summary>
public static float WhiteHeatFraction { get => _whiteHeatFraction ?? 8f; set => _whiteHeatFraction = value; }
static float? _meltdownChance;
/// <summary>V MELTDOWN: the chance a bullet hit from a Blast Furnace gun sets it off. 0.1 — 10%.</summary>
public static float MeltdownChance { get => _meltdownChance ?? 0.1f; set => _meltdownChance = value; }
static float? _meltdownCooldown;
/// <summary>
/// V MELTDOWN: seconds before it can go off again, before the cooldown scales (`MeltdownCooldownFor`). 3.
///
/// ⚠️ MINE, NOT THE USER'S (`AMMO_MODS.md`): without it a 600 rpm gun sets it off about once a second, and a 12-pellet blast
/// about 3 trigger pulls in 4, since every pellet's hit rolls.
/// </summary>
public static float MeltdownCooldown { get => _meltdownCooldown ?? 3f; set => _meltdownCooldown = value; }
/// <summary>The Meltdown's ring, the colour of the flame.</summary>
static Color MeltdownRing => new( 1f, 0.45f, 0.12f );
/// <summary>How many of the nearest this killer's blast takes: III's, I's, or the base.</summary>
static int VictimsFor( NZPlayer player )
{
var level = AmmoModUpgrades.Level( player, "blastfurnace" );
return level >= 3 ? WildfireVictims : level >= 1 ? WiderVictims : Victims;
}
/// <summary>The share of this player's weapon damage each zombie takes: IV's, II's, or the base. Both blasts read it.</summary>
static float FractionFor( NZPlayer player )
=> AmmoModUpgrades.Has( player, "blastfurnace", 4 ) ? WhiteHeatFraction
: AmmoModUpgrades.Has( player, "blastfurnace", 2 ) ? HotterFraction
: Fraction;
/// <summary>V's chance as the roll takes it, 0-1. The roll and every report read this (§3).</summary>
public static float MeltdownOdds => Math.Clamp( MeltdownChance, 0f, 1f );
/// <summary>
/// V's cooldown for this player, cut by Timeslip's Time Warp, Rapid Discharge, Catalyst and Mod Rail exactly as every ammo
/// mod's is (`AmmoMods.ScaledCooldown`, the §19 register's `TimeAugments.Cooldown` among them).
/// </summary>
public static float MeltdownCooldownFor( NZPlayer player ) => AmmoMods.ScaledCooldown( player, MeltdownCooldown );
/// <summary>
/// Set while a detonation is resolving.
///
/// ⛔ THE ANTI-CHAIN GUARD, AND IT IS LOAD-BEARING. A detonation damages three zombies; any of
/// those that dies comes back through `OnZombieKilled` and would detonate too. In a horde that
/// is an unbounded cascade off a single kill. Upstream carries the same module-level flag
/// (`nzAATs._bfExploding`) for the same reason — this is not a port of a safety net nobody
/// needed.
///
/// ⛔ AND IT MATTERS MORE NOW THAT THE MARK IS GONE. With a mark, a collateral kill only chained
/// if that zombie happened to be marked AND still burning. Without one, every collateral kill
/// qualifies — so this went from a precaution to the only thing stopping one kill clearing a
/// room.
///
/// ⚠️ NOT NULLABLE-BACKED, deliberately: a bool that survived a hotload STUCK ON would disable
/// the mod silently and permanently. `false` is the safe default and a plain field gives it.
/// </summary>
static bool _detonating;
/// <summary>
/// THIS MACHINE'S SPLASH VICTIMS, AND WHEN — A CLIENT'S CHAIN GUARD, `_detonating`'s twin. On the host the splash's kills
/// happen inside its damage loop, where `_detonating` turns them away. A client's splash is damage sent to the host, and
/// each kill it makes comes back through `NZNet.AugmentKill` after the loop has ended — so each set off another
/// detonation, and each moved basalt's Blast Furnace ring dot again, past its glyph (the co-op audit, 2026-09-27: *"make
/// sure it works the same way it does for the host"*). A kill of one of these within `ChainWindow` is the splash's own.
/// </summary>
static readonly System.Collections.Generic.Dictionary<Guid, float> _splashed = new();
/// <summary>How long a splash victim's death counts as the splash's own: a network round trip, and room to spare.</summary>
const float ChainWindow = 1.5f;
/// <summary>
/// A zombie died with Blast Furnace fitted — damage its neighbours.
///
/// ⚠️ THE KILLER IS BOTH THE GATE AND THE CREDIT: their held mod decides whether this fires,
/// and the splash is attributed to them so their own on-kill hooks and points resolve normally.
///
/// ⚠️ <paramref name="at"/> IS WHERE IT DIED, 32u up from its feet, as the death site sends it (`ZombieAI` →
/// `AugmentEffects.OnZombieKilled`). A client's kill is relayed to the client after the fact, and its corpse may be
/// gone by then (`NZNet.AugmentKill`): the blast goes off there all the same, and only the flame needs the body.
/// </summary>
/// <param name="mod">
/// The killing hit's mod (`Health.LastMod`, relayed with a client's kill). ⛔ A SPLASHED ZOMBIE'S DEATH IS THE SPLASH'S OWN
/// ONLY WHEN NO BULLET KILLED IT (2026-10-07, the review of tiers IV/V): a splash or a Meltdown carries no bullet mod, so ""
/// is theirs, and a bullet's kill still blasts. Without it a client's Meltdown, which remembers every zombie within 600u,
/// switched off that client's own kill blast across the crowd for 1.5s, kills by their next bullets included — and the
/// host's never did.
/// </param>
public static void OnZombieKilled( GameObject killer, GameObject victim, Vector3? at = null, string mod = "" )
{
if ( _detonating || (!victim.IsValid() && at is null) ) return;
if ( victim.IsValid() && _splashed.Remove( victim.Id, out var splashedAt ) && Time.Now - splashedAt < ChainWindow
&& string.IsNullOrEmpty( mod ) ) return;
var player = killer.IsValid()
? killer.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
: null;
if ( !player.IsValid() ) return;
if ( AmmoMods.Held( player )?.Id != "blastfurnace" ) return;
// ⚠️ THE KILLER'S HELD WEAPON, READ ON THE KILLER'S MACHINE. `AugmentEffects.OnZombieKilled`
// relays the kill there because a proxy holds nothing — which is what makes this readable at all.
var damage = AmmoMods.WeaponDamage( player ) * MathF.Max( 0f, FractionFor( player ) )
// ⚠️ CATALYST'S +50% (2026-10-04): the passive mod has no chance or cooldown to improve, so it gets damage.
* ClassTech.CatalystFurnace( player );
if ( damage <= 0f ) return;
// ⚠️ WHERE IT DIED, 32u up from its feet — as the death site sent it, or read off the corpse when nothing was sent.
// On the host the two are the same point, in the same frame.
var centre = at ?? victim.WorldPosition + Vector3.Up * 32f;
// ⚠️ BASALT'S RINGS HEAR IT HERE, once every gate has passed: where it died — its feet — on the killer's machine.
// The rolled mods reach the same hook from `AmmoMods.Fire` (`HexPlatforms.AmmoModWentOff`).
HexPlatforms.AmmoModWentOff( "blastfurnace", centre - Vector3.Up * 32f );
// ⚠️ THE FLAME FIRST, because a damage handler can remove or ragdoll the corpse and the
// flame needs to parent to it. Dead Wire draws its arc before its damage for the same
// reason. No corpse, no flame: the blast still goes off.
if ( Flame && victim.IsValid() ) Flare( victim );
// ⚠ TWO CUES, AS UPSTREAM HAS. `Die` is the flame burst on the body and `Expl` is the
// heavier incendiary crack — it plays them both, and together they read as one event
// rather than as a duplicate.
Sound.Play( NZSound.PopBlastFurnaceDie, centre );
Sound.Play( NZSound.PopBlastFurnaceExpl, centre );
// ⚠️ NEAREST-FIRST AND THEN TAKE N. The cap is on the count, so the three that get hit have
// to be the three closest — taking any three would make a wide search feel arbitrary.
var victims = VictimsFor( player );
var hit = ZombieAI.All
.Where( z => z.IsValid() && z.GameObject.IsValid() && z.GameObject != victim )
.Where( z => centre.Distance( z.WorldPosition ) <= MathF.Max( 0f, Gather ) )
.Select( z => new
{
Ai = z,
Hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors ),
Dist = centre.Distance( z.WorldPosition ),
} )
.Where( x => x.Hp.IsValid() && !x.Hp.IsDead )
.OrderBy( x => x.Dist )
.Take( Math.Max( 1, victims ) )
.Select( x => (x.Ai, x.Hp) )
.ToList();
// ⚠️ THE CLIENT'S `_splashed` AND THE HOST'S `_detonating` ARE `Detonate`'s (2026-10-06), shared with V's Meltdown.
Detonate( killer, hit, damage );
Log.Info( $"[nz-ammo] blast furnace — {damage:0} to {hit.Count} zombie(s)"
+ $" within {Gather:0}u (cap {victims})" );
}
/// <summary>
/// Deal one blast's damage to <paramref name="hit"/> on THIS machine, the anti-chain guards up.
///
/// ⛔ ONE PATH FOR BOTH BLASTS (2026-10-06, §3): the kill blast and V's Meltdown, so a guard added to one cannot be missing
/// from the other. It was the kill blast's own body until the Meltdown needed the same.
///
/// ⚠️ ON A CLIENT each victim is remembered FIRST (`_splashed`): its damage goes to the host, and its death comes back after
/// this loop has ended. On the host (or solo) the deaths happen inside the loop, where `_detonating` turns them away.
///
/// ⚠️ NO `Weapon` AND NO BULLET TAG ON THE HIT: a blast picks up no tech node, and a zombie it kills is no ammo-mod kill
/// (`AmmoMods.HitModOf`).
/// </summary>
static void Detonate( GameObject attacker, System.Collections.Generic.List<(ZombieAI Ai, Health Hp)> hit, float damage )
{
// ⚠️ A CLIENT REMEMBERS WHOM IT SPLASHED, for when their deaths come back (`_splashed`)
if ( Networking.IsActive && !NZGame.IsHost )
{
foreach ( var stale in _splashed.Where( kv => Time.Now - kv.Value >= ChainWindow ).Select( kv => kv.Key ).ToList() )
_splashed.Remove( stale );
foreach ( var x in hit )
_splashed[x.Ai.GameObject.Id] = Time.Now;
}
// ⛔ THE GUARD WRAPS ONLY THE DAMAGE, and it is released in a `finally` so an exception
// inside one victim's damage handler cannot leave the mod permanently switched off.
//
// ⚠️ PUT BACK AS IT WAS, NOT SET TO `false` (2026-10-06): a blast dealt inside another (none today — only a bullet rolls
// the Meltdown, and no blast is a bullet) must not open the outer one's guard early. The outermost still leaves it `false`.
var was = _detonating;
_detonating = true;
try
{
foreach ( var x in hit )
x.Hp.OnDamage( new DamageInfo
{
Damage = damage,
Attacker = attacker,
Position = x.Ai.WorldPosition + Vector3.Up * 32f,
Tags = new TagSet(),
} );
}
finally
{
_detonating = was;
}
}
/// <summary>
/// V MELTDOWN'S ROLL (2026-10-06): a BULLET from this player's Blast Furnace gun hit <paramref name="zombie"/>. THE SHOOTER'S
/// MACHINE, from `AmmoMods.OnZombieHit` — where every hit mod rolls, and where the gun and its cooldown are.
///
/// ⚠️ ITS COOLDOWN IS THE GUN'S `AmmoModReady` SLOT, which the passive mod never stamps, STAMPED BEFORE THE BLAST (the order
/// `OnZombieHit` keeps) and cut as every mod's is (`MeltdownCooldownFor`, through `TimeAugments.Cooldown`: INSTRUCTIONS §19).
/// The HUD's chip reads the same slot, so it dims while the Meltdown cools.
///
/// ⚠️ THE 10% IS FLAT. Elemental Pop's Overload and Conductor raise *"the trigger chance of the ammo mod fitted"*, which for Blast
/// Furnace is the kill, still every kill; Catalyst's +5% is not Blast Furnace's (*"Blast Furnace deals +50% damage instead"*).
/// Catalyst helps each of its blasts ONCE: the kill blast, which has no cooldown, by its ×1.5; the Meltdown by halving its
/// cooldown, as it halves every mod's.
/// </summary>
public static void TryMeltdown( NZPlayer player, string prefab, GameObject zombie )
{
if ( _detonating || !player.IsValid() || !zombie.IsValid() || string.IsNullOrEmpty( prefab ) ) return;
if ( !AmmoModUpgrades.Has( player, "blastfurnace", 5 ) ) return;
if ( player.AmmoModReady.TryGetValue( prefab, out var ready ) && ready > 0f ) return;
if ( Game.Random.Float() > MeltdownOdds ) return;
player.AmmoModReady[prefab] = MeltdownCooldownFor( player );
Meltdown( player, zombie );
}
/// <summary>
/// V MELTDOWN (2026-10-06): every live zombie within <see cref="Gather"/> of the one hit, THAT ONE INCLUDED, takes the level's
/// share (IV's 800%) of the shooter's weapon damage. No cap, and no boss rule, as the kill blast has none: on a boss, where
/// kills are rare, the zombie hit takes it too.
///
/// ⚠️ THE SHOOTER'S MACHINE, THROUGH THE KILL BLAST'S OWN PATH (`Detonate`). On the host its kills meet `_detonating`; on a
/// client every victim is in `_splashed` before its damage goes — the zombie hit among them, so a bullet that kills it in the
/// same shot sets nothing off either, as on the host, where this blast lands before the bullet's own damage does. Its hits
/// roll no mod (`AmmoMods.WithoutProcs`), and the cooldown stamped before it turns them away besides.
///
/// ⚠️ NOT CATALYST'S ×1.5, WHICH THE KILL BLAST KEEPS: the Meltdown is the level's share of your weapon's damage, as its line
/// reads, and Catalyst already halves its cooldown (`MeltdownCooldownFor`; see `TryMeltdown`). One factor,
/// `ClassTech.CatalystFurnace( player )`, would add the ×1.5 here too.
///
/// ⚠️ THE LOOK: the kill blast's big flame on the zombie hit and its two cues, and a ring drawn at the real reach. The ring is
/// shared from the host (`ShockRing.FireShared`); a client's Meltdown, like its kill blast's flame, shows on its own screen.
/// </summary>
static void Meltdown( NZPlayer player, GameObject zombie )
{
var damage = AmmoMods.WeaponDamage( player ) * MathF.Max( 0f, FractionFor( player ) );
if ( damage <= 0f ) return;
var at = zombie.WorldPosition;
var reach = MathF.Max( 0f, Gather );
var hit = ZombieAI.All
.Where( z => z.IsValid() && z.GameObject.IsValid() && z.State != ZombieState.Dead )
.Where( z => at.Distance( z.WorldPosition ) <= reach )
.Select( z => (Ai: z, Hp: z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors )) )
.Where( x => x.Hp.IsValid() && !x.Hp.IsDead )
.OrderBy( x => at.Distance( x.Ai.WorldPosition ) )
.ToList();
// ⚠️ BASALT'S RINGS HEAR IT, as they hear the kill blast: the Meltdown is Blast Furnace going off, at the zombie hit.
HexPlatforms.AmmoModWentOff( "blastfurnace", at );
// ⚠️ THE LOOK FIRST, for the kill blast's reason: a damage handler can ragdoll the body the flame parents to.
if ( Flame ) Flare( zombie );
ShockRing.FireShared( at + Vector3.Up * 32f, reach, MeltdownRing );
Sound.Play( NZSound.PopBlastFurnaceDie, at + Vector3.Up * 32f );
Sound.Play( NZSound.PopBlastFurnaceExpl, at + Vector3.Up * 32f );
AmmoMods.WithoutProcs( player, () => Detonate( player.GameObject, hit, damage ) );
Log.Info( $"[nz-ammo] blast furnace V MELTDOWN — {damage:0} to {hit.Count} zombie(s) within {reach:0}u of the hit"
+ $" · next in {MeltdownCooldownFor( player ):0.#}s" );
}
/// <summary>
/// One large flame on the zombie that just died.
///
/// ⚠️ IT REUSES THE `burn` STATUS'S OWN PREFAB AND ITS BONE-FOLLOW, so it sits on the head the
/// way a burning zombie's flame does rather than floating at the origin. `StatusEffects`
/// records why a follower beats a parent offset: a walk cycle leans the head forward while the
/// root stays put, so a fixed lift puts the fire beside the head instead of on it.
///
/// ⚠️ SCALED ON THE CLONE, NOT IN THE PREFAB, because the prefab is shared with every burning
/// zombie in the game — editing it to make this one effect bigger would enlarge all of them.
///
/// ⚠️ AND IT IS PARENTED TO A CORPSE ON PURPOSE. If the body is removed the flame goes with it,
/// which is correct: this is that zombie's detonation, not a fire in a location.
/// </summary>
static void Flare( GameObject victim )
{
var path = StatusEffects.ParticlesFor( "burn" );
if ( string.IsNullOrEmpty( path ) ) return;
GameObject go = null;
// ⚠️ `GameObject.Clone` IS STATIC, so it is qualified by the TYPE and not called on the
// victim. `StatusEffects` writes the same call as `GameObject.Clone( … )` from inside a
// Component, where that reads like an instance call on its own `GameObject` property but
// is not — which is exactly what made `victim.Clone( … )` look correct here.
try { go = GameObject.Clone( path, victim.WorldTransform ); }
catch ( Exception ) { go = null; }
// ⚠️ ONE WARNING, NOT ONE PER KILL. A missing prefab during a horde would bury every other
// line in the console — the same reasoning `StatusEffects.EnsureFlame` gives for its own
// once-only flag.
if ( !go.IsValid() )
{
if ( !_flareFailed )
{
_flareFailed = true;
Log.Warning( $"[nz-ammo] blast furnace flame '{path}' would not clone"
+ " — the effect still deals its damage, silently" );
}
return;
}
go.Name = "nz_blastfurnace_flare";
go.SetParent( victim );
go.WorldScale = MathF.Max( 0.1f, FlameScale );
var ai = victim.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
var renderer = victim.Components.Get<SkinnedModelRenderer>(
FindMode.EverythingInSelfAndDescendants );
// ⚠️ THE FOLLOWER IS OPTIONAL. Without a renderer to read a bone from there is nothing to
// follow, and the flame stays where it was cloned — which for a one-second flash on a body
// that is about to stop moving anyway is a fine fallback rather than a reason to bail.
if ( renderer.IsValid() )
{
var follow = go.Components.Create<BoneFollow>();
follow.Source = renderer;
follow.Bone = StatusEffects.FlameBone;
follow.Offset = StatusEffects.FlameBoneOffset;
follow.FallbackHeight = (ai.IsValid() ? ai.BodyHeight : 72f)
* StatusEffects.FlameHeightFraction;
}
SWB.Shared.GameObjectExtensions.DestroyAsync( go, MathF.Max( 0.1f, FlameSeconds ) );
}
static bool _flareFailed;
// ══ diagnostics ══════════════════════════════════════════════════════════
/// <summary>`nz_blastfurnace` — the resolved numbers.</summary>
[ConCmd( "nz_blastfurnace" )]
public static void Report()
{
Log.Info( $"[nz-ammo] BLAST FURNACE · passive, on every kill"
+ $" · {Fraction * 100f:0.#}% of the killer's weapon damage"
+ $" · {Victims} nearest within {Gather:0}u" );
Log.Info( $"[nz-ammo] upgrades: I {WiderVictims} nearest · II {HotterFraction * 100f:0.#}% · III {WildfireVictims} nearest"
+ $" · IV {WhiteHeatFraction * 100f:0.#}%"
+ $" · V meltdown on {MeltdownOdds * 100f:0.#}% of bullet hits, {MeltdownCooldown:0.#}s cooldown, every zombie within {Gather:0}u"
+ $" · you: level {AmmoModUpgrades.Level( NZPlayer.Local, "blastfurnace" )}" );
// ⚠️ YOUR MELTDOWN (2026-10-06), at level V: the cut cooldown, and what is left of it on the gun in hand — the same
// `AmmoModReady` slot the roll stamps and the HUD's chip reads.
var me = NZPlayer.Local;
if ( me.IsValid() && AmmoModUpgrades.Has( me, "blastfurnace", 5 ) )
{
var wep = VultureAugments.HeldWeapon( me );
var prefab = wep.IsValid() ? Rarity.PrefabOf( wep ) : null;
var left = !string.IsNullOrEmpty( prefab ) && me.AmmoModReady.TryGetValue( prefab, out var r ) ? MathF.Max( 0f, (float)r ) : 0f;
Log.Info( $"[nz-ammo] your meltdown: cooldown {MeltdownCooldownFor( me ):0.##}s · ready in {left:0.0}s"
+ (AmmoMods.Held( me )?.Id == "blastfurnace" ? "" : " · ⚠ the gun in hand carries no Blast Furnace") );
}
Log.Info( $"[nz-ammo] flame {(Flame ? $"x{FlameScale:0.##} for {FlameSeconds:0.##}s" : "off")}"
+ $" · {(_detonating ? "DETONATING" : "idle")}"
+ $"{(_flareFailed ? " · FLAME PREFAB FAILED TO CLONE" : "")}" );
// ⚠️ NAMED, because "it does nothing" has an obvious innocent cause: the mod is fitted to
// the PREFAB in your hands, so killing something with the other weapon pays nothing.
Log.Info( "[nz-ammo] ⚠ it reads the mod on the weapon you are HOLDING when the kill lands" );
}
/// <summary>
/// `nz_blastfurnace_meltdown` — set V's Meltdown off on the zombie nearest you, skipping the 10% and the level (2026-10-06).
///
/// ⚠️ A 10% ROLL BEHIND A COOLDOWN IS NOT TESTABLE BY SHOOTING, `nz_ammomod_proc`'s argument; and that command refuses Blast
/// Furnace, which does not roll on a hit below V.
///
/// ⛔ THE COOLDOWN IS STAMPED FIRST WHEN THE GUN IN HAND CARRIES BLAST FURNACE, as the real roll stamps it — `nz_ammomod_proc`'s
/// lesson: a diagnostic that behaves unlike the thing it diagnoses makes every reading from it suspect.
/// </summary>
[ConCmd( "nz_blastfurnace_meltdown" )]
public static void MeltdownCmd()
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-ammo] no player" ); return; }
var at = p.WorldPosition;
var z = ZombieAI.All
.Where( x => x.IsValid() && x.GameObject.IsValid() && x.State != ZombieState.Dead )
.OrderBy( x => at.Distance( x.WorldPosition ) )
.FirstOrDefault();
if ( !z.IsValid() )
{
Log.Warning( "[nz-ammo] refused: no live zombie to melt down on" );
return;
}
// ⚠️ NAMED, NOT SILENT: the blast is a share of the gun in hand, and with nothing in hand it would deal nothing and say so nowhere.
if ( AmmoMods.WeaponDamage( p ) <= 0f )
{
Log.Warning( "[nz-ammo] refused: the gun in hand deals no damage to share — hold a weapon" );
return;
}
var wep = VultureAugments.HeldWeapon( p );
var prefab = wep.IsValid() ? Rarity.PrefabOf( wep ) : null;
if ( !string.IsNullOrEmpty( prefab ) && AmmoMods.Held( p )?.Id == "blastfurnace" )
p.AmmoModReady[prefab] = MeltdownCooldownFor( p );
Log.Info( $"[nz-ammo] FORCING a Meltdown on a zombie {at.Distance( z.WorldPosition ):0}u away"
+ $" (your level {AmmoModUpgrades.Level( p, "blastfurnace" )}, {FractionFor( p ) * 100f:0.#}% of your weapon damage)" );
Meltdown( p, z.GameObject );
}
/// <summary>`nz_blastfurnace_set <key> <value>` — retune one number live.</summary>
[ConCmd( "nz_blastfurnace_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "fraction": Fraction = value; break;
case "gather": Gather = value; break;
case "victims": Victims = (int)value; break;
case "flame": Flame = value > 0.5f; break;
case "scale": FlameScale = value; break;
case "seconds": FlameSeconds = value; break;
case "wider": WiderVictims = (int)value; break;
case "hotter": HotterFraction = value; break;
case "wildfire": WildfireVictims = (int)value; break;
case "whiteheat": WhiteHeatFraction = value; break;
case "meltdown": MeltdownChance = value; break;
case "meltdowncd": MeltdownCooldown = value; break;
default:
Log.Info( "[nz-ammo] nz_blastfurnace_set <fraction|gather|victims"
+ "|flame|scale|seconds|wider|hotter|wildfire|whiteheat|meltdown|meltdowncd> <value>" );
return;
}
Log.Info( $"[nz-ammo] blast furnace {key} = {value:0.###}" );
Report();
}
}