diff --git a/AGENTS.md b/AGENTS.md index 3c95c60..76f678d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ way; this file is *how to work in them*. | defs, JSONC catalog, map validation | `src/HSchool.Content` | | skills, traits, body, needs, name sets | `src/HSchool.Content` | | people generation, families, roster records | `src/HSchool.People` | +| people in a school's World, need decay | `src/HSchool.Simulation` | | the menu API (list, create, delete, mods, catalog) | `src/HSchool.Server/Api` **and** `docs/protocol.md` | | what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` | | connection handling, workers, saves | `src/HSchool.Server` | @@ -104,6 +105,7 @@ say so explicitly in the change description. ## Testing policy - Simulation changes need a `GameClock` or `SchoolRegistry` test. They are fast and need no host. + Putting a roster into `World` and ticking needs belongs there too. - People generation belongs in `tests/HSchool.People.Tests`. Same seed, map and name set must produce the same roster; the suite does not boot a host. - Catalog, inheritance, patches and map validation belong in `tests/HSchool.Content.Tests`. diff --git a/docs/architecture.md b/docs/architecture.md index 57553f9..2a82174 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,8 +16,9 @@ there is no UI on the server. │ │ │ ├─ Clock│ │ │ │ │ ├─ Catalog (frozen Content) │ │ │ │ ├─ Map │ -│ │ │ └─ World│ (Arch ECS, empty for now) │ -│ │ ├── SchoolStore │ saves/{id}.json │ +│ │ │ ├─ Roster │ +│ │ │ └─ World│ (Arch ECS: people, classes) │ +│ │ ├── SchoolStore │ saves/{id}.json + {id}.people.json │ │ │ ├── ModContent │ mods// │ │ │ └── ClientRegistry │ │ │ └────────────────────────┘ │ @@ -75,9 +76,8 @@ touching a school itself. ## Schools -A `School` is one save: an id, a name, a `GameClock` and an Arch `World`. The world is empty -today — pupils, rooms and staff land in it as the game grows — but it is created and destroyed -with the school so ownership is never in question. +A `School` is one save: an id, a name, a `GameClock`, a frozen catalog, a map, a roster and an +Arch `World`. Pupils, staff and parents live in that world as entities; they do not walk yet. `GameClock` moves while it is running, in fixed steps: `realSeconds × gameMinutesPerRealSecond × speedMultiplier`. At the defaults that is 5 game minutes @@ -97,13 +97,16 @@ cannot land between a mouse-down and a click. ## Saves Each school is a JSON file under `Simulation:SavesDirectory` (`saves/{id}.json` plus `index.json` -for the next id). The worker writes on create, on shutdown, and on a rare clock snapshot -(`SaveIntervalSeconds`, 30 by default) — never on every tick. Pause and speed changes are written +for the next id, and `saves/{id}.people.json` for the roster). The worker writes the clock file on +create, on shutdown, and on a rare clock snapshot (`SaveIntervalSeconds`, 30 by default) — never +on every tick. The people file is written only when composition changes (create, and later yearly +intake). Pause and speed changes are written too, but coalesced to at most one write per `MinSaveIntervalMilliseconds`: a client can send those as fast as the socket allows, and each one is a file write on the school's own thread. Shutdown -always flushes, so a pause is never lost. The file also stores the +always flushes, so a pause is never lost. The clock file also stores the mod pack ids and the map layout; the catalog is loaded again from `mods/` on start. A missing -mod folder or a map that no longer validates leaves the file in place and that school unstarted. +mod folder, a map that no longer validates, or a roster that no longer fits the map leaves the +files in place and that school unstarted. ## Connection lifetime diff --git a/docs/design/projects.md b/docs/design/projects.md index 812e001..fc27e2f 100644 --- a/docs/design/projects.md +++ b/docs/design/projects.md @@ -9,8 +9,8 @@ Protocol — ни на кого из игровых проектов (только байты) Content — ни на Protocol, ни на Simulation, ни на ASP.NET People → Content (defs, карта, склонения; без Arch и хоста) -Simulation → Content (каталог и раскладка; People — в фазе 7) -Server → Protocol, Simulation, Content +Simulation → People, Content (ростер в World; без HTTP) +Server → Protocol, Simulation, People, Content Client — своя сторона Protocol (TS) + HTTP ``` diff --git a/docs/phases/07-people-in-school.md b/docs/phases/07-people-in-school.md index 8dccceb..def22bc 100644 --- a/docs/phases/07-people-in-school.md +++ b/docs/phases/07-people-in-school.md @@ -12,16 +12,16 @@ ## Задачи -- [ ] Ванильная карта дорабатывается до одиннадцати кабинетов; подписи кабинетов становятся +- [x] Ванильная карта дорабатывается до одиннадцати кабинетов; подписи кабинетов становятся номерами помещений («204»), а не именами классов -- [ ] Компоненты Arch под слои человека; сущности собираются из записей генератора -- [ ] Класс как сущность: параллель, литера, закреплённый кабинет -- [ ] Нужды тикают вместе со школой — механика есть, скорость в `core` нулевая -- [ ] `saves/{id}.people.json`: сид, люди, семьи, классы. Пишется **только** при изменении +- [x] Компоненты Arch под слои человека; сущности собираются из записей генератора +- [x] Класс как сущность: параллель, литера, закреплённый кабинет +- [x] Нужды тикают вместе со школой — механика есть, скорость в `core` нулевая +- [x] `saves/{id}.people.json`: сид, люди, семьи, классы. Пишется **только** при изменении состава, не по таймеру; `saves/{id}.json` остаётся маленьким и частым -- [ ] Загрузка ростера при старте школы; ростер, не сходящийся с картой, оставляет школу +- [x] Загрузка ростера при старте школы; ростер, не сходящийся с картой, оставляет школу незапущенной и файл нетронутым — как сейчас с картой -- [ ] Воркер публикует неизменяемый снимок ростера, как публикует `SchoolState` +- [x] Воркер публикует неизменяемый снимок ростера, как публикует `SchoolState` ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index 8a72e9d..e9030fe 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -33,6 +33,6 @@ | --- | --- | --- | | [5. Дефы человека](05-people-defs.md) | ✅ | Навыки, черты, тело, нужды, наборы имён в каталоге | | [6. Библиотека генерации](06-people-generator.md) | ✅ | `HSchool.People`: семьи из карты и сида | -| [7. Люди в школе](07-people-in-school.md) | ⬜ | Сущности в `World`, ростер на диске, 11 кабинетов в `core` | +| [7. Люди в школе](07-people-in-school.md) | ✅ | Сущности в `World`, ростер на диске, 11 кабинетов в `core` | | [8. Просмотр людей](08-people-browser.md) | ⬜ | Панель со списком, фильтрами и карточкой | | [9. Годовой набор](09-yearly-intake.md) | ⬜ | Первое сентября: переход, выпуск, набор | diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index 62eaf5c..34e259e 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -60,6 +60,12 @@ public sealed class RoomDef : Def public IReadOnlyList Positions { get; init; } = []; public IReadOnlyList Works { get; init; } = []; + + /// + /// When true, a map room of this def with pupil slots becomes a roster class. + /// Labs keep for lessons without forming a homeroom. + /// + public bool Homeroom { get; init; } } public sealed class BuildingDef : Def; diff --git a/src/HSchool.Content/MapView.cs b/src/HSchool.Content/MapView.cs index b861f90..c4efe67 100644 --- a/src/HSchool.Content/MapView.cs +++ b/src/HSchool.Content/MapView.cs @@ -151,7 +151,7 @@ public static class MapView } /// - /// Same rule as floors: is "1A", not a replacement for "Classroom". + /// Same rule as floors: is "101", not a replacement for "Classroom". /// Do not treat "label equals defName" as "untranslated" — English classrooms are named Classroom. /// private static string RoomName(DefCatalog catalog, string locale, RoomNode room) diff --git a/src/HSchool.People/RosterFit.cs b/src/HSchool.People/RosterFit.cs new file mode 100644 index 0000000..3937429 --- /dev/null +++ b/src/HSchool.People/RosterFit.cs @@ -0,0 +1,47 @@ +namespace HSchool.People; + +/// +/// Whether a generated or loaded roster still fills this map. A mismatch means the file is stale +/// relative to the layout — the school must not start and the file must stay untouched. +/// +public static class RosterFit +{ + public static bool Matches(Roster roster, SchoolDemand demand) + { + if (roster.Classes.Count != demand.Classes.Count || roster.People.Count(person => person.IsStudent) != demand.Seats.Count) + { + return false; + } + + var classesByRoom = roster.Classes.ToDictionary(schoolClass => schoolClass.RoomId, StringComparer.Ordinal); + foreach (var expected in demand.Classes) + { + if (!classesByRoom.TryGetValue(expected.RoomId, out var actual) + || actual.Capacity != expected.Capacity + || actual.PupilIds.Count != expected.Capacity) + { + return false; + } + } + + var staffed = roster.People + .Where(person => person.IsStaff && person.Position is not null && person.WorkplaceRoomId is not null) + .Select(person => new StaffOpening(person.WorkplaceRoomId!, person.Position!)) + .ToHashSet(); + + if (staffed.Count != demand.Staff.Count) + { + return false; + } + + foreach (var opening in demand.Staff) + { + if (!staffed.Contains(opening)) + { + return false; + } + } + + return true; + } +} diff --git a/src/HSchool.People/RosterJson.cs b/src/HSchool.People/RosterJson.cs new file mode 100644 index 0000000..e2a1cf5 --- /dev/null +++ b/src/HSchool.People/RosterJson.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace HSchool.People; + +/// On-disk shape of saves/{id}.people.json. Composition only — needs are not live values. +public sealed class RosterDocument +{ + public const int CurrentFormat = 1; + + public int Format { get; init; } = CurrentFormat; + + public int Seed { get; init; } + + public required IReadOnlyList People { get; init; } + + public required IReadOnlyList Families { get; init; } + + public required IReadOnlyList Classes { get; init; } + + public Roster ToRoster() => new(People, Families, Classes); + + public static RosterDocument From(int seed, Roster roster) => + new() + { + Format = CurrentFormat, + Seed = seed, + People = roster.People, + Families = roster.Families, + Classes = roster.Classes, + }; +} + +public static class RosterJson +{ + public static JsonSerializerOptions Options { get; } = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public static string Serialize(RosterDocument document) => + JsonSerializer.Serialize(document, Options); + + public static RosterDocument Parse(string json) + { + var document = JsonSerializer.Deserialize(json, Options); + if (document is null || document.People is null || document.Families is null || document.Classes is null) + { + throw new InvalidOperationException("People save deserialized to nothing."); + } + + return document; + } +} diff --git a/src/HSchool.People/SchoolDemand.cs b/src/HSchool.People/SchoolDemand.cs index f6c97fe..696471b 100644 --- a/src/HSchool.People/SchoolDemand.cs +++ b/src/HSchool.People/SchoolDemand.cs @@ -5,8 +5,8 @@ public readonly record struct StaffOpening(string RoomId, string Position); public readonly record struct PupilSeat(string ClassId, string RoomId, int Year, string Letter); /// -/// How many pupils and staff a map asks for. Classrooms are rooms with pupil slots; each one -/// becomes a roster class. Positions come from , not the wire labels. +/// How many pupils and staff a map asks for. Homeroom rooms with pupil slots become roster +/// classes; every entry is a staff opening. /// public sealed class SchoolDemand { @@ -34,17 +34,17 @@ public sealed class SchoolDemand foreach (var room in map.Rooms) { var slots = PupilSlotsOf(catalog, room); - if (slots > 0) - { - classrooms.Add((room, slots)); - } - if (catalog.Rooms.TryGetValue(room.Def, out var def)) { foreach (var position in def.Positions) { staff.Add(new StaffOpening(room.Id, position)); } + + if (slots > 0 && def.Homeroom) + { + classrooms.Add((room, slots)); + } } } diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index 8cb7967..c6749b3 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.People; using HSchool.Simulation; using Microsoft.Extensions.Options; @@ -101,7 +102,9 @@ internal sealed class SchoolStore foreach (var path in Directory.EnumerateFiles(DirectoryPath, "*.json")) { - if (string.Equals(Path.GetFileName(path), IndexFileName, StringComparison.OrdinalIgnoreCase)) + var fileName = Path.GetFileName(path); + if (string.Equals(fileName, IndexFileName, StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".people.json", StringComparison.OrdinalIgnoreCase)) { continue; } @@ -179,15 +182,48 @@ internal sealed class SchoolStore { File.Delete(path); } + + var people = PeoplePath(id); + if (File.Exists(people)) + { + File.Delete(people); + } + } + + public RosterDocument? TryReadPeople(int id) + { + var path = PeoplePath(id); + if (!File.Exists(path)) + { + return null; + } + + try + { + return RosterJson.Parse(File.ReadAllText(path)); + } + catch (Exception ex) + { + throw new SchoolContentUnavailableException( + $"School {id} people file could not be read.", + ex); + } + } + + public void SavePeople(int id, RosterDocument document) + { + WriteAtomic(PeoplePath(id), document, RosterJson.Options); } private string SchoolPath(int id) => Path.Combine(DirectoryPath, $"{id}.json"); + private string PeoplePath(int id) => Path.Combine(DirectoryPath, $"{id}.people.json"); + private string IndexPath() => Path.Combine(DirectoryPath, IndexFileName); - private static void WriteAtomic(string path, T value) + private static void WriteAtomic(string path, T value, JsonSerializerOptions? options = null) { - var json = JsonSerializer.Serialize(value, Json); + var json = JsonSerializer.Serialize(value, options ?? Json); var temp = path + ".tmp"; File.WriteAllText(temp, json); File.Move(temp, path, overwrite: true); diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 556a79a..8a6dae2 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Threading.Channels; using HSchool.Content; +using HSchool.People; using HSchool.Protocol; using HSchool.Server.Net; using HSchool.Simulation; @@ -39,6 +40,7 @@ internal sealed class SchoolWorker private readonly int _speedIndex; private SchoolState _snapshot; + private Roster? _rosterSnapshot; private School? _school; private Task? _run; private bool _persistOnStop = true; @@ -89,6 +91,9 @@ internal sealed class SchoolWorker /// Last clock the worker published. Menu requests read this; the live school stays here. public SchoolState Snapshot => Volatile.Read(ref _snapshot); + /// Last roster composition. Published like ; needs live on entities. + public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot); + public void Start() { _run = Task.Factory.StartNew( @@ -194,6 +199,17 @@ internal sealed class SchoolWorker ? School.Create(_id, _name, _time, catalog, map) : School.Load(_id, _name, _time, _running, _speedIndex, catalog, map); + var peopleDirty = false; + try + { + peopleDirty = InstallPeople(school, catalog, map); + } + catch + { + school.Dispose(); + throw; + } + _school = school; PublishSnapshot(); @@ -202,6 +218,11 @@ internal sealed class SchoolWorker Persist(); } + if (peopleDirty) + { + PersistPeople(); + } + _started.TrySetResult(); using var timer = new PeriodicTimer(_options.TickInterval); @@ -391,6 +412,94 @@ internal sealed class SchoolWorker school.Clock.Time, school.Clock.IsRunning, (byte)school.Clock.SpeedIndex)); + Volatile.Write(ref _rosterSnapshot, school.Roster); + } + + /// + /// Writes the composition file. Not called from the 30-second clock save — the roster changes + /// on create, load-migration and (later) yearly intake, not every tick. + /// + private void PersistPeople() + { + var school = _school; + if (school?.Roster is null) + { + return; + } + + try + { + _store.SavePeople(school.Id, RosterDocument.From(school.PeopleSeed, school.Roster)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not save people for school {SchoolId}; composition stays in memory.", _id); + } + } + + private bool InstallPeople(School school, DefCatalog catalog, MapLayout map) + { + var nameSetId = ResolveNameSetId(catalog, _nameSetId); + if (nameSetId is null) + { + throw new SchoolContentUnavailableException($"School {_id} has no name set in its catalog."); + } + + var demand = SchoolDemand.From(catalog, map); + Roster roster; + int seed; + var generated = false; + + if (_isNew) + { + seed = school.Id; + roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time); + generated = true; + } + else + { + var loaded = _store.TryReadPeople(_id); + if (loaded is null) + { + seed = school.Id; + roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time); + generated = true; + } + else + { + seed = loaded.Seed; + roster = loaded.ToRoster(); + } + } + + if (!RosterFit.Matches(roster, demand)) + { + throw new SchoolContentUnavailableException( + $"School {_id} roster does not match its map; the people file was left untouched."); + } + + school.InstallPeople(roster, seed); + return generated; + } + + private static string? ResolveNameSetId(DefCatalog catalog, string? requested) + { + var available = catalog.NameSets.Values + .Where(def => !def.Abstract) + .Select(def => def.DefName) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + if (available.Length == 0) + { + return null; + } + + if (string.IsNullOrWhiteSpace(requested)) + { + return available[0]; + } + + return available.Contains(requested, StringComparer.Ordinal) ? requested : null; } private void Persist() diff --git a/src/HSchool.Server/HSchool.Server.csproj b/src/HSchool.Server/HSchool.Server.csproj index bc0ebda..5be2423 100644 --- a/src/HSchool.Server/HSchool.Server.csproj +++ b/src/HSchool.Server/HSchool.Server.csproj @@ -12,6 +12,7 @@ + diff --git a/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc b/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc index 3ea58de..a3d963f 100644 --- a/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc +++ b/src/HSchool.Server/mods/core/defs/rooms/academic.jsonc @@ -9,6 +9,7 @@ ], "positions": ["Teacher"], "works": ["TeachLesson"], + "homeroom": true, }, { "defName": "Library", diff --git a/src/HSchool.Server/mods/core/maps/default.jsonc b/src/HSchool.Server/mods/core/maps/default.jsonc index 44252fa..5101a20 100644 --- a/src/HSchool.Server/mods/core/maps/default.jsonc +++ b/src/HSchool.Server/mods/core/maps/default.jsonc @@ -1,6 +1,6 @@ { - // A small two-storey school: main building on the yard, gym across the yard. - // Walkable graph is yard + rooms; floors are grouping only. Stairs link the two corridors. + // Two-storey school plus a gym. Eleven homerooms, labelled as room numbers — class names + // live on the roster and change every 1 September. "territory": { "id": "yard", "def": "SchoolYard" }, "buildings": [ { "id": "main", "def": "MainBuilding" }, @@ -47,11 +47,11 @@ ], }, { - "id": "classroom-1a", + "id": "classroom-101", "def": "Classroom", "building": "main", "floor": "floor-1", - "label": "1A", + "label": "101", "slots": [ { "key": "board", "thing": "Blackboard" }, { "key": "teacherDesk", "thing": "Desk" }, @@ -60,11 +60,37 @@ ], }, { - "id": "classroom-1b", + "id": "classroom-102", "def": "Classroom", "building": "main", "floor": "floor-1", - "label": "1B", + "label": "102", + "slots": [ + { "key": "board", "thing": "Blackboard" }, + { "key": "teacherDesk", "thing": "Desk" }, + { "key": "teacherChair", "thing": "Chair" }, + { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, + ], + }, + { + "id": "classroom-103", + "def": "Classroom", + "building": "main", + "floor": "floor-1", + "label": "103", + "slots": [ + { "key": "board", "thing": "Blackboard" }, + { "key": "teacherDesk", "thing": "Desk" }, + { "key": "teacherChair", "thing": "Chair" }, + { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, + ], + }, + { + "id": "classroom-104", + "def": "Classroom", + "building": "main", + "floor": "floor-1", + "label": "104", "slots": [ { "key": "board", "thing": "Blackboard" }, { "key": "teacherDesk", "thing": "Desk" }, @@ -96,11 +122,11 @@ { "id": "corridor-2", "def": "Corridor", "building": "main", "floor": "floor-2", "label": "2" }, { "id": "stairs-2", "def": "Stairwell", "building": "main", "floor": "floor-2", "label": "2" }, { - "id": "classroom-2a", + "id": "classroom-201", "def": "Classroom", "building": "main", "floor": "floor-2", - "label": "2A", + "label": "201", "slots": [ { "key": "board", "thing": "Blackboard" }, { "key": "teacherDesk", "thing": "Desk" }, @@ -109,11 +135,76 @@ ], }, { - "id": "classroom-2b", + "id": "classroom-202", "def": "Classroom", "building": "main", "floor": "floor-2", - "label": "2B", + "label": "202", + "slots": [ + { "key": "board", "thing": "Blackboard" }, + { "key": "teacherDesk", "thing": "Desk" }, + { "key": "teacherChair", "thing": "Chair" }, + { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, + ], + }, + { + "id": "classroom-203", + "def": "Classroom", + "building": "main", + "floor": "floor-2", + "label": "203", + "slots": [ + { "key": "board", "thing": "Blackboard" }, + { "key": "teacherDesk", "thing": "Desk" }, + { "key": "teacherChair", "thing": "Chair" }, + { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, + ], + }, + { + "id": "classroom-204", + "def": "Classroom", + "building": "main", + "floor": "floor-2", + "label": "204", + "slots": [ + { "key": "board", "thing": "Blackboard" }, + { "key": "teacherDesk", "thing": "Desk" }, + { "key": "teacherChair", "thing": "Chair" }, + { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, + ], + }, + { + "id": "classroom-205", + "def": "Classroom", + "building": "main", + "floor": "floor-2", + "label": "205", + "slots": [ + { "key": "board", "thing": "Blackboard" }, + { "key": "teacherDesk", "thing": "Desk" }, + { "key": "teacherChair", "thing": "Chair" }, + { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, + ], + }, + { + "id": "classroom-206", + "def": "Classroom", + "building": "main", + "floor": "floor-2", + "label": "206", + "slots": [ + { "key": "board", "thing": "Blackboard" }, + { "key": "teacherDesk", "thing": "Desk" }, + { "key": "teacherChair", "thing": "Chair" }, + { "key": "studentDesks", "thing": "StudentDesk", "count": 16 }, + ], + }, + { + "id": "classroom-207", + "def": "Classroom", + "building": "main", + "floor": "floor-2", + "label": "207", "slots": [ { "key": "board", "thing": "Blackboard" }, { "key": "teacherDesk", "thing": "Desk" }, @@ -169,15 +260,22 @@ { "a": "corridor-1", "b": "principals-office" }, { "a": "corridor-1", "b": "secretary-office" }, { "a": "corridor-1", "b": "teachers-room" }, - { "a": "corridor-1", "b": "classroom-1a" }, - { "a": "corridor-1", "b": "classroom-1b" }, + { "a": "corridor-1", "b": "classroom-101" }, + { "a": "corridor-1", "b": "classroom-102" }, + { "a": "corridor-1", "b": "classroom-103" }, + { "a": "corridor-1", "b": "classroom-104" }, { "a": "corridor-1", "b": "cafeteria" }, { "a": "corridor-1", "b": "restroom-1" }, { "a": "corridor-1", "b": "medical-office" }, { "a": "stairs-1", "b": "stairs-2" }, { "a": "stairs-2", "b": "corridor-2" }, - { "a": "corridor-2", "b": "classroom-2a" }, - { "a": "corridor-2", "b": "classroom-2b" }, + { "a": "corridor-2", "b": "classroom-201" }, + { "a": "corridor-2", "b": "classroom-202" }, + { "a": "corridor-2", "b": "classroom-203" }, + { "a": "corridor-2", "b": "classroom-204" }, + { "a": "corridor-2", "b": "classroom-205" }, + { "a": "corridor-2", "b": "classroom-206" }, + { "a": "corridor-2", "b": "classroom-207" }, { "a": "corridor-2", "b": "library" }, { "a": "corridor-2", "b": "computer-lab" }, { "a": "corridor-2", "b": "restroom-2" }, diff --git a/src/HSchool.Simulation/Components/PersonComponents.cs b/src/HSchool.Simulation/Components/PersonComponents.cs new file mode 100644 index 0000000..d4fb533 --- /dev/null +++ b/src/HSchool.Simulation/Components/PersonComponents.cs @@ -0,0 +1,39 @@ +using HSchool.People; + +namespace HSchool.Simulation; + +/// Identity, the one layer every person has. +public readonly record struct PersonIdentity( + string Id, + string FamilyId, + bool Female, + DateTime BirthDate, + PersonName Name); + +/// Height, weight and categorical body attributes including derived Build. +public readonly record struct PersonBody( + IReadOnlyDictionary Numbers, + IReadOnlyDictionary Choices); + +public readonly record struct PersonSkills(IReadOnlyDictionary Values); + +public readonly record struct PersonTraits(IReadOnlyList Ids); + +/// Live need values. The dictionary is mutated in place as the clock advances. +public readonly record struct PersonNeeds(Dictionary Values); + +public readonly record struct PersonRoles( + bool IsStudent, + bool IsStaff, + bool IsParent, + string? ClassId, + string? Position, + string? WorkplaceRoomId); + +/// A roster class: year and letter live here, the room is the homeroom they occupy. +public readonly record struct ClassIdentity( + string Id, + int Year, + string Letter, + string RoomId, + int Capacity); diff --git a/src/HSchool.Simulation/GameClock.cs b/src/HSchool.Simulation/GameClock.cs index 10d56ec..68c17a6 100644 --- a/src/HSchool.Simulation/GameClock.cs +++ b/src/HSchool.Simulation/GameClock.cs @@ -54,14 +54,16 @@ public sealed class GameClock /// Advances the calendar by one fixed step of , scaled by the /// base rate and the current speed. Does nothing while paused. /// - public void Advance(double realSeconds, double gameMinutesPerRealSecond) + /// Game minutes actually added; zero while paused. + public double Advance(double realSeconds, double gameMinutesPerRealSecond) { if (!IsRunning) { - return; + return 0; } var gameMinutes = realSeconds * gameMinutesPerRealSecond * Multiplier; Time = Time.AddMinutes(gameMinutes); + return gameMinutes; } } diff --git a/src/HSchool.Simulation/HSchool.Simulation.csproj b/src/HSchool.Simulation/HSchool.Simulation.csproj index dec66fd..6983ce5 100644 --- a/src/HSchool.Simulation/HSchool.Simulation.csproj +++ b/src/HSchool.Simulation/HSchool.Simulation.csproj @@ -5,12 +5,13 @@ true - - - + + + - - - + + + + diff --git a/src/HSchool.Simulation/NeedDecay.cs b/src/HSchool.Simulation/NeedDecay.cs new file mode 100644 index 0000000..5a8c9fb --- /dev/null +++ b/src/HSchool.Simulation/NeedDecay.cs @@ -0,0 +1,36 @@ +using Arch.Core; +using HSchool.Content; + +namespace HSchool.Simulation; + +/// +/// Drains needs by × simulated hours. Core ships decay at zero +/// so this is a no-op until something exists that can refill them. +/// +public static class NeedDecay +{ + private static readonly QueryDescription PeopleWithNeeds = new QueryDescription().WithAll(); + + public static void Apply(World world, DefCatalog catalog, double gameMinutes) + { + if (gameMinutes <= 0 || catalog.Needs.Count == 0) + { + return; + } + + var hours = gameMinutes / 60d; + world.Query(in PeopleWithNeeds, (ref PersonNeeds needs) => + { + foreach (var def in catalog.Needs.Values) + { + if (def.Abstract || !needs.Values.TryGetValue(def.DefName, out var current)) + { + continue; + } + + var next = current - (float)(def.DecayPerHour * hours); + needs.Values[def.DefName] = Math.Clamp(next, def.Min, def.Max); + } + }); + } +} diff --git a/src/HSchool.Simulation/RosterSpawner.cs b/src/HSchool.Simulation/RosterSpawner.cs new file mode 100644 index 0000000..ca2fbe9 --- /dev/null +++ b/src/HSchool.Simulation/RosterSpawner.cs @@ -0,0 +1,39 @@ +using Arch.Core; +using HSchool.People; + +namespace HSchool.Simulation; + +/// Turns roster records into Arch entities. Parents have no map location — by design. +public static class RosterSpawner +{ + public static void Spawn(World world, Roster roster) + { + foreach (var schoolClass in roster.Classes) + { + world.Create( + new ClassIdentity( + schoolClass.Id, + schoolClass.Year, + schoolClass.Letter, + schoolClass.RoomId, + schoolClass.Capacity)); + } + + foreach (var person in roster.People) + { + world.Create( + new PersonIdentity(person.Id, person.FamilyId, person.Female, person.BirthDate, person.Name), + new PersonBody(person.Numbers, person.Choices), + new PersonSkills(person.Skills), + new PersonTraits(person.Traits), + new PersonNeeds(new Dictionary(person.Needs, StringComparer.Ordinal)), + new PersonRoles( + person.IsStudent, + person.IsStaff, + person.IsParent, + person.ClassId, + person.Position, + person.WorkplaceRoomId)); + } + } +} diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 33b5ec3..465bbd7 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -1,13 +1,12 @@ using Arch.Core; using HSchool.Content; +using HSchool.People; namespace HSchool.Simulation; /// -/// One save: a name, a calendar, a frozen def catalog, a map instance, and the ECS world that will -/// hold everything the school is made of. The world is empty for now — pupils, rooms and staff -/// land in it as the game grows — but it is created and destroyed with the school so ownership is -/// never in question. +/// One save: a name, a calendar, a frozen def catalog, a map instance, the ECS world, and — once +/// people exist — the roster those entities were built from. /// public sealed class School : IDisposable { @@ -66,12 +65,34 @@ public sealed class School : IDisposable /// The Arch world backing this school. Only this school's worker thread may touch it. public World World { get; } - /// Runs one fixed step of the school. Today that is only the calendar. + /// Composition snapshot. Null in clock-only tests or before . + public Roster? Roster { get; private set; } + + public int PeopleSeed { get; private set; } + + /// + /// Installs a roster that already matches the map. Spawns entities; does not write to disk. + /// + public void InstallPeople(Roster roster, int seed) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(roster); + + Roster = roster; + PeopleSeed = seed; + RosterSpawner.Spawn(World, roster); + } + + /// Runs one fixed step of the school: calendar, then need decay. public void Tick(double deltaTime, double gameMinutesPerRealSecond) { ObjectDisposedException.ThrowIf(_disposed, this); - Clock.Advance(deltaTime, gameMinutesPerRealSecond); + var gameMinutes = Clock.Advance(deltaTime, gameMinutesPerRealSecond); + if (gameMinutes > 0 && Catalog is not null) + { + NeedDecay.Apply(World, Catalog, gameMinutes); + } } public void Dispose() diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index f47f643..c5976d2 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -106,7 +106,7 @@ public class GameSocketTests(AppHostFixture fixture) Assert.Equal("floor-1", office.ParentId); Assert.Contains("Директор", office.Positions); Assert.NotEmpty(office.Items); - var classroom = Assert.Single(snapshot.Nodes, node => node.Id == "classroom-1a"); + var classroom = Assert.Single(snapshot.Nodes, node => node.Id == "classroom-101"); Assert.Equal(16, classroom.PupilSlots); Assert.Contains(classroom.Items, item => item.Name == "Парта" && item.Count == 16); Assert.Contains(classroom.Items, item => item.Name == "Стул" && item.Count == 1); diff --git a/tests/HSchool.Content.Tests/MapViewTests.cs b/tests/HSchool.Content.Tests/MapViewTests.cs index a46e08f..7d2f533 100644 --- a/tests/HSchool.Content.Tests/MapViewTests.cs +++ b/tests/HSchool.Content.Tests/MapViewTests.cs @@ -41,8 +41,8 @@ public class MapViewTests Assert.Empty(corridor.Positions); Assert.Equal("Коридор 1", corridor.Name); - var classroom = ru.Single(node => node.Id == "classroom-1a"); - Assert.Equal("Класс 1A", classroom.Name); + var classroom = ru.Single(node => node.Id == "classroom-101"); + Assert.Equal("Класс 101", classroom.Name); Assert.Equal( [ new MapViewItem("Доска", 1), @@ -53,7 +53,7 @@ public class MapViewTests classroom.Items); Assert.Equal(16, classroom.PupilSlots); Assert.Equal(["Учитель"], classroom.Positions); - Assert.Equal("Classroom 1A", en.Single(node => node.Id == "classroom-1a").Name); + Assert.Equal("Classroom 101", en.Single(node => node.Id == "classroom-101").Name); } [Fact] diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index 2e4bec9..45692da 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -23,6 +23,8 @@ public class VanillaCoreTests Assert.Equal(["Sit"], catalog.Things["DirectorsChair"].Actions); Assert.True(catalog.Buildings.ContainsKey("GymBuilding")); Assert.True(catalog.Rooms.ContainsKey("Classroom")); + Assert.True(catalog.Rooms["Classroom"].Homeroom); + Assert.False(catalog.Rooms["ComputerLab"].Homeroom); Assert.Equal("Класс", catalog.Label("ru", catalog.Rooms["Classroom"])); Assert.Equal(["Teacher"], catalog.PositionsFor(DefKind.Room, "Classroom")); Assert.Equal(1, catalog.Things["StudentDesk"].PupilSlots); @@ -32,13 +34,16 @@ public class VanillaCoreTests Assert.Contains(catalog.Rooms["Classroom"].Slots, slot => slot.Key == "teacherChair" && slot.Thing == "Chair"); Assert.Equal(16, catalog.Rooms["Classroom"].Slots.Single(slot => slot.Key == "studentDesks").Count); Assert.Equal(2, map.Buildings.Count); - Assert.True(map.Rooms.Count >= 18, "Vanilla layout should look like a small school, not a stub."); - Assert.Contains(map.Rooms, room => room.Id == "classroom-1a" && room.Label == "1A"); + var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList(); + Assert.Equal(11, homerooms.Count); + Assert.Contains(map.Rooms, room => room.Id == "classroom-101" && room.Label == "101"); + Assert.Contains(map.Rooms, room => room.Id == "classroom-207" && room.Label == "207"); + Assert.DoesNotContain(map.Rooms, room => room.Label is "1A" or "1B" or "2A" or "2B"); Assert.Equal( 16, - map.Rooms.Single(room => room.Id == "classroom-1a").Slots.Single(slot => slot.Key == "studentDesks").Count); + map.Rooms.Single(room => room.Id == "classroom-101").Slots.Single(slot => slot.Key == "studentDesks").Count); Assert.Contains( - map.Rooms.Single(room => room.Id == "classroom-1a").Slots, + map.Rooms.Single(room => room.Id == "classroom-101").Slots, slot => slot.Key == "teacherChair" && slot.Thing == "Chair"); } } diff --git a/tests/HSchool.People.Tests/RosterGeneratorTests.cs b/tests/HSchool.People.Tests/RosterGeneratorTests.cs index 5f7ae70..e9719b8 100644 --- a/tests/HSchool.People.Tests/RosterGeneratorTests.cs +++ b/tests/HSchool.People.Tests/RosterGeneratorTests.cs @@ -90,6 +90,37 @@ public class RosterGeneratorTests Assert.Contains(roster.People, person => person.IsStaff && person.IsParent); } + [Fact] + public void VanillaMap_HasElevenHomeroomsNotTheComputerLab() + { + var catalog = Fixtures.Catalog(); + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + var map = CatalogLoader.LastDefaultMap( + [CatalogLoader.CorePackId], + PackDocuments.FromDirectory(CatalogLoader.CorePackId, root)); + Assert.NotNull(map); + + var demand = SchoolDemand.From(catalog, map); + Assert.Equal(11, demand.Classes.Count); + Assert.Equal(11 * 16, demand.Seats.Count); + Assert.DoesNotContain(demand.Classes, schoolClass => schoolClass.RoomId == "computer-lab"); + Assert.Contains(demand.Staff, opening => opening.RoomId == "computer-lab" && opening.Position == "Teacher"); + } + + [Fact] + public void RosterJson_RoundTripsAGeneratedRoster() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(1)); + var json = RosterJson.Serialize(RosterDocument.From(7, roster)); + var loaded = RosterJson.Parse(json).ToRoster(); + + Assert.Equal(roster.People.Count, loaded.People.Count); + Assert.Equal(roster.People[0].Name.Given, loaded.People[0].Name.Given); + Assert.Equal(roster.People[0].Name.SurnameCases.Gen, loaded.People[0].Name.SurnameCases.Gen); + Assert.True(RosterFit.Matches(loaded, SchoolDemand.From(Fixtures.Catalog(), Fixtures.Classrooms(1)))); + Assert.False(RosterFit.Matches(loaded, SchoolDemand.From(Fixtures.Catalog(), Fixtures.Classrooms(2)))); + } + [Fact] public void Assembly_DoesNotReferenceArchAspNetOrSockets() { diff --git a/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj b/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj index 1d20310..5738cea 100644 --- a/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj +++ b/tests/HSchool.Simulation.Tests/HSchool.Simulation.Tests.csproj @@ -15,10 +15,18 @@ + + + + vanilla\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + diff --git a/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs b/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs new file mode 100644 index 0000000..11c6ead --- /dev/null +++ b/tests/HSchool.Simulation.Tests/PeopleInSchoolTests.cs @@ -0,0 +1,124 @@ +using Arch.Core; +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Simulation.Tests; + +public class PeopleInSchoolTests +{ + private static readonly DateTime Start = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc); + + [Fact] + public void InstallPeople_FillsHomeroomsAndJobs() + { + var (catalog, map) = Vanilla(); + var demand = SchoolDemand.From(catalog, map); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Slavic", Start); + + using var school = School.Create(1, "Полная", Start, catalog, map); + school.InstallPeople(roster, seed: 1); + school.Tick(1d / 20d, 5d); + + Assert.Same(roster, school.Roster); + Assert.Equal(11, roster.Classes.Count); + Assert.Equal(demand.Seats.Count, roster.People.Count(person => person.IsStudent)); + Assert.Equal(demand.Staff.Count, roster.People.Count(person => person.IsStaff)); + Assert.True(RosterFit.Matches(roster, demand)); + + var peopleQuery = new QueryDescription().WithAll(); + var classesQuery = new QueryDescription().WithAll(); + Assert.Equal(roster.People.Count, school.World.CountEntities(in peopleQuery)); + Assert.Equal(11, school.World.CountEntities(in classesQuery)); + } + + [Fact] + public void CoreNeeds_DoNotMoveOnTick() + { + var (catalog, map) = Vanilla(); + var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 2, "Slavic", Start); + using var school = School.Create(2, "Нужды", Start, catalog, map); + school.InstallPeople(roster, seed: 2); + + for (var i = 0; i < 20; i++) + { + school.Tick(1d / 20d, 5d); + } + + var hunger = new List(); + var query = new QueryDescription().WithAll(); + school.World.Query(in query, (ref PersonNeeds needs) => hunger.Add(needs.Values["Hunger"])); + Assert.NotEmpty(hunger); + Assert.All(hunger, value => Assert.Equal(1f, value)); + } + + [Fact] + public void NeedDecay_DropsWhenTheDefSaysSo() + { + var catalog = new CatalogLoader().Load( + [CatalogLoader.CorePackId], + [ + new ContentDocument( + CatalogLoader.CorePackId, + "defs/needs/hunger.jsonc", + """{ "defName": "Hunger", "initial": 1, "decayPerHour": 1, "min": 0, "max": 1 }"""), + ]); + + var world = World.Create(); + try + { + world.Create(new PersonNeeds(new Dictionary(StringComparer.Ordinal) { ["Hunger"] = 1f })); + NeedDecay.Apply(world, catalog, gameMinutes: 60); + var query = new QueryDescription().WithAll(); + world.Query(in query, (ref PersonNeeds needs) => Assert.Equal(0f, needs.Values["Hunger"])); + } + finally + { + World.Destroy(world); + } + } + + [Fact] + public void SameSeed_InstallsTheSameNames() + { + var (catalog, map) = Vanilla(); + var first = RosterGenerator.Generate(catalog, map, schoolSeed: 9, "Slavic", Start); + var second = RosterGenerator.Generate(catalog, map, schoolSeed: 9, "Slavic", Start); + + using var a = School.Create(9, "А", Start, catalog, map); + using var b = School.Create(9, "Б", Start, catalog, map); + a.InstallPeople(first, 9); + b.InstallPeople(second, 9); + + Assert.Equal( + first.People.Select(person => $"{person.Name.Surname} {person.Name.Given} {person.Name.Patronymic}"), + second.People.Select(person => $"{person.Name.Surname} {person.Name.Given} {person.Name.Patronymic}")); + } + + 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 List PackDocumentsFrom(string packRoot) + { + var documents = new List(); + foreach (var path in Directory.EnumerateFiles(packRoot, "*.*", SearchOption.AllDirectories)) + { + if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase) + && !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var relative = Path.GetRelativePath(packRoot, path).Replace('\\', '/'); + documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path))); + } + + return documents; + } +}