@
CI / build-test (push) Failing after 1m8s

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:
Leonid Pershin
2026-06-12 07:19:10 +03:00
parent 4ae730cafa
commit fd6343bd09
96 changed files with 2066 additions and 589 deletions
@@ -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);
}
}
+168
View File
@@ -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
}
}