Code/TipTriggerEval.cs

Evaluation core for TipTrigger conditions. TipTriggerEnv wraps delegated world reads (input actions, keys, signals, flags, numbers, elapsed time, analog magnitude). TipTriggerEval.Evaluate walks a TipTrigger tree and returns true/false for each trigger kind including AnyOf/AllOf recursion.

using System;

namespace FieldGuide.Tips;

/// <summary>
/// The environment a <see cref="TipTrigger"/> is evaluated against: the four world reads plus the active
/// tip's elapsed visible time. <see cref="TipsCoach"/> fills these from <see cref="Sandbox.Input"/>, the
/// pushed <see cref="TipContext"/> and its own timer; a headless harness fills them with fakes. Keeping
/// the reads behind delegates is what lets the trigger truth table be unit-tested without the engine.
/// </summary>
public sealed class TipTriggerEnv
{
	/// <summary>Was the named Input.config action pressed this frame? (Input.Pressed)</summary>
	public Func<string, bool> ActionPressed { get; init; } = static _ => false;

	/// <summary>Was the named raw key / mouse button pressed this frame? (Input.Keyboard.Pressed)</summary>
	public Func<string, bool> KeyPressed { get; init; } = static _ => false;

	/// <summary>Has the named string signal been latched this session? (Signal / Ever kinds)</summary>
	public Func<string, bool> SignalLatched { get; init; } = static _ => false;

	/// <summary>Is the named custom context flag true this frame? (TipContext.Flag)</summary>
	public Func<string, bool> Flag { get; init; } = static _ => false;

	/// <summary>The named custom context number this frame. (TipContext.Number)</summary>
	public Func<string, float> Number { get; init; } = static _ => 0f;

	/// <summary>Seconds the active tip has been visible, for the Timer kind.</summary>
	public float Elapsed { get; init; }

	/// <summary>The current magnitude (0..1-ish) of the given analog stick, for the AnalogAxis kind. (Input.AnalogMove / Input.AnalogLook length.)</summary>
	public Func<TipTriggerAnalogSource, float> AnalogMagnitude { get; init; } = static _ => 0f;
}

/// <summary>
/// The pure, engine-free evaluation core for a declarative <see cref="TipTrigger"/>: the per-kind rule and
/// the AnyOf / AllOf recursion, with every world read behind a <see cref="TipTriggerEnv"/> delegate. This
/// holds the whole completion/relevance truth table, so it can be exercised headlessly (the coach passes
/// real Input / context reads; a harness passes fakes) and stays identical between the two.
/// </summary>
public static class TipTriggerEval
{
	/// <summary>Evaluate a trigger against the given environment. Null trigger evaluates false.</summary>
	public static bool Evaluate( TipTrigger t, TipTriggerEnv env )
	{
		if ( t is null || env is null )
			return false;

		switch ( t.Kind )
		{
			case TipTriggerKind.Always:
				return true;

			case TipTriggerKind.InputAction:
				foreach ( var a in t.Actions )
					if ( !string.IsNullOrEmpty( a ) && env.ActionPressed( a ) )
						return true;
				return false;

			case TipTriggerKind.Key:
				foreach ( var k in t.Keys )
					if ( !string.IsNullOrEmpty( k ) && env.KeyPressed( k ) )
						return true;
				return false;

			case TipTriggerKind.Signal:
			case TipTriggerKind.Ever:
				return !string.IsNullOrEmpty( t.Key ) && env.SignalLatched( t.Key );

			case TipTriggerKind.Flag:
				return !string.IsNullOrEmpty( t.Key ) && env.Flag( t.Key );

			case TipTriggerKind.AtLeast:
				return !string.IsNullOrEmpty( t.Key ) && env.Number( t.Key ) >= t.Threshold;

			case TipTriggerKind.Timer:
				return env.Elapsed >= t.Seconds;

			case TipTriggerKind.AnalogAxis:
				return env.AnalogMagnitude( t.AnalogSource ) >= t.Threshold;

			case TipTriggerKind.AnyOf:
				foreach ( var c in t.Children )
					if ( Evaluate( c, env ) )
						return true;
				return false;

			case TipTriggerKind.AllOf:
				if ( t.Children.Length == 0 )
					return false;
				foreach ( var c in t.Children )
					if ( !Evaluate( c, env ) )
						return false;
				return true;

			default:
				return false;
		}
	}
}