116 lines
4.0 KiB
C#
116 lines
4.0 KiB
C#
namespace MrGameEng.AI;
|
|
|
|
/// <summary>
|
|
/// A utility reasoner over a fixed set of <see cref="UtilityAction{TContext}"/>s. Each evaluation
|
|
/// scores every action for the given context and picks one. Scoring writes into a buffer owned by the
|
|
/// reasoner, so repeated evaluations allocate nothing; keep one instance per agent kind (or one per
|
|
/// system, reused across agents) and pass each agent's context in. Not thread-safe: the score buffer
|
|
/// is shared between calls, so a single instance must not be evaluated from two threads at once.
|
|
/// </summary>
|
|
public sealed class UtilityAi<TContext>
|
|
{
|
|
private readonly UtilityAction<TContext>[] _actions;
|
|
private readonly float[] _scores;
|
|
|
|
/// <summary>Creates a reasoner choosing between <paramref name="actions"/> (at least one required).</summary>
|
|
/// <exception cref="ArgumentException"><paramref name="actions"/> is empty.</exception>
|
|
public UtilityAi(params UtilityAction<TContext>[] actions)
|
|
{
|
|
if (actions is null || actions.Length == 0)
|
|
{
|
|
throw new ArgumentException("A UtilityAi needs at least one action.", nameof(actions));
|
|
}
|
|
|
|
_actions = actions;
|
|
_scores = new float[actions.Length];
|
|
}
|
|
|
|
/// <summary>The actions this reasoner chooses between, in evaluation order.</summary>
|
|
public IReadOnlyList<UtilityAction<TContext>> Actions => _actions;
|
|
|
|
/// <summary>
|
|
/// The scores from the most recent <see cref="Select"/> / <see cref="SelectWeighted"/> call, aligned
|
|
/// with <see cref="Actions"/>. Useful for debug overlays and console dumps.
|
|
/// </summary>
|
|
public ReadOnlySpan<float> LastScores => _scores;
|
|
|
|
/// <summary>
|
|
/// Scores every action for <paramref name="context"/> and returns the highest, or <c>null</c> when no
|
|
/// action scores strictly above <paramref name="threshold"/>. Ties resolve to the earliest action,
|
|
/// so selection is fully deterministic for identical inputs.
|
|
/// </summary>
|
|
public UtilityAction<TContext>? Select(TContext context, float threshold = 0f)
|
|
{
|
|
var best = -1;
|
|
var bestScore = threshold;
|
|
for (var i = 0; i < _actions.Length; i++)
|
|
{
|
|
var score = _actions[i].Score(context);
|
|
_scores[i] = score;
|
|
if (score > bestScore)
|
|
{
|
|
bestScore = score;
|
|
best = i;
|
|
}
|
|
}
|
|
|
|
return best >= 0 ? _actions[best] : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scores every action and picks one at random in proportion to its score (roulette selection over
|
|
/// the actions above <paramref name="threshold"/>), giving believable variety while staying
|
|
/// deterministic for a given <paramref name="random"/> sequence. Returns <c>null</c> when nothing
|
|
/// qualifies. Pass a seeded <see cref="Random"/> owned by the calling system — never
|
|
/// <see cref="Random.Shared"/> — to keep the simulation reproducible.
|
|
/// </summary>
|
|
public UtilityAction<TContext>? SelectWeighted(
|
|
TContext context,
|
|
Random random,
|
|
float threshold = 0f
|
|
)
|
|
{
|
|
var total = 0f;
|
|
for (var i = 0; i < _actions.Length; i++)
|
|
{
|
|
var score = _actions[i].Score(context);
|
|
_scores[i] = score;
|
|
if (score > threshold)
|
|
{
|
|
total += score;
|
|
}
|
|
}
|
|
|
|
if (total <= 0f)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var roll = (float)random.NextDouble() * total;
|
|
for (var i = 0; i < _actions.Length; i++)
|
|
{
|
|
if (_scores[i] <= threshold)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
roll -= _scores[i];
|
|
if (roll <= 0f)
|
|
{
|
|
return _actions[i];
|
|
}
|
|
}
|
|
|
|
// Floating-point slack can leave roll just above 0; fall back to the last qualifying action.
|
|
for (var i = _actions.Length - 1; i >= 0; i--)
|
|
{
|
|
if (_scores[i] > threshold)
|
|
{
|
|
return _actions[i];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|