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