Merge branch 'phase/35-appropriateness'
# Conflicts: # docs/phases/README.md
This commit is contained in:
@@ -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 сегодня не меняет одежду до следующего рабочего утра
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -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) | ⬜ | Вкладка в «Управлении», четыре комбобокса, с завтра |
|
||||||
|
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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!,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user