Merge branch 'phase/35-appropriateness'
ci / server (push) Failing after 3m44s
ci / client (push) Failing after 14s

# Conflicts:
#	docs/phases/README.md
This commit is contained in:
Leonid Pershin
2026-08-20 05:19:33 +03:00
33 changed files with 1895 additions and 26 deletions
+16 -16
View File
@@ -14,31 +14,31 @@
## Задачи ## Задачи
- [ ] Оценка уместности из данных: пол, возраст, погода/утепление, `pe`, формальность, - [x] Оценка уместности из данных: пол, возраст, погода/утепление, `pe`, формальность,
цветовые теги, длина низа. Код знает оси, пороги — defs и правила школы цветовые теги, длина низа. Код знает оси, пороги — defs и правила школы
- [ ] На школе хранятся правила учеников и сотрудников (форма + цвет). Дефолт: обычная, - [x] На школе хранятся правила учеников и сотрудников (форма + цвет). Дефолт: обычная,
без яркого. Смена с экрана — фаза 37; API уже пишет «с завтра» без яркого. Смена с экрана — фаза 37; API уже пишет «с завтра»
- [ ] «Короткая» форма: длина юбки / короткий рукав; младше порога возраста ходят в обычной - [x] «Короткая» форма: длина юбки / короткий рукав; младше порога возраста ходят в обычной
- [ ] Дома утром: мгновенно подобрать надетое под правила, погоду и сегодняшний план - [x] Дома утром: мгновенно подобрать надетое под правила, погоду и сегодняшний план
(учебники только сегодняшних уроков в сумке, остальное — шкафчик или дом) (учебники только сегодняшних уроков в сумке, остальное — шкафчик или дом)
- [ ] В школе: действие `ChangeClothes` в своей (м/ж) раздевалке. Снятое — в шкафчик, если - [x] В школе: действие `ChangeClothes` в своей (м/ж) раздевалке. Снятое — в шкафчик, если
есть, иначе в сумку есть, иначе в сумку
- [ ] Цель «одеться к физкультуре» и «вернуться с формы» рядом с обязанностью урока; не - [x] Цель «одеться к физкультуре» и «вернуться с формы» рядом с обязанностью урока; не
каждый тик, а по звонку / приходу каждый тик, а по звонку / приходу
- [ ] Цель «верх не по погоде» — дойти и сменить куртку, если уже в школе - [x] Цель «верх не по погоде» — дойти и сменить куртку, если уже в школе
- [ ] Вместимость действия — сколько одновременно переодеваются в комнате; не путать с - [x] Вместимость действия — сколько одновременно переодеваются в комнате; не путать с
числом шкафчиков числом шкафчиков
- [ ] Игрок по-прежнему не одевает с карточки - [x] Игрок по-прежнему не одевает с карточки
## Тесты, без которых фаза не закрыта ## Тесты, без которых фаза не закрыта
- [ ] Школа с «без яркого»: утром на теле нет цвета с тегом `bright` - [x] Школа с «без яркого»: утром на теле нет цвета с тегом `bright`
- [ ] «Короткая» + первоклассница → обычная длина; старше порога — можно короткое - [x] «Короткая» + первоклассница → обычная длина; старше порога — можно короткое
- [ ] Перед уроком физкультуры ученик идёт в свою раздевалку и надевает `pe`; после — снимает - [x] Перед уроком физкультуры ученик идёт в свою раздевалку и надевает `pe`; после — снимает
- [ ] Мальчик не идёт в женскую раздевалку - [x] Мальчик не идёт в женскую раздевалку
- [ ] Без шкафчика снятое оказывается в сумке, не исчезает - [x] Без шкафчика снятое оказывается в сумке, не исчезает
- [ ] Морозным утром на теле есть `Outer`; в июле шубы на теле нет - [x] Морозным утром на теле есть `Outer`; в июле шубы на теле нет
- [ ] Смена правила через API сегодня не меняет одежду до следующего рабочего утра - [x] Смена правила через API сегодня не меняет одежду до следующего рабочего утра
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -147,7 +147,7 @@
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [34. Износ и дом](34-wear-home.md) | ✅ | Прочность, утренняя замена, из ветхого не выходят | | [34. Износ и дом](34-wear-home.md) | ✅ | Прочность, утренняя замена, из ветхого не выходят |
| [35. Уместность](35-appropriateness.md) | 🔄 | Правила, переодевание действием, физкультура | | [35. Уместность](35-appropriateness.md) | | Правила, переодевание действием, физкультура |
| [36. Вкладки карточки](36-person-card-tabs.md) | 🔄 | Одежда, ноша, сейчас + история за сегодня | | [36. Вкладки карточки](36-person-card-tabs.md) | 🔄 | Одежда, ноша, сейчас + история за сегодня |
| [37. Правила школы](37-school-rules.md) | ⬜ | Вкладка в «Управлении», четыре комбобокса, с завтра | | [37. Правила школы](37-school-rules.md) | ⬜ | Вкладка в «Управлении», четыре комбобокса, с завтра |
+28
View File
@@ -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) `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. 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` ### `GET /api/schools/{id}/staffing`
Money, uncovered subjects, the applicant pool and current staff. Reads the **published** Money, uncovered subjects, the applicant pool and current staff. Reads the **published**
+152
View File
@@ -0,0 +1,152 @@
using HSchool.Content;
namespace HSchool.Ai;
/// <summary>Why someone should walk to their locker room.</summary>
public enum ApparelGoalKind
{
None,
Pe,
Everyday,
Weather,
}
/// <summary>
/// Dress-code and weather goals beside duty. Triggered on bells and arrival, not every tick.
/// </summary>
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));
}
}
/// <summary>Apparel inputs the generic planner does not carry.</summary>
public readonly record struct ApparelActor(
bool Female,
ApparelIssue Issues,
ApparelGoalKind Kind,
string? LockerRoomNode);
+34 -4
View File
@@ -6,6 +6,7 @@ public enum GoalKind
{ {
None, None,
Duty, Duty,
Apparel,
Need, Need,
Leisure, Leisure,
} }
@@ -39,7 +40,8 @@ public readonly record struct ActorState(
string? DutyRoom, string? DutyRoom,
IReadOnlyDictionary<string, float> Needs, IReadOnlyDictionary<string, float> Needs,
Intent Intent, Intent Intent,
bool LunchWindowOpen = false); bool LunchWindowOpen = false,
ApparelActor Apparel = default);
/// <summary> /// <summary>
/// Picks a goal by weight and plans walk-then-do. No world, no clock — a table of inputs to an /// 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; var best = Intent.None;
Consider(ref best, DutyGoal(state, rules)); 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)) foreach (var need in catalog.Needs.Values.OrderBy(def => def.DefName, StringComparer.Ordinal))
{ {
Consider(ref best, NeedGoal(catalog, map, walks, state, occupied, need, rules)); Consider(ref best, NeedGoal(catalog, map, walks, state, occupied, need, rules));
@@ -138,6 +144,8 @@ public static class DecisionPlanner
{ {
case GoalKind.Duty: case GoalKind.Duty:
return DutyGoal(state, rules); return DutyGoal(state, rules);
case GoalKind.Apparel:
return ApparelGoals.Goal(catalog, map, walks, state.Apparel, state, occupied) ?? Intent.None;
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))
{ {
@@ -305,6 +313,27 @@ public static class DecisionPlanner
return new Decision(room, null, goal); 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)) if (goal.ActionId is null || !catalog.Actions.TryGetValue(goal.ActionId, out var action))
{ {
return Decision.Stay(Intent.None); return Decision.Stay(Intent.None);
@@ -485,9 +514,10 @@ public static class DecisionPlanner
private static int Order(GoalKind kind) => kind switch private static int Order(GoalKind kind) => kind switch
{ {
GoalKind.Duty => 0, GoalKind.Duty => 0,
GoalKind.Need => 1, GoalKind.Apparel => 1,
GoalKind.Leisure => 2, GoalKind.Need => 2,
_ => 3, GoalKind.Leisure => 3,
_ => 4,
}; };
/// <summary>Effective numbers: the catalog's BehaviorDef, or the constants when a pack has none.</summary> /// <summary>Effective numbers: the catalog's BehaviorDef, or the constants when a pack has none.</summary>
+204
View File
@@ -0,0 +1,204 @@
namespace HSchool.Content;
/// <summary>What the person should wear right now: everyday school clothes or PE kit.</summary>
public enum ApparelMode
{
Everyday,
Pe,
}
/// <summary>Why the current outfit is wrong. Used by AI goals and tests.</summary>
[Flags]
public enum ApparelIssue
{
None = 0,
PeRequired = 1,
PeForbidden = 2,
OuterRequired = 4,
OuterForbidden = 8,
Formality = 16,
Color = 32,
SkirtLength = 64,
}
/// <summary>
/// Inputs for dress-code and weather checks. Thresholds live on <see cref="BehaviorDef"/>, not here.
/// </summary>
public readonly record struct ApparelContext(
bool Female,
int Age,
bool IsStudent,
DressRulePair Rules,
ApparelMode Mode,
float OutdoorTemperatureC,
BehaviorDef? Behavior);
/// <summary>
/// Scores worn items against school rules, age, weather and lesson. Evaluation only — no mutation.
/// </summary>
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));
}
+79
View File
@@ -0,0 +1,79 @@
namespace HSchool.Content;
/// <summary>School dress-code form strictness. Values are wire and save ids.</summary>
public static class FormPolicies
{
public const string Regular = "regular";
public const string Short = "short";
public const string Strict = "strict";
public static readonly IReadOnlyList<string> All = [Regular, Short, Strict];
public static bool IsKnown(string value) =>
All.Any(candidate => candidate.Equals(value, StringComparison.Ordinal));
}
/// <summary>Colour policy ids. Independent from <see cref="FormPolicies"/>.</summary>
public static class ColorPolicies
{
public const string Free = "free";
public const string NoBright = "noBright";
public const string WhiteTopBlackBottom = "whiteTopBlackBottom";
public static readonly IReadOnlyList<string> All = [Free, NoBright, WhiteTopBlackBottom];
public static bool IsKnown(string value) =>
All.Any(candidate => candidate.Equals(value, StringComparison.Ordinal));
}
/// <summary>One role's form + colour pair.</summary>
public sealed record DressRulePair(string Form, string Color)
{
public static DressRulePair Default { get; } = new(FormPolicies.Regular, ColorPolicies.NoBright);
}
/// <summary>Live rules plus optional next-day overrides waiting for a work morning.</summary>
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;
}
/// <summary>Catalog action ids for locker-room changes.</summary>
public static class ApparelActions
{
public const string ChangeMale = "ChangeClothesMale";
public const string ChangeFemale = "ChangeClothesFemale";
public static string For(bool female) => female ? ChangeFemale : ChangeMale;
}
+21
View File
@@ -287,6 +287,27 @@ public sealed class BehaviorDef : Def
/// </summary> /// </summary>
public IReadOnlyList<ApparelConditionBand> ApparelConditionBands { get; init; } = DefaultConditionBands; public IReadOnlyList<ApparelConditionBand> ApparelConditionBands { get; init; } = DefaultConditionBands;
/// <summary>Younger pupils keep regular hemlines even when the school chose short form.</summary>
public int ShortFormMinAge { get; init; } = 13;
/// <summary>Everyday and short-form minimum formality on worn layers.</summary>
public int FormalityRegularMin { get; init; } = 20;
/// <summary>Strict-form minimum formality on worn layers.</summary>
public int FormalityStrictMin { get; init; } = 60;
/// <summary>Street below this °C expects an <see cref="ApparelLayers.Outer"/> layer.</summary>
public float OuterBelowC { get; init; } = 10f;
/// <summary>Above this street °C a fur coat is inappropriate.</summary>
public float HeavyOuterAboveC { get; init; } = 15f;
/// <summary>Walk to the locker room and change. Just above duty travel.</summary>
public float ApparelGoalWeight { get; init; } = 6f;
/// <summary>Game minutes for <c>ChangeClothes*</c> actions.</summary>
public float ChangeClothesMinutes { get; init; } = 5f;
public static IReadOnlyList<ApparelConditionBand> DefaultConditionBands { get; } = public static IReadOnlyList<ApparelConditionBand> DefaultConditionBands { get; } =
[ [
new() { Min = 0.75f, Id = "ApparelConditionIntact" }, new() { Min = 0.75f, Id = "ApparelConditionIntact" },
@@ -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;
}
}
+72
View File
@@ -317,6 +317,53 @@ internal static class SchoolEndpoints
}) })
.WithName("GenerateSchoolPersonPortrait"); .WithName("GenerateSchoolPersonPortrait");
schools.MapGet("/{id:int}/dress-rules", async (
int id,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
var command = new GameCommand.GetDressRules(id, NewCompletion<DressRulesOutcome>());
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<DressRulesOutcome>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return DressRulesHttp(outcome);
})
.WithName("SetSchoolDressRules");
schools.MapGet("/{id:int}/staffing", ( schools.MapGet("/{id:int}/staffing", (
int id, int id,
string? lang, string? lang,
@@ -683,6 +730,31 @@ internal static class SchoolEndpoints
return Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: extensions); 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."),
};
} }
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary> /// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
+10
View File
@@ -101,4 +101,14 @@ internal abstract record GameCommand
int Day, int Day,
int Period, int Period,
TaskCompletionSource<TimetableOutcome> Result) : GameCommand; TaskCompletionSource<TimetableOutcome> Result) : GameCommand;
internal sealed record GetDressRules(
int SchoolId,
TaskCompletionSource<DressRulesOutcome> Result) : GameCommand;
internal sealed record SetDressRules(
int SchoolId,
DressRulePair? PendingStudents,
DressRulePair? PendingStaff,
TaskCompletionSource<DressRulesOutcome> Result) : GameCommand;
} }
+24 -2
View File
@@ -225,6 +225,17 @@ internal sealed class GameLoopService(
new WorkerCommand.UnpinLesson(unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period, unpin.Result), new WorkerCommand.UnpinLesson(unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period, unpin.Result),
unpin.Result); unpin.Result);
break; 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<DressRulesOutcome> 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<StaffingOutcome> result) private void HandleStaffing(int schoolId, WorkerCommand command, TaskCompletionSource<StaffingOutcome> result)
{ {
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
@@ -560,7 +579,8 @@ internal sealed class GameLoopService(
save.ClimatePresetId, save.ClimatePresetId,
save.NativeLanguage, save.NativeLanguage,
createSeed: null, createSeed: null,
save.Presence); save.Presence,
save.DressRules);
worker.Start(); worker.Start();
try try
@@ -611,7 +631,8 @@ internal sealed class GameLoopService(
string? climatePresetId, string? climatePresetId,
string? nativeLanguage, string? nativeLanguage,
int? createSeed = null, int? createSeed = null,
IReadOnlyList<PresenceSnapshot>? presence = null) => IReadOnlyList<PresenceSnapshot>? presence = null,
SchoolDressRules? dressRules = null) =>
new( new(
id, id,
name, name,
@@ -626,6 +647,7 @@ internal sealed class GameLoopService(
nativeLanguage, nativeLanguage,
createSeed, createSeed,
presence, presence,
dressRules,
_options, _options,
clients, clients,
metrics, metrics,
+5
View File
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using HSchool.Content; using HSchool.Content;
using HSchool.Content;
using HSchool.People; using HSchool.People;
using HSchool.Schedule; using HSchool.Schedule;
using HSchool.Simulation; using HSchool.Simulation;
@@ -36,6 +37,8 @@ internal sealed class SchoolSave
public string? NativeLanguage { get; init; } public string? NativeLanguage { get; init; }
public IReadOnlyList<PresenceSnapshot>? Presence { get; init; } public IReadOnlyList<PresenceSnapshot>? Presence { get; init; }
public SchoolDressRules? DressRules { get; init; }
} }
/// <summary>Allocates school ids that survive a process restart.</summary> /// <summary>Allocates school ids that survive a process restart.</summary>
@@ -188,6 +191,7 @@ internal sealed class SchoolStore
NameSetId = save.NameSetId, NameSetId = save.NameSetId,
NativeLanguage = save.NativeLanguage, NativeLanguage = save.NativeLanguage,
Presence = save.Presence, Presence = save.Presence,
DressRules = save.DressRules,
}); });
} }
catch (Exception ex) catch (Exception ex)
@@ -233,6 +237,7 @@ internal sealed class SchoolStore
NameSetId = save.NameSetId, NameSetId = save.NameSetId,
NativeLanguage = save.NativeLanguage, NativeLanguage = save.NativeLanguage,
Presence = save.Presence, Presence = save.Presence,
DressRules = save.DressRules,
}; };
} }
+40
View File
@@ -37,6 +37,7 @@ internal sealed class SchoolWorker
private string? _nativeLanguage; private string? _nativeLanguage;
private readonly int? _createSeed; private readonly int? _createSeed;
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence; private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
private readonly SchoolDressRules? _savedDressRules;
private readonly Action<int> _onFailed; private readonly Action<int> _onFailed;
private readonly int _id; private readonly int _id;
@@ -72,6 +73,7 @@ internal sealed class SchoolWorker
string? nativeLanguage, string? nativeLanguage,
int? createSeed, int? createSeed,
IReadOnlyList<PresenceSnapshot>? savedPresence, IReadOnlyList<PresenceSnapshot>? savedPresence,
SchoolDressRules? savedDressRules,
SimulationOptions options, SimulationOptions options,
ClientRegistry clients, ClientRegistry clients,
GameMetrics metrics, GameMetrics metrics,
@@ -93,6 +95,7 @@ internal sealed class SchoolWorker
_nativeLanguage = nativeLanguage; _nativeLanguage = nativeLanguage;
_createSeed = createSeed; _createSeed = createSeed;
_savedPresence = savedPresence; _savedPresence = savedPresence;
_savedDressRules = savedDressRules;
_options = options; _options = options;
_clients = clients; _clients = clients;
_metrics = metrics; _metrics = metrics;
@@ -248,6 +251,7 @@ internal sealed class SchoolWorker
} }
_school = school; _school = school;
school.DressRules = _savedDressRules ?? new SchoolDressRules();
PublishSnapshot(); PublishSnapshot();
if (_isNew) if (_isNew)
@@ -458,6 +462,29 @@ internal sealed class SchoolWorker
unpin.Result.TrySetResult( unpin.Result.TrySetResult(
ApplyUnpin(school, unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period)); ApplyUnpin(school, unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period));
break; 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) catch (Exception ex)
@@ -511,6 +538,12 @@ internal sealed class SchoolWorker
case WorkerCommand.UnpinLesson unpin: case WorkerCommand.UnpinLesson unpin:
unpin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool)); unpin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
break; 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: case WorkerCommand.UnpinLesson unpin:
unpin.Result.TrySetException(exception); unpin.Result.TrySetException(exception);
break; 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, ClimatePresetId = school.ClimatePresetId,
NativeLanguage = _nativeLanguage, NativeLanguage = _nativeLanguage,
Presence = school.CapturePresence(), Presence = school.CapturePresence(),
DressRules = school.DressRules,
}); });
} }
catch (Exception ex) catch (Exception ex)
+8
View File
@@ -1,3 +1,4 @@
using HSchool.Content;
using HSchool.People; using HSchool.People;
using HSchool.Server.Api; using HSchool.Server.Api;
using HSchool.Server.Net; using HSchool.Server.Net;
@@ -63,4 +64,11 @@ internal abstract record WorkerCommand
int Day, int Day,
int Period, int Period,
TaskCompletionSource<TimetableOutcome> Result) : WorkerCommand; TaskCompletionSource<TimetableOutcome> Result) : WorkerCommand;
internal sealed record GetDressRules(TaskCompletionSource<DressRulesOutcome> Result) : WorkerCommand;
internal sealed record SetDressRules(
DressRulePair? PendingStudents,
DressRulePair? PendingStaff,
TaskCompletionSource<DressRulesOutcome> Result) : WorkerCommand;
} }
@@ -50,4 +50,20 @@
"roles": ["student", "staff"], "roles": ["student", "staff"],
"weight": 1, "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,
},
] ]
@@ -38,4 +38,12 @@
{ "min": 0.15, "id": "ApparelConditionTorn" }, { "min": 0.15, "id": "ApparelConditionTorn" },
{ "min": 0, "id": "ApparelConditionRags" }, { "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,
} }
@@ -56,6 +56,7 @@
"lockerSex": "male", "lockerSex": "male",
"slots": [ "slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 }, { "key": "lockers", "thing": "Locker", "count": 12 },
{ "key": "spots", "thing": "ChangingSpot", "count": 4 },
], ],
"travelMinutes": 0.5, "travelMinutes": 0.5,
}, },
@@ -64,6 +65,7 @@
"lockerSex": "female", "lockerSex": "female",
"slots": [ "slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 }, { "key": "lockers", "thing": "Locker", "count": 12 },
{ "key": "spots", "thing": "ChangingSpot", "count": 4 },
], ],
"travelMinutes": 0.5, "travelMinutes": 0.5,
}, },
@@ -7,4 +7,5 @@
{ "defName": "Computer", "pupilSlots": 1 }, { "defName": "Computer", "pupilSlots": 1 },
{ "defName": "MedicalCouch" }, { "defName": "MedicalCouch" },
{ "defName": "Locker" }, { "defName": "Locker" },
{ "defName": "ChangingSpot" },
] ]
@@ -95,6 +95,9 @@
"UtilityRoom": "Utility room", "UtilityRoom": "Utility room",
"MaleChangingRoom": "Boys' changing room", "MaleChangingRoom": "Boys' changing room",
"FemaleChangingRoom": "Girls' changing room", "FemaleChangingRoom": "Girls' changing room",
"ChangingSpot": "Changing spot",
"ChangeClothesMale": "Change clothes",
"ChangeClothesFemale": "Change clothes",
"Mathematics": "Mathematics", "Mathematics": "Mathematics",
"RussianLanguage": "Russian", "RussianLanguage": "Russian",
"Literature": "Literature", "Literature": "Literature",
@@ -173,5 +176,6 @@
"ActionStarted": "started: {0}", "ActionStarted": "started: {0}",
"ActionEnded": "finished: {0}", "ActionEnded": "finished: {0}",
"ApparelReplaced": "got a new {0}", "ApparelReplaced": "got a new {0}",
"ApparelChanged": "changed clothes: {0}",
"core": "Core", "core": "Core",
} }
@@ -95,6 +95,9 @@
"UtilityRoom": "Теплоузел", "UtilityRoom": "Теплоузел",
"MaleChangingRoom": "Мужская раздевалка", "MaleChangingRoom": "Мужская раздевалка",
"FemaleChangingRoom": "Женская раздевалка", "FemaleChangingRoom": "Женская раздевалка",
"ChangingSpot": "Место переодевания",
"ChangeClothesMale": "Переодеться",
"ChangeClothesFemale": "Переодеться",
"Mathematics": "Математика", "Mathematics": "Математика",
"RussianLanguage": "Русский язык", "RussianLanguage": "Русский язык",
"Literature": "Литература", "Literature": "Литература",
@@ -173,5 +176,6 @@
"ActionStarted": "начал: {0}", "ActionStarted": "начал: {0}",
"ActionEnded": "закончил: {0}", "ActionEnded": "закончил: {0}",
"ApparelReplaced": "получил новую {0}", "ApparelReplaced": "получил новую {0}",
"ApparelChanged": "переоделся: {0}",
"core": "Базовая игра", "core": "Базовая игра",
} }
@@ -205,6 +205,7 @@
"floor": "gym-floor", "floor": "gym-floor",
"slots": [ "slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 }, { "key": "lockers", "thing": "Locker", "count": 12 },
{ "key": "spots", "thing": "ChangingSpot", "count": 4 },
], ],
}, },
{ {
@@ -214,6 +215,7 @@
"floor": "gym-floor", "floor": "gym-floor",
"slots": [ "slots": [
{ "key": "lockers", "thing": "Locker", "count": 12 }, { "key": "lockers", "thing": "Locker", "count": 12 },
{ "key": "spots", "thing": "ChangingSpot", "count": 4 },
], ],
}, },
], ],
+61
View File
@@ -1,6 +1,7 @@
using Arch.Core; using Arch.Core;
using HSchool.Ai; using HSchool.Ai;
using HSchool.Content; using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation; namespace HSchool.Simulation;
@@ -25,6 +26,21 @@ internal static class ActivitySystem
return false; 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 started = false;
var world = school.World; var world = school.World;
world.Query( world.Query(
@@ -46,6 +62,11 @@ internal static class ActivitySystem
return; return;
} }
if (person is not null && !SexFitsChange(action, person))
{
return;
}
var location = school.Map.NodeDef(presence.NodeId); var location = school.Map.NodeDef(presence.NodeId);
if (location is null || !location.Equals(action.Room, StringComparison.Ordinal)) 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); needs.Values[action.Need] = ActionStepper.ApplyNeedGain(current, action, need);
} }
if (IsChangeClothes(action.DefName))
{
FinishChangeClothes(school, identity.Id);
}
activity = PersonActivity.Idle; activity = PersonActivity.Idle;
completed.Add(identity.Id); completed.Add(identity.Id);
}); });
@@ -124,6 +150,26 @@ internal static class ActivitySystem
return completed; 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) private static int Occupied(School school, string nodeId, string thing)
{ {
var occupied = 0; var occupied = 0;
@@ -168,4 +214,19 @@ internal static class ActivitySystem
return false; 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;
}
} }
+499
View File
@@ -0,0 +1,499 @@
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Simulation;
/// <summary>
/// Picks and applies outfits from worn, bag, locker and home. Used at home instantly and after
/// <c>ChangeClothes</c> in a locker room.
/// </summary>
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<string>? 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<InventoryItem> SelectOutfit(
DefCatalog catalog,
ApparelContext context,
List<(InventoryItem Item, int Index)> pool,
ApparelMode mode)
{
var chosen = new List<InventoryItem>();
var occupied = new HashSet<string>(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<string> 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<InventoryItem> target,
IReadOnlyList<string>? 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<InventoryItem> items,
IReadOnlyList<string> 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<InventoryItem> items,
IReadOnlyList<string> 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<PersonIdentity, PersonInsulation>();
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<InventoryItem> items)
{
if (person.Items is IList<InventoryItem> 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);
/// <summary>Subjects this pupil needs in the bag today.</summary>
public static IReadOnlyList<string> 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();
}
}
+96
View File
@@ -0,0 +1,96 @@
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Simulation;
/// <summary>Builds apparel goal inputs for the decision planner.</summary>
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;
}
}
+5 -2
View File
@@ -47,12 +47,15 @@ internal static class ApparelWear
} }
school.ResetDayLog(); 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;
} }
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) internal static bool CrossedDayStart(DateTime before, DateTime after)
+76
View File
@@ -0,0 +1,76 @@
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Simulation;
/// <summary>Applies pending dress rules and home morning outfits at six in the morning.</summary>
internal static class MorningDress
{
private static readonly Arch.Core.QueryDescription Identities =
new Arch.Core.QueryDescription().WithAll<PersonIdentity, Presence>();
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<string> OffCampusIds(School school)
{
var ids = new HashSet<string>(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));
}
}
+8
View File
@@ -38,6 +38,14 @@ public sealed record PersonLogEvent(string PersonId, DateTime Time, string Type,
return string.Format(CultureInfo.InvariantCulture, catalog.Text(locale, "ApparelReplaced"), name); 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) if (Type.Equals(PersonLogTypes.ActionStarted, StringComparison.Ordinal)
|| Type.Equals(PersonLogTypes.ActionEnded, StringComparison.Ordinal)) || Type.Equals(PersonLogTypes.ActionEnded, StringComparison.Ordinal))
{ {
+3 -1
View File
@@ -378,6 +378,7 @@ internal static class PresenceSystem
var frame = school.Catalog!.DayFrame; var frame = school.Catalog!.DayFrame;
var lunchOpen = frame is not null var lunchOpen = frame is not null
&& SchoolDay.IsLunchWindow(frame, slot, ClassOf(school, person)?.Year); && SchoolDay.IsLunchWindow(frame, slot, ClassOf(school, person)?.Year);
var apparel = ApparelPresence.Build(school, person, ClassOf(school, person));
var state = new ActorState( var state = new ActorState(
presence.NodeId, presence.NodeId,
presence.DestinationId, presence.DestinationId,
@@ -390,7 +391,8 @@ internal static class PresenceSystem
duty, duty,
needs.Values, needs.Values,
intent, intent,
lunchOpen); lunchOpen,
apparel);
var decision = DecisionPlanner.Decide( var decision = DecisionPlanner.Decide(
school.Catalog!, school.Catalog!,
school.Map!, school.Map!,
+8
View File
@@ -85,6 +85,9 @@ public sealed class School : IDisposable
/// <summary>Street temperature and precipitation last committed for the clock and warmth.</summary> /// <summary>Street temperature and precipitation last committed for the clock and warmth.</summary>
public OutdoorWeather Weather { get; private set; } = OutdoorWeather.None; public OutdoorWeather Weather { get; private set; } = OutdoorWeather.None;
/// <summary>Student and staff dress rules. Pending pair applies on the next work morning.</summary>
public SchoolDressRules DressRules { get; set; } = new();
/// <summary>Skill everyone generated for this school speaks natively.</summary> /// <summary>Skill everyone generated for this school speaks natively.</summary>
public string? NativeLanguage { get; private set; } public string? NativeLanguage { get; private set; }
@@ -339,7 +342,12 @@ public sealed class School : IDisposable
var next = EvaluateWeather(); var next = EvaluateWeather();
if (force || next.Tenths != Weather.Tenths || next.Precipitation != Weather.Precipitation) if (force || next.Tenths != Weather.Tenths || next.Precipitation != Weather.Precipitation)
{ {
var before = Weather;
Weather = next; Weather = next;
if (Roster is not null && Catalog is not null)
{
ApparelPresence.EnqueueOnWeatherChange(this, before, next);
}
} }
} }
@@ -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<DressRulesDto>(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<PeopleListResponse>(
$"/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<PersonCardResponse>(
$"/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<PersonCardResponse>(
$"/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<PersonListItemResponse> People);
private sealed record PersonListItemResponse(string Id, IReadOnlyList<string> Roles);
private sealed record PersonCardResponse(IReadOnlyList<WornItemResponse> Worn);
private sealed record WornItemResponse(string DefName);
}
@@ -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);
}
}
@@ -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<InventoryItem> items)
{
if (person.Items is IList<InventoryItem> 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<ContentDocument>();
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);
}
}