Director/DemoDirectorRuntime.cs

Runtime component that adapts the generalized Director system to the demo s&box world. It captures world snapshots, evaluates pressure signals, builds spawn candidates, executes spawn requests by creating DemoNpc objects, and reports diagnostics and simple combat-related state.

ReflectionFile Access
using Sandbox;
using Sandbox.Navigation;
using SboxDirector;
using System;
using System.Collections.Generic;
using System.Linq;

namespace AdaptiveDirectorDemo.Director;

/// <summary>
/// Live adapter between the generalized Director and the demo's s&amp;box world.
/// Every NPC and gameplay pressure signal is resolved through DirectorCore.
/// </summary>
[Title( "Demo Director Runtime" )]
[Category( "Adaptive Director Demo" )]
public sealed class DemoDirectorRuntime : AdaptiveDirectorComponent
{
	public WorldSnapshot LastWorld { get; private set; } = new();
	public string LastDiagnostic { get; private set; } = "waiting for first tick";
	public DirectorConfiguration ActiveConfiguration { get; private set; } = new();
	public SpawnRequest LastSpawnRequest { get; private set; }
	public SpawnRequestResult LastSpawnResult { get; private set; }
	public int TickCount { get; private set; }
	public float RecentPeakMusicIntensity { get; private set; }
	public string LastPressureSignal { get; private set; } = "none";
	public int NearbyThreatCount { get; private set; }
	public int VisibleThreatCount { get; private set; }
	public bool IsPlayerSurrounded { get; private set; }
	public float PlayerHealthFraction { get; private set; } = 1.0f;
	public float ActiveWeaponAmmoFraction { get; private set; } = 1.0f;
	public float SustainedCombatSeconds { get; private set; }

	private readonly List<(double Time, float Intensity)> _musicIntensityHistory = new();
	private readonly Dictionary<string, Vector3> _candidatePositions = new();
	private readonly Dictionary<string, NpcRetentionState> _npcRetention = new();
	private Vector3 _progressOrigin;
	private bool _hasProgressOrigin;
	private float _furthestTravel;
	private double _nextThreatReport;
	private double _nextContextPressureReport;
	private double _nextDamagePressureReport;
	private double _nextKillPressureReport;
	private double _combatStartedAt = -1.0;
	private int _lastPlayerHealth;
	private bool _hasPlayerHealth;

	protected override DirectorConfiguration CreateConfiguration()
	{
		ActiveConfiguration = new DirectorConfiguration
		{
			Seed = Seed,
			RequestTimeoutSeconds = 5.0,
			MaximumRequestsPerTick = 1,
			Tempo = new TempoConfiguration
			{
				BuildUpMinimumSeconds = 7.0,
				SustainPeakSeconds = 9.0,
				RelaxMinimumSeconds = 8.0,
				RelaxMaximumSeconds = 14.0,
				PeakThreshold = 0.45f,
				RelaxThreshold = 0.22f,
				RelaxMaximumProgressTravel = 0.08f
			},
			Population = new PopulationConfiguration
			{
				Limits = new Dictionary<EncounterKind, int>
				{
					[EncounterKind.Ambient] = 5,
					[EncounterKind.CommonWave] = 12,
					[EncounterKind.Special] = 3,
					[EncounterKind.Boss] = 1,
					[EncounterKind.DormantHazard] = 1,
					[EncounterKind.Objective] = 4,
					[EncounterKind.Custom] = 4
				}
			},
			Placement = CreatePlacementProfiles()
		};

		return ActiveConfiguration;
	}

