From e1a0f06017d709e065e84876b6e330154d8542ce Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 00:27:52 +0300 Subject: [PATCH] Move goal weights into BehaviorDef so a pack can rebalance decisions without a fork. Co-authored-by: Cursor --- docs/design/ai.md | 4 +- docs/phases/24-behavior-numbers.md | 18 +-- src/HSchool.Ai/Decision.cs | 75 +++++++---- src/HSchool.Content/CatalogLoader.cs | 6 +- src/HSchool.Content/PeopleDefValidator.cs | 23 +++- src/HSchool.Content/PeopleDefs.cs | 23 +++- .../mods/core/defs/behavior/rules.jsonc | 14 +- .../HSchool.Ai.Tests/DecisionPlannerTests.cs | 126 +++++++++++++++++- .../HSchool.Content.Tests/BehaviorDefTests.cs | 50 +++++++ .../HSchool.Content.Tests/VanillaCoreTests.cs | 4 + 10 files changed, 293 insertions(+), 50 deletions(-) create mode 100644 tests/HSchool.Content.Tests/BehaviorDefTests.cs diff --git a/docs/design/ai.md b/docs/design/ai.md index 42f0a50..1227c72 100644 --- a/docs/design/ai.md +++ b/docs/design/ai.md @@ -137,8 +137,8 @@ делает **сам**: нужды и досуг. Числа поведения — отдельный деф правил, как `StaffingDef` у штата: пороги нужд, скорость -обучения на уроке, разброс запаса на дорогу, порог переключения решения. Это игровой баланс, а -не настройка движка, и место ему в контенте. +обучения на уроке, разброс запаса на дорогу, порог переключения решения и веса целей. +Это игровой баланс, а не настройка движка, и место ему в контенте. ## Как выбирается действие diff --git a/docs/phases/24-behavior-numbers.md b/docs/phases/24-behavior-numbers.md index 072d067..5622b44 100644 --- a/docs/phases/24-behavior-numbers.md +++ b/docs/phases/24-behavior-numbers.md @@ -12,19 +12,19 @@ ## Задачи -- [ ] Веса целей переезжают в `BehaviorDef`: обязанность-урок, обязанность-переход, нужда на нуле, +- [x] Веса целей переезжают в `BehaviorDef`: обязанность-урок, обязанность-переход, нужда на нуле, обед -- [ ] Значения по умолчанию — сегодняшние; поведение не должно измениться ни на минуту -- [ ] Каталог без `BehaviorDef` работает на константах кода, а не падает -- [ ] Валидатор ловит отрицательные веса и порядок, который делает обед сильнее урока -- [ ] Комментарий у каждого числа объясняет, что оно перевешивает — иначе мод-автор крутит вслепую +- [x] Значения по умолчанию — сегодняшние; поведение не должно измениться ни на минуту +- [x] Каталог без `BehaviorDef` работает на константах кода, а не падает +- [x] Валидатор ловит отрицательные веса и порядок, который делает обед сильнее урока +- [x] Комментарий у каждого числа объясняет, что оно перевешивает — иначе мод-автор крутит вслепую ## Тесты, без которых фаза не закрыта -- [ ] Ванильные числа дают ровно те же решения, что и до переезда (таблица входов и выходов) -- [ ] Пак, поднявший вес обеда выше урока, уводит класс с урока в столовую -- [ ] Каталог без `BehaviorDef` принимает решения на значениях по умолчанию -- [ ] Отрицательный вес роняет загрузку каталога +- [x] Ванильные числа дают ровно те же решения, что и до переезда (таблица входов и выходов) +- [x] Пак, поднявший вес обеда выше урока, уводит класс с урока в столовую +- [x] Каталог без `BehaviorDef` принимает решения на значениях по умолчанию +- [x] Отрицательный вес роняет загрузку каталога ## Критерий готовности diff --git a/src/HSchool.Ai/Decision.cs b/src/HSchool.Ai/Decision.cs index 9901309..e91c4b7 100644 --- a/src/HSchool.Ai/Decision.cs +++ b/src/HSchool.Ai/Decision.cs @@ -48,6 +48,9 @@ public readonly record struct ActorState( /// public static class DecisionPlanner { + // Fallbacks when the catalog has no BehaviorDef. Vanilla writes the same numbers into the + // def; a pack that omits the ruleset must keep today's decisions, not empty the classrooms. + /// Lesson or posted work. Beats leisure and a need that only just crossed the threshold. public const float DutyLessonWeight = 10f; @@ -77,12 +80,10 @@ public static class DecisionPlanner ArgumentNullException.ThrowIfNull(walks); ArgumentNullException.ThrowIfNull(occupied); - var rules = catalog.BehaviorRules; - var threshold = rules?.NeedThreshold ?? 0.35f; - var margin = rules?.SwitchMargin ?? 0.15f; - var best = PickGoal(catalog, map, walks, state, occupied, threshold); - var held = HeldGoal(catalog, map, walks, state, occupied, threshold); - if (held.IsSet && best.Weight <= held.Weight + margin && SameGoal(state.Intent, held)) + var rules = Rules.From(catalog); + var best = PickGoal(catalog, map, walks, state, occupied, rules); + var held = HeldGoal(catalog, map, walks, state, occupied, rules); + if (held.IsSet && best.Weight <= held.Weight + rules.Margin && SameGoal(state.Intent, held)) { return Continue(state with { Intent = held }); } @@ -96,16 +97,16 @@ public static class DecisionPlanner WalkGraph walks, ActorState state, OccupiedCount occupied, - float threshold) + Rules rules) { var best = Intent.None; - Consider(ref best, DutyGoal(state)); + Consider(ref best, DutyGoal(state, rules)); foreach (var need in catalog.Needs.Values.OrderBy(def => def.DefName, StringComparer.Ordinal)) { - Consider(ref best, NeedGoal(catalog, map, walks, state, occupied, need, threshold)); + Consider(ref best, NeedGoal(catalog, map, walks, state, occupied, need, rules)); } - if (best.Kind != GoalKind.Duty || best.Weight < DutyLessonWeight) + if (best.Kind != GoalKind.Duty || best.Weight < rules.DutyLesson) { foreach (var action in catalog.Actions.Values.OrderBy(def => def.DefName, StringComparer.Ordinal)) { @@ -118,7 +119,7 @@ public static class DecisionPlanner /// /// The stored intent's weight under current inputs. A lesson that just ended is not still - /// worth 10 — otherwise leisure can never beat a stale duty. + /// worth the lesson weight — otherwise leisure can never beat a stale duty. /// private static Intent HeldGoal( DefCatalog catalog, @@ -126,7 +127,7 @@ public static class DecisionPlanner WalkGraph walks, ActorState state, OccupiedCount occupied, - float threshold) + Rules rules) { if (!state.Intent.IsSet) { @@ -136,14 +137,14 @@ public static class DecisionPlanner switch (state.Intent.Kind) { case GoalKind.Duty: - return DutyGoal(state); + return DutyGoal(state, rules); case GoalKind.Need: if (state.Intent.Id is null || !catalog.Needs.TryGetValue(state.Intent.Id, out var need)) { return Intent.None; } - return NeedGoal(catalog, map, walks, state, occupied, need, threshold); + return NeedGoal(catalog, map, walks, state, occupied, need, rules); case GoalKind.Leisure: if (state.Intent.ActionId is null || !catalog.Actions.TryGetValue(state.Intent.ActionId, out var action)) { @@ -156,7 +157,7 @@ public static class DecisionPlanner } } - private static Intent DutyGoal(ActorState state) + private static Intent DutyGoal(ActorState state, Rules rules) { if (state.DutyRoom is null) { @@ -165,7 +166,7 @@ public static class DecisionPlanner if (state.BoundToLesson) { - return new Intent(GoalKind.Duty, state.DutyRoom, DutyLessonWeight, null); + return new Intent(GoalKind.Duty, state.DutyRoom, rules.DutyLesson, null); } if (state.Intent.Kind == GoalKind.Leisure) @@ -178,19 +179,19 @@ public static class DecisionPlanner && !state.IsWalking) { // Arrived this break (travel-weight duty). Stay put so a 3-weight chat does not - // pull them out of the gym they just walked to. A leftover lesson intent (weight - // 10) in the same room is the other case: the next lesson is here, leisure can win. + // pull them out of the gym they just walked to. A leftover lesson intent in the + // same room is the other case: the next lesson is here, leisure can win. if (state.Intent.Kind == GoalKind.Duty && string.Equals(state.Intent.Id, state.DutyRoom, StringComparison.Ordinal) - && state.Intent.Weight <= DutyTravelWeight) + && state.Intent.Weight <= rules.DutyTravel) { - return new Intent(GoalKind.Duty, state.DutyRoom, DutyTravelWeight, null); + return new Intent(GoalKind.Duty, state.DutyRoom, rules.DutyTravel, null); } return Intent.None; } - return new Intent(GoalKind.Duty, state.DutyRoom, DutyTravelWeight, null); + return new Intent(GoalKind.Duty, state.DutyRoom, rules.DutyTravel, null); } private static Intent NeedGoal( @@ -200,14 +201,14 @@ public static class DecisionPlanner ActorState state, OccupiedCount occupied, NeedDef need, - float threshold) + Rules rules) { if (need.Abstract || !state.Needs.TryGetValue(need.DefName, out var value)) { return Intent.None; } - var urgent = value < threshold; + var urgent = value < rules.Threshold; // ActionForNeed already refuses a sitting outside its window, so an action that comes back // Lunch means this person's own break is open right now. @@ -222,11 +223,11 @@ public static class DecisionPlanner return Intent.None; } - var span = Math.Max(threshold, 0.0001f); - var weight = urgent ? (threshold - value) / span * NeedWeightAtZero : 0f; + var span = Math.Max(rules.Threshold, 0.0001f); + var weight = urgent ? (rules.Threshold - value) / span * rules.NeedAtZero : 0f; if (action.Lunch) { - weight = Math.Max(weight, LunchWeight); + weight = Math.Max(weight, rules.Lunch); } return new Intent(GoalKind.Need, need.DefName, weight, action.DefName); @@ -488,6 +489,28 @@ public static class DecisionPlanner GoalKind.Leisure => 2, _ => 3, }; + + /// Effective numbers: the catalog's BehaviorDef, or the constants when a pack has none. + private readonly record struct Rules( + float DutyLesson, + float DutyTravel, + float NeedAtZero, + float Lunch, + float Threshold, + float Margin) + { + public static Rules From(DefCatalog catalog) + { + var def = catalog.BehaviorRules; + return new( + def?.DutyLessonWeight ?? DutyLessonWeight, + def?.DutyTravelWeight ?? DutyTravelWeight, + def?.NeedWeightAtZero ?? NeedWeightAtZero, + def?.LunchWeight ?? LunchWeight, + def?.NeedThreshold ?? 0.35f, + def?.SwitchMargin ?? 0.15f); + } + } } public delegate int OccupiedCount(string nodeId, string thing); diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs index 77366f4..2d55b58 100644 --- a/src/HSchool.Content/CatalogLoader.cs +++ b/src/HSchool.Content/CatalogLoader.cs @@ -34,7 +34,7 @@ public sealed class CatalogLoader var resolved = ResolveInheritance(defs); ApplyPatches(resolved, patches); var catalog = Materialize(order, resolved, localesRu, localesEn); - ResolveReferences(catalog); + ResolveReferences(catalog, log); return catalog; } @@ -398,7 +398,7 @@ public sealed class CatalogLoader en); } - private static void ResolveReferences(DefCatalog catalog) + private static void ResolveReferences(DefCatalog catalog, IContentLog log) { foreach (var thing in catalog.Things.Values) { @@ -488,7 +488,7 @@ public sealed class CatalogLoader } } - PeopleDefValidator.Validate(catalog); + PeopleDefValidator.Validate(catalog, log); } private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source); diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index e5f2844..2e0fc15 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -2,8 +2,9 @@ namespace HSchool.Content; internal static class PeopleDefValidator { - public static void Validate(DefCatalog catalog) + public static void Validate(DefCatalog catalog, IContentLog? log = null) { + log ??= NullContentLog.Instance; foreach (var body in catalog.BodyAttributes.Values) { ValidateBody(body); @@ -56,7 +57,7 @@ internal static class PeopleDefValidator foreach (var behavior in catalog.Behavior.Values) { - ValidateBehavior(behavior); + ValidateBehavior(behavior, log); } if (catalog.Staffing.Values.Count(def => !def.Abstract) > 1) @@ -476,7 +477,7 @@ internal static class PeopleDefValidator } } - private static void ValidateBehavior(BehaviorDef behavior) + private static void ValidateBehavior(BehaviorDef behavior, IContentLog log) { if (behavior.Abstract) { @@ -503,6 +504,22 @@ internal static class PeopleDefValidator throw new ContentLoadException( $"BehaviorDef '{behavior.DefName}' commute slack must be a non-negative range with min ≤ max."); } + + if (behavior.DutyLessonWeight < 0f + || behavior.DutyTravelWeight < 0f + || behavior.NeedWeightAtZero < 0f + || behavior.LunchWeight < 0f) + { + throw new ContentLoadException($"BehaviorDef '{behavior.DefName}' goal weights cannot be negative."); + } + + // A pack may invert this on purpose — lunch then pulls the class out of the lesson. + // The warning is the catch; refusing to load would make the number unmoddable. + if (behavior.LunchWeight > behavior.DutyLessonWeight) + { + log.Warning( + $"BehaviorDef '{behavior.DefName}' lunchWeight {behavior.LunchWeight} is above dutyLessonWeight {behavior.DutyLessonWeight}; pupils will leave class for lunch."); + } } private static void ValidateHoliday(HolidayDef holiday) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 4847705..390d787 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -215,12 +215,13 @@ public sealed class StaffingDef : Def /// /// One school's behaviour numbers: when a need is urgent, how fast lessons teach, commute slack, -/// and how much a new goal must beat the current one before a person switches. A catalog may have -/// only one concrete ruleset. +/// how much a new goal must beat the current one before a person switches, and the four goal +/// weights the decision planner compares. A catalog may have only one concrete ruleset. Missing +/// weights keep today's numbers so a pack without them does not empty the classrooms. /// public sealed class BehaviorDef : Def { - /// A need at or below this value is urgent. Phase 21 turns that into a goal weight. + /// A need at or below this value is urgent. The planner turns that into a goal weight. public float NeedThreshold { get; init; } /// Skill points a lesson adds per game hour, before traits and need state. @@ -233,6 +234,22 @@ public sealed class BehaviorDef : Def /// A new goal must beat the current one by this much before the person switches. public float SwitchMargin { get; init; } + + /// Lesson or posted work. Beats leisure and a need that only just crossed the threshold. + public float DutyLessonWeight { get; init; } = 10f; + + /// Walk to the next room on a break. Beats chatting in the corridor you are standing in. + public float DutyTravelWeight { get; init; } = 5f; + + /// Need at zero. Beats a lesson so a desperate toilet trip leaves class. + public float NeedWeightAtZero { get; init; } = 20f; + + /// + /// A sitting during this parallel's own lunch break. Above so + /// lunch beats walking on to the next room, below so it never + /// pulls anybody out of a lesson. + /// + public float LunchWeight { get; init; } = 6f; } public enum BodyAttributeKind diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc index 6dc1066..3328f29 100644 --- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc @@ -1,6 +1,6 @@ { "defName": "Behavior", - // A need at or below this is urgent. Phase 21 turns that into a goal weight. + // A need at or below this is urgent. The planner turns that into a goal weight. "needThreshold": 0.35, // Skill points a lesson adds per game hour, before traits and need state. "lessonSkillPerHour": 0.05, @@ -9,4 +9,16 @@ "commuteSlackMin": 0, "commuteSlackMax": 6, "switchMargin": 0.15, + // Lesson or posted work. Beats leisure (3) and a need that only just crossed + // the threshold; a toilet at zero (20) still leaves class. + "dutyLessonWeight": 10, + // Walk to the next room on a break. Beats chatting in the corridor you are + // standing in; below lunch so a sitting still pulls people to the canteen. + "dutyTravelWeight": 5, + // Need at zero. Beats a lesson so a desperate toilet trip leaves class. + "needWeightAtZero": 20, + // A sitting during this parallel's own lunch break. Above travel so lunch + // beats walking on to the next room, below the lesson so it never pulls + // anybody out of class. Raise it past dutyLessonWeight and they will. + "lunchWeight": 6, } diff --git a/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs b/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs index 0a6f4d2..1a9c084 100644 --- a/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs +++ b/tests/HSchool.Ai.Tests/DecisionPlannerTests.cs @@ -265,6 +265,94 @@ public class DecisionPlannerTests 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); + } + + 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, @@ -287,10 +375,12 @@ public class DecisionPlannerTests intent ?? Intent.None, lunchWindowOpen); - private static Dictionary FullNeeds() => new(StringComparer.Ordinal) + private static Dictionary FullNeeds() => Needs(); + + private static Dictionary Needs(float toilet = 1f, float hunger = 1f) => new(StringComparer.Ordinal) { - ["Toilet"] = 1f, - ["Hunger"] = 1f, + ["Toilet"] = toilet, + ["Hunger"] = hunger, ["Social"] = 1f, ["Sleep"] = 1f, }; @@ -300,4 +390,34 @@ public class DecisionPlannerTests 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)); + } } diff --git a/tests/HSchool.Content.Tests/BehaviorDefTests.cs b/tests/HSchool.Content.Tests/BehaviorDefTests.cs new file mode 100644 index 0000000..28ad0c0 --- /dev/null +++ b/tests/HSchool.Content.Tests/BehaviorDefTests.cs @@ -0,0 +1,50 @@ +namespace HSchool.Content.Tests; + +public class BehaviorDefTests +{ + private readonly CatalogLoader _loader = new(); + + [Theory] + [InlineData("dutyLessonWeight")] + [InlineData("dutyTravelWeight")] + [InlineData("needWeightAtZero")] + [InlineData("lunchWeight")] + public void NegativeGoalWeight_FailsTheCatalog(string field) + { + var error = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "behavior", + "rules", + $$"""{ "defName": "Behavior", "{{field}}": -1 }"""), + ])); + + Assert.Contains("negative", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Behavior", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void LunchHeavierThanLesson_WarnsAndStillLoads() + { + var log = new RecordingLog(); + var catalog = _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "behavior", + "rules", + """{ "defName": "Behavior", "dutyLessonWeight": 10, "lunchWeight": 11 }"""), + ], + log); + + Assert.NotNull(catalog.BehaviorRules); + Assert.Equal(11f, catalog.BehaviorRules.LunchWeight); + Assert.Contains( + log.Warnings, + warning => warning.Contains("lunchWeight", StringComparison.Ordinal) + && warning.Contains("dutyLessonWeight", StringComparison.Ordinal)); + } +} diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index d798a65..4909b6b 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -81,6 +81,10 @@ public class VanillaCoreTests Assert.Equal(0, catalog.BehaviorRules.CommuteSlackMin); Assert.Equal(6, catalog.BehaviorRules.CommuteSlackMax); Assert.Equal(0.35f, catalog.BehaviorRules.NeedThreshold); + Assert.Equal(10f, catalog.BehaviorRules.DutyLessonWeight); + Assert.Equal(5f, catalog.BehaviorRules.DutyTravelWeight); + Assert.Equal(20f, catalog.BehaviorRules.NeedWeightAtZero); + Assert.Equal(6f, catalog.BehaviorRules.LunchWeight); } ///