Merge phase 24 behavior numbers.
ci / server (push) Failing after 3m35s
ci / client (push) Successful in 13s

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	docs/phases/README.md
#	src/HSchool.Content/CatalogLoader.cs
This commit is contained in:
Leonid Pershin
2026-08-20 00:36:32 +03:00
11 changed files with 294 additions and 51 deletions
+2 -2
View File
@@ -137,8 +137,8 @@
делает **сам**: нужды и досуг. делает **сам**: нужды и досуг.
Числа поведения — отдельный деф правил, как `StaffingDef` у штата: пороги нужд, скорость Числа поведения — отдельный деф правил, как `StaffingDef` у штата: пороги нужд, скорость
обучения на уроке, разброс запаса на дорогу, порог переключения решения. Это игровой баланс, а обучения на уроке, разброс запаса на дорогу, порог переключения решения и веса целей.
не настройка движка, и место ему в контенте. Это игровой баланс, а не настройка движка, и место ему в контенте.
## Как выбирается действие ## Как выбирается действие
+9 -9
View File
@@ -12,19 +12,19 @@
## Задачи ## Задачи
- [ ] Веса целей переезжают в `BehaviorDef`: обязанность-урок, обязанность-переход, нужда на нуле, - [x] Веса целей переезжают в `BehaviorDef`: обязанность-урок, обязанность-переход, нужда на нуле,
обед обед
- [ ] Значения по умолчанию — сегодняшние; поведение не должно измениться ни на минуту - [x] Значения по умолчанию — сегодняшние; поведение не должно измениться ни на минуту
- [ ] Каталог без `BehaviorDef` работает на константах кода, а не падает - [x] Каталог без `BehaviorDef` работает на константах кода, а не падает
- [ ] Валидатор ловит отрицательные веса и порядок, который делает обед сильнее урока - [x] Валидатор ловит отрицательные веса и порядок, который делает обед сильнее урока
- [ ] Комментарий у каждого числа объясняет, что оно перевешивает — иначе мод-автор крутит вслепую - [x] Комментарий у каждого числа объясняет, что оно перевешивает — иначе мод-автор крутит вслепую
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Ванильные числа дают ровно те же решения, что и до переезда (таблица входов и выходов) - [x] Ванильные числа дают ровно те же решения, что и до переезда (таблица входов и выходов)
- [ ] Пак, поднявший вес обеда выше урока, уводит класс с урока в столовую - [x] Пак, поднявший вес обеда выше урока, уводит класс с урока в столовую
- [ ] Каталог без `BehaviorDef` принимает решения на значениях по умолчанию - [x] Каталог без `BehaviorDef` принимает решения на значениях по умолчанию
- [ ] Отрицательный вес роняет загрузку каталога - [x] Отрицательный вес роняет загрузку каталога
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -108,7 +108,7 @@
| --- | --- | --- | | --- | --- | --- |
| [22. Удостоверение пака](22-mod-identity.md) | ✅ | Название, версия, зависимости и порядок загрузки | | [22. Удостоверение пака](22-mod-identity.md) | ✅ | Название, версия, зависимости и порядок загрузки |
| [23. Пример мода](23-example-pack.md) | ⬜ | Настоящая папка вместо документов в памяти | | [23. Пример мода](23-example-pack.md) | ⬜ | Настоящая папка вместо документов в памяти |
| [24. Числа поведения](24-behavior-numbers.md) | 🔄 | Веса целей в `BehaviorDef`, а не в коде | | [24. Числа поведения](24-behavior-numbers.md) | | Веса целей в `BehaviorDef`, а не в коде |
| [25. Золотые файлы](25-golden-fixtures.md) | ✅ | Отпечаток ростера, старый сейв, кривая голода, нехватка учителей | | [25. Золотые файлы](25-golden-fixtures.md) | ✅ | Отпечаток ростера, старый сейв, кривая голода, нехватка учителей |
| [26. Свой сид](26-school-seed.md) | 🔄 | Состав школы перестаёт зависеть от порядка создания | | [26. Свой сид](26-school-seed.md) | 🔄 | Состав школы перестаёт зависеть от порядка создания |
| [27. Версия сейва и дамп](27-serviceability.md) | ✅ | Формат читается, внутрь школы можно заглянуть | | [27. Версия сейва и дамп](27-serviceability.md) | ✅ | Формат читается, внутрь школы можно заглянуть |
+49 -26
View File
@@ -48,6 +48,9 @@ public readonly record struct ActorState(
/// </summary> /// </summary>
public static class DecisionPlanner 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.
/// <summary>Lesson or posted work. Beats leisure and a need that only just crossed the threshold.</summary> /// <summary>Lesson or posted work. Beats leisure and a need that only just crossed the threshold.</summary>
public const float DutyLessonWeight = 10f; public const float DutyLessonWeight = 10f;
@@ -77,12 +80,10 @@ public static class DecisionPlanner
ArgumentNullException.ThrowIfNull(walks); ArgumentNullException.ThrowIfNull(walks);
ArgumentNullException.ThrowIfNull(occupied); ArgumentNullException.ThrowIfNull(occupied);
var rules = catalog.BehaviorRules; var rules = Rules.From(catalog);
var threshold = rules?.NeedThreshold ?? 0.35f; var best = PickGoal(catalog, map, walks, state, occupied, rules);
var margin = rules?.SwitchMargin ?? 0.15f; var held = HeldGoal(catalog, map, walks, state, occupied, rules);
var best = PickGoal(catalog, map, walks, state, occupied, threshold); if (held.IsSet && best.Weight <= held.Weight + rules.Margin && SameGoal(state.Intent, held))
var held = HeldGoal(catalog, map, walks, state, occupied, threshold);
if (held.IsSet && best.Weight <= held.Weight + margin && SameGoal(state.Intent, held))
{ {
return Continue(state with { Intent = held }); return Continue(state with { Intent = held });
} }
@@ -96,16 +97,16 @@ public static class DecisionPlanner
WalkGraph walks, WalkGraph walks,
ActorState state, ActorState state,
OccupiedCount occupied, OccupiedCount occupied,
float threshold) Rules rules)
{ {
var best = Intent.None; 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)) 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)) foreach (var action in catalog.Actions.Values.OrderBy(def => def.DefName, StringComparer.Ordinal))
{ {
@@ -118,7 +119,7 @@ public static class DecisionPlanner
/// <summary> /// <summary>
/// The stored intent's weight under current inputs. A lesson that just ended is not still /// 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.
/// </summary> /// </summary>
private static Intent HeldGoal( private static Intent HeldGoal(
DefCatalog catalog, DefCatalog catalog,
@@ -126,7 +127,7 @@ public static class DecisionPlanner
WalkGraph walks, WalkGraph walks,
ActorState state, ActorState state,
OccupiedCount occupied, OccupiedCount occupied,
float threshold) Rules rules)
{ {
if (!state.Intent.IsSet) if (!state.Intent.IsSet)
{ {
@@ -136,14 +137,14 @@ public static class DecisionPlanner
switch (state.Intent.Kind) switch (state.Intent.Kind)
{ {
case GoalKind.Duty: case GoalKind.Duty:
return DutyGoal(state); return DutyGoal(state, rules);
case GoalKind.Need: case GoalKind.Need:
if (state.Intent.Id is null || !catalog.Needs.TryGetValue(state.Intent.Id, out var need)) if (state.Intent.Id is null || !catalog.Needs.TryGetValue(state.Intent.Id, out var need))
{ {
return Intent.None; return Intent.None;
} }
return NeedGoal(catalog, map, walks, state, occupied, need, threshold); return NeedGoal(catalog, map, walks, state, occupied, need, rules);
case GoalKind.Leisure: case GoalKind.Leisure:
if (state.Intent.ActionId is null || !catalog.Actions.TryGetValue(state.Intent.ActionId, out var action)) 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) if (state.DutyRoom is null)
{ {
@@ -165,7 +166,7 @@ public static class DecisionPlanner
if (state.BoundToLesson) 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) if (state.Intent.Kind == GoalKind.Leisure)
@@ -178,19 +179,19 @@ public static class DecisionPlanner
&& !state.IsWalking) && !state.IsWalking)
{ {
// Arrived this break (travel-weight duty). Stay put so a 3-weight chat does not // 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 // pull them out of the gym they just walked to. A leftover lesson intent in the
// 10) in the same room is the other case: the next lesson is here, leisure can win. // same room is the other case: the next lesson is here, leisure can win.
if (state.Intent.Kind == GoalKind.Duty if (state.Intent.Kind == GoalKind.Duty
&& string.Equals(state.Intent.Id, state.DutyRoom, StringComparison.Ordinal) && 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 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( private static Intent NeedGoal(
@@ -200,14 +201,14 @@ public static class DecisionPlanner
ActorState state, ActorState state,
OccupiedCount occupied, OccupiedCount occupied,
NeedDef need, NeedDef need,
float threshold) Rules rules)
{ {
if (need.Abstract || !state.Needs.TryGetValue(need.DefName, out var value)) if (need.Abstract || !state.Needs.TryGetValue(need.DefName, out var value))
{ {
return Intent.None; 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 // 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. // Lunch means this person's own break is open right now.
@@ -222,11 +223,11 @@ public static class DecisionPlanner
return Intent.None; return Intent.None;
} }
var span = Math.Max(threshold, 0.0001f); var span = Math.Max(rules.Threshold, 0.0001f);
var weight = urgent ? (threshold - value) / span * NeedWeightAtZero : 0f; var weight = urgent ? (rules.Threshold - value) / span * rules.NeedAtZero : 0f;
if (action.Lunch) if (action.Lunch)
{ {
weight = Math.Max(weight, LunchWeight); weight = Math.Max(weight, rules.Lunch);
} }
return new Intent(GoalKind.Need, need.DefName, weight, action.DefName); return new Intent(GoalKind.Need, need.DefName, weight, action.DefName);
@@ -488,6 +489,28 @@ public static class DecisionPlanner
GoalKind.Leisure => 2, GoalKind.Leisure => 2,
_ => 3, _ => 3,
}; };
/// <summary>Effective numbers: the catalog's BehaviorDef, or the constants when a pack has none.</summary>
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); public delegate int OccupiedCount(string nodeId, string thing);
+3 -3
View File
@@ -34,7 +34,7 @@ public sealed class CatalogLoader
var resolved = ResolveInheritance(defs); var resolved = ResolveInheritance(defs);
ApplyPatches(resolved, patches); ApplyPatches(resolved, patches);
var catalog = Materialize(order, resolved, localesRu, localesEn); var catalog = Materialize(order, resolved, localesRu, localesEn);
ResolveReferences(catalog); ResolveReferences(catalog, log);
WarnMissingLabels(catalog, log); WarnMissingLabels(catalog, log);
return catalog; return catalog;
} }
@@ -399,7 +399,7 @@ public sealed class CatalogLoader
en); en);
} }
private static void ResolveReferences(DefCatalog catalog) private static void ResolveReferences(DefCatalog catalog, IContentLog log)
{ {
foreach (var thing in catalog.Things.Values) foreach (var thing in catalog.Things.Values)
{ {
@@ -489,7 +489,7 @@ public sealed class CatalogLoader
} }
} }
PeopleDefValidator.Validate(catalog); PeopleDefValidator.Validate(catalog, log);
} }
private static void WarnMissingLabels(DefCatalog catalog, IContentLog log) private static void WarnMissingLabels(DefCatalog catalog, IContentLog log)
+20 -3
View File
@@ -2,8 +2,9 @@ namespace HSchool.Content;
internal static class PeopleDefValidator 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) foreach (var body in catalog.BodyAttributes.Values)
{ {
ValidateBody(body); ValidateBody(body);
@@ -56,7 +57,7 @@ internal static class PeopleDefValidator
foreach (var behavior in catalog.Behavior.Values) foreach (var behavior in catalog.Behavior.Values)
{ {
ValidateBehavior(behavior); ValidateBehavior(behavior, log);
} }
if (catalog.Staffing.Values.Count(def => !def.Abstract) > 1) 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) if (behavior.Abstract)
{ {
@@ -503,6 +504,22 @@ internal static class PeopleDefValidator
throw new ContentLoadException( throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' commute slack must be a non-negative range with min ≤ max."); $"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) private static void ValidateHoliday(HolidayDef holiday)
+20 -3
View File
@@ -215,12 +215,13 @@ public sealed class StaffingDef : Def
/// <summary> /// <summary>
/// One school's behaviour numbers: when a need is urgent, how fast lessons teach, commute slack, /// 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 /// how much a new goal must beat the current one before a person switches, and the four goal
/// only one concrete ruleset. /// 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.
/// </summary> /// </summary>
public sealed class BehaviorDef : Def public sealed class BehaviorDef : Def
{ {
/// <summary>A need at or below this value is urgent. Phase 21 turns that into a goal weight.</summary> /// <summary>A need at or below this value is urgent. The planner turns that into a goal weight.</summary>
public float NeedThreshold { get; init; } public float NeedThreshold { get; init; }
/// <summary>Skill points a lesson adds per game hour, before traits and need state.</summary> /// <summary>Skill points a lesson adds per game hour, before traits and need state.</summary>
@@ -233,6 +234,22 @@ public sealed class BehaviorDef : Def
/// <summary>A new goal must beat the current one by this much before the person switches.</summary> /// <summary>A new goal must beat the current one by this much before the person switches.</summary>
public float SwitchMargin { get; init; } public float SwitchMargin { get; init; }
/// <summary>Lesson or posted work. Beats leisure and a need that only just crossed the threshold.</summary>
public float DutyLessonWeight { get; init; } = 10f;
/// <summary>Walk to the next room on a break. Beats chatting in the corridor you are standing in.</summary>
public float DutyTravelWeight { get; init; } = 5f;
/// <summary>Need at zero. Beats a lesson so a desperate toilet trip leaves class.</summary>
public float NeedWeightAtZero { get; init; } = 20f;
/// <summary>
/// A sitting during this parallel's own lunch break. Above <see cref="DutyTravelWeight"/> so
/// lunch beats walking on to the next room, below <see cref="DutyLessonWeight"/> so it never
/// pulls anybody out of a lesson.
/// </summary>
public float LunchWeight { get; init; } = 6f;
} }
public enum BodyAttributeKind public enum BodyAttributeKind
@@ -1,6 +1,6 @@
{ {
"defName": "Behavior", "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, "needThreshold": 0.35,
// Skill points a lesson adds per game hour, before traits and need state. // Skill points a lesson adds per game hour, before traits and need state.
"lessonSkillPerHour": 0.05, "lessonSkillPerHour": 0.05,
@@ -9,4 +9,16 @@
"commuteSlackMin": 0, "commuteSlackMin": 0,
"commuteSlackMax": 6, "commuteSlackMax": 6,
"switchMargin": 0.15, "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,
} }
+123 -3
View File
@@ -265,6 +265,94 @@ public class DecisionPlannerTests
Assert.Equal(GoalKind.Duty, decision.Intent.Kind); Assert.Equal(GoalKind.Duty, decision.Intent.Kind);
} }
/// <summary>
/// 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.
/// </summary>
[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( private static ActorState Actor(
string node, string node,
bool boundToLesson, bool boundToLesson,
@@ -287,10 +375,12 @@ public class DecisionPlannerTests
intent ?? Intent.None, intent ?? Intent.None,
lunchWindowOpen); lunchWindowOpen);
private static Dictionary<string, float> FullNeeds() => new(StringComparer.Ordinal) private static Dictionary<string, float> FullNeeds() => Needs();
private static Dictionary<string, float> Needs(float toilet = 1f, float hunger = 1f) => new(StringComparer.Ordinal)
{ {
["Toilet"] = 1f, ["Toilet"] = toilet,
["Hunger"] = 1f, ["Hunger"] = hunger,
["Social"] = 1f, ["Social"] = 1f,
["Sleep"] = 1f, ["Sleep"] = 1f,
}; };
@@ -300,4 +390,34 @@ public class DecisionPlannerTests
var (catalog, map) = Fixtures.Vanilla(); var (catalog, map) = Fixtures.Vanilla();
return (catalog, map, WalkGraph.Build(catalog, map)); 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));
}
} }
@@ -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<ContentLoadException>(() => _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));
}
}
@@ -81,6 +81,10 @@ public class VanillaCoreTests
Assert.Equal(0, catalog.BehaviorRules.CommuteSlackMin); Assert.Equal(0, catalog.BehaviorRules.CommuteSlackMin);
Assert.Equal(6, catalog.BehaviorRules.CommuteSlackMax); Assert.Equal(6, catalog.BehaviorRules.CommuteSlackMax);
Assert.Equal(0.35f, catalog.BehaviorRules.NeedThreshold); 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);
} }
/// <summary> /// <summary>