Doors/FlagAmbush.cs

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.

Networking
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 );
	}
}