Player/PlayerSpawner.cs

PlayerSpawner is a static utility that places player bodies at configured spawn points. It selects randomized spawn indices, handles host/client authority (host sends placements, owners perform them), defers placements that arrive before a body exists, and zeroes velocity on placement to avoid fall damage.

NetworkingFile Access
using Sandbox;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// PLAYER/SPAWNING — puts players on the config's player spawn points.
///
/// The placed spawns were dead data until this existed: MapEditor wrote them,
/// the config saved and reloaded them, the markers drew them, and nothing ever
/// read the list to move anybody. A config could say where a game starts and
/// the game would ignore it.
///
/// One spawn is chosen at RANDOM per player, per game — the original picks from
/// the eligible set rather than using a fixed start, so two runs of the same
/// config don't open identically.
///
/// ⛔ WITH TWO MACHINES, MOVING A BODY IS NOT THE SAME AS MOVING AN OBJECT. A body owned by
/// somebody else is simulated on THEIR pc; writing its `WorldPosition` here lands a value their
/// next controller tick overwrites, and leaves no trace of having been ignored. So the host
/// DECIDES the placement and the OWNER PERFORMS it — see `MoveTo`. Everything above still holds;
/// only the last inch changed.
/// </summary>
public static class PlayerSpawner
{
	/// <summary>
	/// Move every player to a spawn point. No-op with nothing placed.
	///
	/// Returns how many were moved, so a caller (or a test) can tell "there were
	/// no spawns" from "it worked".
	///
	/// ⛔ HOST ONLY WHEN NETWORKED. Two machines both shuffling the spawn list would deal
	/// different hands and each move only itself — everybody would end up somewhere legal and
	/// nobody would agree on who was where.
	/// </summary>
	/// <param name="only">
	/// ⛔ WHICH PLAYERS, BECAUSE "ALL" WAS THE WRONG ANSWER FOR THE CALLER THAT NEEDED ONE.
	/// `RoundManager.BringBackTheBledOut` used this to return the players who died last round — and
	/// it teleported EVERYBODY to spawn, mid-game, every time anyone came back. User: *"when a
	/// player respawns all players are teleported to the player spawns, this should not happen."*
	///
	/// ⚠️ NULL STILL MEANS EVERYONE, which is right for the four callers that start a game or
	/// load a map. Only the respawn wanted a subset, and only the respawn passes one.
	/// </param>
	public static int PlaceAll( int forceIndex = -1, System.Func<GameObject, bool> only = null )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return 0;

		if ( NZGame.IsClient )
		{
			// ⚠️ NOT A FAILURE, AND IT MUST NOT READ AS ONE. A client reaching here is the normal
			// case — `RoundManager.StartGame` calls this and the client runs it too. The host is
			// already sending the answer.
			Log.Info( "[nz-spawn] client — the host decides where everyone stands" );
			return 0;
		}

		var spawns = ActiveConfig.Current.PlayerSpawns;
		if ( spawns.Count == 0 )
		{
			Log.Warning( "[nz] no player spawns placed — leaving players where "
				+ "they are. nz_tool player_spawn, then nz_place." );
			return 0;
		}

		var players = only is null
			? AllBodies( scene )
			: AllBodies( scene ).Where( only ).ToList();

		if ( players.Count == 0 ) return 0;

		// Shuffled indices, so with enough spawns two players never start inside
		// each other. Falls back to repeats once the spawns run out rather than
		// refusing to place anyone.
		//
		// ⚠️ forceIndex bypasses the shuffle. Not for gameplay — for TESTING.
		// Anything measured relative to the player (a path probe, a placement
		// offset) is only comparable across runs if the player starts in the
		// same spot facing the same way, and a random spawn makes every reading
		// a different experiment.
		var order = forceIndex >= 0 && forceIndex < spawns.Count
			? new List<int> { forceIndex }
			: Shuffled( spawns.Count );

		for ( int i = 0; i < players.Count; i++ )
		{
			var pick = order[i % order.Count];
			var spawn = spawns[pick];

			MoveTo( players[i], spawn.Position, spawn.Rotation );

			Log.Info( $"[nz-spawn] '{players[i].Name}' to spawn #{pick} {spawn.Position}"
				+ $" facing {spawn.Yaw:0}" );
		}

