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);
}
///