Implement phase 35 dress appropriateness and school rules.

Adds outfit evaluation, morning home dressing, locker-room ChangeClothes with day-log events, PE/weather AI goals, dress-rules API, and tests.
This commit is contained in:
Leonid Pershin
2026-08-20 05:16:52 +03:00
parent 5b63c72806
commit 6a0d5a9886
32 changed files with 1894 additions and 25 deletions
@@ -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);
}
}