Add MrGameEng.AI utility-AI module; format codebase with CSharpier New MrGameEng.AI module (ResponseCurve, Consideration, UtilityAction, UtilityAi selector, Blackboard) plus CSharpier formatting applied across the whole engine. Documents the CSharpier convention in CLAUDE.md. @
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user