Enhance school simulation and management by integrating roster functionality, allowing for the installation and persistence of student and staff data. Update the school architecture to include a roster alongside existing components, ensuring proper validation against map layouts. Revise room definitions to support homeroom designations and update related tests to validate new functionalities and ensure robustness in roster handling.
This commit is contained in:
@@ -12,6 +12,7 @@ way; this file is *how to work in them*.
|
|||||||
| defs, JSONC catalog, map validation | `src/HSchool.Content` |
|
| defs, JSONC catalog, map validation | `src/HSchool.Content` |
|
||||||
| skills, traits, body, needs, name sets | `src/HSchool.Content` |
|
| skills, traits, body, needs, name sets | `src/HSchool.Content` |
|
||||||
| people generation, families, roster records | `src/HSchool.People` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| connection handling, workers, saves | `src/HSchool.Server` |
|
||||||
@@ -104,6 +105,7 @@ say so explicitly in the change description.
|
|||||||
## Testing policy
|
## Testing policy
|
||||||
|
|
||||||
- Simulation changes need a `GameClock` or `SchoolRegistry` test. They are fast and need no host.
|
- 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
|
- 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.
|
produce the same roster; the suite does not boot a host.
|
||||||
- Catalog, inheritance, patches and map validation belong in `tests/HSchool.Content.Tests`.
|
- Catalog, inheritance, patches and map validation belong in `tests/HSchool.Content.Tests`.
|
||||||
|
|||||||
+12
-9
@@ -16,8 +16,9 @@ there is no UI on the server.
|
|||||||
│ │ │ ├─ Clock│ │
|
│ │ │ ├─ Clock│ │
|
||||||
│ │ │ ├─ Catalog (frozen Content) │
|
│ │ │ ├─ Catalog (frozen Content) │
|
||||||
│ │ │ ├─ Map │
|
│ │ │ ├─ Map │
|
||||||
│ │ │ └─ World│ (Arch ECS, empty for now) │
|
│ │ │ ├─ Roster │
|
||||||
│ │ ├── SchoolStore │ saves/{id}.json │
|
│ │ │ └─ World│ (Arch ECS: people, classes) │
|
||||||
|
│ │ ├── SchoolStore │ saves/{id}.json + {id}.people.json │
|
||||||
│ │ ├── ModContent │ mods/<id>/ │
|
│ │ ├── ModContent │ mods/<id>/ │
|
||||||
│ │ └── ClientRegistry │ │
|
│ │ └── ClientRegistry │ │
|
||||||
│ └────────────────────────┘ │
|
│ └────────────────────────┘ │
|
||||||
@@ -75,9 +76,8 @@ touching a school itself.
|
|||||||
|
|
||||||
## Schools
|
## Schools
|
||||||
|
|
||||||
A `School` is one save: an id, a name, a `GameClock` and an Arch `World`. The world is empty
|
A `School` is one save: an id, a name, a `GameClock`, a frozen catalog, a map, a roster and an
|
||||||
today — pupils, rooms and staff land in it as the game grows — but it is created and destroyed
|
Arch `World`. Pupils, staff and parents live in that world as entities; they do not walk yet.
|
||||||
with the school so ownership is never in question.
|
|
||||||
|
|
||||||
`GameClock` moves while it is running, in fixed steps:
|
`GameClock` moves while it is running, in fixed steps:
|
||||||
`realSeconds × gameMinutesPerRealSecond × speedMultiplier`. At the defaults that is 5 game minutes
|
`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
|
## Saves
|
||||||
|
|
||||||
Each school is a JSON file under `Simulation:SavesDirectory` (`saves/{id}.json` plus `index.json`
|
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
|
for the next id, and `saves/{id}.people.json` for the roster). The worker writes the clock file on
|
||||||
(`SaveIntervalSeconds`, 30 by default) — never on every tick. Pause and speed changes are written
|
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
|
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
|
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 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
|
## Connection lifetime
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
Protocol — ни на кого из игровых проектов (только байты)
|
Protocol — ни на кого из игровых проектов (только байты)
|
||||||
Content — ни на Protocol, ни на Simulation, ни на ASP.NET
|
Content — ни на Protocol, ни на Simulation, ни на ASP.NET
|
||||||
People → Content (defs, карта, склонения; без Arch и хоста)
|
People → Content (defs, карта, склонения; без Arch и хоста)
|
||||||
Simulation → Content (каталог и раскладка; People — в фазе 7)
|
Simulation → People, Content (ростер в World; без HTTP)
|
||||||
Server → Protocol, Simulation, Content
|
Server → Protocol, Simulation, People, Content
|
||||||
Client — своя сторона Protocol (TS) + HTTP
|
Client — своя сторона Protocol (TS) + HTTP
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,16 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] Ванильная карта дорабатывается до одиннадцати кабинетов; подписи кабинетов становятся
|
- [x] Ванильная карта дорабатывается до одиннадцати кабинетов; подписи кабинетов становятся
|
||||||
номерами помещений («204»), а не именами классов
|
номерами помещений («204»), а не именами классов
|
||||||
- [ ] Компоненты Arch под слои человека; сущности собираются из записей генератора
|
- [x] Компоненты Arch под слои человека; сущности собираются из записей генератора
|
||||||
- [ ] Класс как сущность: параллель, литера, закреплённый кабинет
|
- [x] Класс как сущность: параллель, литера, закреплённый кабинет
|
||||||
- [ ] Нужды тикают вместе со школой — механика есть, скорость в `core` нулевая
|
- [x] Нужды тикают вместе со школой — механика есть, скорость в `core` нулевая
|
||||||
- [ ] `saves/{id}.people.json`: сид, люди, семьи, классы. Пишется **только** при изменении
|
- [x] `saves/{id}.people.json`: сид, люди, семьи, классы. Пишется **только** при изменении
|
||||||
состава, не по таймеру; `saves/{id}.json` остаётся маленьким и частым
|
состава, не по таймеру; `saves/{id}.json` остаётся маленьким и частым
|
||||||
- [ ] Загрузка ростера при старте школы; ростер, не сходящийся с картой, оставляет школу
|
- [x] Загрузка ростера при старте школы; ростер, не сходящийся с картой, оставляет школу
|
||||||
незапущенной и файл нетронутым — как сейчас с картой
|
незапущенной и файл нетронутым — как сейчас с картой
|
||||||
- [ ] Воркер публикует неизменяемый снимок ростера, как публикует `SchoolState`
|
- [x] Воркер публикует неизменяемый снимок ростера, как публикует `SchoolState`
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,6 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [5. Дефы человека](05-people-defs.md) | ✅ | Навыки, черты, тело, нужды, наборы имён в каталоге |
|
| [5. Дефы человека](05-people-defs.md) | ✅ | Навыки, черты, тело, нужды, наборы имён в каталоге |
|
||||||
| [6. Библиотека генерации](06-people-generator.md) | ✅ | `HSchool.People`: семьи из карты и сида |
|
| [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) | ⬜ | Панель со списком, фильтрами и карточкой |
|
| [8. Просмотр людей](08-people-browser.md) | ⬜ | Панель со списком, фильтрами и карточкой |
|
||||||
| [9. Годовой набор](09-yearly-intake.md) | ⬜ | Первое сентября: переход, выпуск, набор |
|
| [9. Годовой набор](09-yearly-intake.md) | ⬜ | Первое сентября: переход, выпуск, набор |
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ public sealed class RoomDef : Def
|
|||||||
public IReadOnlyList<string> Positions { get; init; } = [];
|
public IReadOnlyList<string> Positions { get; init; } = [];
|
||||||
|
|
||||||
public IReadOnlyList<string> Works { get; init; } = [];
|
public IReadOnlyList<string> Works { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, a map room of this def with pupil slots becomes a roster class.
|
||||||
|
/// Labs keep <see cref="ThingDef.PupilSlots"/> for lessons without forming a homeroom.
|
||||||
|
/// </summary>
|
||||||
|
public bool Homeroom { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class BuildingDef : Def;
|
public sealed class BuildingDef : Def;
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ public static class MapView
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Same rule as floors: <see cref="RoomNode.Label"/> is "1A", not a replacement for "Classroom".
|
/// Same rule as floors: <see cref="RoomNode.Label"/> is "101", not a replacement for "Classroom".
|
||||||
/// Do not treat "label equals defName" as "untranslated" — English classrooms are named Classroom.
|
/// Do not treat "label equals defName" as "untranslated" — English classrooms are named Classroom.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string RoomName(DefCatalog catalog, string locale, RoomNode room)
|
private static string RoomName(DefCatalog catalog, string locale, RoomNode room)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace HSchool.People;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace HSchool.People;
|
||||||
|
|
||||||
|
/// <summary>On-disk shape of <c>saves/{id}.people.json</c>. Composition only — needs are not live values.</summary>
|
||||||
|
public sealed class RosterDocument
|
||||||
|
{
|
||||||
|
public const int CurrentFormat = 1;
|
||||||
|
|
||||||
|
public int Format { get; init; } = CurrentFormat;
|
||||||
|
|
||||||
|
public int Seed { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<Person> People { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<Family> Families { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<SchoolClass> 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<RosterDocument>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
public readonly record struct PupilSeat(string ClassId, string RoomId, int Year, string Letter);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How many pupils and staff a map asks for. Classrooms are rooms with pupil slots; each one
|
/// How many pupils and staff a map asks for. Homeroom rooms with pupil slots become roster
|
||||||
/// becomes a roster class. Positions come from <see cref="RoomDef.Positions"/>, not the wire labels.
|
/// classes; every <see cref="RoomDef.Positions"/> entry is a staff opening.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SchoolDemand
|
public sealed class SchoolDemand
|
||||||
{
|
{
|
||||||
@@ -34,17 +34,17 @@ public sealed class SchoolDemand
|
|||||||
foreach (var room in map.Rooms)
|
foreach (var room in map.Rooms)
|
||||||
{
|
{
|
||||||
var slots = PupilSlotsOf(catalog, room);
|
var slots = PupilSlotsOf(catalog, room);
|
||||||
if (slots > 0)
|
|
||||||
{
|
|
||||||
classrooms.Add((room, slots));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (catalog.Rooms.TryGetValue(room.Def, out var def))
|
if (catalog.Rooms.TryGetValue(room.Def, out var def))
|
||||||
{
|
{
|
||||||
foreach (var position in def.Positions)
|
foreach (var position in def.Positions)
|
||||||
{
|
{
|
||||||
staff.Add(new StaffOpening(room.Id, position));
|
staff.Add(new StaffOpening(room.Id, position));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (slots > 0 && def.Homeroom)
|
||||||
|
{
|
||||||
|
classrooms.Add((room, slots));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using HSchool.Content;
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
using HSchool.Simulation;
|
using HSchool.Simulation;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
@@ -101,7 +102,9 @@ internal sealed class SchoolStore
|
|||||||
|
|
||||||
foreach (var path in Directory.EnumerateFiles(DirectoryPath, "*.json"))
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -179,15 +182,48 @@ internal sealed class SchoolStore
|
|||||||
{
|
{
|
||||||
File.Delete(path);
|
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 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 string IndexPath() => Path.Combine(DirectoryPath, IndexFileName);
|
||||||
|
|
||||||
private static void WriteAtomic<T>(string path, T value)
|
private static void WriteAtomic<T>(string path, T value, JsonSerializerOptions? options = null)
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(value, Json);
|
var json = JsonSerializer.Serialize(value, options ?? Json);
|
||||||
var temp = path + ".tmp";
|
var temp = path + ".tmp";
|
||||||
File.WriteAllText(temp, json);
|
File.WriteAllText(temp, json);
|
||||||
File.Move(temp, path, overwrite: true);
|
File.Move(temp, path, overwrite: true);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using HSchool.Content;
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
using HSchool.Protocol;
|
using HSchool.Protocol;
|
||||||
using HSchool.Server.Net;
|
using HSchool.Server.Net;
|
||||||
using HSchool.Simulation;
|
using HSchool.Simulation;
|
||||||
@@ -39,6 +40,7 @@ internal sealed class SchoolWorker
|
|||||||
private readonly int _speedIndex;
|
private readonly int _speedIndex;
|
||||||
|
|
||||||
private SchoolState _snapshot;
|
private SchoolState _snapshot;
|
||||||
|
private Roster? _rosterSnapshot;
|
||||||
private School? _school;
|
private School? _school;
|
||||||
private Task? _run;
|
private Task? _run;
|
||||||
private bool _persistOnStop = true;
|
private bool _persistOnStop = true;
|
||||||
@@ -89,6 +91,9 @@ internal sealed class SchoolWorker
|
|||||||
/// <summary>Last clock the worker published. Menu requests read this; the live school stays here.</summary>
|
/// <summary>Last clock the worker published. Menu requests read this; the live school stays here.</summary>
|
||||||
public SchoolState Snapshot => Volatile.Read(ref _snapshot);
|
public SchoolState Snapshot => Volatile.Read(ref _snapshot);
|
||||||
|
|
||||||
|
/// <summary>Last roster composition. Published like <see cref="Snapshot"/>; needs live on entities.</summary>
|
||||||
|
public Roster? RosterSnapshot => Volatile.Read(ref _rosterSnapshot);
|
||||||
|
|
||||||
public void Start()
|
public void Start()
|
||||||
{
|
{
|
||||||
_run = Task.Factory.StartNew(
|
_run = Task.Factory.StartNew(
|
||||||
@@ -194,6 +199,17 @@ internal sealed class SchoolWorker
|
|||||||
? School.Create(_id, _name, _time, catalog, map)
|
? School.Create(_id, _name, _time, catalog, map)
|
||||||
: School.Load(_id, _name, _time, _running, _speedIndex, 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;
|
_school = school;
|
||||||
PublishSnapshot();
|
PublishSnapshot();
|
||||||
|
|
||||||
@@ -202,6 +218,11 @@ internal sealed class SchoolWorker
|
|||||||
Persist();
|
Persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (peopleDirty)
|
||||||
|
{
|
||||||
|
PersistPeople();
|
||||||
|
}
|
||||||
|
|
||||||
_started.TrySetResult();
|
_started.TrySetResult();
|
||||||
|
|
||||||
using var timer = new PeriodicTimer(_options.TickInterval);
|
using var timer = new PeriodicTimer(_options.TickInterval);
|
||||||
@@ -391,6 +412,94 @@ internal sealed class SchoolWorker
|
|||||||
school.Clock.Time,
|
school.Clock.Time,
|
||||||
school.Clock.IsRunning,
|
school.Clock.IsRunning,
|
||||||
(byte)school.Clock.SpeedIndex));
|
(byte)school.Clock.SpeedIndex));
|
||||||
|
Volatile.Write(ref _rosterSnapshot, school.Roster);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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()
|
private void Persist()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
|
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
|
||||||
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
|
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
|
||||||
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
|
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
|
||||||
|
<ProjectReference Include="..\HSchool.People\HSchool.People.csproj" />
|
||||||
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
],
|
],
|
||||||
"positions": ["Teacher"],
|
"positions": ["Teacher"],
|
||||||
"works": ["TeachLesson"],
|
"works": ["TeachLesson"],
|
||||||
|
"homeroom": true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defName": "Library",
|
"defName": "Library",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
// A small two-storey school: main building on the yard, gym across the yard.
|
// Two-storey school plus a gym. Eleven homerooms, labelled as room numbers — class names
|
||||||
// Walkable graph is yard + rooms; floors are grouping only. Stairs link the two corridors.
|
// live on the roster and change every 1 September.
|
||||||
"territory": { "id": "yard", "def": "SchoolYard" },
|
"territory": { "id": "yard", "def": "SchoolYard" },
|
||||||
"buildings": [
|
"buildings": [
|
||||||
{ "id": "main", "def": "MainBuilding" },
|
{ "id": "main", "def": "MainBuilding" },
|
||||||
@@ -47,11 +47,11 @@
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "classroom-1a",
|
"id": "classroom-101",
|
||||||
"def": "Classroom",
|
"def": "Classroom",
|
||||||
"building": "main",
|
"building": "main",
|
||||||
"floor": "floor-1",
|
"floor": "floor-1",
|
||||||
"label": "1A",
|
"label": "101",
|
||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "board", "thing": "Blackboard" },
|
{ "key": "board", "thing": "Blackboard" },
|
||||||
{ "key": "teacherDesk", "thing": "Desk" },
|
{ "key": "teacherDesk", "thing": "Desk" },
|
||||||
@@ -60,11 +60,37 @@
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "classroom-1b",
|
"id": "classroom-102",
|
||||||
"def": "Classroom",
|
"def": "Classroom",
|
||||||
"building": "main",
|
"building": "main",
|
||||||
"floor": "floor-1",
|
"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": [
|
"slots": [
|
||||||
{ "key": "board", "thing": "Blackboard" },
|
{ "key": "board", "thing": "Blackboard" },
|
||||||
{ "key": "teacherDesk", "thing": "Desk" },
|
{ "key": "teacherDesk", "thing": "Desk" },
|
||||||
@@ -96,11 +122,11 @@
|
|||||||
{ "id": "corridor-2", "def": "Corridor", "building": "main", "floor": "floor-2", "label": "2" },
|
{ "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": "stairs-2", "def": "Stairwell", "building": "main", "floor": "floor-2", "label": "2" },
|
||||||
{
|
{
|
||||||
"id": "classroom-2a",
|
"id": "classroom-201",
|
||||||
"def": "Classroom",
|
"def": "Classroom",
|
||||||
"building": "main",
|
"building": "main",
|
||||||
"floor": "floor-2",
|
"floor": "floor-2",
|
||||||
"label": "2A",
|
"label": "201",
|
||||||
"slots": [
|
"slots": [
|
||||||
{ "key": "board", "thing": "Blackboard" },
|
{ "key": "board", "thing": "Blackboard" },
|
||||||
{ "key": "teacherDesk", "thing": "Desk" },
|
{ "key": "teacherDesk", "thing": "Desk" },
|
||||||
@@ -109,11 +135,76 @@
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "classroom-2b",
|
"id": "classroom-202",
|
||||||
"def": "Classroom",
|
"def": "Classroom",
|
||||||
"building": "main",
|
"building": "main",
|
||||||
"floor": "floor-2",
|
"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": [
|
"slots": [
|
||||||
{ "key": "board", "thing": "Blackboard" },
|
{ "key": "board", "thing": "Blackboard" },
|
||||||
{ "key": "teacherDesk", "thing": "Desk" },
|
{ "key": "teacherDesk", "thing": "Desk" },
|
||||||
@@ -169,15 +260,22 @@
|
|||||||
{ "a": "corridor-1", "b": "principals-office" },
|
{ "a": "corridor-1", "b": "principals-office" },
|
||||||
{ "a": "corridor-1", "b": "secretary-office" },
|
{ "a": "corridor-1", "b": "secretary-office" },
|
||||||
{ "a": "corridor-1", "b": "teachers-room" },
|
{ "a": "corridor-1", "b": "teachers-room" },
|
||||||
{ "a": "corridor-1", "b": "classroom-1a" },
|
{ "a": "corridor-1", "b": "classroom-101" },
|
||||||
{ "a": "corridor-1", "b": "classroom-1b" },
|
{ "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": "cafeteria" },
|
||||||
{ "a": "corridor-1", "b": "restroom-1" },
|
{ "a": "corridor-1", "b": "restroom-1" },
|
||||||
{ "a": "corridor-1", "b": "medical-office" },
|
{ "a": "corridor-1", "b": "medical-office" },
|
||||||
{ "a": "stairs-1", "b": "stairs-2" },
|
{ "a": "stairs-1", "b": "stairs-2" },
|
||||||
{ "a": "stairs-2", "b": "corridor-2" },
|
{ "a": "stairs-2", "b": "corridor-2" },
|
||||||
{ "a": "corridor-2", "b": "classroom-2a" },
|
{ "a": "corridor-2", "b": "classroom-201" },
|
||||||
{ "a": "corridor-2", "b": "classroom-2b" },
|
{ "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": "library" },
|
||||||
{ "a": "corridor-2", "b": "computer-lab" },
|
{ "a": "corridor-2", "b": "computer-lab" },
|
||||||
{ "a": "corridor-2", "b": "restroom-2" },
|
{ "a": "corridor-2", "b": "restroom-2" },
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using HSchool.People;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>Identity, the one layer every person has.</summary>
|
||||||
|
public readonly record struct PersonIdentity(
|
||||||
|
string Id,
|
||||||
|
string FamilyId,
|
||||||
|
bool Female,
|
||||||
|
DateTime BirthDate,
|
||||||
|
PersonName Name);
|
||||||
|
|
||||||
|
/// <summary>Height, weight and categorical body attributes including derived <c>Build</c>.</summary>
|
||||||
|
public readonly record struct PersonBody(
|
||||||
|
IReadOnlyDictionary<string, int> Numbers,
|
||||||
|
IReadOnlyDictionary<string, string> Choices);
|
||||||
|
|
||||||
|
public readonly record struct PersonSkills(IReadOnlyDictionary<string, int> Values);
|
||||||
|
|
||||||
|
public readonly record struct PersonTraits(IReadOnlyList<string> Ids);
|
||||||
|
|
||||||
|
/// <summary>Live need values. The dictionary is mutated in place as the clock advances.</summary>
|
||||||
|
public readonly record struct PersonNeeds(Dictionary<string, float> Values);
|
||||||
|
|
||||||
|
public readonly record struct PersonRoles(
|
||||||
|
bool IsStudent,
|
||||||
|
bool IsStaff,
|
||||||
|
bool IsParent,
|
||||||
|
string? ClassId,
|
||||||
|
string? Position,
|
||||||
|
string? WorkplaceRoomId);
|
||||||
|
|
||||||
|
/// <summary>A roster class: year and letter live here, the room is the homeroom they occupy.</summary>
|
||||||
|
public readonly record struct ClassIdentity(
|
||||||
|
string Id,
|
||||||
|
int Year,
|
||||||
|
string Letter,
|
||||||
|
string RoomId,
|
||||||
|
int Capacity);
|
||||||
@@ -54,14 +54,16 @@ public sealed class GameClock
|
|||||||
/// Advances the calendar by one fixed step of <paramref name="realSeconds"/>, scaled by the
|
/// Advances the calendar by one fixed step of <paramref name="realSeconds"/>, scaled by the
|
||||||
/// base rate and the current speed. Does nothing while paused.
|
/// base rate and the current speed. Does nothing while paused.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Advance(double realSeconds, double gameMinutesPerRealSecond)
|
/// <returns>Game minutes actually added; zero while paused.</returns>
|
||||||
|
public double Advance(double realSeconds, double gameMinutesPerRealSecond)
|
||||||
{
|
{
|
||||||
if (!IsRunning)
|
if (!IsRunning)
|
||||||
{
|
{
|
||||||
return;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var gameMinutes = realSeconds * gameMinutesPerRealSecond * Multiplier;
|
var gameMinutes = realSeconds * gameMinutesPerRealSecond * Multiplier;
|
||||||
Time = Time.AddMinutes(gameMinutes);
|
Time = Time.AddMinutes(gameMinutes);
|
||||||
|
return gameMinutes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Arch" />
|
<PackageReference Include="Arch" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
||||||
</ItemGroup>
|
<ProjectReference Include="..\HSchool.People\HSchool.People.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Arch.Core;
|
||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drains needs by <see cref="NeedDef.DecayPerHour"/> × simulated hours. Core ships decay at zero
|
||||||
|
/// so this is a no-op until something exists that can refill them.
|
||||||
|
/// </summary>
|
||||||
|
public static class NeedDecay
|
||||||
|
{
|
||||||
|
private static readonly QueryDescription PeopleWithNeeds = new QueryDescription().WithAll<PersonNeeds>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Arch.Core;
|
||||||
|
using HSchool.People;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>Turns roster records into Arch entities. Parents have no map location — by design.</summary>
|
||||||
|
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<string, float>(person.Needs, StringComparer.Ordinal)),
|
||||||
|
new PersonRoles(
|
||||||
|
person.IsStudent,
|
||||||
|
person.IsStaff,
|
||||||
|
person.IsParent,
|
||||||
|
person.ClassId,
|
||||||
|
person.Position,
|
||||||
|
person.WorkplaceRoomId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
using Arch.Core;
|
using Arch.Core;
|
||||||
using HSchool.Content;
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
|
||||||
namespace HSchool.Simulation;
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One save: a name, a calendar, a frozen def catalog, a map instance, and the ECS world that will
|
/// One save: a name, a calendar, a frozen def catalog, a map instance, the ECS world, and — once
|
||||||
/// hold everything the school is made of. The world is empty for now — pupils, rooms and staff
|
/// people exist — the roster those entities were built from.
|
||||||
/// land in it as the game grows — but it is created and destroyed with the school so ownership is
|
|
||||||
/// never in question.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class School : IDisposable
|
public sealed class School : IDisposable
|
||||||
{
|
{
|
||||||
@@ -66,12 +65,34 @@ public sealed class School : IDisposable
|
|||||||
/// <summary>The Arch world backing this school. Only this school's worker thread may touch it.</summary>
|
/// <summary>The Arch world backing this school. Only this school's worker thread may touch it.</summary>
|
||||||
public World World { get; }
|
public World World { get; }
|
||||||
|
|
||||||
/// <summary>Runs one fixed step of the school. Today that is only the calendar.</summary>
|
/// <summary>Composition snapshot. Null in clock-only tests or before <see cref="InstallPeople"/>.</summary>
|
||||||
|
public Roster? Roster { get; private set; }
|
||||||
|
|
||||||
|
public int PeopleSeed { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
|
||||||
|
/// </summary>
|
||||||
|
public void InstallPeople(Roster roster, int seed)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
ArgumentNullException.ThrowIfNull(roster);
|
||||||
|
|
||||||
|
Roster = roster;
|
||||||
|
PeopleSeed = seed;
|
||||||
|
RosterSpawner.Spawn(World, roster);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs one fixed step of the school: calendar, then need decay.</summary>
|
||||||
public void Tick(double deltaTime, double gameMinutesPerRealSecond)
|
public void Tick(double deltaTime, double gameMinutesPerRealSecond)
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
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()
|
public void Dispose()
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
Assert.Equal("floor-1", office.ParentId);
|
Assert.Equal("floor-1", office.ParentId);
|
||||||
Assert.Contains("Директор", office.Positions);
|
Assert.Contains("Директор", office.Positions);
|
||||||
Assert.NotEmpty(office.Items);
|
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.Equal(16, classroom.PupilSlots);
|
||||||
Assert.Contains(classroom.Items, item => item.Name == "Парта" && item.Count == 16);
|
Assert.Contains(classroom.Items, item => item.Name == "Парта" && item.Count == 16);
|
||||||
Assert.Contains(classroom.Items, item => item.Name == "Стул" && item.Count == 1);
|
Assert.Contains(classroom.Items, item => item.Name == "Стул" && item.Count == 1);
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ public class MapViewTests
|
|||||||
Assert.Empty(corridor.Positions);
|
Assert.Empty(corridor.Positions);
|
||||||
Assert.Equal("Коридор 1", corridor.Name);
|
Assert.Equal("Коридор 1", corridor.Name);
|
||||||
|
|
||||||
var classroom = ru.Single(node => node.Id == "classroom-1a");
|
var classroom = ru.Single(node => node.Id == "classroom-101");
|
||||||
Assert.Equal("Класс 1A", classroom.Name);
|
Assert.Equal("Класс 101", classroom.Name);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
[
|
[
|
||||||
new MapViewItem("Доска", 1),
|
new MapViewItem("Доска", 1),
|
||||||
@@ -53,7 +53,7 @@ public class MapViewTests
|
|||||||
classroom.Items);
|
classroom.Items);
|
||||||
Assert.Equal(16, classroom.PupilSlots);
|
Assert.Equal(16, classroom.PupilSlots);
|
||||||
Assert.Equal(["Учитель"], classroom.Positions);
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public class VanillaCoreTests
|
|||||||
Assert.Equal(["Sit"], catalog.Things["DirectorsChair"].Actions);
|
Assert.Equal(["Sit"], catalog.Things["DirectorsChair"].Actions);
|
||||||
Assert.True(catalog.Buildings.ContainsKey("GymBuilding"));
|
Assert.True(catalog.Buildings.ContainsKey("GymBuilding"));
|
||||||
Assert.True(catalog.Rooms.ContainsKey("Classroom"));
|
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("Класс", catalog.Label("ru", catalog.Rooms["Classroom"]));
|
||||||
Assert.Equal(["Teacher"], catalog.PositionsFor(DefKind.Room, "Classroom"));
|
Assert.Equal(["Teacher"], catalog.PositionsFor(DefKind.Room, "Classroom"));
|
||||||
Assert.Equal(1, catalog.Things["StudentDesk"].PupilSlots);
|
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.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(16, catalog.Rooms["Classroom"].Slots.Single(slot => slot.Key == "studentDesks").Count);
|
||||||
Assert.Equal(2, map.Buildings.Count);
|
Assert.Equal(2, map.Buildings.Count);
|
||||||
Assert.True(map.Rooms.Count >= 18, "Vanilla layout should look like a small school, not a stub.");
|
var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList();
|
||||||
Assert.Contains(map.Rooms, room => room.Id == "classroom-1a" && room.Label == "1A");
|
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(
|
Assert.Equal(
|
||||||
16,
|
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(
|
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");
|
slot => slot.Key == "teacherChair" && slot.Thing == "Chair");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,37 @@ public class RosterGeneratorTests
|
|||||||
Assert.Contains(roster.People, person => person.IsStaff && person.IsParent);
|
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]
|
[Fact]
|
||||||
public void Assembly_DoesNotReferenceArchAspNetOrSockets()
|
public void Assembly_DoesNotReferenceArchAspNetOrSockets()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,10 +15,18 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\HSchool.Simulation\HSchool.Simulation.csproj" />
|
<ProjectReference Include="..\..\src\HSchool.Simulation\HSchool.Simulation.csproj" />
|
||||||
<ProjectReference Include="..\..\src\HSchool.Content\HSchool.Content.csproj" />
|
<ProjectReference Include="..\..\src\HSchool.Content\HSchool.Content.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\HSchool.People\HSchool.People.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\..\src\HSchool.Server\mods\core\**\*">
|
||||||
|
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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<PersonIdentity, PersonNeeds, PersonRoles>();
|
||||||
|
var classesQuery = new QueryDescription().WithAll<ClassIdentity>();
|
||||||
|
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<float>();
|
||||||
|
var query = new QueryDescription().WithAll<PersonNeeds>();
|
||||||
|
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<string, float>(StringComparer.Ordinal) { ["Hunger"] = 1f }));
|
||||||
|
NeedDecay.Apply(world, catalog, gameMinutes: 60);
|
||||||
|
var query = new QueryDescription().WithAll<PersonNeeds>();
|
||||||
|
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<ContentDocument> PackDocumentsFrom(string packRoot)
|
||||||
|
{
|
||||||
|
var documents = new List<ContentDocument>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user