Npcs/Npc.Schedule.cs

NPC schedule management component. It stores and reuses ScheduleBase instances, gathers per-tick awareness, selects and runs the active schedule, handles preemption based on rising awareness, and starts/ends schedules.

Reflection
namespace Sandbox.Npcs;

public partial class Npc : Component
{
	/// <summary>
	/// The current running schedule for this NPC.
	/// </summary>
	public ScheduleBase ActiveSchedule { get; private set; }

	/// <summary>
	/// What the NPC is aware of this tick, refreshed before selection.
	/// Schedules declare which of these interrupt them via <see cref="ScheduleBase.InterruptedBy"/>.
	/// </summary>
	public NpcAwareness Awareness { get; private set; }

	private NpcAwareness _prevAwareness;

	readonly Dictionary<Type, ScheduleBase> _schedules = [];

	/// <summary>
	/// Get a schedule -- if it doesn't exist, one will be created. The instance is reused
	/// across selections, so set its inputs each time you return it; its runtime state is
	/// reset when it starts.
	/// </summary>
	protected T GetSchedule<T>() where T : ScheduleBase, new()
	{
		var type = typeof( T );
		if ( !_schedules.TryGetValue( type, out var schedule ) )
		{
			schedule = new T();
			_schedules[type] = schedule;
		}

		return (T)schedule;
	}

	/// <summary>
	/// Pick the schedule the NPC most wants to run right now -- return the highest-priority
	/// applicable one. Only called when (re)selecting, so a little work is fine, but keep it
	/// free of side effects and avoid randomness that shouldn't re-roll each selection.
	/// </summary>
	public virtual ScheduleBase GetSchedule()
	{
		return null;
	}

	/// <summary>
	/// Build what the NPC is aware of this tick. Override to add NPC-specific
	/// awareness -- call base first.
	/// </summary>
	protected virtual NpcAwareness GatherAwareness()
	{
		var a = NpcAwareness.None;

		if ( Senses.IsValid() )
		{
			if ( Senses.VisibleTargets.Count > 0 )
				a |= NpcAwareness.SeesHostile;

			if ( Senses.VisibleThreats.Count > 0 )
				a |= NpcAwareness.SeesThreat;

			if ( Senses.Disturbance.HasValue )
				a |= NpcAwareness.HeardDisturbance;

			if ( Senses.GetPushingPlayer().IsValid() )
				a |= NpcAwareness.PlayerPushing;
		}

		return a;
	}

	/// <summary>
	/// Runs once per tick: refresh conditions, then preempt the active schedule with
	/// something more important, run it, or select a new one when it finishes.
	/// </summary>
	void TickSchedule()
	{
		if ( !NpcConVars.Enabled )
			return;

		Awareness = GatherAwareness();

		// Edge-triggered: only awareness that newly appeared this tick can preempt.
		var rising = Awareness & ~_prevAwareness;
		_prevAwareness = Awareness;

		if ( ActiveSchedule is null )
		{
			SelectNewSchedule();
			return;
		}

		// A stimulus the active schedule reacts to just appeared -- preempt only if
		// something genuinely more important wants to run. This is what lets an idle
		// schedule yield to combat without polling for it itself.
		if ( (ActiveSchedule.InterruptedBy & rising) != 0 )
		{
			var desired = GetSchedule();
			if ( desired is not null
				&& !ReferenceEquals( desired, ActiveSchedule )
				&& desired.Priority > ActiveSchedule.Priority )
			{
				EndCurrentSchedule();
				StartSchedule( desired );
				return;
			}
		}

		// Run the active schedule. It ends itself by completing, failing, or cancelling.
		var status = ActiveSchedule.InternalUpdate();
		if ( status != TaskStatus.Running )
		{
			SelectNewSchedule();
		}
	}

	/// <summary>
	/// End the active schedule and start whatever the NPC wants next.
	/// </summary>
	private void SelectNewSchedule()
	{
		EndCurrentSchedule();

		var next = GetSchedule();
		if ( next is null )
			return;

		StartSchedule( next );
	}

	private void StartSchedule( ScheduleBase schedule )
	{
		ActiveSchedule = schedule;
		ActiveSchedule.InternalInit( this );
	}

	protected override void OnDisabled()
	{
		EndCurrentSchedule();
	}

	/// <summary>
	/// End the current schedule cleanly. Can be called by subclasses to interrupt
	/// the active schedule (e.g. when damaged).
	/// </summary>
	protected void EndCurrentSchedule()
	{
		ActiveSchedule?.InternalEnd();
		ActiveSchedule = null;
	}
}