diff --git a/docs/phases/35-appropriateness.md b/docs/phases/35-appropriateness.md
index 83fc6af..dad5e79 100644
--- a/docs/phases/35-appropriateness.md
+++ b/docs/phases/35-appropriateness.md
@@ -14,31 +14,31 @@
## Задачи
-- [ ] Оценка уместности из данных: пол, возраст, погода/утепление, `pe`, формальность,
+- [x] Оценка уместности из данных: пол, возраст, погода/утепление, `pe`, формальность,
цветовые теги, длина низа. Код знает оси, пороги — defs и правила школы
-- [ ] На школе хранятся правила учеников и сотрудников (форма + цвет). Дефолт: обычная,
+- [x] На школе хранятся правила учеников и сотрудников (форма + цвет). Дефолт: обычная,
без яркого. Смена с экрана — фаза 37; API уже пишет «с завтра»
-- [ ] «Короткая» форма: длина юбки / короткий рукав; младше порога возраста ходят в обычной
-- [ ] Дома утром: мгновенно подобрать надетое под правила, погоду и сегодняшний план
+- [x] «Короткая» форма: длина юбки / короткий рукав; младше порога возраста ходят в обычной
+- [x] Дома утром: мгновенно подобрать надетое под правила, погоду и сегодняшний план
(учебники только сегодняшних уроков в сумке, остальное — шкафчик или дом)
-- [ ] В школе: действие `ChangeClothes` в своей (м/ж) раздевалке. Снятое — в шкафчик, если
+- [x] В школе: действие `ChangeClothes` в своей (м/ж) раздевалке. Снятое — в шкафчик, если
есть, иначе в сумку
-- [ ] Цель «одеться к физкультуре» и «вернуться с формы» рядом с обязанностью урока; не
+- [x] Цель «одеться к физкультуре» и «вернуться с формы» рядом с обязанностью урока; не
каждый тик, а по звонку / приходу
-- [ ] Цель «верх не по погоде» — дойти и сменить куртку, если уже в школе
-- [ ] Вместимость действия — сколько одновременно переодеваются в комнате; не путать с
+- [x] Цель «верх не по погоде» — дойти и сменить куртку, если уже в школе
+- [x] Вместимость действия — сколько одновременно переодеваются в комнате; не путать с
числом шкафчиков
-- [ ] Игрок по-прежнему не одевает с карточки
+- [x] Игрок по-прежнему не одевает с карточки
## Тесты, без которых фаза не закрыта
-- [ ] Школа с «без яркого»: утром на теле нет цвета с тегом `bright`
-- [ ] «Короткая» + первоклассница → обычная длина; старше порога — можно короткое
-- [ ] Перед уроком физкультуры ученик идёт в свою раздевалку и надевает `pe`; после — снимает
-- [ ] Мальчик не идёт в женскую раздевалку
-- [ ] Без шкафчика снятое оказывается в сумке, не исчезает
-- [ ] Морозным утром на теле есть `Outer`; в июле шубы на теле нет
-- [ ] Смена правила через API сегодня не меняет одежду до следующего рабочего утра
+- [x] Школа с «без яркого»: утром на теле нет цвета с тегом `bright`
+- [x] «Короткая» + первоклассница → обычная длина; старше порога — можно короткое
+- [x] Перед уроком физкультуры ученик идёт в свою раздевалку и надевает `pe`; после — снимает
+- [x] Мальчик не идёт в женскую раздевалку
+- [x] Без шкафчика снятое оказывается в сумке, не исчезает
+- [x] Морозным утром на теле есть `Outer`; в июле шубы на теле нет
+- [x] Смена правила через API сегодня не меняет одежду до следующего рабочего утра
## Критерий готовности
diff --git a/docs/phases/README.md b/docs/phases/README.md
index 44409e6..dcc83c6 100644
--- a/docs/phases/README.md
+++ b/docs/phases/README.md
@@ -147,7 +147,7 @@
| Фаза | Статус | Зачем |
| --- | --- | --- |
| [34. Износ и дом](34-wear-home.md) | ✅ | Прочность, утренняя замена, из ветхого не выходят |
-| [35. Уместность](35-appropriateness.md) | 🔄 | Правила, переодевание действием, физкультура |
+| [35. Уместность](35-appropriateness.md) | ✅ | Правила, переодевание действием, физкультура |
| [36. Вкладки карточки](36-person-card-tabs.md) | 🔄 | Одежда, ноша, сейчас + история за сегодня |
| [37. Правила школы](37-school-rules.md) | ⬜ | Вкладка в «Управлении», четыре комбобокса, с завтра |
diff --git a/docs/protocol.md b/docs/protocol.md
index 7d05e0e..eb5c8cc 100644
--- a/docs/protocol.md
+++ b/docs/protocol.md
@@ -379,6 +379,34 @@ land under `saves/{id}.portraits/` and survive until the school is deleted.
`GET /api/status` includes `swarmUiConfigured` and `swarmUiConnected` (`null` when not configured)
so the client can disable generate buttons and show reachability without trying POST first.
+### `GET /api/schools/{id}/dress-rules`
+
+Student and staff dress-code pairs for the school. Unknown `{id}` is `404` `unknown-school`.
+
+`form` is one of `regular`, `short`, `strict`. `color` is one of `noBright`, `whiteTopBlackBottom`,
+`free`. When a `POST` has been accepted but not yet applied, `pendingStudents` and/or `pendingStaff`
+show what takes effect on the **next work morning** (six o'clock on a weekday outside holidays) —
+not immediately.
+
+```json
+{
+ "students": { "form": "regular", "color": "noBright" },
+ "staff": { "form": "regular", "color": "noBright" },
+ "pendingStudents": null,
+ "pendingStaff": null
+}
+```
+
+### `POST /api/schools/{id}/dress-rules`
+
+Queues a change for the next work morning. Either or both of `students` and `staff` may be sent;
+omitted sides keep their current rule. Unknown `{id}` is `404` `unknown-school`. Unknown `form` or
+`color` is `400` `unknown-form` / `400` `unknown-color`. Response body matches `GET`.
+
+```json
+{ "students": { "form": "strict", "color": "noBright" } }
+```
+
### `GET /api/schools/{id}/staffing`
Money, uncovered subjects, the applicant pool and current staff. Reads the **published**
diff --git a/src/HSchool.Ai/ApparelGoals.cs b/src/HSchool.Ai/ApparelGoals.cs
new file mode 100644
index 0000000..e1fa7f7
--- /dev/null
+++ b/src/HSchool.Ai/ApparelGoals.cs
@@ -0,0 +1,152 @@
+using HSchool.Content;
+
+namespace HSchool.Ai;
+
+/// Why someone should walk to their locker room.
+public enum ApparelGoalKind
+{
+ None,
+ Pe,
+ Everyday,
+ Weather,
+}
+
+///
+/// Dress-code and weather goals beside duty. Triggered on bells and arrival, not every tick.
+///
+public static class ApparelGoals
+{
+ public static Intent? Goal(
+ DefCatalog catalog,
+ MapLayout map,
+ WalkGraph walks,
+ ApparelActor apparel,
+ ActorState state,
+ OccupiedCount occupied)
+ {
+ ArgumentNullException.ThrowIfNull(catalog);
+ ArgumentNullException.ThrowIfNull(map);
+ ArgumentNullException.ThrowIfNull(walks);
+
+ if (apparel.Issues == ApparelIssue.None || apparel.LockerRoomNode is null)
+ {
+ return null;
+ }
+
+ var weight = catalog.BehaviorRules?.ApparelGoalWeight ?? 5.5f;
+ var actionId = ApparelActions.For(apparel.Female);
+ if (!catalog.Actions.TryGetValue(actionId, out var action) || action.Abstract)
+ {
+ return null;
+ }
+
+ if (state.BoundToLesson && (apparel.Kind == ApparelGoalKind.Pe || apparel.Kind == ApparelGoalKind.Everyday))
+ {
+ return null;
+ }
+
+ var room = RoomFor(catalog, map, walks, state, occupied, action, apparel.LockerRoomNode);
+ if (room is null)
+ {
+ return null;
+ }
+
+ var id = apparel.Kind switch
+ {
+ ApparelGoalKind.Pe => "pe",
+ ApparelGoalKind.Everyday => "everyday",
+ ApparelGoalKind.Weather => "weather",
+ _ => null,
+ };
+ if (id is null)
+ {
+ return null;
+ }
+
+ return new Intent(GoalKind.Apparel, id, weight, actionId);
+ }
+
+ public static ApparelGoalKind Classify(ApparelIssue issues)
+ {
+ if (issues.HasFlag(ApparelIssue.PeRequired))
+ {
+ return ApparelGoalKind.Pe;
+ }
+
+ if (issues.HasFlag(ApparelIssue.PeForbidden))
+ {
+ return ApparelGoalKind.Everyday;
+ }
+
+ if (issues.HasFlag(ApparelIssue.OuterRequired) || issues.HasFlag(ApparelIssue.OuterForbidden))
+ {
+ return ApparelGoalKind.Weather;
+ }
+
+ if (issues.HasFlag(ApparelIssue.Formality)
+ || issues.HasFlag(ApparelIssue.Color)
+ || issues.HasFlag(ApparelIssue.SkirtLength))
+ {
+ return ApparelGoalKind.Everyday;
+ }
+
+ return ApparelGoalKind.None;
+ }
+
+ private static string? RoomFor(
+ DefCatalog catalog,
+ MapLayout map,
+ WalkGraph walks,
+ ActorState state,
+ OccupiedCount occupied,
+ ActionDef action,
+ string preferredNode)
+ {
+ foreach (var room in map.Rooms)
+ {
+ if (!room.Id.Equals(preferredNode, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (!action.Room!.Equals(room.Def, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (!HasSlot(catalog, map, occupied, room.Id, action.Thing))
+ {
+ continue;
+ }
+
+ return room.Id;
+ }
+
+ if (state.NodeId is not null && state.NodeId.Equals(preferredNode, StringComparison.Ordinal))
+ {
+ return preferredNode;
+ }
+
+ var from = state.NodeId ?? walks.TerritoryId;
+ var cost = walks.Minutes(from, preferredNode);
+ return float.IsInfinity(cost) ? null : preferredNode;
+ }
+
+ private static bool HasSlot(DefCatalog catalog, MapLayout map, OccupiedCount occupied, string nodeId, string? thing)
+ {
+ if (string.IsNullOrWhiteSpace(thing))
+ {
+ return true;
+ }
+
+ var available = RoomOccupancy.ThingCount(catalog, map, nodeId, thing);
+ return ActionStepper.CanOccupy(available, occupied(nodeId, thing));
+ }
+}
+
+/// Apparel inputs the generic planner does not carry.
+public readonly record struct ApparelActor(
+ bool Female,
+ ApparelIssue Issues,
+ ApparelGoalKind Kind,
+ string? LockerRoomNode);
diff --git a/src/HSchool.Ai/Decision.cs b/src/HSchool.Ai/Decision.cs
index e91c4b7..3c17569 100644
--- a/src/HSchool.Ai/Decision.cs
+++ b/src/HSchool.Ai/Decision.cs
@@ -6,6 +6,7 @@ public enum GoalKind
{
None,
Duty,
+ Apparel,
Need,
Leisure,
}
@@ -39,7 +40,8 @@ public readonly record struct ActorState(
string? DutyRoom,
IReadOnlyDictionary Needs,
Intent Intent,
- bool LunchWindowOpen = false);
+ bool LunchWindowOpen = false,
+ ApparelActor Apparel = default);
///
/// Picks a goal by weight and plans walk-then-do. No world, no clock — a table of inputs to an
@@ -101,6 +103,10 @@ public static class DecisionPlanner
{
var best = Intent.None;
Consider(ref best, DutyGoal(state, rules));
+ if (ApparelGoals.Goal(catalog, map, walks, state.Apparel, state, occupied) is { } apparelGoal)
+ {
+ Consider(ref best, apparelGoal);
+ }
foreach (var need in catalog.Needs.Values.OrderBy(def => def.DefName, StringComparer.Ordinal))
{
Consider(ref best, NeedGoal(catalog, map, walks, state, occupied, need, rules));
@@ -138,6 +144,8 @@ public static class DecisionPlanner
{
case GoalKind.Duty:
return DutyGoal(state, rules);
+ case GoalKind.Apparel:
+ return ApparelGoals.Goal(catalog, map, walks, state.Apparel, state, occupied) ?? Intent.None;
case GoalKind.Need:
if (state.Intent.Id is null || !catalog.Needs.TryGetValue(state.Intent.Id, out var need))
{
@@ -305,6 +313,27 @@ public static class DecisionPlanner
return new Decision(room, null, goal);
}
+ if (goal.Kind == GoalKind.Apparel)
+ {
+ if (goal.ActionId is null || !catalog.Actions.TryGetValue(goal.ActionId, out var apparelAction))
+ {
+ return Decision.Stay(Intent.None);
+ }
+
+ var apparelNode = RoomFor(catalog, map, walks, state, occupied, apparelAction);
+ if (apparelNode is null)
+ {
+ return Decision.Stay(Intent.None);
+ }
+
+ if (state.NodeId is not null && state.NodeId.Equals(apparelNode, StringComparison.Ordinal) && !state.IsWalking)
+ {
+ return new Decision(null, apparelAction.DefName, goal);
+ }
+
+ return new Decision(apparelNode, null, goal);
+ }
+
if (goal.ActionId is null || !catalog.Actions.TryGetValue(goal.ActionId, out var action))
{
return Decision.Stay(Intent.None);
@@ -485,9 +514,10 @@ public static class DecisionPlanner
private static int Order(GoalKind kind) => kind switch
{
GoalKind.Duty => 0,
- GoalKind.Need => 1,
- GoalKind.Leisure => 2,
- _ => 3,
+ GoalKind.Apparel => 1,
+ GoalKind.Need => 2,
+ GoalKind.Leisure => 3,
+ _ => 4,
};
/// Effective numbers: the catalog's BehaviorDef, or the constants when a pack has none.
diff --git a/src/HSchool.Content/Appropriateness.cs b/src/HSchool.Content/Appropriateness.cs
new file mode 100644
index 0000000..4d75f3a
--- /dev/null
+++ b/src/HSchool.Content/Appropriateness.cs
@@ -0,0 +1,204 @@
+namespace HSchool.Content;
+
+/// What the person should wear right now: everyday school clothes or PE kit.
+public enum ApparelMode
+{
+ Everyday,
+ Pe,
+}
+
+/// Why the current outfit is wrong. Used by AI goals and tests.
+[Flags]
+public enum ApparelIssue
+{
+ None = 0,
+ PeRequired = 1,
+ PeForbidden = 2,
+ OuterRequired = 4,
+ OuterForbidden = 8,
+ Formality = 16,
+ Color = 32,
+ SkirtLength = 64,
+}
+
+///
+/// Inputs for dress-code and weather checks. Thresholds live on , not here.
+///
+public readonly record struct ApparelContext(
+ bool Female,
+ int Age,
+ bool IsStudent,
+ DressRulePair Rules,
+ ApparelMode Mode,
+ float OutdoorTemperatureC,
+ BehaviorDef? Behavior);
+
+///
+/// Scores worn items against school rules, age, weather and lesson. Evaluation only — no mutation.
+///
+public static class Appropriateness
+{
+ public static ApparelIssue Issues(
+ DefCatalog catalog,
+ ApparelContext context,
+ IEnumerable<(ThingDef Def, string? Color)> worn)
+ {
+ var issues = ApparelIssue.None;
+ var behavior = context.Behavior;
+ var list = worn.ToList();
+ var hasPe = list.Any(entry => entry.Def.Pe);
+ var hasOuter = list.Any(entry => entry.Def.Layers.Contains(ApparelLayers.Outer, StringComparer.Ordinal));
+
+ if (context.Mode == ApparelMode.Pe)
+ {
+ if (!hasPe || list.Any(entry => !entry.Def.Pe && entry.Def.Layers.Count > 0 && !IsUnderLayer(entry.Def)))
+ {
+ issues |= ApparelIssue.PeRequired;
+ }
+ }
+ else if (hasPe)
+ {
+ issues |= ApparelIssue.PeForbidden;
+ }
+
+ var outerBelow = behavior?.OuterBelowC ?? 10f;
+ var heavyAbove = behavior?.HeavyOuterAboveC ?? 15f;
+ if (context.OutdoorTemperatureC < outerBelow && !hasOuter)
+ {
+ issues |= ApparelIssue.OuterRequired;
+ }
+
+ if (context.OutdoorTemperatureC > heavyAbove && list.Any(entry => entry.Def.DefName.Equals("FurCoat", StringComparison.Ordinal)))
+ {
+ issues |= ApparelIssue.OuterForbidden;
+ }
+
+ foreach (var (def, color) in list.Where(entry => !entry.Def.Pe && entry.Def.Layers.Count > 0))
+ {
+ if (!FormalityFits(context, def))
+ {
+ issues |= ApparelIssue.Formality;
+ }
+
+ if (!ColorFits(catalog, context, def, color))
+ {
+ issues |= ApparelIssue.Color;
+ }
+
+ if (!SkirtLengthFits(context, def))
+ {
+ issues |= ApparelIssue.SkirtLength;
+ }
+ }
+
+ return issues;
+ }
+
+ public static bool ColorAllowed(DefCatalog catalog, DressRulePair rules, ThingDef def, string? color)
+ {
+ if (color is null)
+ {
+ return true;
+ }
+
+ if (!catalog.Colors.TryGetValue(color, out var colorDef))
+ {
+ return true;
+ }
+
+ if (rules.Color.Equals(ColorPolicies.NoBright, StringComparison.Ordinal)
+ && colorDef.Tags.Contains(ColorTags.Bright, StringComparer.Ordinal))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ public static bool FormalityFits(ApparelContext context, ThingDef def)
+ {
+ if (def.Pe || IsUnderLayer(def))
+ {
+ return true;
+ }
+
+ var behavior = context.Behavior;
+ var regularMin = behavior?.FormalityRegularMin ?? 20;
+ var strictMin = behavior?.FormalityStrictMin ?? 60;
+ var form = EffectiveForm(context);
+
+ return form switch
+ {
+ FormPolicies.Strict => def.Formality >= strictMin,
+ _ => def.Formality >= regularMin || def.Pe,
+ };
+ }
+
+ public static bool SkirtLengthFits(ApparelContext context, ThingDef def)
+ {
+ if (def.SkirtLength is null
+ || !def.SkirtLength.Equals(SkirtLengths.Short, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ var minAge = context.Behavior?.ShortFormMinAge ?? 13;
+ if (context.Age < minAge)
+ {
+ return false;
+ }
+
+ if (!EffectiveForm(context).Equals(FormPolicies.Short, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ public static string EffectiveForm(ApparelContext context)
+ {
+ var form = context.Rules.Form;
+ if (form.Equals(FormPolicies.Short, StringComparison.Ordinal)
+ && context.Age < (context.Behavior?.ShortFormMinAge ?? 13))
+ {
+ return FormPolicies.Regular;
+ }
+
+ return form;
+ }
+
+ private static bool ColorFits(DefCatalog catalog, ApparelContext context, ThingDef def, string? color)
+ {
+ if (!ColorAllowed(catalog, context.Rules, def, color))
+ {
+ return false;
+ }
+
+ if (!context.Rules.Color.Equals(ColorPolicies.WhiteTopBlackBottom, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ if (def.Layers.Contains(ApparelLayers.Top, StringComparer.Ordinal)
+ && color is not null
+ && !color.Equals("White", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (def.Layers.Contains(ApparelLayers.Bottom, StringComparer.Ordinal)
+ && color is not null
+ && !color.Equals("Black", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static bool IsUnderLayer(ThingDef def) =>
+ def.Layers.All(layer =>
+ layer.Equals(ApparelLayers.Underwear, StringComparison.Ordinal)
+ || layer.Equals(ApparelLayers.Socks, StringComparison.Ordinal));
+}
diff --git a/src/HSchool.Content/DressRules.cs b/src/HSchool.Content/DressRules.cs
new file mode 100644
index 0000000..e55dcae
--- /dev/null
+++ b/src/HSchool.Content/DressRules.cs
@@ -0,0 +1,79 @@
+namespace HSchool.Content;
+
+/// School dress-code form strictness. Values are wire and save ids.
+public static class FormPolicies
+{
+ public const string Regular = "regular";
+ public const string Short = "short";
+ public const string Strict = "strict";
+
+ public static readonly IReadOnlyList All = [Regular, Short, Strict];
+
+ public static bool IsKnown(string value) =>
+ All.Any(candidate => candidate.Equals(value, StringComparison.Ordinal));
+}
+
+/// Colour policy ids. Independent from .
+public static class ColorPolicies
+{
+ public const string Free = "free";
+ public const string NoBright = "noBright";
+ public const string WhiteTopBlackBottom = "whiteTopBlackBottom";
+
+ public static readonly IReadOnlyList All = [Free, NoBright, WhiteTopBlackBottom];
+
+ public static bool IsKnown(string value) =>
+ All.Any(candidate => candidate.Equals(value, StringComparison.Ordinal));
+}
+
+/// One role's form + colour pair.
+public sealed record DressRulePair(string Form, string Color)
+{
+ public static DressRulePair Default { get; } = new(FormPolicies.Regular, ColorPolicies.NoBright);
+}
+
+/// Live rules plus optional next-day overrides waiting for a work morning.
+public sealed record SchoolDressRules
+{
+ public DressRulePair Students { get; init; } = DressRulePair.Default;
+
+ public DressRulePair Staff { get; init; } = DressRulePair.Default;
+
+ public DressRulePair? PendingStudents { get; init; }
+
+ public DressRulePair? PendingStaff { get; init; }
+
+ public DressRulePair ForStudent => Students;
+
+ public DressRulePair ForStaff => Staff;
+
+ public SchoolDressRules WithPending(DressRulePair? students, DressRulePair? staff) =>
+ this with { PendingStudents = students, PendingStaff = staff };
+
+ public SchoolDressRules ApplyPending()
+ {
+ var next = this;
+ if (PendingStudents is { } students)
+ {
+ next = next with { Students = students, PendingStudents = null };
+ }
+
+ if (PendingStaff is { } staff)
+ {
+ next = next with { Staff = staff, PendingStaff = null };
+ }
+
+ return next;
+ }
+
+ public bool HasPending => PendingStudents is not null || PendingStaff is not null;
+}
+
+/// Catalog action ids for locker-room changes.
+public static class ApparelActions
+{
+ public const string ChangeMale = "ChangeClothesMale";
+ public const string ChangeFemale = "ChangeClothesFemale";
+
+ public static string For(bool female) => female ? ChangeFemale : ChangeMale;
+}
diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs
index 624e3b8..460cef4 100644
--- a/src/HSchool.Content/PeopleDefs.cs
+++ b/src/HSchool.Content/PeopleDefs.cs
@@ -287,6 +287,27 @@ public sealed class BehaviorDef : Def
///
public IReadOnlyList ApparelConditionBands { get; init; } = DefaultConditionBands;
+ /// Younger pupils keep regular hemlines even when the school chose short form.
+ public int ShortFormMinAge { get; init; } = 13;
+
+ /// Everyday and short-form minimum formality on worn layers.
+ public int FormalityRegularMin { get; init; } = 20;
+
+ /// Strict-form minimum formality on worn layers.
+ public int FormalityStrictMin { get; init; } = 60;
+
+ /// Street below this °C expects an layer.
+ public float OuterBelowC { get; init; } = 10f;
+
+ /// Above this street °C a fur coat is inappropriate.
+ public float HeavyOuterAboveC { get; init; } = 15f;
+
+ /// Walk to the locker room and change. Just above duty travel.
+ public float ApparelGoalWeight { get; init; } = 6f;
+
+ /// Game minutes for ChangeClothes* actions.
+ public float ChangeClothesMinutes { get; init; } = 5f;
+
public static IReadOnlyList DefaultConditionBands { get; } =
[
new() { Min = 0.75f, Id = "ApparelConditionIntact" },
diff --git a/src/HSchool.Server/Api/DressRulesModels.cs b/src/HSchool.Server/Api/DressRulesModels.cs
new file mode 100644
index 0000000..42edde9
--- /dev/null
+++ b/src/HSchool.Server/Api/DressRulesModels.cs
@@ -0,0 +1,65 @@
+using HSchool.Content;
+
+namespace HSchool.Server.Api;
+
+internal sealed record DressRulePairDto(string Form, string Color);
+
+internal sealed record DressRulesResponse(
+ DressRulePairDto Students,
+ DressRulePairDto Staff,
+ DressRulePairDto? PendingStudents,
+ DressRulePairDto? PendingStaff)
+{
+ public static DressRulesResponse From(SchoolDressRules rules) =>
+ new(
+ Pair(rules.Students),
+ Pair(rules.Staff),
+ rules.PendingStudents is { } students ? Pair(students) : null,
+ rules.PendingStaff is { } staff ? Pair(staff) : null);
+
+ private static DressRulePairDto Pair(DressRulePair pair) => new(pair.Form, pair.Color);
+}
+
+internal sealed record SetDressRulesRequest(
+ DressRulePairDto? Students,
+ DressRulePairDto? Staff);
+
+internal enum DressRulesError
+{
+ None,
+ UnknownSchool,
+ UnknownForm,
+ UnknownColor,
+}
+
+internal sealed record DressRulesOutcome(DressRulesError Error, DressRulesResponse? Rules)
+{
+ public static DressRulesOutcome Ok(SchoolDressRules rules) =>
+ new(DressRulesError.None, DressRulesResponse.From(rules));
+
+ public static DressRulesOutcome Fail(DressRulesError error) => new(error, null);
+}
+
+internal static class DressRulesValidation
+{
+ public static bool TryParse(DressRulePairDto dto, out DressRulePair pair, out DressRulesError error)
+ {
+ if (!FormPolicies.IsKnown(dto.Form))
+ {
+ pair = default!;
+ error = DressRulesError.UnknownForm;
+ return false;
+ }
+
+ if (!ColorPolicies.IsKnown(dto.Color))
+ {
+ pair = default!;
+ error = DressRulesError.UnknownColor;
+ return false;
+ }
+
+ pair = new DressRulePair(dto.Form, dto.Color);
+ error = DressRulesError.None;
+ return true;
+ }
+}
diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs
index 98e5d91..36a244a 100644
--- a/src/HSchool.Server/Api/SchoolEndpoints.cs
+++ b/src/HSchool.Server/Api/SchoolEndpoints.cs
@@ -317,6 +317,53 @@ internal static class SchoolEndpoints
})
.WithName("GenerateSchoolPersonPortrait");
+ schools.MapGet("/{id:int}/dress-rules", async (
+ int id,
+ GameCommandQueue commands,
+ CancellationToken cancellationToken) =>
+ {
+ var command = new GameCommand.GetDressRules(id, NewCompletion());
+ commands.Enqueue(command);
+ var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
+ return DressRulesHttp(outcome);
+ })
+ .WithName("GetSchoolDressRules");
+
+ schools.MapPost("/{id:int}/dress-rules", async (
+ int id,
+ SetDressRulesRequest request,
+ GameCommandQueue commands,
+ CancellationToken cancellationToken) =>
+ {
+ DressRulePair? students = null;
+ if (request.Students is { } studentDto)
+ {
+ if (!DressRulesValidation.TryParse(studentDto, out var parsed, out var studentError))
+ {
+ return DressRulesBadRequest(studentError);
+ }
+
+ students = parsed;
+ }
+
+ DressRulePair? staff = null;
+ if (request.Staff is { } staffDto)
+ {
+ if (!DressRulesValidation.TryParse(staffDto, out var parsed, out var staffError))
+ {
+ return DressRulesBadRequest(staffError);
+ }
+
+ staff = parsed;
+ }
+
+ var command = new GameCommand.SetDressRules(id, students, staff, NewCompletion());
+ commands.Enqueue(command);
+ var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
+ return DressRulesHttp(outcome);
+ })
+ .WithName("SetSchoolDressRules");
+
schools.MapGet("/{id:int}/staffing", (
int id,
string? lang,
@@ -683,6 +730,31 @@ internal static class SchoolEndpoints
return Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: extensions);
}
+
+ private static IResult DressRulesHttp(DressRulesOutcome outcome) =>
+ outcome.Error switch
+ {
+ DressRulesError.None => Results.Ok(outcome.Rules),
+ DressRulesError.UnknownSchool => Problem(
+ StatusCodes.Status404NotFound,
+ "unknown-school",
+ "That school does not exist."),
+ _ => DressRulesBadRequest(outcome.Error),
+ };
+
+ private static IResult DressRulesBadRequest(DressRulesError error) =>
+ error switch
+ {
+ DressRulesError.UnknownForm => Problem(
+ StatusCodes.Status400BadRequest,
+ "unknown-form",
+ "That form policy is not recognized."),
+ DressRulesError.UnknownColor => Problem(
+ StatusCodes.Status400BadRequest,
+ "unknown-color",
+ "That colour policy is not recognized."),
+ _ => Problem(StatusCodes.Status400BadRequest, "invalid-query", "The dress rules request is not valid."),
+ };
}
/// Body of POST /api/schools. The start date is a game calendar date, not a real one.
diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs
index 2b975d1..e1e2f0c 100644
--- a/src/HSchool.Server/Game/GameCommand.cs
+++ b/src/HSchool.Server/Game/GameCommand.cs
@@ -101,4 +101,14 @@ internal abstract record GameCommand
int Day,
int Period,
TaskCompletionSource Result) : GameCommand;
+
+ internal sealed record GetDressRules(
+ int SchoolId,
+ TaskCompletionSource Result) : GameCommand;
+
+ internal sealed record SetDressRules(
+ int SchoolId,
+ DressRulePair? PendingStudents,
+ DressRulePair? PendingStaff,
+ TaskCompletionSource Result) : GameCommand;
}
diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs
index 14ff3b7..8dfac56 100644
--- a/src/HSchool.Server/Game/GameLoopService.cs
+++ b/src/HSchool.Server/Game/GameLoopService.cs
@@ -225,6 +225,17 @@ internal sealed class GameLoopService(
new WorkerCommand.UnpinLesson(unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period, unpin.Result),
unpin.Result);
break;
+
+ case GameCommand.GetDressRules getRules:
+ HandleDressRules(getRules.SchoolId, new WorkerCommand.GetDressRules(getRules.Result), getRules.Result);
+ break;
+
+ case GameCommand.SetDressRules setRules:
+ HandleDressRules(
+ setRules.SchoolId,
+ new WorkerCommand.SetDressRules(setRules.PendingStudents, setRules.PendingStaff, setRules.Result),
+ setRules.Result);
+ break;
}
}
@@ -255,6 +266,14 @@ internal sealed class GameLoopService(
}
}
+ private void HandleDressRules(int schoolId, WorkerCommand command, TaskCompletionSource result)
+ {
+ if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
+ {
+ result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
+ }
+ }
+
private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource result)
{
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
@@ -560,7 +579,8 @@ internal sealed class GameLoopService(
save.ClimatePresetId,
save.NativeLanguage,
createSeed: null,
- save.Presence);
+ save.Presence,
+ save.DressRules);
worker.Start();
try
@@ -611,7 +631,8 @@ internal sealed class GameLoopService(
string? climatePresetId,
string? nativeLanguage,
int? createSeed = null,
- IReadOnlyList? presence = null) =>
+ IReadOnlyList? presence = null,
+ SchoolDressRules? dressRules = null) =>
new(
id,
name,
@@ -626,6 +647,7 @@ internal sealed class GameLoopService(
nativeLanguage,
createSeed,
presence,
+ dressRules,
_options,
clients,
metrics,
diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs
index ed70576..db80d5f 100644
--- a/src/HSchool.Server/Game/SchoolStore.cs
+++ b/src/HSchool.Server/Game/SchoolStore.cs
@@ -1,5 +1,6 @@
using System.Text.Json;
using HSchool.Content;
+using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
using HSchool.Simulation;
@@ -36,6 +37,8 @@ internal sealed class SchoolSave
public string? NativeLanguage { get; init; }
public IReadOnlyList? Presence { get; init; }
+
+ public SchoolDressRules? DressRules { get; init; }
}
/// Allocates school ids that survive a process restart.
@@ -188,6 +191,7 @@ internal sealed class SchoolStore
NameSetId = save.NameSetId,
NativeLanguage = save.NativeLanguage,
Presence = save.Presence,
+ DressRules = save.DressRules,
});
}
catch (Exception ex)
@@ -233,6 +237,7 @@ internal sealed class SchoolStore
NameSetId = save.NameSetId,
NativeLanguage = save.NativeLanguage,
Presence = save.Presence,
+ DressRules = save.DressRules,
};
}
diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs
index 930302a..e066858 100644
--- a/src/HSchool.Server/Game/SchoolWorker.cs
+++ b/src/HSchool.Server/Game/SchoolWorker.cs
@@ -37,6 +37,7 @@ internal sealed class SchoolWorker
private string? _nativeLanguage;
private readonly int? _createSeed;
private readonly IReadOnlyList? _savedPresence;
+ private readonly SchoolDressRules? _savedDressRules;
private readonly Action _onFailed;
private readonly int _id;
@@ -72,6 +73,7 @@ internal sealed class SchoolWorker
string? nativeLanguage,
int? createSeed,
IReadOnlyList? savedPresence,
+ SchoolDressRules? savedDressRules,
SimulationOptions options,
ClientRegistry clients,
GameMetrics metrics,
@@ -93,6 +95,7 @@ internal sealed class SchoolWorker
_nativeLanguage = nativeLanguage;
_createSeed = createSeed;
_savedPresence = savedPresence;
+ _savedDressRules = savedDressRules;
_options = options;
_clients = clients;
_metrics = metrics;
@@ -248,6 +251,7 @@ internal sealed class SchoolWorker
}
_school = school;
+ school.DressRules = _savedDressRules ?? new SchoolDressRules();
PublishSnapshot();
if (_isNew)
@@ -458,6 +462,29 @@ internal sealed class SchoolWorker
unpin.Result.TrySetResult(
ApplyUnpin(school, unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period));
break;
+
+ case WorkerCommand.GetDressRules getRules:
+ getRules.Result.TrySetResult(DressRulesOutcome.Ok(school.DressRules));
+ break;
+
+ case WorkerCommand.SetDressRules setRules:
+ {
+ var next = school.DressRules;
+ if (setRules.PendingStudents is { } students)
+ {
+ next = next with { PendingStudents = students };
+ }
+
+ if (setRules.PendingStaff is { } staff)
+ {
+ next = next with { PendingStaff = staff };
+ }
+
+ school.DressRules = next;
+ setRules.Result.TrySetResult(DressRulesOutcome.Ok(school.DressRules));
+ dirty = true;
+ break;
+ }
}
}
catch (Exception ex)
@@ -511,6 +538,12 @@ internal sealed class SchoolWorker
case WorkerCommand.UnpinLesson unpin:
unpin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
break;
+ case WorkerCommand.GetDressRules getRules:
+ getRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
+ break;
+ case WorkerCommand.SetDressRules setRules:
+ setRules.Result.TrySetResult(DressRulesOutcome.Fail(DressRulesError.UnknownSchool));
+ break;
}
}
@@ -542,6 +575,12 @@ internal sealed class SchoolWorker
case WorkerCommand.UnpinLesson unpin:
unpin.Result.TrySetException(exception);
break;
+ case WorkerCommand.GetDressRules getRules:
+ getRules.Result.TrySetException(exception);
+ break;
+ case WorkerCommand.SetDressRules setRules:
+ setRules.Result.TrySetException(exception);
+ break;
}
}
@@ -1014,6 +1053,7 @@ internal sealed class SchoolWorker
ClimatePresetId = school.ClimatePresetId,
NativeLanguage = _nativeLanguage,
Presence = school.CapturePresence(),
+ DressRules = school.DressRules,
});
}
catch (Exception ex)
diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs
index 5fdbda5..3ae06d7 100644
--- a/src/HSchool.Server/Game/WorkerCommand.cs
+++ b/src/HSchool.Server/Game/WorkerCommand.cs
@@ -1,3 +1,4 @@
+using HSchool.Content;
using HSchool.People;
using HSchool.Server.Api;
using HSchool.Server.Net;
@@ -63,4 +64,11 @@ internal abstract record WorkerCommand
int Day,
int Period,
TaskCompletionSource Result) : WorkerCommand;
+
+ internal sealed record GetDressRules(TaskCompletionSource Result) : WorkerCommand;
+
+ internal sealed record SetDressRules(
+ DressRulePair? PendingStudents,
+ DressRulePair? PendingStaff,
+ TaskCompletionSource Result) : WorkerCommand;
}
diff --git a/src/HSchool.Server/mods/core/defs/actions/living.jsonc b/src/HSchool.Server/mods/core/defs/actions/living.jsonc
index a7c67b3..fe8be2a 100644
--- a/src/HSchool.Server/mods/core/defs/actions/living.jsonc
+++ b/src/HSchool.Server/mods/core/defs/actions/living.jsonc
@@ -50,4 +50,20 @@
"roles": ["student", "staff"],
"weight": 1,
},
+ {
+ "defName": "ChangeClothesMale",
+ "room": "MaleChangingRoom",
+ "thing": "ChangingSpot",
+ "minutes": 5,
+ "roles": ["student", "staff"],
+ "weight": 0,
+ },
+ {
+ "defName": "ChangeClothesFemale",
+ "room": "FemaleChangingRoom",
+ "thing": "ChangingSpot",
+ "minutes": 5,
+ "roles": ["student", "staff"],
+ "weight": 0,
+ },
]
diff --git a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
index 8500666..bd665eb 100644
--- a/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
+++ b/src/HSchool.Server/mods/core/defs/behavior/rules.jsonc
@@ -38,4 +38,12 @@
{ "min": 0.15, "id": "ApparelConditionTorn" },
{ "min": 0, "id": "ApparelConditionRags" },
],
+ // Dress code thresholds — axes live in code, numbers here.
+ "shortFormMinAge": 13,
+ "formalityRegularMin": 20,
+ "formalityStrictMin": 60,
+ "outerBelowC": 10,
+ "heavyOuterAboveC": 15,
+ "apparelGoalWeight": 6,
+ "changeClothesMinutes": 5,
}
diff --git a/src/HSchool.Server/mods/core/defs/rooms/service.jsonc b/src/HSchool.Server/mods/core/defs/rooms/service.jsonc
index 3a9f5e8..9f0a6e3 100644
--- a/src/HSchool.Server/mods/core/defs/rooms/service.jsonc
+++ b/src/HSchool.Server/mods/core/defs/rooms/service.jsonc
@@ -56,6 +56,7 @@
"lockerSex": "male",
"slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 },
+ { "key": "spots", "thing": "ChangingSpot", "count": 4 },
],
"travelMinutes": 0.5,
},
@@ -64,6 +65,7 @@
"lockerSex": "female",
"slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 },
+ { "key": "spots", "thing": "ChangingSpot", "count": 4 },
],
"travelMinutes": 0.5,
},
diff --git a/src/HSchool.Server/mods/core/defs/things/furniture.jsonc b/src/HSchool.Server/mods/core/defs/things/furniture.jsonc
index 1f9825b..b9e0a12 100644
--- a/src/HSchool.Server/mods/core/defs/things/furniture.jsonc
+++ b/src/HSchool.Server/mods/core/defs/things/furniture.jsonc
@@ -7,4 +7,5 @@
{ "defName": "Computer", "pupilSlots": 1 },
{ "defName": "MedicalCouch" },
{ "defName": "Locker" },
+ { "defName": "ChangingSpot" },
]
diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc
index 9be1656..f88bf3f 100644
--- a/src/HSchool.Server/mods/core/localizations/en.jsonc
+++ b/src/HSchool.Server/mods/core/localizations/en.jsonc
@@ -95,6 +95,9 @@
"UtilityRoom": "Utility room",
"MaleChangingRoom": "Boys' changing room",
"FemaleChangingRoom": "Girls' changing room",
+ "ChangingSpot": "Changing spot",
+ "ChangeClothesMale": "Change clothes",
+ "ChangeClothesFemale": "Change clothes",
"Mathematics": "Mathematics",
"RussianLanguage": "Russian",
"Literature": "Literature",
@@ -173,5 +176,6 @@
"ActionStarted": "started: {0}",
"ActionEnded": "finished: {0}",
"ApparelReplaced": "got a new {0}",
+ "ApparelChanged": "changed clothes: {0}",
"core": "Core",
}
diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc
index 7e49a91..f7bdfaf 100644
--- a/src/HSchool.Server/mods/core/localizations/ru.jsonc
+++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc
@@ -95,6 +95,9 @@
"UtilityRoom": "Теплоузел",
"MaleChangingRoom": "Мужская раздевалка",
"FemaleChangingRoom": "Женская раздевалка",
+ "ChangingSpot": "Место переодевания",
+ "ChangeClothesMale": "Переодеться",
+ "ChangeClothesFemale": "Переодеться",
"Mathematics": "Математика",
"RussianLanguage": "Русский язык",
"Literature": "Литература",
@@ -173,5 +176,6 @@
"ActionStarted": "начал: {0}",
"ActionEnded": "закончил: {0}",
"ApparelReplaced": "получил новую {0}",
+ "ApparelChanged": "переоделся: {0}",
"core": "Базовая игра",
}
diff --git a/src/HSchool.Server/mods/core/maps/default.jsonc b/src/HSchool.Server/mods/core/maps/default.jsonc
index b66aabf..2f47011 100644
--- a/src/HSchool.Server/mods/core/maps/default.jsonc
+++ b/src/HSchool.Server/mods/core/maps/default.jsonc
@@ -205,6 +205,7 @@
"floor": "gym-floor",
"slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 },
+ { "key": "spots", "thing": "ChangingSpot", "count": 4 },
],
},
{
@@ -214,6 +215,7 @@
"floor": "gym-floor",
"slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 },
+ { "key": "spots", "thing": "ChangingSpot", "count": 4 },
],
},
],
diff --git a/src/HSchool.Simulation/ActivitySystem.cs b/src/HSchool.Simulation/ActivitySystem.cs
index b68b5f5..84dc1f2 100644
--- a/src/HSchool.Simulation/ActivitySystem.cs
+++ b/src/HSchool.Simulation/ActivitySystem.cs
@@ -1,6 +1,7 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
+using HSchool.People;
namespace HSchool.Simulation;
@@ -25,6 +26,21 @@ internal static class ActivitySystem
return false;
}
+ Person? person = null;
+ if (IsChangeClothes(action.DefName))
+ {
+ if (school.Roster is null)
+ {
+ return false;
+ }
+
+ person = school.Roster.People.FirstOrDefault(row => row.Id.Equals(personId, StringComparison.Ordinal));
+ if (person is null)
+ {
+ return false;
+ }
+ }
+
var started = false;
var world = school.World;
world.Query(
@@ -46,6 +62,11 @@ internal static class ActivitySystem
return;
}
+ if (person is not null && !SexFitsChange(action, person))
+ {
+ return;
+ }
+
var location = school.Map.NodeDef(presence.NodeId);
if (location is null || !location.Equals(action.Room, StringComparison.Ordinal))
{
@@ -117,6 +138,11 @@ internal static class ActivitySystem
needs.Values[action.Need] = ActionStepper.ApplyNeedGain(current, action, need);
}
+ if (IsChangeClothes(action.DefName))
+ {
+ FinishChangeClothes(school, identity.Id);
+ }
+
activity = PersonActivity.Idle;
completed.Add(identity.Id);
});
@@ -124,6 +150,26 @@ internal static class ActivitySystem
return completed;
}
+ private static void FinishChangeClothes(School school, string personId)
+ {
+ var person = school.Roster?.People.FirstOrDefault(row => row.Id.Equals(personId, StringComparison.Ordinal));
+ if (person is null)
+ {
+ return;
+ }
+
+ var schoolClass = person.ClassId is { } classId
+ ? school.Roster!.Classes.FirstOrDefault(row => row.Id.Equals(classId, StringComparison.Ordinal))
+ : null;
+ var mode = ApparelPresence.ModeAt(school, person, schoolClass);
+ var subjects = ApparelDresser.TodaySubjects(school, person, schoolClass);
+ ApparelDresser.RedressPerson(school, person, mode, includeHome: false, todaySubjects: subjects);
+ }
+
+ private static bool IsChangeClothes(string actionId) =>
+ actionId.Equals(ApparelActions.ChangeMale, StringComparison.Ordinal)
+ || actionId.Equals(ApparelActions.ChangeFemale, StringComparison.Ordinal);
+
private static int Occupied(School school, string nodeId, string thing)
{
var occupied = 0;
@@ -168,4 +214,19 @@ internal static class ActivitySystem
return false;
}
+
+ private static bool SexFitsChange(ActionDef action, Person person)
+ {
+ if (action.DefName.Equals(ApparelActions.ChangeMale, StringComparison.Ordinal))
+ {
+ return !person.Female;
+ }
+
+ if (action.DefName.Equals(ApparelActions.ChangeFemale, StringComparison.Ordinal))
+ {
+ return person.Female;
+ }
+
+ return true;
+ }
}
diff --git a/src/HSchool.Simulation/ApparelDresser.cs b/src/HSchool.Simulation/ApparelDresser.cs
new file mode 100644
index 0000000..09892d5
--- /dev/null
+++ b/src/HSchool.Simulation/ApparelDresser.cs
@@ -0,0 +1,499 @@
+using HSchool.Ai;
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Schedule;
+
+namespace HSchool.Simulation;
+
+///
+/// Picks and applies outfits from worn, bag, locker and home. Used at home instantly and after
+/// ChangeClothes in a locker room.
+///
+public static class ApparelDresser
+{
+ private static readonly string[] LayerOrder =
+ [
+ ApparelLayers.Underwear,
+ ApparelLayers.Socks,
+ ApparelLayers.Bottom,
+ ApparelLayers.Top,
+ ApparelLayers.OverTop,
+ ApparelLayers.Outer,
+ ApparelLayers.Shoes,
+ ApparelLayers.Head,
+ ApparelLayers.Accessory,
+ ];
+
+ public static bool RedressPerson(
+ School school,
+ Person person,
+ ApparelMode mode,
+ bool includeHome,
+ IReadOnlyList? todaySubjects = null,
+ bool logChanges = true)
+ {
+ if (school.Catalog is null || school.Roster is null)
+ {
+ return false;
+ }
+
+ var catalog = school.Catalog;
+ var rules = RuleFor(school, person);
+ var age = person.AgeOn(school.Clock.Time);
+ var context = new ApparelContext(
+ person.Female,
+ age,
+ person.IsStudent,
+ rules,
+ mode,
+ school.Weather.TemperatureC,
+ catalog.BehaviorRules);
+
+ var pool = Pool(person, includeHome);
+ var target = SelectOutfit(catalog, context, pool, mode);
+ if (target.Count == 0)
+ {
+ return false;
+ }
+
+ var changed = ApplyOutfit(school, person, pool, target, todaySubjects, logChanges);
+ if (changed)
+ {
+ var updated = school.Roster!.People.First(row => row.Id.Equals(person.Id, StringComparison.Ordinal));
+ SyncInsulation(school, updated);
+ }
+
+ return changed;
+ }
+
+ public static ApparelIssue CurrentIssues(School school, Person person, ApparelMode mode)
+ {
+ if (school.Catalog is null)
+ {
+ return ApparelIssue.None;
+ }
+
+ var catalog = school.Catalog;
+ var rules = RuleFor(school, person);
+ var context = new ApparelContext(
+ person.Female,
+ person.AgeOn(school.Clock.Time),
+ person.IsStudent,
+ rules,
+ mode,
+ school.Weather.TemperatureC,
+ catalog.BehaviorRules);
+ var worn = WornEntries(catalog, person);
+ return Appropriateness.Issues(catalog, context, worn);
+ }
+
+ public static ApparelMode ModeForLesson(string? subject) =>
+ subject is not null && subject.Equals("PhysicalEducation", StringComparison.Ordinal)
+ ? ApparelMode.Pe
+ : ApparelMode.Everyday;
+
+ public static string ChangeActionId(bool female) => ApparelActions.For(female);
+
+ public static string? ChangingRoomDef(bool female) =>
+ female ? "FemaleChangingRoom" : "MaleChangingRoom";
+
+ internal static DressRulePair RuleFor(School school, Person person) =>
+ person.IsStudent ? school.DressRules.Students : school.DressRules.Staff;
+
+ private static List<(InventoryItem Item, int Index)> Pool(Person person, bool includeHome)
+ {
+ var pool = new List<(InventoryItem, int)>();
+ for (var i = 0; i < person.Items.Count; i++)
+ {
+ var item = person.Items[i];
+ if (item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal)
+ || item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal)
+ || item.Location.Equals(ItemLocations.Locker, StringComparison.Ordinal)
+ || (includeHome && item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)))
+ {
+ pool.Add((item, i));
+ }
+ }
+
+ return pool;
+ }
+
+ private static List SelectOutfit(
+ DefCatalog catalog,
+ ApparelContext context,
+ List<(InventoryItem Item, int Index)> pool,
+ ApparelMode mode)
+ {
+ var chosen = new List();
+ var occupied = new HashSet(StringComparer.Ordinal);
+ var candidates = pool
+ .Where(entry => catalog.Things.TryGetValue(entry.Item.Def, out var def) && def.Layers.Count > 0)
+ .Select(entry => (Item: entry.Item, Def: catalog.Things[entry.Item.Def]))
+ .Where(entry => SexFits(entry.Def, context.Female) && AgeFits(entry.Def, context.Age))
+ .ToList();
+
+ foreach (var layer in LayerOrder)
+ {
+ if (occupied.Contains(layer))
+ {
+ continue;
+ }
+
+ var pick = PickLayer(catalog, context, candidates, layer, mode, occupied);
+ if (pick is null)
+ {
+ continue;
+ }
+
+ chosen.Add(pick.Value.Item);
+ foreach (var taken in pick.Value.Def.Layers)
+ {
+ occupied.Add(taken);
+ }
+ }
+
+ return chosen;
+ }
+
+ private static (InventoryItem Item, ThingDef Def)? PickLayer(
+ DefCatalog catalog,
+ ApparelContext context,
+ List<(InventoryItem Item, ThingDef Def)> candidates,
+ string layer,
+ ApparelMode mode,
+ HashSet occupied)
+ {
+ (InventoryItem Item, ThingDef Def)? best = null;
+ foreach (var entry in candidates)
+ {
+ if (!entry.Def.Layers.Any(candidate => candidate.Equals(layer, StringComparison.Ordinal))
+ || !entry.Def.Layers.All(candidate => !occupied.Contains(candidate)))
+ {
+ continue;
+ }
+
+ if (mode == ApparelMode.Pe && !entry.Def.Pe)
+ {
+ continue;
+ }
+
+ if (mode == ApparelMode.Everyday && entry.Def.Pe)
+ {
+ continue;
+ }
+
+ if (!LayerAllowed(catalog, context, entry.Def, entry.Item.Color, mode))
+ {
+ continue;
+ }
+
+ if (best is null || Score(entry.Def) > Score(best.Value.Def))
+ {
+ best = entry;
+ }
+ }
+
+ return best;
+ }
+
+ private static bool LayerAllowed(
+ DefCatalog catalog,
+ ApparelContext context,
+ ThingDef def,
+ string? color,
+ ApparelMode mode)
+ {
+ if (mode == ApparelMode.Pe)
+ {
+ return def.Pe;
+ }
+
+ if (def.Layers.Any(layer => layer.Equals(ApparelLayers.Outer, StringComparison.Ordinal)))
+ {
+ var outerBelow = context.Behavior?.OuterBelowC ?? 10f;
+ var heavyAbove = context.Behavior?.HeavyOuterAboveC ?? 15f;
+ if (context.OutdoorTemperatureC >= outerBelow)
+ {
+ return false;
+ }
+
+ if (context.OutdoorTemperatureC > heavyAbove
+ && def.DefName.Equals("FurCoat", StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ return Appropriateness.FormalityFits(context, def)
+ && Appropriateness.SkirtLengthFits(context, def)
+ && Appropriateness.ColorAllowed(catalog, context.Rules, def, color);
+ }
+
+ private static int Score(ThingDef def)
+ {
+ var score = def.Formality;
+ if (def.Layers.Any(layer => layer.Equals(ApparelLayers.Outer, StringComparison.Ordinal)))
+ {
+ score += (int)def.Insulation;
+ }
+
+ return score;
+ }
+
+ private static bool ApplyOutfit(
+ School school,
+ Person person,
+ List<(InventoryItem Item, int Index)> pool,
+ List target,
+ IReadOnlyList? todaySubjects,
+ bool logChanges)
+ {
+ var targetKeys = target
+ .Select(item => Key(item))
+ .ToHashSet(StringComparer.Ordinal);
+ var hasLocker = person.LockerRoomId is not null;
+ var changed = false;
+ var items = person.Items.ToList();
+
+ for (var i = 0; i < items.Count; i++)
+ {
+ var item = items[i];
+ if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (targetKeys.Contains(Key(item)))
+ {
+ continue;
+ }
+
+ items[i] = item with { Location = StashLocation(hasLocker) };
+ if (logChanges)
+ {
+ school.AppendDayLog(new PersonLogEvent(
+ person.Id,
+ school.Clock.Time,
+ PersonLogTypes.ApparelChanged,
+ item.Def));
+ }
+ changed = true;
+ }
+
+ foreach (var want in target)
+ {
+ var index = items.FindIndex(candidate =>
+ Key(candidate).Equals(Key(want), StringComparison.Ordinal));
+ if (index < 0)
+ {
+ continue;
+ }
+
+ if (items[index].Location.Equals(ItemLocations.Worn, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ items[index] = items[index] with { Location = ItemLocations.Worn };
+ if (logChanges)
+ {
+ school.AppendDayLog(new PersonLogEvent(
+ person.Id,
+ school.Clock.Time,
+ PersonLogTypes.ApparelChanged,
+ want.Def));
+ }
+ changed = true;
+ }
+
+ if (todaySubjects is not null && school.Catalog is { } catalog)
+ {
+ changed |= RepackBag(school, person, items, todaySubjects);
+ changed |= StagePeKit(catalog, person, items, todaySubjects);
+ }
+
+ if (changed)
+ {
+ ReplacePerson(person, items);
+ }
+
+ return changed;
+ }
+
+ private static bool StagePeKit(
+ DefCatalog catalog,
+ Person person,
+ List items,
+ IReadOnlyList todaySubjects)
+ {
+ if (!todaySubjects.Contains("PhysicalEducation", StringComparer.Ordinal))
+ {
+ return false;
+ }
+
+ var hasLocker = person.LockerRoomId is not null;
+ var changed = false;
+ for (var i = 0; i < items.Count; i++)
+ {
+ var item = items[i];
+ if (!item.Location.Equals(ItemLocations.Home, StringComparison.Ordinal)
+ || !catalog.Things.TryGetValue(item.Def, out var def)
+ || !def.Pe)
+ {
+ continue;
+ }
+
+ items[i] = item with { Location = StashLocation(hasLocker) };
+ changed = true;
+ }
+
+ return changed;
+ }
+
+ private static bool RepackBag(
+ School school,
+ Person person,
+ List items,
+ IReadOnlyList todaySubjects)
+ {
+ if (school.Catalog is null)
+ {
+ return false;
+ }
+
+ var catalog = school.Catalog;
+ var haveLocker = person.LockerRoomId is not null;
+ var changed = false;
+ var want = todaySubjects.ToHashSet(StringComparer.Ordinal);
+ var capacity = CarryMass.Capacity(catalog, person.Skills);
+ var held = 0f;
+
+ foreach (var item in items.Where(row => row.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal)))
+ {
+ if (catalog.Things.TryGetValue(item.Def, out var def))
+ {
+ held += def.Mass;
+ }
+ }
+
+ for (var i = 0; i < items.Count; i++)
+ {
+ var item = items[i];
+ if (item.Subject is null)
+ {
+ continue;
+ }
+
+ if (want.Contains(item.Subject))
+ {
+ if (!item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal)
+ && catalog.Things.TryGetValue(item.Def, out var def)
+ && held + def.Mass <= capacity)
+ {
+ items[i] = item with { Location = ItemLocations.Bag };
+ held += def.Mass;
+ changed = true;
+ }
+
+ continue;
+ }
+
+ if (item.Location.Equals(ItemLocations.Bag, StringComparison.Ordinal))
+ {
+ items[i] = item with { Location = StashLocation(haveLocker) };
+ if (catalog.Things.TryGetValue(item.Def, out var def))
+ {
+ held = Math.Max(0, held - def.Mass);
+ }
+
+ changed = true;
+ }
+ }
+
+ return changed;
+ }
+
+ private static string StashLocation(bool hasLocker) =>
+ hasLocker ? ItemLocations.Locker : ItemLocations.Bag;
+
+ private static string Key(InventoryItem item) =>
+ item.Subject is null ? item.Def : $"{item.Def}:{item.Subject}";
+
+ private static IEnumerable<(ThingDef Def, string? Color)> WornEntries(DefCatalog catalog, Person person)
+ {
+ foreach (var item in person.Items)
+ {
+ if (!item.Location.Equals(ItemLocations.Worn, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (catalog.Things.TryGetValue(item.Def, out var def))
+ {
+ yield return (def, item.Color);
+ }
+ }
+ }
+
+ private static void SyncInsulation(School school, Person person)
+ {
+ var insulation = PersonInsulation.FromPerson(person, school.Catalog);
+ var world = school.World;
+ var query = new Arch.Core.QueryDescription().WithAll();
+ world.Query(in query, (ref PersonIdentity identity, ref PersonInsulation worn) =>
+ {
+ if (identity.Id.Equals(person.Id, StringComparison.Ordinal))
+ {
+ worn = insulation;
+ }
+ });
+ }
+
+ private static void ReplacePerson(Person person, List items)
+ {
+ if (person.Items is IList list && !list.IsReadOnly)
+ {
+ list.Clear();
+ foreach (var item in items)
+ {
+ list.Add(item);
+ }
+
+ return;
+ }
+
+ throw new InvalidOperationException($"Cannot mutate items for {person.Id}.");
+ }
+
+ private static bool SexFits(ThingDef def, bool female)
+ {
+ if (def.Sex is null)
+ {
+ return true;
+ }
+
+ var want = female ? "female" : "male";
+ return def.Sex.Equals(want, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool AgeFits(ThingDef def, int age) =>
+ def.Age is not { } range || (age >= range.Min && age <= range.Max);
+
+ /// Subjects this pupil needs in the bag today.
+ public static IReadOnlyList TodaySubjects(
+ School school,
+ Person person,
+ SchoolClass? schoolClass)
+ {
+ if (school.Catalog is null || school.Timetable is null || !person.IsStudent)
+ {
+ return [];
+ }
+
+ var day = SchoolDay.WeekdayIndex(school.Clock.Time);
+ return Duty.LessonsToday(person, schoolClass, school.Timetable, day)
+ .Select(lesson => lesson.Subject)
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ }
+}
diff --git a/src/HSchool.Simulation/ApparelPresence.cs b/src/HSchool.Simulation/ApparelPresence.cs
new file mode 100644
index 0000000..cef4ed5
--- /dev/null
+++ b/src/HSchool.Simulation/ApparelPresence.cs
@@ -0,0 +1,96 @@
+using HSchool.Ai;
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Schedule;
+
+namespace HSchool.Simulation;
+
+/// Builds apparel goal inputs for the decision planner.
+internal static class ApparelPresence
+{
+ public static ApparelActor Build(School school, Person person, SchoolClass? schoolClass)
+ {
+ if (school.Catalog is null || !person.IsStudent && !person.IsStaff)
+ {
+ return default;
+ }
+
+ var mode = ModeAt(school, person, schoolClass);
+ var issues = ApparelDresser.CurrentIssues(school, person, mode);
+ var kind = ApparelGoals.Classify(issues);
+ var room = ChangingRoomNode(school, person);
+ return new ApparelActor(person.Female, issues, kind, room);
+ }
+
+ public static void EnqueueOnWeatherChange(School school, OutdoorWeather before, OutdoorWeather after)
+ {
+ if (before.Tenths == after.Tenths && before.Precipitation == after.Precipitation)
+ {
+ return;
+ }
+
+ foreach (var person in school.Roster!.People.OrderBy(row => row.Id, StringComparer.Ordinal))
+ {
+ school.DecisionQueue.Enqueue(person.Id);
+ }
+ }
+
+ internal static ApparelMode ModeAt(School school, Person person, SchoolClass? schoolClass)
+ {
+ if (school.Catalog is null || school.Timetable is null)
+ {
+ return ApparelMode.Everyday;
+ }
+
+ var slot = SchoolDay.At(school.Catalog, school.Clock.Time, school.SchoolWeekDays);
+ var day = SchoolDay.WeekdayIndex(school.Clock.Time);
+ var lessons = Duty.LessonsToday(person, schoolClass, school.Timetable, day);
+ if (lessons.Count == 0)
+ {
+ return ApparelMode.Everyday;
+ }
+
+ if (slot.Kind == DaySlotKind.Lesson)
+ {
+ var current = lessons.FirstOrDefault(lesson => lesson.Period == slot.Index);
+ return ApparelDresser.ModeForLesson(current?.Subject);
+ }
+
+ var next = lessons.Where(lesson => lesson.Period > slot.Index).OrderBy(lesson => lesson.Period).FirstOrDefault();
+ if (next is not null)
+ {
+ return ApparelDresser.ModeForLesson(next.Subject);
+ }
+
+ var previous = lessons.Where(lesson => lesson.Period < slot.Index).OrderByDescending(lesson => lesson.Period).FirstOrDefault();
+ if (previous is not null && ApparelDresser.ModeForLesson(previous.Subject) == ApparelMode.Pe)
+ {
+ return ApparelMode.Everyday;
+ }
+
+ return ApparelMode.Everyday;
+ }
+
+ internal static string? ChangingRoomNode(School school, Person person)
+ {
+ if (person.LockerRoomId is { } assigned)
+ {
+ return assigned;
+ }
+
+ if (school.Map is null || school.Catalog is null)
+ {
+ return null;
+ }
+
+ var want = ApparelDresser.ChangingRoomDef(person.Female);
+ if (want is null)
+ {
+ return null;
+ }
+
+ return school.Map.Rooms
+ .FirstOrDefault(room => room.Def.Equals(want, StringComparison.Ordinal))
+ ?.Id;
+ }
+}
diff --git a/src/HSchool.Simulation/ApparelWear.cs b/src/HSchool.Simulation/ApparelWear.cs
index cb6efd7..cf21fd5 100644
--- a/src/HSchool.Simulation/ApparelWear.cs
+++ b/src/HSchool.Simulation/ApparelWear.cs
@@ -47,12 +47,15 @@ internal static class ApparelWear
}
school.ResetDayLog();
- if (school.Catalog is null || !SchoolDay.IsWorkday(school.Catalog, school.Clock.Time, school.SchoolWeekDays))
+ if (school.Catalog is null)
{
return bandCrossed;
}
- return bandCrossed | ReplaceRags(school);
+ var morningChanged = MorningDress.Apply(school);
+ var ragsReplaced = SchoolDay.IsWorkday(school.Catalog, school.Clock.Time, school.SchoolWeekDays)
+ && ReplaceRags(school);
+ return bandCrossed | morningChanged | ragsReplaced;
}
internal static bool CrossedDayStart(DateTime before, DateTime after)
diff --git a/src/HSchool.Simulation/MorningDress.cs b/src/HSchool.Simulation/MorningDress.cs
new file mode 100644
index 0000000..e4d9e51
--- /dev/null
+++ b/src/HSchool.Simulation/MorningDress.cs
@@ -0,0 +1,76 @@
+using HSchool.Ai;
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Schedule;
+
+namespace HSchool.Simulation;
+
+/// Applies pending dress rules and home morning outfits at six in the morning.
+internal static class MorningDress
+{
+ private static readonly Arch.Core.QueryDescription Identities =
+ new Arch.Core.QueryDescription().WithAll();
+
+ public static bool Apply(School school)
+ {
+ if (school.Catalog is null || school.Roster is null)
+ {
+ return false;
+ }
+
+ var isWorkday = SchoolDay.IsWorkday(school.Catalog, school.Clock.Time, school.SchoolWeekDays);
+ var changed = false;
+ if (isWorkday && school.DressRules.HasPending)
+ {
+ school.DressRules = school.DressRules.ApplyPending();
+ changed = true;
+ }
+
+ var offCampus = OffCampusIds(school);
+ foreach (var person in school.Roster.People)
+ {
+ if (!offCampus.Contains(person.Id))
+ {
+ continue;
+ }
+
+ var schoolClass = ClassOf(school, person);
+ var subjects = isWorkday
+ ? ApparelDresser.TodaySubjects(school, person, schoolClass)
+ : [];
+ changed |= ApparelDresser.RedressPerson(
+ school,
+ person,
+ ApparelMode.Everyday,
+ includeHome: true,
+ todaySubjects: subjects,
+ logChanges: false);
+ }
+
+ return changed;
+ }
+
+ private static HashSet OffCampusIds(School school)
+ {
+ var ids = new HashSet(StringComparer.Ordinal);
+ var world = school.World;
+ world.Query(in Identities, (ref PersonIdentity identity, ref Presence presence) =>
+ {
+ if (!presence.IsOnCampus)
+ {
+ ids.Add(identity.Id);
+ }
+ });
+ return ids;
+ }
+
+ private static SchoolClass? ClassOf(School school, Person person)
+ {
+ if (person.ClassId is null)
+ {
+ return null;
+ }
+
+ return school.Roster?.Classes.FirstOrDefault(row => row.Id.Equals(person.ClassId, StringComparison.Ordinal));
+ }
+}
diff --git a/src/HSchool.Simulation/PersonLog.cs b/src/HSchool.Simulation/PersonLog.cs
index 59984f2..24534b1 100644
--- a/src/HSchool.Simulation/PersonLog.cs
+++ b/src/HSchool.Simulation/PersonLog.cs
@@ -38,6 +38,14 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type,
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "ApparelReplaced"), name);
}
+ if (Type.Equals(PersonLogTypes.ApparelChanged, StringComparison.Ordinal))
+ {
+ var name = catalog.Things.TryGetValue(ThingDef, out var def)
+ ? catalog.Label(locale, def)
+ : ThingDef;
+ return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "ApparelChanged"), name);
+ }
+
if (Type.Equals(PersonLogTypes.ActionStarted, StringComparison.Ordinal)
|| Type.Equals(PersonLogTypes.ActionEnded, StringComparison.Ordinal))
{
diff --git a/src/HSchool.Simulation/PresenceSystem.cs b/src/HSchool.Simulation/PresenceSystem.cs
index c9108a1..04ac485 100644
--- a/src/HSchool.Simulation/PresenceSystem.cs
+++ b/src/HSchool.Simulation/PresenceSystem.cs
@@ -378,6 +378,7 @@ internal static class PresenceSystem
var frame = school.Catalog!.DayFrame;
var lunchOpen = frame is not null
&& SchoolDay.IsLunchWindow(frame, slot, ClassOf(school, person)?.Year);
+ var apparel = ApparelPresence.Build(school, person, ClassOf(school, person));
var state = new ActorState(
presence.NodeId,
presence.DestinationId,
@@ -390,7 +391,8 @@ internal static class PresenceSystem
duty,
needs.Values,
intent,
- lunchOpen);
+ lunchOpen,
+ apparel);
var decision = DecisionPlanner.Decide(
school.Catalog!,
school.Map!,
diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs
index 9c3d47c..c3d1a2d 100644
--- a/src/HSchool.Simulation/School.cs
+++ b/src/HSchool.Simulation/School.cs
@@ -85,6 +85,9 @@ public sealed class School : IDisposable
/// Street temperature and precipitation last committed for the clock and warmth.
public OutdoorWeather Weather { get; private set; } = OutdoorWeather.None;
+ /// Student and staff dress rules. Pending pair applies on the next work morning.
+ public SchoolDressRules DressRules { get; set; } = new();
+
/// Skill everyone generated for this school speaks natively.
public string? NativeLanguage { get; private set; }
@@ -339,7 +342,12 @@ public sealed class School : IDisposable
var next = EvaluateWeather();
if (force || next.Tenths != Weather.Tenths || next.Precipitation != Weather.Precipitation)
{
+ var before = Weather;
Weather = next;
+ if (Roster is not null && Catalog is not null)
+ {
+ ApparelPresence.EnqueueOnWeatherChange(this, before, next);
+ }
}
}
diff --git a/tests/HSchool.AppHost.Tests/DressRulesApiTests.cs b/tests/HSchool.AppHost.Tests/DressRulesApiTests.cs
new file mode 100644
index 0000000..b50e9f2
--- /dev/null
+++ b/tests/HSchool.AppHost.Tests/DressRulesApiTests.cs
@@ -0,0 +1,68 @@
+using System.Net;
+using System.Net.Http.Json;
+
+namespace HSchool.AppHost.Tests;
+
+[Collection(AppHostCollection.Name)]
+public class DressRulesApiTests(AppHostFixture fixture)
+{
+ private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public async Task NewSchool_DefaultRules_AreRegularNoBright()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Правила дефолт", Start, seed: 35);
+
+ var response = await client.GetAsync($"/api/schools/{school.Id}/dress-rules", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var rules = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(rules);
+ Assert.Equal("regular", rules.Students.Form);
+ Assert.Equal("noBright", rules.Students.Color);
+ Assert.Equal("regular", rules.Staff.Form);
+ Assert.Equal("noBright", rules.Staff.Color);
+ }
+
+ [Fact]
+ public async Task PostStrictToday_DoesNotChangeWornUntilNextMorning()
+ {
+ using var client = fixture.App.CreateHttpClient("server");
+ await SchoolApiTests.ResetAsync(client);
+ var school = await SchoolApiTests.CreateAsync(client, "Правила завтра", Start, seed: 35);
+ var people = await client.GetFromJsonAsync(
+ $"/api/schools/{school.Id}/people?role=student&pageSize=100",
+ TestContext.Current.CancellationToken);
+ Assert.NotNull(people);
+ var pupil = people.People.First(row => row.Roles.Contains("student"));
+ var before = await client.GetFromJsonAsync(
+ $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(pupil.Id)}?lang=ru",
+ TestContext.Current.CancellationToken);
+ Assert.NotNull(before);
+
+ var post = await client.PostAsJsonAsync(
+ $"/api/schools/{school.Id}/dress-rules",
+ new { students = new { form = "strict", color = "noBright" } },
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, post.StatusCode);
+
+ var after = await client.GetFromJsonAsync(
+ $"/api/schools/{school.Id}/people/{Uri.EscapeDataString(pupil.Id)}?lang=ru",
+ TestContext.Current.CancellationToken);
+ Assert.NotNull(after);
+ Assert.Equal(before.Worn.Select(row => row.DefName), after.Worn.Select(row => row.DefName));
+ }
+
+ private sealed record DressRulesDto(DressRulePairDto Students, DressRulePairDto Staff);
+
+ private sealed record DressRulePairDto(string Form, string Color);
+
+ private sealed record PeopleListResponse(IReadOnlyList People);
+
+ private sealed record PersonListItemResponse(string Id, IReadOnlyList Roles);
+
+ private sealed record PersonCardResponse(IReadOnlyList Worn);
+
+ private sealed record WornItemResponse(string DefName);
+}
diff --git a/tests/HSchool.Content.Tests/AppropriatenessTests.cs b/tests/HSchool.Content.Tests/AppropriatenessTests.cs
new file mode 100644
index 0000000..25d417c
--- /dev/null
+++ b/tests/HSchool.Content.Tests/AppropriatenessTests.cs
@@ -0,0 +1,55 @@
+using HSchool.Content;
+
+namespace HSchool.Content.Tests;
+
+public class AppropriatenessTests
+{
+ [Fact]
+ public void NoBright_RejectsBrightTag()
+ {
+ var catalog = LoadCatalog();
+ var rules = new DressRulePair(FormPolicies.Regular, ColorPolicies.NoBright);
+ Assert.False(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["TShirt"], "Red"));
+ Assert.True(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["TShirt"], "Blue"));
+ }
+
+ [Fact]
+ public void ShortForm_YoungPupil_UsesRegularEffectiveForm()
+ {
+ var catalog = LoadCatalog();
+ var rules = new DressRulePair(FormPolicies.Short, ColorPolicies.Free);
+ var context = new ApparelContext(
+ Female: true,
+ Age: 7,
+ IsStudent: true,
+ rules,
+ ApparelMode.Everyday,
+ OutdoorTemperatureC: 15f,
+ catalog.BehaviorRules);
+ Assert.Equal(FormPolicies.Regular, Appropriateness.EffectiveForm(context));
+ Assert.False(Appropriateness.SkirtLengthFits(context, catalog.Things["ShortSkirt"]));
+ }
+
+ [Fact]
+ public void ShortForm_OlderPupil_AllowsShortSkirt()
+ {
+ var catalog = LoadCatalog();
+ var rules = new DressRulePair(FormPolicies.Short, ColorPolicies.Free);
+ var context = new ApparelContext(
+ Female: true,
+ Age: 15,
+ IsStudent: true,
+ rules,
+ ApparelMode.Everyday,
+ OutdoorTemperatureC: 15f,
+ catalog.BehaviorRules);
+ Assert.True(Appropriateness.SkirtLengthFits(context, catalog.Things["ShortSkirt"]));
+ }
+
+ private static DefCatalog LoadCatalog()
+ {
+ var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
+ var documents = PackDocuments.FromDirectory(CatalogLoader.CorePackId, root);
+ return new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
+ }
+}
diff --git a/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs b/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs
new file mode 100644
index 0000000..7eec250
--- /dev/null
+++ b/tests/HSchool.Simulation.Tests/AppropriatenessSimulationTests.cs
@@ -0,0 +1,220 @@
+using HSchool.Ai;
+using HSchool.Content;
+using HSchool.People;
+using HSchool.Schedule;
+using HSchool.Simulation;
+
+namespace HSchool.Simulation.Tests;
+
+public class AppropriatenessSimulationTests
+{
+ private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
+ private static readonly DateTime JanuaryMorning = new(2012, 1, 10, 6, 0, 0, DateTimeKind.Utc);
+ private static readonly DateTime JulyMorning = new(2012, 7, 10, 6, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void Morning_NoBrightPolicy_WornColorsAreNotBright()
+ {
+ using var school = Open(TuesdayMorning, seed: 31);
+ school.DressRules = school.DressRules with
+ {
+ Students = new DressRulePair(FormPolicies.Regular, ColorPolicies.NoBright),
+ };
+ MorningDress.Apply(school);
+
+ foreach (var pupil in school.Roster!.People.Where(person => person.IsStudent))
+ {
+ foreach (var item in pupil.Items.Where(row => row.Location == ItemLocations.Worn))
+ {
+ if (item.Color is { } color
+ && school.Catalog!.Colors.TryGetValue(color, out var colorDef))
+ {
+ Assert.DoesNotContain(ColorTags.Bright, colorDef.Tags, StringComparer.Ordinal);
+ }
+ }
+ }
+ }
+
+ [Fact]
+ public void FrostMorning_WornOuterExists_JulyHasNoFurCoat()
+ {
+ using var january = Open(JanuaryMorning, seed: 31);
+ MorningDress.Apply(january);
+ Assert.Contains(
+ january.Roster!.People.Where(person => person.IsStudent),
+ person => person.Items.Any(item =>
+ item.Location == ItemLocations.Worn
+ && january.Catalog!.Things[item.Def].Layers.Any(layer =>
+ layer.Equals(ApparelLayers.Outer, StringComparison.Ordinal))));
+
+ using var july = Open(JulyMorning, seed: 31);
+ july.SyncWeather(force: true);
+ MorningDress.Apply(july);
+ Assert.DoesNotContain(
+ july.Roster!.People.SelectMany(person => person.Items),
+ item => item.Location == ItemLocations.Worn && item.Def == "FurCoat");
+ }
+
+ [Fact]
+ public void PeBreak_PupilReachesOwnChangingRoom()
+ {
+ var (school, pupilId) = StaffedPeClass();
+ using (school)
+ {
+ MorningDress.Apply(school);
+ AdvanceTo(school, new DateTime(2012, 4, 3, 9, 24, 0, DateTimeKind.Utc));
+ var row = school.CapturePresence().Single(item => item.PersonId == pupilId);
+ var person = school.Roster!.People.Single(item => item.Id == pupilId);
+ var room = person.Female ? "girls-changing-room" : "boys-changing-room";
+ Assert.Equal(room, row.NodeId);
+ Assert.Empty(row.Path);
+ }
+ }
+
+ [Fact]
+ public void Boy_DoesNotEnterGirlsChangingRoom()
+ {
+ var (school, pupilId) = StaffedPeClass(forceBoy: true);
+ using (school)
+ {
+ MorningDress.Apply(school);
+ AdvanceTo(school, new DateTime(2012, 4, 3, 9, 18, 0, DateTimeKind.Utc));
+ var row = school.CapturePresence().Single(item => item.PersonId == pupilId);
+ Assert.NotEqual("girls-changing-room", row.NodeId);
+ }
+ }
+
+ [Fact]
+ public void ChangeWithoutLocker_StashedItemGoesToBag()
+ {
+ using var school = Open(TuesdayMorning, seed: 31);
+ school.DressRules = school.DressRules with
+ {
+ Students = new DressRulePair(FormPolicies.Regular, ColorPolicies.NoBright),
+ };
+ var pupil = school.Roster!.People.First(person => person.IsStudent && person.LockerRoomId is null);
+ var items = pupil.Items.ToList();
+ items.Add(new InventoryItem("TShirt", "Red", 1f, ItemLocations.Worn));
+ ReplaceItems(pupil, items);
+ ApparelDresser.RedressPerson(school, pupil, ApparelMode.Everyday, includeHome: false);
+ pupil = school.Roster.People.Single(person => person.Id == pupil.Id);
+ Assert.Contains(pupil.Items, item => item.Def == "TShirt" && item.Color == "Red" && item.Location == ItemLocations.Bag);
+ }
+
+ [Fact]
+ public void PendingStrictRule_DoesNotApplyUntilNextMorning()
+ {
+ using var school = Open(TuesdayMorning, seed: 31);
+ MorningDress.Apply(school);
+ var before = BrightWornCount(school);
+
+ school.DressRules = school.DressRules with
+ {
+ PendingStudents = new DressRulePair(FormPolicies.Strict, ColorPolicies.NoBright),
+ };
+ MorningDress.Apply(school);
+ Assert.Equal(before, BrightWornCount(school));
+
+ school.Clock.JumpTo(new DateTime(2012, 4, 4, 6, 0, 0, DateTimeKind.Utc));
+ ApparelWear.Apply(school, gameMinutes: 0, before: new DateTime(2012, 4, 3, 22, 0, 0, DateTimeKind.Utc));
+ Assert.Equal(0, BrightWornCount(school));
+ }
+
+ private static int BrightWornCount(School school)
+ {
+ var count = 0;
+ foreach (var pupil in school.Roster!.People.Where(person => person.IsStudent))
+ {
+ foreach (var item in pupil.Items.Where(row => row.Location == ItemLocations.Worn))
+ {
+ if (item.Color is { } color
+ && school.Catalog!.Colors.TryGetValue(color, out var colorDef)
+ && colorDef.Tags.Contains(ColorTags.Bright, StringComparer.Ordinal))
+ {
+ count++;
+ }
+ }
+ }
+
+ return count;
+ }
+
+ private static void ReplaceItems(Person person, List items)
+ {
+ if (person.Items is IList list && !list.IsReadOnly)
+ {
+ list.Clear();
+ foreach (var item in items)
+ {
+ list.Add(item);
+ }
+ }
+ }
+
+ private static (School School, string PupilId) StaffedPeClass(bool forceBoy = false)
+ {
+ var (catalog, map) = Vanilla();
+ var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", TuesdayMorning);
+ roster = LockerAssigner.Apply(catalog, map, roster);
+ roster = DressGenerator.EnsureRoster(catalog, roster, schoolSeed: 1, TuesdayMorning);
+ var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", TuesdayMorning);
+ var schoolClass = roster.Classes.First(row =>
+ row.RoomId is "classroom-101" or "classroom-102" or "classroom-103" or "classroom-104");
+ var pupil = roster.People
+ .Where(person => schoolClass.PupilIds.Contains(person.Id))
+ .First(person => forceBoy ? !person.Female : true);
+ var school = School.Create(1, "PE", TuesdayMorning, catalog, map);
+ school.InstallPeople(roster, seed: 1, "Russia", pool, climatePresetId: "TemperateContinental");
+ school.SetTimetable(new Timetable(
+ [
+ new LessonPlacement(schoolClass.Id, "Mathematics", "t1", schoolClass.RoomId, Day: 1, Period: 1),
+ new LessonPlacement(schoolClass.Id, "PhysicalEducation", "t2", "gym-hall", Day: 1, Period: 2),
+ ],
+ []));
+ school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
+ return (school, pupil.Id);
+ }
+
+ private static School Open(DateTime start, int seed)
+ {
+ var (catalog, map) = Vanilla();
+ var roster = RosterGenerator.Generate(catalog, map, seed, "Russia", start);
+ roster = LockerAssigner.Apply(catalog, map, roster);
+ roster = DressGenerator.EnsureRoster(catalog, roster, seed, start);
+ var pool = ApplicantPool.Create(catalog, roster, seed, "Russia", start);
+ var school = School.Create(1, "Одежда", start, catalog, map);
+ school.InstallPeople(roster, seed, "Russia", pool, climatePresetId: "TemperateContinental");
+ school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 10_000);
+ return school;
+ }
+
+ private static void AdvanceTo(School school, DateTime until)
+ {
+ while (school.Clock.Time < until)
+ {
+ school.Tick(0.2d, 5d);
+ }
+ }
+
+ private static (DefCatalog Catalog, MapLayout Map) Vanilla()
+ {
+ var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
+ var documents = new List();
+ foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories))
+ {
+ if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
+ && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var relative = Path.GetRelativePath(root, path).Replace('\\', '/');
+ documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path)));
+ }
+
+ var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
+ var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
+ Assert.NotNull(map);
+ return (catalog, map);
+ }
+}