	protected override IEncounterSchedule CreateSchedule()
	{
		return new RuleBasedEncounterSchedule( new[]
		{
			new EncounterRule
			{
				Id = "boss",
				Kind = EncounterKind.Boss,
				Count = 1,
				Priority = 40,
				CooldownSeconds = 42.0,
				MinimumPressure = 0.12f,
				MaximumPressure = 0.86f,
				Probability = 0.55f,
				Phases = new HashSet<TempoPhase> { TempoPhase.SustainPeak }
			},
			new EncounterRule
			{
				Id = "special",
				Kind = EncounterKind.Special,
				Count = 1,
				Priority = 30,
				CooldownSeconds = 12.0,
				Probability = 0.45f,
				Phases = new HashSet<TempoPhase> { TempoPhase.BuildUp, TempoPhase.SustainPeak }
			},
			new EncounterRule
			{
				Id = "wave",
				Kind = EncounterKind.CommonWave,
				Count = 5,
				Priority = 20,
				CooldownSeconds = 8.0,
				Phases = new HashSet<TempoPhase> { TempoPhase.BuildUp, TempoPhase.SustainPeak }
			},
			new EncounterRule
			{
				Id = "ambient",
				Kind = EncounterKind.Ambient,
				Count = 1,
				Priority = 10,
				CooldownSeconds = 3.5,
				Phases = new HashSet<TempoPhase> { TempoPhase.Relax, TempoPhase.PeakFade }
			}
		} );
	}

	protected override IEncounterComposer CreateComposer()
	{
		return new WeightedEncounterComposer( new Dictionary<EncounterKind, IReadOnlyList<WeightedArchetype>>
		{
			[EncounterKind.Ambient] = new[]
			{
				new WeightedArchetype( "drifter", 3.0f ),
				new WeightedArchetype( "shambler", 1.0f )
			},
			[EncounterKind.CommonWave] = new[]
			{
				new WeightedArchetype( "runner", 4.0f ),
				new WeightedArchetype( "bruiser", 1.25f ),
				new WeightedArchetype( "swarm", 2.25f )
			},
			[EncounterKind.Special] = new[]
			{
				new WeightedArchetype( "stalker", 2.0f ),
				new WeightedArchetype( "charger", 1.0f ),
				new WeightedArchetype( "screecher", 1.5f )
			},
			[EncounterKind.Boss] = new[]
			{
				new WeightedArchetype( "juggernaut", 3.0f ),
				new WeightedArchetype( "behemoth", 1.0f )
			}
		} );
	}

	protected override void OnDirectorInitialized( DirectorCore director )
	{
		// Use a named handler so s&box hot load can remap the delegate safely.
		director.Diagnostic += HandleDirectorDiagnostic;
	}

	private void HandleDirectorDiagnostic( DirectorDiagnostic diagnostic )
	{
		LastDiagnostic = $"{diagnostic.Category}: {diagnostic.Message}";
	}

	protected override WorldSnapshot CaptureDirectorWorld()
	{
		var player = FindPlayer();
		var participants = new List<ParticipantSnapshot>();
		var livingNpcs = Scene.GetAllComponents<DemoNpc>().Where( npc => npc.IsAlive ).ToArray();
		var combatActive = false;
		var progress = 0.0f;

		if ( player is not null )
		{
			CullStaleNpcs( player, livingNpcs );
			livingNpcs = livingNpcs.Where( npc => npc.IsAlive && npc.IsValid ).ToArray();

			if ( !_hasProgressOrigin )
			{
				_progressOrigin = player.WorldPosition;
				_hasProgressOrigin = true;
			}

			_furthestTravel = Math.Max( _furthestTravel, (player.WorldPosition - _progressOrigin).Length );
			progress = Math.Clamp( _furthestTravel / 5000.0f, 0.0f, 1.0f );
			combatActive = livingNpcs.Any( npc => (npc.WorldPosition - player.WorldPosition).Length < 650.0f );
			EvaluatePressureSignals( player, livingNpcs, combatActive );

			participants.Add( new ParticipantSnapshot
			{
				Id = player.Id.ToString(),
				IsAlive = player.Active,
				IsEligible = player.Active,
				IsInCombat = combatActive,
				Position = ToDirectorVector( player.WorldPosition ),
				Progress = progress
			} );
		}

		var populationCounts = livingNpcs
			.GroupBy( npc => npc.Kind )
			.ToDictionary( group => group.Key, group => group.Count() );

		LastWorld = new WorldSnapshot
		{
			Time = Time.Now,
			Participants = participants,
			Population = new PopulationSnapshot( populationCounts ),
			SpawnCandidates = BuildSpawnCandidates( player ),
			CombatActive = combatActive,
			TeamProgress = progress,
			ModeId = "continuous-demo"
		};

		return LastWorld;
	}

