Static manager for flag ambushes. It listens for a flag opening and spawns the configured special enemy at each special spawn for that flag, with console command support to list or force-spawn ambushes.
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// A ROOM'S AMBUSH — what waits behind a door. The first time a flag opens in a game, an enemy steps out at each of that flag's
/// special spawns (`RoomName.Ambush`, set per room). Asked for as *"when opening debris with the flag 5 make it so a pest spawns in
/// each of flag 5 special spawns — so basically the player opens up the reliquary and there are pests inside"* (2026-09-28):
/// basalt's Reliquary holds eight.
///
/// ⛔ ON THE FLAG OPENING, NOT ON THE PURCHASE. `DebrisManager` calls here from both of its openings — a debris bought (`OpenFree`)
/// and a flag opened any other way (`OpenLink`: the power, a soul box, the console) — and only when the flag was really closed
/// (`DoorLinks.Open`'s `fresh`). So it happens once a game, however the room comes open.
///
/// ⚠️ THE HOST'S, AND ONLY IN A GAME. Zombies are the host's (`ZombieCommands.SpawnAt` network-spawns them). ⛔ A CLIENT DOES REACH
/// HERE — its own power opening its free doors (`PowerManager`), `nz_link_open` — so the host test in `OnOpened` is what keeps a
/// client from spawning, not the path; only the host's own word (`OpenLinkFromHost`) never calls here. The lobby, Creative and a
/// finished game spawn nothing; `nz_ambush` springs one by hand, anywhere.
///
/// ⚠️ THEY JOIN THE ROUND AS AN AMBIENT SPECIAL DOES. Alive, they hold it open (`RoundManager.AliveBlocking`), and they count on
/// the round bar as they die. Opened between rounds, they are still waiting when the next round begins.
///
/// ⚠️ EVERY SPECIAL SPAWN CARRYING THE FLAG, on any of its three links, that is eligible now (power, round — `SpawnPoint.IsEligible`).
/// The spawn's own `Special` label (basalt's all read "hellhound") is not asked: the room says what waits in it.
/// </summary>
public static class FlagAmbush
{
/// <summary>A flag has just opened for the first time (`DebrisManager`). Spring its room's ambush, if it has one.</summary>
public static void OnOpened( string link )
{
if ( !NZGame.IsHost || NZGame.Mode != GameMode.Survival ) return;
var rm = RoundManager.Instance;
if ( !rm.IsValid() || rm.State is not (RoundState.Prep or RoundState.Active) ) return;
var enemy = RoomNames.AmbushFor( link );
if ( enemy.Length == 0 ) return;
Spring( link, enemy, force: false );
}
/// <summary>
/// Put <paramref name="enemy"/> at each of the flag's special spawns, and say how many came.
/// <paramref name="force"/> skips the eligibility test — `nz_ambush` on a flag that is still shut.
/// </summary>
public static int Spring( string link, string enemy, bool force )
{
var scene = Game.ActiveScene;
var variant = SpecialEnemies.VariantFor( enemy );
if ( !scene.IsValid() || variant is null )
{
Log.Warning( $"[nz-ambush] flag {link}: '{enemy}' is not an enemy that spawns — one of {string.Join( ", ", SpecialEnemies.Names )}" );
return 0;
}
var round = RoundManager.Instance.IsValid() ? RoundManager.Instance.Round : 1;
var points = SpawnsOf( link );
var came = 0;
foreach ( var point in points )
{
if ( !force && !point.IsEligible( round, Power.IsOn ) ) continue;
var z = ZombieCommands.SpawnAt( scene, point.Position, variant );
if ( z is null ) continue;
// ⚠️ THE SPAWN'S FACING ONLY, as `AmbientSpecials` does — the rig's own turn is added once the zombie starts
z.WorldRotation = point.Rotation;
z.GameObject.Name = enemy;
came++;
}
var room = RoomNames.NameOf( link );
Log.Info( $"[nz-ambush] flag {link}{(room.Length > 0 ? $" '{room}'" : "")} opened — {came} {enemy}(s) were waiting inside"
+ (came < points.Count ? $" ({points.Count - came} spawn(s) not eligible yet)" : "")
+ (points.Count == 0 ? " · ⚠ the flag carries no special spawns" : "") );
return came;
}
/// <summary>The special spawns that carry this flag, on any of their three links. None for the start (flag 0 never opens).</summary>
public static List<SpawnPoint> SpawnsOf( string link )
{
if ( DoorLinks.IsUnlinked( link ) ) return new();
return (ActiveConfig.Current?.SpecialSpawns ?? new List<SpawnPoint>())
.Where( s => s is not null
&& (DoorLinks.Same( s.Link, link ) || DoorLinks.Same( s.Link2, link ) || DoorLinks.Same( s.Link3, link )) )
.ToList();
}
/// <summary>
/// `nz_ambush [flag] [enemy]` — spring a room's ambush now, open or not: its own enemy, or the one given. Host only. Bare, every
/// room's ambush and how many special spawns its flag has. `nz_room_ambush` sets one.
/// </summary>
[ConCmd( "nz_ambush" )]
public static void Cmd( string flag = "", string enemy = "" )
{
if ( string.IsNullOrWhiteSpace( flag ) )
{
var rooms = ActiveConfig.Current?.Rooms ?? new List<RoomName>();
var any = false;
foreach ( var r in rooms )
{
if ( r is null || string.IsNullOrWhiteSpace( r.Ambush ) ) continue;
any = true;
Log.Info( $"[nz-ambush] flag {r.Link} '{r.Name}': {r.Ambush.Trim()} at each of {SpawnsOf( r.Link ).Count} special spawn(s)"
+ (DoorLinks.IsOpen( r.Link ) ? " · already open this game" : " · waiting") );
}
if ( !any ) Log.Info( "[nz-ambush] no room has an ambush — nz_room_ambush <flag> <enemy>" );
return;
}
if ( !NZGame.IsHost ) { Log.Warning( "[nz-ambush] the host springs ambushes — zombies are the host's" ); return; }
var link = DoorLinks.Clean( flag );
var what = string.IsNullOrWhiteSpace( enemy ) ? RoomNames.AmbushFor( link ) : enemy.Trim().ToLowerInvariant();
if ( what.Length == 0 )
{
Log.Warning( $"[nz-ambush] flag {link} has no ambush — nz_ambush {link} <enemy>, or nz_room_ambush {link} <enemy>" );
return;
}
Spring( link, what, force: true );
}
}