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,64 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using MrGameEng.AI;
|
||||
using Xunit;
|
||||
|
||||
namespace MrGameEng.AI.Tests;
|
||||
|
||||
public class BlackboardTests
|
||||
{
|
||||
[Fact]
|
||||
public void SetThenTryGet_RoundTripsTypedValues()
|
||||
{
|
||||
var board = new Blackboard();
|
||||
board.Set("target", new Vector2(3f, 4f));
|
||||
|
||||
Assert.True(board.TryGet<Vector2>("target", out var value));
|
||||
Assert.Equal(new Vector2(3f, 4f), value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_ReturnsFalse_OnMissingKeyOrTypeMismatch()
|
||||
{
|
||||
var board = new Blackboard();
|
||||
board.Set("count", 5);
|
||||
|
||||
Assert.False(board.TryGet<int>("missing", out _));
|
||||
Assert.False(board.TryGet<string>("count", out _)); // wrong type
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrDefault_FallsBackWhenAbsent()
|
||||
{
|
||||
var board = new Blackboard();
|
||||
|
||||
Assert.Equal(42, board.GetOrDefault("hp", 42));
|
||||
board.Set("hp", 7);
|
||||
Assert.Equal(7, board.GetOrDefault("hp", 42));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_OverwritesExistingValue()
|
||||
{
|
||||
var board = new Blackboard();
|
||||
board.Set("k", 1);
|
||||
board.Set("k", 2);
|
||||
|
||||
Assert.Equal(2, board.GetOrDefault("k", 0));
|
||||
Assert.Equal(1, board.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveAndClear_DropKeys()
|
||||
{
|
||||
var board = new Blackboard();
|
||||
board.Set("a", 1);
|
||||
board.Set("b", 2);
|
||||
|
||||
Assert.True(board.Remove("a"));
|
||||
Assert.False(board.Remove("a"));
|
||||
Assert.True(board.Has("b"));
|
||||
|
||||
board.Clear();
|
||||
Assert.Equal(0, board.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\MrGameEng.AI\MrGameEng.AI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,75 @@
|
||||
using MrGameEng.AI;
|
||||
using Xunit;
|
||||
|
||||
namespace MrGameEng.AI.Tests;
|
||||
|
||||
public class ResponseCurveTests
|
||||
{
|
||||
[Fact]
|
||||
public void Identity_ReturnsInputUnchanged()
|
||||
{
|
||||
var curve = ResponseCurve.Identity;
|
||||
|
||||
Assert.Equal(0f, curve.Evaluate(0f));
|
||||
Assert.Equal(0.5f, curve.Evaluate(0.5f), 5);
|
||||
Assert.Equal(1f, curve.Evaluate(1f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_HasZeroSlope_SoIdentityMustBeUsedExplicitly()
|
||||
{
|
||||
// Guards the gotcha that default(ResponseCurve) is NOT the identity curve.
|
||||
var zero = default(ResponseCurve);
|
||||
|
||||
Assert.Equal(0f, zero.Evaluate(0.5f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ClampsInputAndOutputToUnitRange()
|
||||
{
|
||||
var curve = ResponseCurve.Linear();
|
||||
|
||||
Assert.Equal(0f, curve.Evaluate(-2f));
|
||||
Assert.Equal(1f, curve.Evaluate(5f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Linear_NegativeSlope_InvertsTheInput()
|
||||
{
|
||||
var curve = ResponseCurve.Linear(slope: -1f, yShift: 1f);
|
||||
|
||||
Assert.Equal(1f, curve.Evaluate(0f), 5);
|
||||
Assert.Equal(0.75f, curve.Evaluate(0.25f), 5);
|
||||
Assert.Equal(0f, curve.Evaluate(1f), 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Polynomial_Quadratic_EasesInFromZero()
|
||||
{
|
||||
var curve = ResponseCurve.Polynomial(exponent: 2f);
|
||||
|
||||
Assert.Equal(0.25f, curve.Evaluate(0.5f), 5);
|
||||
Assert.True(curve.Evaluate(0.25f) < 0.25f); // below the linear line: slow start
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Logistic_IsMonotonicAndCentredOnMidpoint()
|
||||
{
|
||||
var curve = ResponseCurve.Logistic(steepness: 12f, midpoint: 0.5f);
|
||||
|
||||
Assert.Equal(0.5f, curve.Evaluate(0.5f), 2);
|
||||
Assert.True(curve.Evaluate(0.2f) < 0.5f);
|
||||
Assert.True(curve.Evaluate(0.8f) > 0.5f);
|
||||
Assert.True(curve.Evaluate(0.6f) > curve.Evaluate(0.4f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmoothStep_IsFlatAtTheEnds()
|
||||
{
|
||||
var curve = ResponseCurve.SmoothStep();
|
||||
|
||||
Assert.Equal(0f, curve.Evaluate(0f), 5);
|
||||
Assert.Equal(1f, curve.Evaluate(1f), 5);
|
||||
Assert.Equal(0.5f, curve.Evaluate(0.5f), 5);
|
||||
}
|
||||
}
|
||||
@@ -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