169 lines
4.8 KiB
C#
169 lines
4.8 KiB
C#
using MrGameEng.AI;
|
|
using Xunit;
|
|
|
|
namespace MrGameEng.AI.Tests;
|
|
|
|
public class UtilityAiTests
|
|
{
|
|
// A minimal agent context: everything the considerations read.
|
|
private record struct Ctx(float Energy, float Hunger);
|
|
|
|
private static UtilityAi<Ctx> BuildBrain()
|
|
{
|
|
// Rest gets attractive as energy drops; wander as energy is high.
|
|
var rest = new UtilityAction<Ctx>(
|
|
"rest",
|
|
new Consideration<Ctx>(
|
|
"tired",
|
|
c => c.Energy,
|
|
0f,
|
|
1f,
|
|
ResponseCurve.Linear(slope: -1f, yShift: 1f)
|
|
)
|
|
);
|
|
var wander = new UtilityAction<Ctx>(
|
|
"wander",
|
|
new Consideration<Ctx>("rested", c => c.Energy)
|
|
);
|
|
return new UtilityAi<Ctx>(rest, wander);
|
|
}
|
|
|
|
[Fact]
|
|
public void Select_PicksTheHighestScoringAction()
|
|
{
|
|
var brain = BuildBrain();
|
|
|
|
Assert.Equal("rest", brain.Select(new Ctx(Energy: 0.1f, Hunger: 0f))!.Name);
|
|
Assert.Equal("wander", brain.Select(new Ctx(Energy: 0.9f, Hunger: 0f))!.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void Select_IsDeterministicAcrossRepeatedCalls()
|
|
{
|
|
var brain = BuildBrain();
|
|
var ctx = new Ctx(Energy: 0.3f, Hunger: 0.5f);
|
|
|
|
var first = brain.Select(ctx)!.Name;
|
|
for (var i = 0; i < 100; i++)
|
|
{
|
|
Assert.Equal(first, brain.Select(ctx)!.Name);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Select_TieResolvesToEarliestAction()
|
|
{
|
|
// Two actions that always score equally; the first declared must win.
|
|
var a = new UtilityAction<Ctx>("a", new Consideration<Ctx>("k", _ => 0.5f));
|
|
var b = new UtilityAction<Ctx>("b", new Consideration<Ctx>("k", _ => 0.5f));
|
|
var brain = new UtilityAi<Ctx>(a, b);
|
|
|
|
Assert.Equal("a", brain.Select(default)!.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void Select_ReturnsNull_WhenNothingBeatsThreshold()
|
|
{
|
|
var brain = BuildBrain();
|
|
|
|
Assert.Null(brain.Select(new Ctx(Energy: 0.5f, Hunger: 0f), threshold: 0.99f));
|
|
}
|
|
|
|
[Fact]
|
|
public void Select_PopulatesLastScoresAlignedWithActions()
|
|
{
|
|
var brain = BuildBrain();
|
|
|
|
brain.Select(new Ctx(Energy: 0.2f, Hunger: 0f));
|
|
|
|
Assert.Equal(2, brain.LastScores.Length);
|
|
Assert.True(brain.LastScores[0] > brain.LastScores[1]); // rest scores above wander
|
|
}
|
|
|
|
[Fact]
|
|
public void VetoConsideration_ZeroesTheAction()
|
|
{
|
|
var action = new UtilityAction<Ctx>(
|
|
"eat",
|
|
new Consideration<Ctx>("has-food", _ => 0f), // veto: no food
|
|
new Consideration<Ctx>("hungry", _ => 1f)
|
|
);
|
|
|
|
Assert.Equal(0f, action.Score(default));
|
|
}
|
|
|
|
[Fact]
|
|
public void Weight_ScalesTheActionScore()
|
|
{
|
|
var low = new UtilityAction<Ctx>("a", 0.5f, new Consideration<Ctx>("k", _ => 0.4f));
|
|
var high = new UtilityAction<Ctx>("a", 2f, new Consideration<Ctx>("k", _ => 0.4f));
|
|
|
|
Assert.True(high.Score(default) > low.Score(default));
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectWeighted_IsReproducibleForTheSameSeed()
|
|
{
|
|
var brain = BuildBrain();
|
|
var ctx = new Ctx(Energy: 0.5f, Hunger: 0f);
|
|
|
|
var first = Run(new Random(1234));
|
|
var second = Run(new Random(1234));
|
|
Assert.Equal(first, second);
|
|
|
|
List<string> Run(Random random)
|
|
{
|
|
var picks = new List<string>();
|
|
for (var i = 0; i < 50; i++)
|
|
{
|
|
picks.Add(brain.SelectWeighted(ctx, random)!.Name);
|
|
}
|
|
|
|
return picks;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectWeighted_FavoursTheHigherScoreOverManyRolls()
|
|
{
|
|
var brain = BuildBrain();
|
|
var ctx = new Ctx(Energy: 0.1f, Hunger: 0f); // rest should dominate
|
|
var random = new Random(7);
|
|
|
|
var rest = 0;
|
|
for (var i = 0; i < 1000; i++)
|
|
{
|
|
if (brain.SelectWeighted(ctx, random)!.Name == "rest")
|
|
{
|
|
rest++;
|
|
}
|
|
}
|
|
|
|
Assert.True(rest > 800, $"expected rest to dominate, got {rest}/1000");
|
|
}
|
|
|
|
[Fact]
|
|
public void Constructor_Throws_WhenNoActions()
|
|
{
|
|
Assert.Throws<ArgumentException>(() => new UtilityAi<Ctx>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Consideration_Throws_WhenRangeIsDegenerate()
|
|
{
|
|
Assert.Throws<ArgumentException>(() =>
|
|
new Consideration<Ctx>("bad", c => c.Energy, min: 1f, max: 1f)
|
|
);
|
|
}
|
|
|
|
[Fact]
|
|
public void Consideration_NormalizesRawValuesAgainstItsRange()
|
|
{
|
|
var c = new Consideration<Ctx>("hunger", x => x.Hunger, min: 0f, max: 200f);
|
|
|
|
Assert.Equal(0f, c.Score(new Ctx(0f, 0f)), 5);
|
|
Assert.Equal(0.5f, c.Score(new Ctx(0f, 100f)), 5);
|
|
Assert.Equal(1f, c.Score(new Ctx(0f, 9999f)), 5); // clamps above range
|
|
}
|
|
}
|