	protected override SpawnRequestResult ExecuteDirectorSpawn( SpawnRequest request )
	{
		LastSpawnRequest = request;
		if ( !_candidatePositions.TryGetValue( request.CandidateId, out var origin ) )
		{
			LastSpawnResult = new SpawnRequestResult(
				request.RequestId, RequestResultKind.Rejected, 0, "candidate expired before host execution" );
			return LastSpawnResult;
		}

		var spawned = 0;
		var composedArchetypes = new List<string>();
		foreach ( var member in request.Composition )
		{
			for ( var count = 0; count < member.Count; count++ )
				composedArchetypes.Add( member.Archetype );
		}

		for ( var index = 0; index < request.RequestedCount; index++ )
		{
			var angle = index * (MathF.PI * 2.0f / Math.Max( 1, request.RequestedCount ));
			var ring = 42.0f + 24.0f * (index / 8);
			var offset = new Vector3( MathF.Cos( angle ) * ring, MathF.Sin( angle ) * ring, 0.0f );
			var position = SnapToGround( origin + offset );

			var npcObject = Scene.CreateObject( false );
			npcObject.WorldPosition = position;
			npcObject.Tags.Add( "director_npc" );

			var npc = npcObject.Components.GetOrCreate<DemoNpc>();
			npc.Kind = request.Kind;
			npc.Archetype = composedArchetypes.Count > 0
				? composedArchetypes[Math.Min( index, composedArchetypes.Count - 1 )]
				: request.Archetype;
			npcObject.Name = $"Director {request.Kind} - {npc.Archetype}";
			npcObject.Enabled = true;
			spawned++;
		}

		LastSpawnResult = new SpawnRequestResult(
			request.RequestId,
			spawned == request.RequestedCount ? RequestResultKind.Succeeded : RequestResultKind.PartiallySucceeded,
			spawned,
			$"spawned {spawned} grounded Citizen NPC(s) through the Director host" );

		return LastSpawnResult;
	}

	protected override void OnDirectorDecisions( DirectorDecisionBatch decisions, IReadOnlyList<SpawnRequestResult> results )
	{
		TickCount++;

		if ( decisions.SpawnRequests.Count > 0 )
			LastSpawnRequest = decisions.SpawnRequests[0];

		if ( results.Count > 0 )
			LastSpawnResult = results[results.Count - 1];

		_musicIntensityHistory.Add( (decisions.Time, decisions.MusicIntensity) );
		_musicIntensityHistory.RemoveAll( sample => decisions.Time - sample.Time > 10.0 );
		RecentPeakMusicIntensity = _musicIntensityHistory.Count == 0
			? 0.0f
			: _musicIntensityHistory.Max( sample => sample.Intensity );
	}

	public void ReportNpcThreat( DemoNpc npc )
	{
		if ( Time.Now < _nextThreatReport )
			return;

		var player = FindPlayer();
		if ( player is null )
			return;

		var playerComponent = FindNamedComponent( player, "DemoPlayer" );
		if ( playerComponent is null || !playerComponent.IsValid ||
			!TryGetIntProperty( playerComponent, "Health", out var health ) || health <= 0 )
			return;

		_nextThreatReport = Time.Now + 1.0;
		var damage = npc.Kind switch
		{
			EncounterKind.Special => 7,
			EncounterKind.Boss => 12,
			_ => 4
		};

		// The demo deliberately omits incapacitation. Ordinary contact attacks can
		// demonstrate damage and low-health pressure, but never reduce the player
		// below one hit point. Special NPCs do not have special attack mechanics.
		TrySetIntProperty( playerComponent, "Health", Math.Max( 1, health - damage ) );
	}

	public void ReportNpcKilled( DemoNpc npc )
	{
		if ( Time.Now < _nextKillPressureReport )
			return;

		var player = FindPlayer();
		if ( player is null )
			return;

		_nextKillPressureReport = Time.Now + 0.6;
		var severity = npc.Kind switch
		{
			EncounterKind.Boss => PressureSeverity.Major,
			EncounterKind.Special => PressureSeverity.Moderate,
			_ => PressureSeverity.Minor
		};
		ReportPressureSignal( player.Id.ToString(), severity, $"{npc.Kind} kill" );
	}

