using HSchool.Content; namespace HSchool.Ai.Tests; public class DecisionPlannerTests { [Fact] public void ZeroToilet_BeatsALesson() { var (catalog, map, walks) = World(); var duty = map.Rooms.First(room => room.Def == "Classroom").Id; var decision = DecisionPlanner.Decide( catalog, map, walks, Actor(duty, boundToLesson: true, duty, new Dictionary(StringComparer.Ordinal) { ["Toilet"] = 0f, ["Hunger"] = 1f, ["Social"] = 1f, ["Sleep"] = 1f, }), (_, _) => 0); Assert.Equal(GoalKind.Need, decision.Intent.Kind); Assert.Equal("Toilet", decision.Intent.Id); Assert.Equal("UseToilet", decision.Intent.ActionId); Assert.NotNull(decision.WalkTo); Assert.Equal("Restroom", map.Rooms.First(room => room.Id == decision.WalkTo).Def); } [Fact] public void NeedJustBelowThreshold_DoesNotLeaveClass() { var (catalog, map, walks) = World(); var duty = map.Rooms.First(room => room.Def == "Classroom").Id; var decision = DecisionPlanner.Decide( catalog, map, walks, Actor(duty, boundToLesson: true, duty, new Dictionary(StringComparer.Ordinal) { ["Toilet"] = 0.34f, ["Hunger"] = 1f, ["Social"] = 1f, ["Sleep"] = 1f, }), (_, _) => 0); Assert.Equal(GoalKind.Duty, decision.Intent.Kind); Assert.Null(decision.StartAction); Assert.True(decision.WalkTo is null || decision.WalkTo == duty); } [Fact] public void EqualNeeds_FinishTheCurrentAction() { var (catalog, map, walks) = World(); var restroom = map.Rooms.First(room => room.Def == "Restroom").Id; var intent = new Intent(GoalKind.Need, "Toilet", DecisionPlanner.NeedWeightAtZero, "UseToilet"); var decision = DecisionPlanner.Decide( catalog, map, walks, Actor( restroom, boundToLesson: true, dutyRoom: map.Rooms.First(room => room.Def == "Classroom").Id, needs: new Dictionary(StringComparer.Ordinal) { ["Toilet"] = 0f, ["Hunger"] = 0f, ["Social"] = 1f, ["Sleep"] = 1f, }, intent: intent, activityActive: true), (_, _) => 0); Assert.Equal("Toilet", decision.Intent.Id); Assert.Null(decision.WalkTo); Assert.Null(decision.StartAction); } [Fact] public void BreakAtDutyRoom_PicksLeisureEvenWithAStaleLessonIntent() { var (catalog, map, walks) = World(); var duty = map.Rooms.First(room => room.Def == "Classroom").Id; var decision = DecisionPlanner.Decide( catalog, map, walks, Actor( duty, boundToLesson: false, duty, FullNeeds(), intent: new Intent(GoalKind.Duty, duty, DecisionPlanner.DutyLessonWeight, null)), (_, _) => 0); Assert.Equal(GoalKind.Leisure, decision.Intent.Kind); } [Fact] public void LeisureOnABreak_IsNotYankedBackToTheNextHomeroom() { var (catalog, map, walks) = World(); var classroom = "classroom-101"; var corridor = "corridor-1"; var decision = DecisionPlanner.Decide( catalog, map, walks, Actor( corridor, boundToLesson: false, classroom, FullNeeds(), intent: new Intent(GoalKind.Leisure, "Chat", 3f, "Chat")), (_, _) => 0); Assert.Equal(GoalKind.Leisure, decision.Intent.Kind); Assert.NotEqual(classroom, decision.WalkTo); } [Fact] public void BreakAtDutyRoom_PicksLeisure_LessonDoesNot() { var (catalog, map, walks) = World(); var duty = map.Rooms.First(room => room.Def == "Classroom").Id; var onBreak = DecisionPlanner.Decide( catalog, map, walks, Actor(duty, boundToLesson: false, duty, FullNeeds()), (_, _) => 0); var inLesson = DecisionPlanner.Decide( catalog, map, walks, Actor(duty, boundToLesson: true, duty, FullNeeds()), (_, _) => 0); Assert.Equal(GoalKind.Leisure, onBreak.Intent.Kind); Assert.NotNull(onBreak.Intent.ActionId); Assert.True(catalog.Actions[onBreak.Intent.ActionId!].Weight > 0); Assert.Equal(GoalKind.Duty, inLesson.Intent.Kind); Assert.Null(inLesson.StartAction); } [Fact] public void BreakAwayFromNextRoom_WalksThereInsteadOfChatting() { var (catalog, map, walks) = World(); var classroom = map.Rooms.First(room => room.Id == "classroom-101").Id; var gym = "gym-hall"; var fromIdle = DecisionPlanner.Decide( catalog, map, walks, Actor(classroom, boundToLesson: false, gym, FullNeeds()), (_, _) => 0); var fromStaleLesson = DecisionPlanner.Decide( catalog, map, walks, Actor( classroom, boundToLesson: false, gym, FullNeeds(), intent: new Intent(GoalKind.Duty, classroom, DecisionPlanner.DutyLessonWeight, null)), (_, _) => 0); Assert.Equal(GoalKind.Duty, fromIdle.Intent.Kind); Assert.Equal(gym, fromIdle.WalkTo); Assert.Equal(gym, fromStaleLesson.WalkTo); Assert.Equal(gym, fromStaleLesson.Intent.Id); } [Fact] public void ArrivedAtNextRoomThisBreak_StaysInsteadOfChatting() { var (catalog, map, walks) = World(); var gym = "gym-hall"; var decision = DecisionPlanner.Decide( catalog, map, walks, Actor( gym, boundToLesson: false, gym, FullNeeds(), intent: new Intent(GoalKind.Duty, gym, DecisionPlanner.DutyTravelWeight, null)), (_, _) => 0); Assert.Equal(GoalKind.Duty, decision.Intent.Kind); Assert.Equal(gym, decision.Intent.Id); Assert.Null(decision.WalkTo); Assert.Null(decision.StartAction); } /// /// Lunch is a timetable, not an urge. Inside the sitting a pupil who is merely peckish still /// goes; outside it a hungry one does not walk out of the corridor to the canteen, and a /// hungry one in class does not walk out of the lesson either. /// [Fact] public void OutsideTheSitting_HungerFindsNoAction() { var (catalog, map, walks) = World(); var corridor = map.Rooms.First(room => room.Def == "Corridor").Id; var needs = FullNeeds(); needs["Hunger"] = 0.05f; var closed = DecisionPlanner.Decide( catalog, map, walks, Actor(corridor, boundToLesson: false, corridor, needs, lunchWindowOpen: false), (_, _) => 0); Assert.NotEqual("EatLunch", closed.Intent.ActionId); var open = DecisionPlanner.Decide( catalog, map, walks, Actor(corridor, boundToLesson: false, corridor, needs, lunchWindowOpen: true), (_, _) => 0); Assert.Equal(GoalKind.Need, open.Intent.Kind); Assert.Equal("EatLunch", open.Intent.ActionId); Assert.Equal("Cafeteria", map.Rooms.First(room => room.Id == open.WalkTo).Def); } [Fact] public void InsideTheSitting_EvenAPeckishPupilGoesToEat() { var (catalog, map, walks) = World(); var corridor = map.Rooms.First(room => room.Def == "Corridor").Id; var needs = FullNeeds(); needs["Hunger"] = 0.6f; var decision = DecisionPlanner.Decide( catalog, map, walks, Actor(corridor, boundToLesson: false, corridor, needs, lunchWindowOpen: true), (_, _) => 0); Assert.Equal("EatLunch", decision.Intent.ActionId); Assert.Equal(DecisionPlanner.LunchWeight, decision.Intent.Weight); } [Fact] public void ALessonOutweighsTheSitting() { var (catalog, map, walks) = World(); var classroom = map.Rooms.First(room => room.Def == "Classroom").Id; var needs = FullNeeds(); needs["Hunger"] = 0.6f; var decision = DecisionPlanner.Decide( catalog, map, walks, Actor(classroom, boundToLesson: true, classroom, needs, lunchWindowOpen: true), (_, _) => 0); Assert.Equal(GoalKind.Duty, decision.Intent.Kind); } /// /// Vanilla numbers after the move must keep the same winners: a toilet at zero leaves class, /// a need just under the threshold does not, a sitting beats a walk but not a lesson. /// [Fact] public void VanillaWeights_MatchTheKnownDecisionTable() { var (catalog, map, walks) = World(); var classroom = map.Rooms.First(room => room.Def == "Classroom").Id; var corridor = map.Rooms.First(room => room.Def == "Corridor").Id; var zeroToilet = Decide(catalog, map, walks, Actor(classroom, boundToLesson: true, classroom, Needs(toilet: 0f))); Assert.Equal(GoalKind.Need, zeroToilet.Intent.Kind); Assert.Equal("Toilet", zeroToilet.Intent.Id); Assert.Equal("UseToilet", zeroToilet.Intent.ActionId); Assert.Equal(DecisionPlanner.NeedWeightAtZero, zeroToilet.Intent.Weight); var justBelow = Decide(catalog, map, walks, Actor(classroom, boundToLesson: true, classroom, Needs(toilet: 0.34f))); Assert.Equal(GoalKind.Duty, justBelow.Intent.Kind); Assert.Equal(classroom, justBelow.Intent.Id); Assert.Equal(DecisionPlanner.DutyLessonWeight, justBelow.Intent.Weight); var peckishSitting = Decide( catalog, map, walks, Actor(corridor, boundToLesson: false, corridor, Needs(hunger: 0.6f), lunchWindowOpen: true)); Assert.Equal(GoalKind.Need, peckishSitting.Intent.Kind); Assert.Equal("EatLunch", peckishSitting.Intent.ActionId); Assert.Equal(DecisionPlanner.LunchWeight, peckishSitting.Intent.Weight); var peckishLesson = Decide( catalog, map, walks, Actor(classroom, boundToLesson: true, classroom, Needs(hunger: 0.6f), lunchWindowOpen: true)); Assert.Equal(GoalKind.Duty, peckishLesson.Intent.Kind); Assert.Equal(DecisionPlanner.DutyLessonWeight, peckishLesson.Intent.Weight); Assert.NotEqual("EatLunch", peckishLesson.Intent.ActionId); } [Fact] public void PackWithLunchAboveLesson_PullsTheClassToTheCafeteria() { var (catalog, map, walks) = WorldWithLunchFirst(); var classroom = map.Rooms.First(room => room.Def == "Classroom").Id; var rules = catalog.BehaviorRules; Assert.NotNull(rules); Assert.True(rules.LunchWeight > rules.DutyLessonWeight); var decision = Decide( catalog, map, walks, Actor(classroom, boundToLesson: true, classroom, Needs(hunger: 0.6f), lunchWindowOpen: true)); Assert.Equal(GoalKind.Need, decision.Intent.Kind); Assert.Equal("EatLunch", decision.Intent.ActionId); Assert.Equal("Cafeteria", map.Rooms.First(room => room.Id == decision.WalkTo).Def); } [Fact] public void CatalogWithoutBehaviorDef_UsesCodeDefaults() { var (catalog, map, walks) = WorldWithoutBehavior(); Assert.Null(catalog.BehaviorRules); var classroom = map.Rooms.First(room => room.Def == "Classroom").Id; var corridor = map.Rooms.First(room => room.Def == "Corridor").Id; var zeroToilet = Decide(catalog, map, walks, Actor(classroom, boundToLesson: true, classroom, Needs(toilet: 0f))); Assert.Equal(GoalKind.Need, zeroToilet.Intent.Kind); Assert.Equal("UseToilet", zeroToilet.Intent.ActionId); Assert.Equal(DecisionPlanner.NeedWeightAtZero, zeroToilet.Intent.Weight); var peckishSitting = Decide( catalog, map, walks, Actor(corridor, boundToLesson: false, corridor, Needs(hunger: 0.6f), lunchWindowOpen: true)); Assert.Equal("EatLunch", peckishSitting.Intent.ActionId); Assert.Equal(DecisionPlanner.LunchWeight, peckishSitting.Intent.Weight); var peckishLesson = Decide( catalog, map, walks, Actor(classroom, boundToLesson: true, classroom, Needs(hunger: 0.6f), lunchWindowOpen: true)); Assert.Equal(GoalKind.Duty, peckishLesson.Intent.Kind); Assert.Equal(DecisionPlanner.DutyLessonWeight, peckishLesson.Intent.Weight); } [Fact] public void LowSocialWeight_MakesChatLessAttractiveThanControl() { var (catalog, map, walks) = World(); var corridor = map.Rooms.First(room => room.Def == "Corridor").Id; var needs = new Dictionary(StringComparer.Ordinal) { ["Toilet"] = 1f, ["Hunger"] = 1f, ["Social"] = 0.4f, ["Sleep"] = 1f, }; var healthy = DecisionPlanner.Decide( catalog, map, walks, new ActorState( corridor, null, false, false, true, false, false, false, corridor, needs, Intent.None, SocialWeightFactor: 1f), (_, _) => 0); var sick = DecisionPlanner.Decide( catalog, map, walks, new ActorState( corridor, null, false, false, true, false, false, false, corridor, needs, Intent.None, SocialWeightFactor: 0.15f), (_, _) => 0); var chatWeight = catalog.Actions[TalkActions.Chat].Weight; Assert.True(chatWeight * 0.15f < chatWeight); if (healthy.Intent.ActionId == TalkActions.Chat) { Assert.True( sick.Intent.ActionId != TalkActions.Chat || sick.Intent.Weight < healthy.Intent.Weight); } else { // Control did not pick Chat — still assert the scaled candidate loses to other leisure. Assert.True(sick.Intent.ActionId != TalkActions.Chat || sick.Intent.Weight <= chatWeight * 0.15f + 1e-3f); } } private static Decision Decide( DefCatalog catalog, MapLayout map, WalkGraph walks, ActorState state) => DecisionPlanner.Decide(catalog, map, walks, state, (_, _) => 0); private static ActorState Actor( string node, bool boundToLesson, string dutyRoom, IReadOnlyDictionary needs, Intent? intent = null, bool activityActive = false, bool lunchWindowOpen = false) => new( node, node, IsWalking: false, activityActive, IsStudent: true, IsStaff: false, IsParent: false, boundToLesson, dutyRoom, needs, intent ?? Intent.None, lunchWindowOpen); private static Dictionary FullNeeds() => Needs(); private static Dictionary Needs(float toilet = 1f, float hunger = 1f) => new(StringComparer.Ordinal) { ["Toilet"] = toilet, ["Hunger"] = hunger, ["Social"] = 1f, ["Sleep"] = 1f, }; private static (DefCatalog Catalog, MapLayout Map, WalkGraph Walks) World() { var (catalog, map) = Fixtures.Vanilla(); return (catalog, map, WalkGraph.Build(catalog, map)); } private static (DefCatalog Catalog, MapLayout Map, WalkGraph Walks) WorldWithoutBehavior() { var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); var documents = PackDocuments.FromDirectory(CatalogLoader.CorePackId, root) .Where(document => { var path = document.RelativePath.Replace('\\', '/'); return path.IndexOf("defs/behavior/", StringComparison.OrdinalIgnoreCase) < 0; }) .ToList(); var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents); var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); Assert.NotNull(map); return (catalog, map, WalkGraph.Build(catalog, map)); } private static (DefCatalog Catalog, MapLayout Map, WalkGraph Walks) WorldWithLunchFirst() { var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); var documents = PackDocuments.FromDirectory(CatalogLoader.CorePackId, root).ToList(); documents.Add(new ContentDocument( "addon", "patches/lunch-first.jsonc", """{ "target": "Behavior", "ops": [ { "op": "replace", "path": "/lunchWeight", "value": 11 } ] }""")); var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId, "addon"], documents); var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents); Assert.NotNull(map); return (catalog, map, WalkGraph.Build(catalog, map)); } }