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:
@@ -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<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";
|
||||
File.WriteAllText(temp, json);
|
||||
File.Move(temp, path, overwrite: true);
|
||||
|
||||
@@ -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
|
||||
/// <summary>Last clock the worker published. Menu requests read this; the live school stays here.</summary>
|
||||
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()
|
||||
{
|
||||
_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);
|
||||
}
|
||||
|
||||
/// <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()
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
|
||||
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
|
||||
<ProjectReference Include="..\HSchool.People\HSchool.People.csproj" />
|
||||
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
],
|
||||
"positions": ["Teacher"],
|
||||
"works": ["TeachLesson"],
|
||||
"homeroom": true,
|
||||
},
|
||||
{
|
||||
"defName": "Library",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user