diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index 9df1452..a2404ed 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -519,15 +519,16 @@ Calendar+обеды 20, планировщик 9, TimetableApi 6. ## Срез 5. Жизнь школы - **Фазы:** 18–21 -- **Проверен на:** `5a00ad7`, 2026-08-19 (первичная проверка — `21d79cb`). Фаза 21 закрыта за час до проверки, `core` правился - параллельно — проверка велась в отдельном worktree на коммите, не на рабочем дереве +- **Проверен на:** `450495f`, 2026-08-20 (первичная проверка — `21d79cb`; предыдущая перепроверка — `5a00ad7`) - **Пути:** `src/HSchool.Ai`, `src/HSchool.Simulation/{PresenceSystem,ActivitySystem,LessonLearningSystem,School}.cs`, `src/HSchool.Protocol` (кадр `Presence`, пропуск в кадре часов), `src/HSchool.Server/Game/PresenceFrame.cs`, `src/HSchool.Client/src/ui/gameScreen.ts`, `src/HSchool.Server/mods/core/defs/{actions,behavior,needs}`, `tests/HSchool.Ai.Tests`, - `tests/HSchool.Simulation.Tests/{PresenceTests,DecisionTests,ActivityTests,NeedDecayTests}.cs` -- **Итог:** все 32 теста из списков четырёх фаз на месте и проходят; тестов не дописано, зато - измерен прожитый учебный день — и он показал две вещи, которых списки не ловят + `tests/HSchool.Simulation.Tests/{PresenceTests,DecisionTests,ActivityTests,NeedDecayTests,LunchTests}.cs`, + `tests/HSchool.AppHost.Tests/GameSocketTests.cs` +- **Итог:** все 32 теста из списков четырёх фаз на месте; дописаны 2 теста явки после смены таблицы, + `SetTimetable` сбрасывает планы дня, хостовый тест ждёт `Clock.Running` после снятия паузы. + Голод из первого прохода уже закрыт данными. AppHost не гонялся — Aspire держит DLL. Что подтверждено: @@ -606,6 +607,52 @@ Calendar+обеды 20, планировщик 9, TimetableApi 6. ходьба, действия и решения не тронуты, `decayPerHour` и `needGain` тоже, поэтому измеренная выше картина с голодом остаётся в силе: вопрос «чем закрывать голод» по-прежнему открыт. +### Перепроверка на `450495f`, 2026-08-20 + +Дрейф после `5a00ad7` — обеденные смены, веса в `BehaviorDef` (фаза 24), `Decision.cs`, +`PresenceSystem` (очередь после действий). Обещания фаз 18–21 целы; протокол остался v7. + +Что подтверждено повторно: + +- **Фаза 18.** Все 13 тестов списка на месте в `WalkingTests` / `PresenceTests`. Пустая школа + без учителей в рабочее окно не пропускается. `EnsurePlans` пересобирал планы только при смене + дня или численности — после пина или назначения предмета без найма `AppearAt` оставался `null`. +- **Фаза 19.** Раскладка присутствия с обеих сторон (`Presence_RoundTrips`, `protocol.test.ts`), + снимок карты без людей, пропуск в учебное время отбрасывается. Кадр часов несёт разрешение на + пропуск; клиент его не считает. +- **Фаза 20.** Декей голода, обед, стулья, ночь вне школы, неизвестная нужда, детерминизм. + `SleepAndHunger_ReturnToMaxOffCampus` и `AfterASchoolDay_NobodyIsLeftStarving` закрывают + открытый в первом проходе голод. +- **Фаза 21.** Восемь тестов списка плюс обеденные веса в `DecisionPlannerTests`. `HSchool.Ai` + без Arch/ASP.NET/`DateTime.Now`, `Simulation` без сокетов. + +Исправлено: + +- `School.SetTimetable` обнуляет `PlanDay`. Иначе `EnsurePlans` считает планы актуальными, + пока не сменятся день или численность, и после назначения математики в 10:20 никто не приходит: + вчерашний план собран по пустой таблице, `AppearAt == null`. Найм это маскировал (численность + растёт), пин и назначение — нет. +- `OpeningASchoolDuringAMathLesson_...` после снятия паузы сразу ждал людей в кадре. Присутствие + уходит и на паузе (пустые люди, подписи урока из таблицы), и 40 кадров бюджета уходили в эти + кадры до обработки `SetRunning`. Теперь тест ждёт `clock.Running`, потом людей. Сам хост + с Aspire в этой сессии не гонялся (MSB3021). + +Дописано: + +- `HireAndAssignMath_DuringLessonThree_PutsSomeoneOnCampus` — найм на паузе в 10:20, потолок + 64 решения, шаг 20 Гц, как у работника. +- `AssigningMath_AfterDayPlansWereBuilt_PutsSomeoneOnCampus` — назначение без смены численности; + без сброса `PlanDay` кампус остаётся пустым. + +Закрыто из прошлого прохода: + +- **Голод.** Уже закрыто 2026-08-19 (`restoredOffCampus`, стулья 112, смены). Повторно зелёные + `NeedDecayTests.SleepAndHunger_ReturnToMaxOffCampus` и `LunchTests.AfterASchoolDay_NobodyIsLeftStarving`. +- **`OpeningASchoolDuringAMathLesson_...`.** Это была явка плюс чтение паузных кадров, не флак + нагрузки. Симуляция после правки зелёная; хостовый прогон — когда Aspire отпустят DLL. + +Ai 29, Presence+Decision+Activity+NeedDecay+Lunch 31, Presence_RoundTrips 1. + ## Что из журнала закрыто, 2026-08-19 Ревью нашло семь пунктов; шесть закрыты в этот же день, седьмой снят как несуществующий. diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index dff5e09..1e997b6 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -231,6 +231,10 @@ public sealed class School : IDisposable ArgumentNullException.ThrowIfNull(timetable); Timetable = timetable; TimetableDirty = false; + // Duty rooms and AppearAt come from this table. EnsurePlans only rebuilds when the day + // or the roster size changes, so a pin or an assign would otherwise keep yesterday's + // empty-staff plans and leave the campus empty. + PlanDay = null; LastDecisionSlot = null; foreach (var id in Roster?.People.Select(person => person.Id) ?? []) { diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index 2dcedbc..cb498b6 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -212,6 +212,10 @@ public class GameSocketTests(AppHostFixture fixture) // one person made this test fail under load. await SendAsync(socket, buffer => ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true))); + // Presence still goes out while paused (empty people, lesson labels from the table). + // Waiting for people first drains those frames and can spend the 40-frame budget + // before the resume even lands. Clock.Running is the signal that time is moving. + await ReceiveClockWhereAsync(socket, clock => clock.Running); presence = await ReceivePresenceWhereAsync(socket, frame => frame.People.Count > 0); Assert.All(presence.People, person => Assert.False(string.IsNullOrWhiteSpace(person.NodeId))); Assert.Contains(presence.People, person => directory.People.Any(row => row.Id == person.Id)); diff --git a/tests/HSchool.Simulation.Tests/PresenceTests.cs b/tests/HSchool.Simulation.Tests/PresenceTests.cs index 2f98e81..91ff749 100644 --- a/tests/HSchool.Simulation.Tests/PresenceTests.cs +++ b/tests/HSchool.Simulation.Tests/PresenceTests.cs @@ -224,11 +224,87 @@ public class PresenceTests [Fact] public void HireAndAssignMath_DuringLessonThree_PutsSomeoneOnCampus() { - var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc); - using var school = OpenEmpty(start); - school.Tick(0.2d, 5d); - Assert.True(school.IsCampusEmpty()); + using var school = UnstaffedDuringLessonThree(); + HireMathTeacher(school); + Assert.True(school.IsCampusEmpty(), "paused ticks must not walk anyone in"); + school.Clock.IsRunning = true; + Assert.True(SomeoneWalksIn(school), $"nobody on campus after mid-lesson hire at {school.Clock.Time:HH:mm}"); + } + + [Fact] + public void AssigningMath_AfterDayPlansWereBuilt_PutsSomeoneOnCampus() + { + using var school = UnstaffedDuringLessonThree(); + var catalog = school.Catalog!; + var map = school.Map!; + var hired = Staffing.Hire( + catalog, + map, + school.Roster!, + school.Applicants!, + school.Applicants!.Applicants[0].Person.Id, + Staffing.TeacherPosition, + Cap); + Assert.Equal(StaffingError.None, hired.Error); + school.ApplyStaffing(hired.Roster, hired.Pool); + school.SetTimetable(SchoolTimetables.Build(catalog, map, hired.Roster, null, weekDays: 5)); + + school.Clock.IsRunning = true; + school.Tick(1d / 20d, 5d); + school.Clock.IsRunning = false; + Assert.True(school.IsCampusEmpty(), "a teacher with no subjects still has no reason to come"); + + var assigned = Staffing.AssignSubject( + catalog, + hired.Roster, + hired.Pool, + hired.Roster.People.Single(person => person.IsStaff).Id, + "Mathematics", + Cap); + Assert.Equal(StaffingError.None, assigned.Error); + school.ApplyStaffing(assigned.Roster, assigned.Pool); + school.SetTimetable(SchoolTimetables.Build(catalog, map, assigned.Roster, null, weekDays: 5)); + + school.Clock.IsRunning = true; + Assert.True( + SomeoneWalksIn(school), + "stale day plans after an assign that did not change headcount left the campus empty"); + } + + [Fact] + public void Simulation_DoesNotReferenceSockets() + { + var names = typeof(School).Assembly.GetReferencedAssemblies().Select(assembly => assembly.Name!); + Assert.DoesNotContain(names, name => name.Contains("Sockets", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(names, name => name.Contains("AspNet", StringComparison.OrdinalIgnoreCase)); + } + + private static School UnstaffedDuringLessonThree() + { + var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc); + var school = OpenEmpty(start); + school.ConfigurePresence(weekDays: 5, maxDecisionsPerTick: 64); + school.SetTimetable(SchoolTimetables.Build(school.Catalog!, school.Map!, school.Roster!, null, weekDays: 5)); + + var step = 1d / 20d; + for (var i = 0; i < 40; i++) + { + school.Tick(step, 5d); + } + + Assert.True(school.IsCampusEmpty()); + school.Clock.IsRunning = false; + for (var i = 0; i < 10; i++) + { + school.Tick(step, 5d); + } + + return school; + } + + private static void HireMathTeacher(School school) + { var catalog = school.Catalog!; var map = school.Map!; var hired = Staffing.Hire( @@ -242,25 +318,31 @@ public class PresenceTests Assert.Equal(StaffingError.None, hired.Error); school.ApplyStaffing(hired.Roster, hired.Pool); - var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, hired.Roster.People.Single(person => person.IsStaff).Id, "Mathematics", Cap); + var assigned = Staffing.AssignSubject( + catalog, + hired.Roster, + hired.Pool, + hired.Roster.People.Single(person => person.IsStaff).Id, + "Mathematics", + Cap); Assert.Equal(StaffingError.None, assigned.Error); school.ApplyStaffing(assigned.Roster, assigned.Pool); school.SetTimetable(SchoolTimetables.Build(catalog, map, assigned.Roster, null, weekDays: 5)); - - for (var i = 0; i < 20; i++) - { - school.Tick(0.2d, 5d); - } - - Assert.Contains(school.CapturePresence(), row => row.NodeId is not null); } - [Fact] - public void Simulation_DoesNotReferenceSockets() + private static bool SomeoneWalksIn(School school) { - var names = typeof(School).Assembly.GetReferencedAssemblies().Select(assembly => assembly.Name!); - Assert.DoesNotContain(names, name => name.Contains("Sockets", StringComparison.OrdinalIgnoreCase)); - Assert.DoesNotContain(names, name => name.Contains("AspNet", StringComparison.OrdinalIgnoreCase)); + var step = 1d / 20d; + for (var i = 0; i < 400; i++) + { + school.Tick(step, 5d); + if (school.CapturePresence().Any(row => row.NodeId is not null)) + { + return true; + } + } + + return false; } private static (School School, string Homeroom, string PupilId) StaffedFirstFloorClass()