Enhance school day structure and decision-making for lunch breaks
ci / server (push) Failing after 3m43s
ci / client (push) Successful in 15s

- Updated `ai.md` to clarify the mechanics of hunger restoration and the importance of lunch breaks in the school schedule.
- Revised `schedule.md` to detail the new lunch break structure, allowing for separate sittings for different grade levels.
- Enhanced `Decision.cs` and `DecisionPlanner.cs` to incorporate logic for lunch breaks, ensuring that students only leave lessons during their designated lunch windows.
- Updated `DayFrameDef` and related classes to support multiple lunch breaks and validate their configurations.
- Adjusted tests to validate the new decision-making logic regarding lunch breaks and hunger management, ensuring robust functionality.
- Improved localization strings to reflect changes in the school day structure and lunch functionalities.
This commit is contained in:
Leonid Pershin
2026-08-19 23:17:16 +03:00
parent a441ed9763
commit 5eabc90d53
33 changed files with 831 additions and 404 deletions
@@ -0,0 +1,160 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Simulation.Tests;
/// <summary>
/// The canteen is fed in two sittings so it is not the whole school at once: juniors eat in the
/// break after the third lesson, seniors after the fourth. Before that, everybody crossed the
/// hunger threshold at roughly the same minute and the room could not hold them.
/// </summary>
public class LunchTests
{
private static readonly DateTime Tuesday6 = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public void JuniorsEatAtTheFirstSitting_SeniorsAtTheSecond()
{
using var school = StaffedSchool();
var (junior, senior) = Parallels(school);
// Staff belong to no parallel and may eat at either sitting, so the claim is about pupils:
// the two halves of the school never meet in the canteen.
AdvanceTo(school, new DateTime(2012, 4, 3, 11, 15, 0, DateTimeKind.Utc));
var first = InCafeteria(school);
Assert.Contains(first, junior.Contains);
Assert.DoesNotContain(first, senior.Contains);
AdvanceTo(school, new DateTime(2012, 4, 3, 12, 20, 0, DateTimeKind.Utc));
var second = InCafeteria(school);
Assert.Contains(second, senior.Contains);
Assert.DoesNotContain(second, junior.Contains);
}
/// <summary>
/// Hunger is restored off campus — people eat at home — so a school week does not leave the
/// roster starving. It used to: nothing refilled hunger except eight chairs, and by the end of
/// the week a sixth of the school sat at zero.
/// </summary>
[Fact]
public void AfterASchoolDay_NobodyIsLeftStarving()
{
using var school = StaffedSchool();
var pupils = school.Roster!.People.Where(person => person.IsStudent).Select(person => person.Id)
.ToHashSet(StringComparer.Ordinal);
AdvanceTo(school, new DateTime(2012, 4, 4, 8, 0, 0, DateTimeKind.Utc));
var hunger = Hunger(school, pupils);
Assert.NotEmpty(hunger);
Assert.All(hunger.Values, value => Assert.True(value > 0.5f, $"somebody came back to school at {value:F2}"));
}
private static School StaffedSchool()
{
var (catalog, map) = Vanilla();
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", Tuesday6);
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Slavic", Tuesday6);
foreach (var subject in catalog.Subjects.Values.Where(def => !def.Abstract).Select(def => def.DefName))
{
if (pool.Applicants.Count == 0)
{
break;
}
var candidate = pool.Applicants[0].Person.Id;
var hired = Staffing.Hire(catalog, map, roster, pool, candidate, Staffing.TeacherPosition, 1_000_000f);
if (hired.Error != StaffingError.None)
{
break;
}
roster = hired.Roster;
pool = hired.Pool;
var assigned = Staffing.AssignSubject(catalog, roster, pool, candidate, subject, 1_000_000f);
if (assigned.Error == StaffingError.None)
{
roster = assigned.Roster;
}
}
var school = School.Create(1, "Столовая", Tuesday6, catalog, map);
school.InstallPeople(roster, seed: 1, "Slavic", pool);
school.SetTimetable(SchoolTimetables.Build(catalog, map, roster, null, weekDays: 5));
school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 64);
return school;
}
private static (HashSet<string> Junior, HashSet<string> Senior) Parallels(School school)
{
var junior = new HashSet<string>(StringComparer.Ordinal);
var senior = new HashSet<string>(StringComparer.Ordinal);
foreach (var row in school.Roster!.Classes)
{
foreach (var id in row.PupilIds)
{
(row.Year <= 5 ? junior : senior).Add(id);
}
}
return (junior, senior);
}
private static string[] InCafeteria(School school) =>
school.CapturePresence()
.Where(row => row.NodeId == "cafeteria")
.Select(row => row.PersonId)
.ToArray();
private static Dictionary<string, float> Hunger(School school, HashSet<string> pupils)
{
var values = new Dictionary<string, float>(StringComparer.Ordinal);
var query = new Arch.Core.QueryDescription().WithAll<PersonIdentity, PersonNeeds>();
school.World.Query(in query, (ref PersonIdentity identity, ref PersonNeeds needs) =>
{
if (pupils.Contains(identity.Id) && needs.Values.TryGetValue("Hunger", out var value))
{
values[identity.Id] = value;
}
});
return values;
}
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 = PackDocumentsFrom(root);
var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
Assert.NotNull(map);
return (catalog, map);
}
private static IReadOnlyList<ContentDocument> PackDocumentsFrom(string root)
{
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(Path.DirectorySeparatorChar, (char)47);
documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path)));
}
return documents;
}
}
@@ -32,7 +32,7 @@ public class NeedDecayTests
}
[Fact]
public void Sleep_ReturnsToMaxOffCampus()
public void SleepAndHunger_ReturnToMaxOffCampus()
{
var catalog = VanillaCatalog();
var world = World.Create();
@@ -50,8 +50,9 @@ public class NeedDecayTests
var query = new QueryDescription().WithAll<PersonNeeds>();
world.Query(in query, (ref PersonNeeds needs) =>
{
// Both are restored за кадром: the day at home covers a night and meals alike.
Assert.Equal(catalog.Needs["Sleep"].Max, needs.Values["Sleep"]);
Assert.Equal(0.4f, needs.Values["Hunger"]);
Assert.Equal(catalog.Needs["Hunger"].Max, needs.Values["Hunger"]);
});
}
finally
@@ -0,0 +1,81 @@
namespace HSchool.Simulation.Tests;
/// <summary>
/// What used to be checked through <c>SchoolRegistry</c>. The registry is gone — the server gives
/// every school its own worker — but the name and start-date rules it guarded are still the ones
/// the create endpoint applies.
/// </summary>
public class SchoolNamesTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\u0009\u000A")]
public void BlankNames_AreRejected(string? name)
{
Assert.False(SchoolNames.TryNormalize(name, out _));
}
[Fact]
public void OverlongNames_AreRejected()
{
Assert.False(SchoolNames.TryNormalize(new string('ш', School.MaxNameLength + 1), out _));
Assert.True(SchoolNames.TryNormalize(new string('ш', School.MaxNameLength), out _));
}
[Fact]
public void SurroundingSpaceAndControlCharacters_AreStripped()
{
Assert.True(SchoolNames.TryNormalize(" Лицей ", out var normalized));
Assert.Equal("Лицей", normalized);
}
[Fact]
public void StartDatesOutsideTheSupportedRange_AreRejected()
{
Assert.False(GameClock.IsValidStartDate(new DateTime(1500, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
Assert.False(GameClock.IsValidStartDate(new DateTime(3200, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
Assert.True(GameClock.IsValidStartDate(new DateTime(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc)));
}
[Fact]
public void SuggestedNames_AreUsableAndNeverRepeatATakenOne()
{
var generator = new SchoolNameGenerator(new Random(1234));
var taken = new List<string>();
for (var i = 0; i < 20; i++)
{
var suggestion = generator.Next(taken, SchoolNameLanguage.Russian);
Assert.True(SchoolNames.TryNormalize(suggestion, out _), $"\"{suggestion}\" is not a usable name.");
Assert.DoesNotContain(suggestion, taken, StringComparer.OrdinalIgnoreCase);
taken.Add(suggestion);
}
}
[Fact]
public void SuggestedNames_FitTheNameLimitInBothLanguages()
{
var generator = new SchoolNameGenerator(new Random(1234));
foreach (var language in new[] { SchoolNameLanguage.Russian, SchoolNameLanguage.English })
{
for (var i = 0; i < 200; i++)
{
Assert.InRange(generator.Next([], language).Length, 1, School.MaxNameLength);
}
}
}
[Fact]
public void SuggestedEnglishNames_AreAscii()
{
var generator = new SchoolNameGenerator(new Random(1234));
for (var i = 0; i < 50; i++)
{
Assert.Matches("^[A-Za-z0-9 .]+$", generator.Next([], SchoolNameLanguage.English));
}
}
}
@@ -1,194 +0,0 @@
namespace HSchool.Simulation.Tests;
public class SchoolRegistryTests
{
private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
private static SchoolRegistry NewRegistry(int maxSchools = 6) =>
new(new SimulationOptions { MaxSchools = maxSchools, TickRate = 20, GameMinutesPerRealSecond = 5 });
[Fact]
public void NewRegistry_IsEmpty()
{
using var registry = NewRegistry();
Assert.Equal(0, registry.Count);
Assert.Equal(6, registry.MaxSchools);
Assert.False(registry.IsFull);
}
[Fact]
public void Create_AddsASchoolAtTheGivenStartDate()
{
using var registry = NewRegistry();
var result = registry.Create("Гимназия №1", Start);
Assert.True(result.Succeeded);
Assert.Equal("Гимназия №1", result.School!.Name);
Assert.Equal(Start, result.School.Clock.Time);
// A new school starts living straight away; only the pause button stops it.
Assert.True(result.School.Clock.IsRunning);
Assert.Equal(1, registry.Count);
}
[Fact]
public void Create_BeyondTheLimit_Fails()
{
using var registry = NewRegistry(maxSchools: 2);
registry.Create("Первая", Start);
registry.Create("Вторая", Start);
var result = registry.Create("Третья", Start);
Assert.False(result.Succeeded);
Assert.Equal(SchoolCreationError.LimitReached, result.Error);
Assert.True(registry.IsFull);
Assert.Equal(2, registry.Count);
}
[Fact]
public void Delete_FreesASlot()
{
using var registry = NewRegistry(maxSchools: 1);
var first = registry.Create("Первая", Start).School!;
Assert.False(registry.Create("Вторая", Start).Succeeded);
Assert.True(registry.Delete(first.Id));
Assert.True(registry.Create("Вторая", Start).Succeeded);
}
[Fact]
public void Delete_UnknownId_ReportsFailure()
{
using var registry = NewRegistry();
Assert.False(registry.Delete(42));
}
[Fact]
public void Ids_AreNotReusedAfterDeletion()
{
using var registry = NewRegistry();
var first = registry.Create("Первая", Start).School!;
registry.Delete(first.Id);
var second = registry.Create("Вторая", Start).School!;
Assert.NotEqual(first.Id, second.Id);
Assert.Null(registry.Find(first.Id));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t\n")]
public void Create_RejectsBlankNames(string name)
{
using var registry = NewRegistry();
Assert.Equal(SchoolCreationError.InvalidName, registry.Create(name, Start).Error);
}
[Fact]
public void Create_RejectsOverlongNames()
{
using var registry = NewRegistry();
var result = registry.Create(new string('ш', School.MaxNameLength + 1), Start);
Assert.Equal(SchoolCreationError.InvalidName, result.Error);
}
[Fact]
public void Create_TrimsAndStripsControlCharacters()
{
using var registry = NewRegistry();
var result = registry.Create(" Лицей ", Start);
Assert.Equal("Лицей", result.School!.Name);
}
[Fact]
public void Create_RejectsStartDatesOutsideTheSupportedRange()
{
using var registry = NewRegistry();
var result = registry.Create("Школа", new DateTime(1500, 1, 1, 0, 0, 0, DateTimeKind.Utc));
Assert.Equal(SchoolCreationError.InvalidStartDate, result.Error);
}
[Fact]
public void Tick_AdvancesOnlyRunningSchools()
{
using var registry = NewRegistry();
var running = registry.Create("Идёт", Start).School!;
var paused = registry.Create("Стоит", Start).School!;
paused.Clock.IsRunning = false;
for (var i = 0; i < 20; i++)
{
registry.Tick();
}
Assert.Equal(Start.AddMinutes(5), running.Clock.Time);
Assert.Equal(Start, paused.Clock.Time);
}
[Fact]
public void SuggestName_NeverRepeatsAnExistingName()
{
using var registry = NewRegistry(maxSchools: 20);
for (var i = 0; i < 20; i++)
{
var suggestion = registry.SuggestName();
Assert.True(registry.Create(suggestion, Start).Succeeded, $"\"{suggestion}\" was rejected.");
}
var names = registry.Schools.Select(school => school.Name).ToArray();
Assert.Equal(names.Length, names.Distinct(StringComparer.OrdinalIgnoreCase).Count());
}
[Fact]
public void SuggestedNames_FitTheNameLimit()
{
var generator = new SchoolNameGenerator(new Random(1234));
foreach (var language in new[] { SchoolNameLanguage.Russian, SchoolNameLanguage.English })
{
for (var i = 0; i < 200; i++)
{
var name = generator.Next([], language);
Assert.InRange(name.Length, 1, School.MaxNameLength);
}
}
}
[Fact]
public void SuggestedEnglishNames_AreAscii()
{
var generator = new SchoolNameGenerator(new Random(1234));
for (var i = 0; i < 50; i++)
{
var name = generator.Next([], SchoolNameLanguage.English);
Assert.Matches("^[A-Za-z0-9 .]+$", name);
}
}
[Fact]
public void Dispose_DropsEverySchool()
{
var registry = NewRegistry();
registry.Create("Школа", Start);
registry.Dispose();
Assert.Equal(0, registry.Count);
}
}