Files
mrgameeng/src/MrGameEng.Simulation/AI/UtilityAi.cs
T

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;
}
}