		return players.Count;
	}

	/// <summary>
	/// EVERY player body in the scene, ENABLED OR NOT — one per connection.
	///
	/// ⛔ `GetAllComponents&lt;NZPlayer&gt;` DOES NOT SEE A DISABLED BODY, and here that is not an
	/// edge case, it is the normal case. A game is started FROM THE LOBBY, where `PlayerPresence`
	/// has the body disabled, and a joining player's body is a clone of a lobby body so it is
	/// disabled too. Placing "every player" through the enabled-only lookup placed the host and
	/// nobody else — which is exactly why two players opened a round standing inside each other.
	/// `NZMap.PlacePlayers` and `PlayerStats.All` carry the same note; this is the fourth time it
	/// has bitten.
	/// </summary>
	public static List<GameObject> AllBodies( Scene scene = null )
	{
		scene ??= Game.ActiveScene;
		if ( !scene.IsValid() ) return new List<GameObject>();

		return scene.GetAllObjects( false )
			.Where( o => o.IsValid()
				&& o.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) is not null )
			.ToList();
	}

	/// <summary>
	/// Put one body somewhere — directly if it is ours, by asking its owner if it is not.
	///
	/// ⛔ THE PROXY BRANCH IS THE WHOLE POINT. `Network.IsProxy` means "not being simulated on
	/// the local pc": the owner's `PlayerController` runs every tick and writes the transform, so
	/// a position written from here is gone within a frame. The teleport has to happen on the
	/// machine that owns the body.
	///
	/// ⚠️ AND IT READS CORRECTLY SOLO. Nothing is networked in single player, so no body is a
	/// proxy and every move takes the direct branch.
	/// </summary>
	public static void MoveTo( GameObject go, Vector3 position, Rotation rotation )
	{
		if ( !go.IsValid() ) return;

		// ⚠️ `PlayerPresence.Mine`, NOT `!IsProxy` — one predicate for "whose body is this",
		// shared with `Find` and `RefreshBodies`, so the three cannot disagree about a body that
		// nobody has claimed.
		if ( PlayerPresence.Mine( go ) )
		{
			PlaceLocal( go, position, rotation );
			return;
		}

		// ⛔ ONLY THE HOST GETS TO SEND ONE. `PlaceAt` is a broadcast addressed by id, so a
		// client that reached here would be teleporting the HOST — and `NZMap.PlacePlayers`
		// runs on whichever machine loads a map, which `nz_map_load ... force` lets a client do.
		// The guard is here rather than at the caller because this is the only door.
		if ( NZGame.IsClient )
		{
			Log.Warning( $"[nz-spawn] refusing to move '{go.Name}' — a client does not place people" );
			return;
		}

		NZNet.PlaceAt( go.Network.OwnerId, position, rotation );
	}

	/// <summary>
	/// The actual teleport, on the machine that owns the body.
	///
	/// ⚠️ Moving the GameObject IS the teleport — PlayerController reads its position from the
	/// transform. There is no Teleport method and Velocity is READ-ONLY (it is derived), so there
	/// is nothing to zero; any fall speed carries over, which is fine for a spawn on the floor.
	/// </summary>
	public static void PlaceLocal( GameObject go, Vector3 position, Rotation rotation )
	{
		if ( !go.IsValid() ) return;

		go.WorldPosition = position;
		go.WorldRotation = rotation;

		// Aim the VIEW too, not just the body. The controller keeps its own eye
		// angles, so rotating the object alone leaves you standing in the right
		// place looking the wrong way.
		var cc = go.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
		if ( !cc.IsValid() ) return;

		cc.EyeAngles = rotation.Angles();

		// ⛔ AND THE VELOCITY, OR THE FIRST SPAWN OF A SESSION KILLS YOU. Reported as
		// *"whenever i spawn the first time i just die"*, and the console had it exactly:
		//
		//   [nz-spawn] 'Player Controller' to spawn #2 -1272.8,738.2,-160 facing 160
		//   [nz] round 1 starting in 1.0s
		//   [NZPlayer] hit for 736 — 0/150
		//   [nz] KILLED — no reviver available
		//
		// One hit for 736 against 150 health, in the same second as the placement, before round
		// one and before a single zombie existed. The body accumulates downward velocity while
		// the map builds — debris, barricades, the box, the ammo box and the trade table all get
		// built in that window — and setting `WorldPosition` TELEPORTS IT WITHOUT CLEARING THAT.
		// The engine controller then lands a body already travelling at fall speed and charges
		// for the whole descent.
		//
		// ⚠️ IT IS THE FIRST SPAWN ONLY, WHICH IS WHY THIS SURVIVED THIS LONG. A player coming
		// from the lobby has a body that has been standing still, so there is nothing to carry;
		// only the session's very first placement happens while the body is still falling.
		//
		// ⛔ "FALL DAMAGE IS NOT IN THIS GAME" IS A STALE CLAIM — `Placeable.cs` says so in a
		// comment and it is wrong. `Armor.cs` already reads `tags.Has( "fall" )` and its own ⛔
		// admits the tag was "still unverified"; 736 points of it is the verification.
		//
		// ⚠️ THIS IS THE ONLY DOOR, host and client alike. `MoveTo` sends a remote player
		// through `NZNet.PlaceAt`, which calls straight back into this method — so the fix does
		// not need repeating anywhere, and a second copy would be the thing that drifts.
		var body = cc.Body;
		if ( !body.IsValid() ) return;

		body.Velocity = Vector3.Zero;
		body.AngularVelocity = Vector3.Zero;
	}

	// ══ placement that arrives too early ══════════════════════════════════════════════

	/// <summary>
	/// Where this machine has been told to stand, held until it has a body to stand with.
	///
	/// ⛔ THE MESSAGE ARRIVES BEFORE THE BODY IS UP, RELIABLY. The host places everyone inside
	/// `StartGame`, and that is the same instant the client is still leaving the lobby with its
	/// body disabled. Dropping the message would leave that player at the map's default start —
	/// which is where the host is: the exact stacking this whole change exists to stop.
	/// </summary>
	static (Vector3 Position, Rotation Rotation)? _pending;

	/// <summary>Remember a placement to apply when a body next comes up.</summary>
	public static void Defer( Vector3 position, Rotation rotation )
		=> _pending = (position, rotation);

	/// <summary>
	/// Apply a placement that arrived early. Called by `PlayerPresence` the moment it enables a
	/// body — the first instant one can be moved and made to stay moved.
	/// </summary>
	public static void TakePending( GameObject go )
	{
		if ( _pending is null || !go.IsValid() ) return;

		var (pos, rot) = _pending.Value;
		_pending = null;

		Log.Info( $"[nz-spawn] applying the placement that arrived early to {pos}" );
		PlaceLocal( go, pos, rot );
	}

	/// <summary>Forget a held placement — a new game, or a map change.</summary>
	public static void ClearPending() => _pending = null;

	/// <summary>0..count-1 in random order.</summary>
	static List<int> Shuffled( int count )
	{
		var list = Enumerable.Range( 0, count ).ToList();

		for ( int i = list.Count - 1; i > 0; i-- )
		{
			int j = Game.Random.Int( 0, i );
			(list[i], list[j]) = (list[j], list[i]);
		}

		return list;
	}

	/// <summary>
	/// `nz_spawn_where` — where everybody is, and where the config says they should be.
	///
	/// ⚠️ RUN IT ON BOTH MACHINES. "We spawned on top of each other" is one symptom of two
	/// different faults — the host dealt one spawn to two bodies, or it dealt two and one of them
	/// never arrived — and only comparing the two machines separates them.
	/// </summary>
	[ConCmd( "nz_spawn_where" )]
	public static void WhereCmd()
	{
		var spawns = ActiveConfig.Current?.PlayerSpawns ?? new List<SpawnPoint>();

		Log.Info( $"[nz-spawn] {spawns.Count} player spawn(s) in '{ActiveConfig.Current?.Name}'"
			+ $" · I am the {(NZGame.IsHost ? "HOST" : "CLIENT")}" );

		for ( var i = 0; i < spawns.Count; i++ )
			Log.Info( $"[nz-spawn]   #{i} {spawns[i].Position} facing {spawns[i].Yaw:0}" );

		foreach ( var go in AllBodies() )
			Log.Info( $"[nz-spawn] '{go.Name}' at {go.WorldPosition}"
				+ $" · enabled={go.Enabled} proxy={go.Network.IsProxy}" );
	}
}