	/// <summary>Compact live state exposed to the autonomous editor gate.</summary>
	public string GetPressureDiagnostics()
	{
		return $"signal={LastPressureSignal}; near={NearbyThreatCount}; visible={VisibleThreatCount}; " +
			$"surrounded={IsPlayerSurrounded}; health={PlayerHealthFraction:0.00}; " +
			$"ammo={ActiveWeaponAmmoFraction:0.00}; combat={SustainedCombatSeconds:0.0}s";
	}

	private void EvaluatePressureSignals( GameObject player, IReadOnlyList<DemoNpc> livingNpcs, bool combatActive )
	{
		var playerId = player.Id.ToString();
		var playerComponent = FindNamedComponent( player, "DemoPlayer" );

		if ( playerComponent is not null && playerComponent.IsValid &&
			TryGetIntProperty( playerComponent, "Health", out var health ) &&
			TryGetIntProperty( playerComponent, "MaxHealth", out var rawMaximumHealth ) )
		{
			var maximumHealth = Math.Max( 1, rawMaximumHealth );
			PlayerHealthFraction = Math.Clamp( health / (float)maximumHealth, 0.0f, 1.0f );
			if ( _hasPlayerHealth && health < _lastPlayerHealth && Time.Now >= _nextDamagePressureReport )
			{
				var damageFraction = (_lastPlayerHealth - health) / (float)maximumHealth;
				var severity = damageFraction switch
				{
					>= 0.25f => PressureSeverity.Critical,
					>= 0.10f => PressureSeverity.Major,
					_ => PressureSeverity.Moderate
				};
				_nextDamagePressureReport = Time.Now + 0.6;
				ReportPressureSignal( playerId, severity, "player damage" );
			}
			_lastPlayerHealth = health;
			_hasPlayerHealth = true;
		}
		else
		{
			PlayerHealthFraction = 1.0f;
			_hasPlayerHealth = false;
		}

		NearbyThreatCount = livingNpcs.Count( npc => (npc.WorldPosition - player.WorldPosition).Length <= 350.0f );
		VisibleThreatCount = livingNpcs.Count( npc =>
			(npc.WorldPosition - player.WorldPosition).Length <= 1000.0f && IsVisibleToPlayer( npc.WorldPosition ) );
		IsPlayerSurrounded = IsSurroundedByThreats( player, livingNpcs );

		if ( combatActive )
		{
			if ( _combatStartedAt < 0.0 )
				_combatStartedAt = Time.Now;
			SustainedCombatSeconds = (float)Math.Max( 0.0, Time.Now - _combatStartedAt );
		}
		else
		{
			_combatStartedAt = -1.0;
			SustainedCombatSeconds = 0.0f;
		}

		var weapon = Scene.GetAllObjects( false )
			.SelectMany( item => item.Components.GetAll() )
			.FirstOrDefault( component => component.Active && component.Enabled && component.GetType().Name == "Weapon" );
		var isShooting = weapon is not null && Input.Down( "attack1" );
		var primary = weapon is null ? null : GetPropertyValue( weapon, "Primary" );
		if ( primary is not null && TryGetIntProperty( primary, "ClipSize", out var clipSize ) && clipSize > 0 &&
			TryGetIntProperty( primary, "Ammo", out var ammo ) )
			ActiveWeaponAmmoFraction = Math.Clamp( ammo / (float)clipSize, 0.0f, 1.0f );
		else
			ActiveWeaponAmmoFraction = 1.0f;

		if ( Time.Now < _nextContextPressureReport )
			return;

		var selectedSeverity = (PressureSeverity)0;
		var selectedReason = string.Empty;
		void Consider( PressureSeverity severity, string reason )
		{
			if ( severity <= selectedSeverity )
				return;
			selectedSeverity = severity;
			selectedReason = reason;
		}

		if ( IsPlayerSurrounded )
			Consider( PressureSeverity.Major, "surrounded" );
		if ( NearbyThreatCount >= 7 )
			Consider( PressureSeverity.Major, "dense nearby horde" );
		else if ( NearbyThreatCount >= 3 )
			Consider( PressureSeverity.Moderate, "nearby enemy density" );
		if ( VisibleThreatCount >= 7 )
			Consider( PressureSeverity.Moderate, "many visible enemies" );
		else if ( VisibleThreatCount >= 3 )
			Consider( PressureSeverity.Minor, "visible enemy density" );
		if ( combatActive && PlayerHealthFraction <= 0.25f )
			Consider( PressureSeverity.Moderate, "critical health" );
		else if ( combatActive && PlayerHealthFraction <= 0.50f )
			Consider( PressureSeverity.Minor, "low health" );
		if ( combatActive && ActiveWeaponAmmoFraction <= 0.15f )
			Consider( PressureSeverity.Moderate, "critical ammunition" );
		else if ( combatActive && ActiveWeaponAmmoFraction <= 0.30f )
			Consider( PressureSeverity.Minor, "low ammunition" );
		if ( isShooting )
			Consider( PressureSeverity.Minor, "weapon fire" );
		if ( SustainedCombatSeconds >= 25.0f )
			Consider( PressureSeverity.Moderate, "prolonged combat" );
		else if ( SustainedCombatSeconds >= 10.0f )
			Consider( PressureSeverity.Minor, "sustained combat" );

		if ( selectedSeverity == (PressureSeverity)0 )
			return;

		_nextContextPressureReport = Time.Now + 2.0;
		ReportPressureSignal( playerId, selectedSeverity, selectedReason );
	}

