Code/V2/Runtime/SpawnSelection.cs

Spawn selection code for the game. Defines a HexSpawnRequest record, an IHexSpawnSelector interface, and SceneTagSpawnSelector which picks a spawn Transform from scene SpawnPoint entities using tagged candidates and a slot allocator to avoid stacking and preserve connection slots.

Native Interop
#nullable enable

using System;
using System.Collections.Generic;
using System.Linq;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;
using Sandbox;

namespace Hexagon.V2.Runtime;

/// <summary>Why a placement is being chosen, so a game can spawn differently on respawn.</summary>
public readonly record struct HexSpawnRequest(
	Guid ConnectionId,
	CharacterId? CharacterId,
	bool IsRespawn );

/// <summary>
/// Decides where a player is placed. The framework owns the MECHANISM of placement - moving a
/// body without tripping the movement validator is something only it can do correctly - while
/// this seam owns the POLICY of which point a given player gets, which is a game rule.
/// <para>
/// Both the initial connect placement and every later embodiment resolve through one
/// implementation of this, so there is a single answer to "where does this player go" rather
/// than a connect-time answer and a stale copy of it.
/// </para>
/// </summary>
public interface IHexSpawnSelector
{
	OperationResult<Transform> Select( HexSpawnRequest request );
}

/// <summary>
/// The default policy: schema-authored spawn points tagged
/// <see cref="SpawnCandidateSelection.NamespacedTag"/> when the scene has any, otherwise every
/// active spawn point, distributed across leased slots and then outward in rings so players do
/// not stack. A game replaces this through <c>HexSchemaRuntimeDescriptor.CreateSpawnSelector</c>
/// when it needs faction spawns, cells, or respawn-near-death.
/// </summary>
public sealed class SceneTagSpawnSelector : IHexSpawnSelector
{
	private readonly Scene _scene;
	private readonly SpawnSlotAllocator _slots;

	internal SceneTagSpawnSelector( Scene scene, SpawnSlotAllocator slots )
	{
		_scene = scene ?? throw new ArgumentNullException( nameof(scene) );
		_slots = slots ?? throw new ArgumentNullException( nameof(slots) );
	}

	public OperationResult<Transform> Select( HexSpawnRequest request )
	{
		var points = _scene.GetAll<SpawnPoint>().Where( candidate => candidate.Active ).ToArray();
		var candidates = points
			.Select( point => new SpawnCandidate(
				point.GameObject.Id,
				point.GameObject.Tags.Has( SpawnCandidateSelection.NamespacedTag ) ) )
			.ToArray();
		// A slot lease is per connection and is reused, so a respawn returns to the same point
		// the connection was assigned rather than drifting to whichever slot is free now.
		var slot = _slots.Acquire( request.ConnectionId );
		if ( !SpawnCandidateSelection.TrySelect( candidates, slot, out var selection ) )
			return OperationResult<Transform>.Failure(
				ErrorCode.ConfigurationInvalid, "The scene has no active spawn point." );

		var point = points[selection.CandidateIndex];
		var position = point.WorldPosition;
		if ( selection.Placement.Ring > 0 )
		{
			var offset = new Vector3(
				(float)(Math.Cos( selection.Placement.AngleRadians ) * selection.Placement.Radius),
				(float)(Math.Sin( selection.Placement.AngleRadians ) * selection.Placement.Radius),
				0.0f );
			position += point.WorldRotation * offset;
		}

		return OperationResult<Transform>.Success(
			point.WorldTransform.WithPosition( position ).WithScale( 1 ) );
	}
}