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 BuildBrain() { // Rest gets attractive as energy drops; wander as energy is high. var rest = new UtilityAction( "rest", new Consideration( "tired", c => c.Energy, 0f, 1f, ResponseCurve.Linear(slope: -1f, yShift: 1f) ) ); var wander = new UtilityAction( "wander", new Consideration("rested", c => c.Energy) ); return new UtilityAi(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("a", new Consideration("k", _ => 0.5f)); var b = new UtilityAction("b", new Consideration("k", _ => 0.5f)); var brain = new UtilityAi(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( "eat", new Consideration("has-food", _ => 0f), // veto: no food new Consideration("hungry", _ => 1f) ); Assert.Equal(0f, action.Score(default)); } [Fact] public void Weight_ScalesTheActionScore() { var low = new UtilityAction("a", 0.5f, new Consideration("k", _ => 0.4f)); var high = new UtilityAction("a", 2f, new Consideration("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 Run(Random random) { var picks = new List(); 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(() => new UtilityAi()); } [Fact] public void Consideration_Throws_WhenRangeIsDegenerate() { Assert.Throws(() => new Consideration("bad", c => c.Energy, min: 1f, max: 1f) ); } [Fact] public void Consideration_NormalizesRawValuesAgainstItsRange() { var c = new Consideration("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 } }