namespace MrGameEng.AI;
///
/// A utility reasoner over a fixed set of 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.
///
public sealed class UtilityAi
{
private readonly UtilityAction[] _actions;
private readonly float[] _scores;
/// Creates a reasoner choosing between (at least one required).
/// is empty.
public UtilityAi(params UtilityAction[] 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];
}
/// The actions this reasoner chooses between, in evaluation order.
public IReadOnlyList> Actions => _actions;
///
/// The scores from the most recent / call, aligned
/// with . Useful for debug overlays and console dumps.
///
public ReadOnlySpan LastScores => _scores;
///
/// Scores every action for and returns the highest, or null when no
/// action scores strictly above . Ties resolve to the earliest action,
/// so selection is fully deterministic for identical inputs.
///
public UtilityAction? 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;
}
///
/// Scores every action and picks one at random in proportion to its score (roulette selection over
/// the actions above ), giving believable variety while staying
/// deterministic for a given sequence. Returns null when nothing
/// qualifies. Pass a seeded owned by the calling system — never
/// — to keep the simulation reproducible.
///
public UtilityAction? 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;
}
}