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
+49 -26
View File
@@ -48,6 +48,9 @@ public readonly record struct ActorState(
/// </summary>
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>
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
/// <summary>
/// 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>
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,
};
/// <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);
+3 -3
View File
@@ -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);
WarnMissingLabels(catalog, log);
return catalog;
}
@@ -399,7 +399,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)
{
@@ -489,7 +489,7 @@ public sealed class CatalogLoader
}
}
PeopleDefValidator.Validate(catalog);
PeopleDefValidator.Validate(catalog, log);
}
private static void WarnMissingLabels(DefCatalog catalog, IContentLog log)
+20 -3
View File
@@ -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)
+20 -3
View File
@@ -215,12 +215,13 @@ public sealed class StaffingDef : Def
/// <summary>
/// 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.
/// </summary>
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; }
/// <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>
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
@@ -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,
}