	private bool IsSurroundedByThreats( GameObject player, IReadOnlyList<DemoNpc> livingNpcs )
	{
		var sectors = new HashSet<int>();
		var nearby = 0;
		foreach ( var npc in livingNpcs )
		{
			var delta = npc.WorldPosition - player.WorldPosition;
			if ( delta.Length > 300.0f )
				continue;

			nearby++;
			var angle = MathF.Atan2( delta.y, delta.x ) + MathF.PI;
			var sector = Math.Clamp( (int)(angle / (MathF.PI * 0.5f)), 0, 3 );
			sectors.Add( sector );
		}

		return nearby >= 4 && sectors.Count >= 3;
	}

	private void ReportPressureSignal( string playerId, PressureSeverity severity, string reason )
	{
		LastPressureSignal = $"{severity}: {reason}";
		ReportPressure( new PressureEvent( playerId, severity, reason ), Time.Now );
	}

	private static Component FindNamedComponent( GameObject gameObject, string typeName )
	{
		return gameObject.Components.GetAll()
			.FirstOrDefault( component => component.GetType().Name == typeName );
	}

	private static object GetPropertyValue( object target, string propertyName )
	{
		if ( target is null )
			return null;

		var description = TypeLibrary.GetType( target.GetType() );
		var property = description?.Properties.FirstOrDefault( item => item.Name == propertyName && !item.IsStatic );
		return property?.GetValue( target );
	}

	private static bool TryGetIntProperty( object target, string propertyName, out int value )
	{
		value = 0;
		var raw = GetPropertyValue( target, propertyName );
		if ( raw is not int number )
			return false;

		value = number;
		return true;
	}

	private static bool TrySetIntProperty( object target, string propertyName, int value )
	{
		if ( target is null )
			return false;

		var description = TypeLibrary.GetType( target.GetType() );
		var property = description?.Properties.FirstOrDefault( item =>
			item.Name == propertyName && !item.IsStatic && item.CanWrite );
		if ( property is null )
			return false;

		property.SetValue( target, value );
		return true;
	}

