Component that manages ambient special enemies (napalm zombie, Shrieker) that intermittently spawn into normal rounds. It tracks per-rule quotas each round, defers or drops quotas across special rounds, checks alive caps, picks spawn points, and issues spawns; includes console commands to report and force spawns.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// SPECIALS THAT TRICKLE INTO ORDINARY ROUNDS — the napalm zombie and the Shrieker.
///
/// ⛔ NEITHER OF THE TWO EXISTING SPAWN SYSTEMS WOULD DO IT, AND THE REASONS ARE DIFFERENT. A
/// SPECIAL round replaces a round's contents and runs on a fixed cadence; a BOSS round is excluded
/// from special rounds entirely, so a napalm zombie on a 3-round cadence would silently lose every
/// collision with Basalt's 5-round pest horde — about one in five, with nothing in the log saying
/// so. This is the third case: an enemy that joins a normal round without being the round.
///
/// ⚠️ IT ALSO LEAVES BOSS ROUNDS ALONE, which matters because the Basalt easter egg ends in an
/// actual boss. Parking the napalm zombie on the boss schedule would mean untangling them later.
///
/// ⛔ WHETHER THE ROUND CAN END WITH ONE ALIVE IS NOT DECIDED HERE. `RoundManager.AliveBlocking`
/// counts every zombie whose variant is not `IsBoss`, so:
///
/// napalm.zvar IsBoss true the round ends with one still walking around
/// shrieker.zvar IsBoss false the round waits until it is dead
///
/// That is the whole mechanism, it already existed, and this class does not touch it. Which also
/// means changing a `.zvar` flag silently changes round pacing — worth knowing before anyone
/// "tidies" one.
/// </summary>
public sealed class AmbientSpecials : Component
{
/// <summary>Live tracker per rule, rebuilt each round.</summary>
sealed class Track
{
public AmbientSpecial Rule;
public int Quota;
public int Made;
public TimeUntil Next;
}
readonly List<Track> _tracks = new();
/// <summary>
/// Arrivals a special round pushed into the next one, by enemy id.
///
/// ⛔ DEFERRED, NOT DROPPED, AND THE DIFFERENCE IS A WHOLE ARRIVAL. The first version of this
/// threw the quota away — Basalt's round 20 is both a napalm round and a pest horde, so that
/// napalm zombie simply never existed, and the cadence the designer wrote quietly lost one in
/// five. Skipping a round should move the arrival, not delete it.
///
/// ⚠️ KEYED BY ENEMY ID RATHER THAN BY RULE OBJECT. The config is reloaded on a map change and
/// the rule instances are replaced, so holding references would carry a debt owed by objects
/// that no longer exist.
/// </summary>
readonly Dictionary<string, int> _carried = new();
int _round = -1;
/// <summary>Turn the whole system off: `nz_ambient 0`.</summary>
/// ⛔ NOT CALLED `Enabled`. `Component` already has an `Enabled`, and a static of that name
/// SHADOWS it — so every `if ( !Enabled )` inside this class silently reads the convar instead
/// of "is this component running", and the compiler says so as CS0108 rather than an error.
/// This project has hit that collision seven times now; `SonicDazeOverlay.ShowOverlay` carries
/// the same note. The CONVAR NAME IS UNCHANGED — `nz_ambient` is what anyone actually types.
[ConVar( "nz_ambient" )] public static bool AmbientOn { get; set; } = true;
/// <summary>
/// Chatter every arrival to the console.
///
/// ⚠️ OFF BY DEFAULT BUT WORTH HAVING, because "why is there no napalm zombie" has four
/// possible answers — not due this round, at the alive cap, no usable spawn point, or the
/// variant failed to load — and they need completely different fixes.
/// </summary>
[ConVar( "nz_ambient_debug" )] public static bool Debug { get; set; } = false;
protected override void OnUpdate()
{
if ( !AmbientOn ) return;
var rm = RoundManager.Instance;
if ( !rm.IsValid() || rm.State != RoundState.Active ) return;
// ⛔ NOR WHILE BASALT'S BOSS FIGHT FREEZES THE ROUND: the fight spawns its own (`HexPlatforms.FreezesRound`)
if ( HexPlatforms.FreezesRound ) return;
if ( rm.Round != _round ) Rebuild( rm.Round );
foreach ( var t in _tracks ) Tick( t, rm );
}
/// <summary>Work out each rule's quota for a new round.</summary>
void Rebuild( int round )
{
// ⚠️ A ROUND NUMBER GOING BACKWARDS IS A NEW GAME. Carrying a debt across one would have the
// first ordinary round of a fresh run pay for a special round nobody played.
if ( round < _round ) _carried.Clear();
_round = round;
_tracks.Clear();
var cfg = ActiveConfig.Current;
if ( cfg?.AmbientSpecials is null ) return;
foreach ( var rule in cfg.AmbientSpecials )
{
var id = rule.Enemy ?? "";
_carried.TryGetValue( id, out var carried );
var due = rule.CountForRound( round );
// ⛔ PER RULE, NOT GLOBAL, AND IT MOVES THE QUOTA RATHER THAN DELETING IT. See
// `AmbientSpecial.SkipSpecialRounds` — this is the one place the three spawn systems
// have to know about each other at all.
if ( rule.SkipSpecialRounds && RoundManager.Instance.IsValid()
&& RoundManager.Instance.InSpecialRound )
{
// ⚠️ A RULE THAT DOES NOT DEFER SIMPLY LOSES THE ROUND, which is correct for an
// every-round enemy: there is another next round by definition, and carrying one
// forward would stack a whole quota on top of the next. See `DeferOnSkip`.
if ( !rule.DeferOnSkip )
{
if ( carried != 0 ) _carried[id] = 0;
if ( Debug )
Log.Info( $"[nz-ambient] round {round}: {due} x {rule.Enemy} skipped"
+ " — special round, and this rule does not defer" );
continue;
}
// ⚠️ CAPPED, SO A RUN OF SPECIALS CANNOT COMPOUND INTO AN AVALANCHE. It cannot happen
// on Basalt — specials are every fifth round — but a map with an interval of 1 would
// otherwise bank an arrival per round forever and pay it all at once the first time
// it stopped.
_carried[id] = Math.Min( carried + due, Math.Max( 1, due ) * 3 );
if ( Debug )
Log.Info( $"[nz-ambient] round {round}: {due} x {rule.Enemy} DEFERRED"
+ $" — special round; {_carried[id]} now owed to the next ordinary round" );
continue;
}
var n = due + carried;
// ⚠️ CLEARED WHETHER OR NOT ANYTHING WAS OWED, so a debt cannot be paid twice.
if ( carried != 0 ) _carried[id] = 0;
if ( n <= 0 ) continue;
// ⚠️ UNSPAWNED QUOTA IS DROPPED AT ROUND END, NOT CARRIED. A player who cleared a round
// fast enough that the third Shrieker never arrived has earned that; banking it would
// mean the next round opens with a backlog nobody caused.
_tracks.Add( new Track
{
Rule = rule,
Quota = n,
Made = 0,
Next = MathF.Max( 0f, rule.FirstDelay ),
} );
if ( Debug )
Log.Info( $"[nz-ambient] round {round}: {n} x {rule.Enemy}"
+ ( carried > 0 ? $" ({due} due + {carried} deferred)" : "" )
+ $" (max {rule.MaxAliveForRound( round )} alive,"
+ $" every {rule.SpawnDelay:0.#}s, first at {rule.FirstDelay:0.#}s)" );
}
}
void Tick( Track t, RoundManager rm )
{
if ( t.Made >= t.Quota ) return;
if ( t.Next > 0f ) return;
// ⛔ THE ALIVE CAP IS CHECKED WITHOUT PUSHING THE TIMER, so the moment one dies the next
// arrives rather than waiting out another interval. Same population-target shape the wave
// spawner uses, and for the same reason: "there are always two Shriekers" should mean two,
// not "two, then a gap you can feel".
if ( AliveOf( t.Rule.Enemy ) >= t.Rule.MaxAliveForRound( rm.Round ) ) return;
if ( !Spawn( t.Rule, rm ) ) return;
t.Made++;
t.Next = MathF.Max( 1f, t.Rule.SpawnDelay );
}
/// <summary>
/// How many of this enemy are alive.
///
/// ⚠️ BY VARIANT PATH, NOT BY COMPONENT TYPE. The Pest has no component of its own and a future
/// special may not either, so asking "does it have a ShriekerZombie" would work for exactly the
/// two enemies that happen to have one today.
/// </summary>
static int AliveOf( string id )
{
var path = SpecialEnemies.PathFor( id );
if ( string.IsNullOrWhiteSpace( path ) ) return 0;
return ZombieAI.All.Count( z => z.IsValid()
&& z.State != ZombieState.Dead
&& (z.Variant?.ResourcePath?.EndsWith( path, StringComparison.OrdinalIgnoreCase ) ?? false) );
}
bool Spawn( AmbientSpecial rule, RoundManager rm )
{
var variant = SpecialEnemies.VariantFor( rule.Enemy );
if ( variant is null )
{
if ( Debug ) Log.Warning( $"[nz-ambient] '{rule.Enemy}' did not load" );
return false;
}
// ⚠️ BOSS POINTS ARE TAKEN NEAREST-FIRST, ordinary ones weighted toward the players — both
// reusing what the round manager already does, so an ambient arrival reads the same as any
// other and does not need its own idea of where the player is.
var points = rule.UseBossSpawns
? rm.BossSpawnsNearestFirst()
: rm.EligibleSpawns;
if ( points is null || points.Count == 0 )
{
if ( Debug )
Log.Warning( $"[nz-ambient] no usable {( rule.UseBossSpawns ? "boss" : "zombie" )}"
+ $" spawn for '{rule.Enemy}' — placed but gated, or none placed" );
return false;
}
var point = rule.UseBossSpawns
? points[Game.Random.Int( 0, points.Count - 1 )]
: rm.PickSpawnNearPlayers( points );
if ( point is null ) return false;
var z = ZombieCommands.SpawnAt( Scene, point.Position, variant );
if ( z is null ) return false;
// ⚠️ THE RIG'S CORRECTION IS NOT COMPOSED HERE, AND IT CANNOT BE. `ZombieAI.OnStart` has not
// run yet at this point, so the variant's offsets are still zero and `z.ModelTurn` is
// identity — an attempt to fix the spawn angle here multiplied by nothing and changed
// nothing. `ApplyVariantBody` does it, at the first moment those offsets exist.
z.WorldRotation = point.Rotation;
z.GameObject.Name = rule.Enemy;
if ( Debug )
Log.Info( $"[nz-ambient] round {rm.Round}: {rule.Enemy} at {point.Position:0}"
+ $" — {AliveOf( rule.Enemy )} alive" );
return true;
}
// ── commands ─────────────────────────────────────────────────────────────
/// <summary>
/// `nz_ambient_report` — every rule, what it does this round, and what it will do next.
///
/// ⛔ IT PRINTS THE NEXT FEW ROUNDS, NOT JUST THIS ONE. A cadence is the thing most likely to be
/// wrong and the thing least visible from a single round: "every 3 from 8" and "every 3 from 9"
/// look identical until you see 8/11/14 against 9/12/15.
/// </summary>
[ConCmd( "nz_ambient_report" )]
public static void Report()
{
var cfg = ActiveConfig.Current;
var rm = Game.ActiveScene?.GetAllComponents<RoundManager>().FirstOrDefault();
var round = rm.IsValid() ? rm.Round : 0;
if ( cfg?.AmbientSpecials is null || cfg.AmbientSpecials.Count == 0 )
{
Log.Warning( "[nz-ambient] no ambient rules on this map's config" );
return;
}
Log.Info( $"[nz-ambient] {( AmbientOn ? "ENABLED" : "DISABLED" )} · round {round}" );
foreach ( var r in cfg.AmbientSpecials )
{
var t = r.TierFor( round );
Log.Info( $"[nz-ambient] {r.Enemy,-10} from {r.FirstRound,3}"
+ $" · {( r.UseBossSpawns ? "boss" : "zombie" )} spawns"
+ $" · now {r.CountForRound( round )} due, max {r.MaxAliveForRound( round )} alive"
+ $" · {AliveOf( r.Enemy )} alive"
+ $"{( t is null ? " (no tier)" : $" (tier from {t.FromRound}, every {t.RoundInterval})" )}" );
var next = new List<int>();
for ( var i = round + 1; i <= round + 40 && next.Count < 6; i++ )
if ( r.CountForRound( i ) > 0 ) next.Add( i );
// ⚠️ THE MOVED ROUNDS ARE NAMED. A cadence that shifts an arrival by a round is fine; one
// that silently LOSES it reads as "the napalm zombie is broken" months later, and the
// two are indistinguishable from a schedule printed without this line.
var moved = r.SkipSpecialRounds
? next.Where( i => cfg.IsSpecialRound( i ) ).Select( i => $"{i}->{i + 1}" ).ToList()
: new List<string>();
var owed = _Owed( r.Enemy );
Log.Info( $"[nz-ambient] next: {( next.Count == 0 ? "none in 40 rounds" : string.Join( ", ", next ) )}"
+ ( moved.Count == 0 ? "" : $" (special round, moved: {string.Join( ", ", moved )})" )
+ ( owed > 0 ? $" [{owed} deferred right now]" : "" ) );
}
}
/// <summary>How many arrivals are owed to the next ordinary round, for the report.</summary>
static int _Owed( string id )
{
var sys = Game.ActiveScene?.GetAllComponents<AmbientSpecials>().FirstOrDefault();
return sys.IsValid() && sys._carried.TryGetValue( id ?? "", out var n ) ? n : 0;
}
/// <summary>`nz_ambient_now [id]` — force this round's rules to fire immediately.</summary>
[ConCmd( "nz_ambient_now" )]
public static void NowCmd( string id = "" )
{
var sys = Game.ActiveScene?.GetAllComponents<AmbientSpecials>().FirstOrDefault();
var rm = RoundManager.Instance;
if ( !sys.IsValid() || !rm.IsValid() ) { Log.Warning( "[nz-ambient] not running" ); return; }
int n = 0;
foreach ( var t in sys._tracks )
{
if ( !string.IsNullOrWhiteSpace( id ) && t.Rule.Enemy != id ) continue;
t.Next = 0f;
n++;
}
Log.Info( $"[nz-ambient] {n} rule(s) armed to spawn on the next tick" );
}
}