Move goal weights into BehaviorDef so a pack can rebalance decisions without a fork.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-2
@@ -137,8 +137,8 @@
|
|||||||
делает **сам**: нужды и досуг.
|
делает **сам**: нужды и досуг.
|
||||||
|
|
||||||
Числа поведения — отдельный деф правил, как `StaffingDef` у штата: пороги нужд, скорость
|
Числа поведения — отдельный деф правил, как `StaffingDef` у штата: пороги нужд, скорость
|
||||||
обучения на уроке, разброс запаса на дорогу, порог переключения решения. Это игровой баланс, а
|
обучения на уроке, разброс запаса на дорогу, порог переключения решения и веса целей.
|
||||||
не настройка движка, и место ему в контенте.
|
Это игровой баланс, а не настройка движка, и место ему в контенте.
|
||||||
|
|
||||||
## Как выбирается действие
|
## Как выбирается действие
|
||||||
|
|
||||||
|
|||||||
@@ -12,19 +12,19 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] Веса целей переезжают в `BehaviorDef`: обязанность-урок, обязанность-переход, нужда на нуле,
|
- [x] Веса целей переезжают в `BehaviorDef`: обязанность-урок, обязанность-переход, нужда на нуле,
|
||||||
обед
|
обед
|
||||||
- [ ] Значения по умолчанию — сегодняшние; поведение не должно измениться ни на минуту
|
- [x] Значения по умолчанию — сегодняшние; поведение не должно измениться ни на минуту
|
||||||
- [ ] Каталог без `BehaviorDef` работает на константах кода, а не падает
|
- [x] Каталог без `BehaviorDef` работает на константах кода, а не падает
|
||||||
- [ ] Валидатор ловит отрицательные веса и порядок, который делает обед сильнее урока
|
- [x] Валидатор ловит отрицательные веса и порядок, который делает обед сильнее урока
|
||||||
- [ ] Комментарий у каждого числа объясняет, что оно перевешивает — иначе мод-автор крутит вслепую
|
- [x] Комментарий у каждого числа объясняет, что оно перевешивает — иначе мод-автор крутит вслепую
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Ванильные числа дают ровно те же решения, что и до переезда (таблица входов и выходов)
|
- [x] Ванильные числа дают ровно те же решения, что и до переезда (таблица входов и выходов)
|
||||||
- [ ] Пак, поднявший вес обеда выше урока, уводит класс с урока в столовую
|
- [x] Пак, поднявший вес обеда выше урока, уводит класс с урока в столовую
|
||||||
- [ ] Каталог без `BehaviorDef` принимает решения на значениях по умолчанию
|
- [x] Каталог без `BehaviorDef` принимает решения на значениях по умолчанию
|
||||||
- [ ] Отрицательный вес роняет загрузку каталога
|
- [x] Отрицательный вес роняет загрузку каталога
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
+49
-26
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
return catalog;
|
return catalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +398,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)
|
||||||
{
|
{
|
||||||
@@ -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);
|
private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user