	private IReadOnlyList<SpawnCandidate> BuildSpawnCandidates( GameObject player )
	{
		_candidatePositions.Clear();
		if ( player is null )
			return Array.Empty<SpawnCandidate>();

		var positions = new List<(string Id, Vector3 Position, float PathDistance)>();
		foreach ( var marker in Scene.GetAllComponents<SpawnPoint>() )
		{
			if ( !TryProjectReachableSpawn( marker.WorldPosition, player.WorldPosition, out var position, out var pathDistance ) )
				continue;

			var distance = (position - player.WorldPosition).Length;
			if ( distance < 260.0f || distance > 2200.0f )
				continue;

			positions.Add( ($"map-{marker.GameObject.Id}", position, pathDistance) );
			if ( positions.Count >= 36 )
				break;
		}

		if ( positions.Count < 8 )
		{
			for ( var index = 0; index < 12; index++ )
			{
				var angle = index * MathF.PI * 2.0f / 12.0f;
				var distance = 650.0f + (index % 3) * 220.0f;
				var proposed = player.WorldPosition + new Vector3(
					MathF.Cos( angle ) * distance,
					MathF.Sin( angle ) * distance,
					0.0f );
				if ( TryProjectReachableSpawn( proposed, player.WorldPosition, out var position, out var pathDistance ) )
					positions.Add( ($"ring-{index}", position, pathDistance) );
			}
		}

		var output = new List<SpawnCandidate>();
		foreach ( var item in positions )
		{
			var distance = (item.Position - player.WorldPosition).Length;
			_candidatePositions[item.Id] = item.Position;
			output.Add( new SpawnCandidate
			{
				Id = item.Id,
				Position = ToDirectorVector( item.Position ),
				Reachable = true,
				Spawnable = !IsOccupied( item.Position ),
				Visible = IsVisibleToPlayer( item.Position ),
				NearestParticipantDistance = distance,
				Progress = LastWorld.TeamProgress,
				RelativeProgress = 0.0f,
				AreaCapacity = 8.0f,
				ThreatSeparation = NearestNpcDistance( item.Position ),
				PathDistance = item.PathDistance,
				IngressDistance = item.PathDistance,
				ClearRadius = 96.0f,
				AreaWidth = 192.0f,
				AreaHeight = 96.0f,
				AdvanceRelation = AdvanceRelation.Lateral
			} );
		}

		return output;
	}

	private void CullStaleNpcs( GameObject player, IReadOnlyList<DemoNpc> livingNpcs )
	{
		var liveIds = new HashSet<string>();
		foreach ( var npc in livingNpcs )
		{
			if ( npc is null || !npc.IsValid || !npc.IsAlive )
				continue;

			var id = npc.GameObject.Id.ToString();
			liveIds.Add( id );
			if ( !_npcRetention.TryGetValue( id, out var retention ) )
			{
				retention = new NpcRetentionState( npc.WorldPosition, Time.Now );
				_npcRetention[id] = retention;
			}

			var distance = (npc.WorldPosition - player.WorldPosition).Length;
			var moved = (npc.WorldPosition - retention.LastPosition).Length >= 48.0f;
			var relevant = distance <= 900.0f || IsVisibleToPlayer( npc.WorldPosition ) || moved;
			if ( relevant )
			{
				retention.LastRelevantTime = Time.Now;
				if ( moved )
					retention.LastPosition = npc.WorldPosition;
				continue;
			}

			// The host, not DirectorCore, owns entity visibility and destruction.
			// Twenty seconds keeps cleanup conservative while preventing unreachable,
			// off-screen encounters from permanently consuming population capacity.
			if ( Time.Now - retention.LastRelevantTime >= 20.0 )
			{
				npc.DespawnForDirector();
				LastDiagnostic = $"cleanup: released stale off-screen {npc.Kind} encounter";
			}
		}

		foreach ( var staleId in _npcRetention.Keys.Where( id => !liveIds.Contains( id ) ).ToArray() )
			_npcRetention.Remove( staleId );
	}

	private bool TryProjectReachableSpawn(
		Vector3 proposed,
		Vector3 playerPosition,
		out Vector3 position,
		out float pathDistance )
	{
		position = default;
		pathDistance = 0.0f;

		var navMesh = Scene.NavMesh;
		if ( navMesh is null || !navMesh.IsEnabled )
			return false;

		// A small snap radius prevents a ground-level proposal from snapping back
		// onto a roof or another vertically separated navigation island.
		var candidatePoint = navMesh.GetClosestPoint( proposed, 192.0f );
		var playerPoint = navMesh.GetClosestPoint( playerPosition, 192.0f );
		if ( candidatePoint is null || playerPoint is null )
			return false;

		const float maximumVerticalSeparation = 128.0f;
		if ( MathF.Abs( candidatePoint.Value.z - playerPoint.Value.z ) > maximumVerticalSeparation )
			return false;

		var path = navMesh.CalculatePath( new CalculatePathRequest
		{
			Start = playerPoint.Value,
			Target = candidatePoint.Value
		} );
		if ( path.Status != NavMeshPathStatus.Complete || path.Points is null || path.Points.Count == 0 )
			return false;

		for ( var index = 1; index < path.Points.Count; index++ )
			pathDistance += (path.Points[index].Position - path.Points[index - 1].Position).Length;

		position = SnapToGround( candidatePoint.Value );
		return MathF.Abs( position.z - playerPoint.Value.z ) <= maximumVerticalSeparation;
	}

	private Vector3 SnapToGround( Vector3 position )
	{
		var navPoint = Scene.NavMesh?.GetClosestPoint( position, 512.0f );
		var basePoint = navPoint ?? position;
		var start = basePoint + Vector3.Up * 192.0f;
		var end = basePoint + Vector3.Down * 512.0f;
		var trace = Scene.Trace.Ray( start, end )
			.WithoutTags( "player", "director_npc" )
			.Run();
		return trace.Hit ? trace.EndPosition : basePoint;
	}

	private bool IsVisibleToPlayer( Vector3 position )
	{
		var camera = Scene.Camera;
		if ( camera is null )
			return false;

		var target = position + Vector3.Up * 40.0f;
		var delta = target - camera.WorldPosition;
		if ( Vector3.Dot( camera.WorldRotation.Forward, delta.Normal ) < 0.2f )
			return false;

		var trace = Scene.Trace.Ray( camera.WorldPosition, target )
			.WithoutTags( "player", "director_npc" )
			.Run();
		return !trace.Hit || trace.Distance >= delta.Length - 48.0f;
	}

	private bool IsOccupied( Vector3 position )
	{
		return Scene.GetAllComponents<DemoNpc>()
			.Any( npc => npc.IsAlive && (npc.WorldPosition - position).Length < 110.0f );
	}

	private float NearestNpcDistance( Vector3 position )
	{
		return Scene.GetAllComponents<DemoNpc>()
			.Where( npc => npc.IsAlive )
			.Select( npc => (npc.WorldPosition - position).Length )
			.DefaultIfEmpty( 2500.0f )
			.Min();
	}

	private GameObject FindPlayer()
	{
		return Scene.GetAllObjects( false ).FirstOrDefault( item => item.Name == "Player" && item.Active );
	}

	private static DirectorVector ToDirectorVector( Vector3 value ) => new( value.x, value.y, value.z );

	private sealed class NpcRetentionState
	{
		public Vector3 LastPosition { get; set; }
		public double LastRelevantTime { get; set; }

		public NpcRetentionState( Vector3 lastPosition, double lastRelevantTime )
		{
			LastPosition = lastPosition;
			LastRelevantTime = lastRelevantTime;
		}
	}

	private static IReadOnlyDictionary<EncounterKind, PlacementProfile> CreatePlacementProfiles()
	{
		return new Dictionary<EncounterKind, PlacementProfile>
		{
			[EncounterKind.Ambient] = DemoPlacement( 280.0f, 750.0f, 1800.0f ),
			[EncounterKind.CommonWave] = DemoPlacement( 420.0f, 1000.0f, 2000.0f ),
			[EncounterKind.Special] = DemoPlacement( 520.0f, 1250.0f, 2100.0f ),
			[EncounterKind.Boss] = DemoPlacement( 700.0f, 1500.0f, 2200.0f ),
			[EncounterKind.DormantHazard] = DemoPlacement( 600.0f, 1300.0f, 2200.0f ),
			[EncounterKind.Objective] = DemoPlacement( 200.0f, 700.0f, 2000.0f ),
			[EncounterKind.Custom] = DemoPlacement( 400.0f, 1000.0f, 2100.0f )
		};
	}

	private static PlacementProfile DemoPlacement( float minimum, float ideal, float maximum )
	{
		return new PlacementProfile
		{
			MinimumDistance = minimum,
			IdealDistance = ideal,
			MaximumDistance = maximum,
			MinimumThreatSeparation = 90.0f,
			DistanceWeight = 1.0f,
			CapacityWeight = 0.3f,
			SeparationWeight = 0.35f,
			PathDistanceWeight = 0.15f,
			IngressWeight = 0.1f,
			FallbackMode = PlacementFallbackMode.AnyReachableHidden
		};
	}
}