Add HSchool.Content project for JSONC definitions, catalog, and map validation. Update solution structure to include new content and tests projects. Enhance school management to support mod packs and map instances, ensuring proper loading and validation. Revise documentation to reflect these changes and update tests for new functionality.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.Protocol;
|
||||
using HSchool.Server.Net;
|
||||
using HSchool.Simulation;
|
||||
@@ -15,6 +16,7 @@ internal sealed class GameLoopService(
|
||||
ClientRegistry clients,
|
||||
GameMetrics metrics,
|
||||
SchoolStore store,
|
||||
ModContent mods,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILogger<GameLoopService> logger) : BackgroundService
|
||||
{
|
||||
@@ -170,7 +172,7 @@ internal sealed class GameLoopService(
|
||||
var id = _nextId++;
|
||||
store.WriteNextId(_nextId);
|
||||
|
||||
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true);
|
||||
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, modIds: null, map: null);
|
||||
Track(worker);
|
||||
worker.Start();
|
||||
|
||||
@@ -327,15 +329,25 @@ internal sealed class GameLoopService(
|
||||
save.GameTime,
|
||||
save.Running,
|
||||
save.SpeedIndex,
|
||||
isNew: false);
|
||||
Track(worker);
|
||||
isNew: false,
|
||||
save.ModIds,
|
||||
save.Map);
|
||||
worker.Start();
|
||||
}
|
||||
|
||||
if (_workers.Count > 0)
|
||||
{
|
||||
await Task.WhenAll(_workers.Values.Select(worker => worker.Started)).ConfigureAwait(false);
|
||||
logger.LogInformation("Restored {Count} school(s) from disk.", _workers.Count);
|
||||
try
|
||||
{
|
||||
await worker.Started.ConfigureAwait(false);
|
||||
Track(worker);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"School {SchoolId} \"{Name}\" was not started; the save file is unchanged.",
|
||||
save.Id,
|
||||
save.Name);
|
||||
await worker.StopAsync(persist: false).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,9 +362,22 @@ internal sealed class GameLoopService(
|
||||
{
|
||||
await Task.WhenAll(stopping).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_workers.Count > 0)
|
||||
{
|
||||
logger.LogInformation("Restored {Count} school(s) from disk.", _workers.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private SchoolWorker SpawnWorker(int id, string name, DateTime time, bool running, int speedIndex, bool isNew) =>
|
||||
private SchoolWorker SpawnWorker(
|
||||
int id,
|
||||
string name,
|
||||
DateTime time,
|
||||
bool running,
|
||||
int speedIndex,
|
||||
bool isNew,
|
||||
IReadOnlyList<string>? modIds,
|
||||
MapLayout? map) =>
|
||||
new(
|
||||
id,
|
||||
name,
|
||||
@@ -360,10 +385,13 @@ internal sealed class GameLoopService(
|
||||
running,
|
||||
speedIndex,
|
||||
isNew,
|
||||
modIds,
|
||||
map,
|
||||
_options,
|
||||
clients,
|
||||
metrics,
|
||||
store,
|
||||
mods,
|
||||
loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}"));
|
||||
|
||||
private void Track(SchoolWorker worker)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
internal sealed class LoggerContentLog(ILogger logger) : IContentLog
|
||||
{
|
||||
public void Warning(string message) => logger.LogWarning("{Message}", message);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.Simulation;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>mods/<id>/</c> from disk and hands the files to <see cref="CatalogLoader"/>.
|
||||
/// Content itself never sees these paths.
|
||||
/// </summary>
|
||||
internal sealed class ModContent
|
||||
{
|
||||
private readonly CatalogLoader _loader = new();
|
||||
private readonly ILogger<ModContent> _logger;
|
||||
|
||||
public ModContent(IOptions<SimulationOptions> options, IHostEnvironment environment, ILogger<ModContent> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
var configured = options.Value.ModsDirectory;
|
||||
Root = Path.IsPathRooted(configured)
|
||||
? configured
|
||||
: Path.GetFullPath(Path.Combine(environment.ContentRootPath, configured));
|
||||
|
||||
logger.LogInformation("Mod packs directory is {Directory}.", Root);
|
||||
}
|
||||
|
||||
public string Root { get; }
|
||||
|
||||
public bool PackExists(string packId) => Directory.Exists(PackPath(packId));
|
||||
|
||||
public IReadOnlyList<string> NormalizePackIds(IReadOnlyList<string>? extraModIds) =>
|
||||
CatalogLoader.NormalizePackOrder(extraModIds ?? []);
|
||||
|
||||
public IReadOnlyList<ContentDocument> ReadDocuments(IReadOnlyList<string> packIds)
|
||||
{
|
||||
var documents = new List<ContentDocument>();
|
||||
foreach (var packId in packIds)
|
||||
{
|
||||
var packRoot = PackPath(packId);
|
||||
if (!Directory.Exists(packRoot))
|
||||
{
|
||||
throw new SchoolContentUnavailableException($"Mod folder '{packId}' is missing under {Root}.");
|
||||
}
|
||||
|
||||
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(packId, relative, File.ReadAllText(path)));
|
||||
}
|
||||
}
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
public DefCatalog LoadCatalog(IReadOnlyList<string> packIds, ILogger workerLog)
|
||||
{
|
||||
var documents = ReadDocuments(packIds);
|
||||
try
|
||||
{
|
||||
return _loader.Load(packIds, documents, new LoggerContentLog(workerLog));
|
||||
}
|
||||
catch (ContentLoadException ex)
|
||||
{
|
||||
throw new SchoolContentUnavailableException(ex.Message, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public MapLayout LoadMap(IReadOnlyList<string> packIds, MapLayout? saved)
|
||||
{
|
||||
if (saved is not null)
|
||||
{
|
||||
return saved;
|
||||
}
|
||||
|
||||
var documents = ReadDocuments(packIds);
|
||||
var map = CatalogLoader.LastDefaultMap(packIds, documents);
|
||||
if (map is null)
|
||||
{
|
||||
throw new SchoolContentUnavailableException(
|
||||
$"No maps/default.jsonc found for packs [{string.Join(", ", packIds)}].");
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private string PackPath(string packId) => Path.Combine(Root, packId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// The school's pack list cannot be turned into a catalog (missing folder, broken defs, bad map).
|
||||
/// The save file stays on disk; this school simply does not start.
|
||||
/// </summary>
|
||||
internal sealed class SchoolContentUnavailableException : Exception
|
||||
{
|
||||
public SchoolContentUnavailableException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public SchoolContentUnavailableException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,29 @@
|
||||
using System.Text.Json;
|
||||
using HSchool.Content;
|
||||
using HSchool.Simulation;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>On-disk record of one school. Extra JSON fields are ignored so later slices can grow it.</summary>
|
||||
internal sealed record SchoolSave(int Format, int Id, string Name, DateTime GameTime, bool Running, int SpeedIndex);
|
||||
internal sealed class SchoolSave
|
||||
{
|
||||
public int Format { get; init; }
|
||||
|
||||
public int Id { get; init; }
|
||||
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public DateTime GameTime { get; init; }
|
||||
|
||||
public bool Running { get; init; }
|
||||
|
||||
public int SpeedIndex { get; init; }
|
||||
|
||||
public IReadOnlyList<string>? ModIds { get; init; }
|
||||
|
||||
public MapLayout? Map { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Allocates school ids that survive a process restart.</summary>
|
||||
internal sealed record SchoolSaveIndex(int NextId);
|
||||
@@ -16,7 +34,7 @@ internal sealed record SchoolSaveIndex(int NextId);
|
||||
/// </summary>
|
||||
internal sealed class SchoolStore
|
||||
{
|
||||
public const int CurrentFormat = 1;
|
||||
public const int CurrentFormat = 2;
|
||||
|
||||
private const string IndexFileName = "index.json";
|
||||
|
||||
@@ -99,7 +117,17 @@ internal sealed class SchoolStore
|
||||
continue;
|
||||
}
|
||||
|
||||
saves.Add(save with { GameTime = DateTime.SpecifyKind(save.GameTime, DateTimeKind.Utc) });
|
||||
saves.Add(new SchoolSave
|
||||
{
|
||||
Format = save.Format,
|
||||
Id = save.Id,
|
||||
Name = save.Name,
|
||||
GameTime = DateTime.SpecifyKind(save.GameTime, DateTimeKind.Utc),
|
||||
Running = save.Running,
|
||||
SpeedIndex = save.SpeedIndex,
|
||||
ModIds = save.ModIds,
|
||||
Map = save.Map,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Channels;
|
||||
using HSchool.Content;
|
||||
using HSchool.Protocol;
|
||||
using HSchool.Server.Net;
|
||||
using HSchool.Simulation;
|
||||
@@ -8,8 +9,8 @@ namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Dedicated thread for one school: fixed-step clock, that school's Arch world, that school's
|
||||
/// save file. Awaits are resolved with <c>GetResult</c> so <see cref="School.Tick"/> stays on
|
||||
/// this thread instead of hopping back onto the pool.
|
||||
/// frozen catalog, that school's save file. Awaits are resolved with <c>GetResult</c> so
|
||||
/// <see cref="School.Tick"/> stays on this thread instead of hopping back onto the pool.
|
||||
/// </summary>
|
||||
internal sealed class SchoolWorker
|
||||
{
|
||||
@@ -19,12 +20,15 @@ internal sealed class SchoolWorker
|
||||
private readonly ClientRegistry _clients;
|
||||
private readonly GameMetrics _metrics;
|
||||
private readonly SchoolStore _store;
|
||||
private readonly ModContent _mods;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Channel<WorkerCommand> _mailbox = Channel.CreateUnbounded<WorkerCommand>(
|
||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||||
private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly CancellationTokenSource _stopping = new();
|
||||
private readonly bool _isNew;
|
||||
private readonly IReadOnlyList<string>? _modIds;
|
||||
private readonly MapLayout? _savedMap;
|
||||
|
||||
private readonly int _id;
|
||||
private readonly string _name;
|
||||
@@ -44,10 +48,13 @@ internal sealed class SchoolWorker
|
||||
bool running,
|
||||
int speedIndex,
|
||||
bool isNew,
|
||||
IReadOnlyList<string>? modIds,
|
||||
MapLayout? savedMap,
|
||||
SimulationOptions options,
|
||||
ClientRegistry clients,
|
||||
GameMetrics metrics,
|
||||
SchoolStore store,
|
||||
ModContent mods,
|
||||
ILogger logger)
|
||||
{
|
||||
_id = id;
|
||||
@@ -56,10 +63,13 @@ internal sealed class SchoolWorker
|
||||
_running = running;
|
||||
_speedIndex = speedIndex;
|
||||
_isNew = isNew;
|
||||
_modIds = modIds;
|
||||
_savedMap = savedMap;
|
||||
_options = options;
|
||||
_clients = clients;
|
||||
_metrics = metrics;
|
||||
_store = store;
|
||||
_mods = mods;
|
||||
_logger = logger;
|
||||
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex);
|
||||
}
|
||||
@@ -113,6 +123,11 @@ internal sealed class SchoolWorker
|
||||
{
|
||||
RunLoop(_stopping.Token);
|
||||
}
|
||||
catch (SchoolContentUnavailableException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "School {SchoolId} was not started; the save file is unchanged.", _id);
|
||||
_started.TrySetException(ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "School {SchoolId} worker died.", _id);
|
||||
@@ -122,9 +137,30 @@ internal sealed class SchoolWorker
|
||||
|
||||
private void RunLoop(CancellationToken cancellationToken)
|
||||
{
|
||||
var packIds = _mods.NormalizePackIds(_modIds);
|
||||
foreach (var packId in packIds)
|
||||
{
|
||||
if (!_mods.PackExists(packId))
|
||||
{
|
||||
throw new SchoolContentUnavailableException(
|
||||
$"School {_id} needs mod '{packId}', but that folder is missing.");
|
||||
}
|
||||
}
|
||||
|
||||
var catalog = _mods.LoadCatalog(packIds, _logger);
|
||||
var map = _mods.LoadMap(packIds, _savedMap);
|
||||
try
|
||||
{
|
||||
MapValidator.Validate(map, catalog);
|
||||
}
|
||||
catch (MapValidationException ex)
|
||||
{
|
||||
throw new SchoolContentUnavailableException(ex.Message, ex);
|
||||
}
|
||||
|
||||
var school = _isNew
|
||||
? School.Create(_id, _name, _time)
|
||||
: School.Load(_id, _name, _time, _running, _speedIndex);
|
||||
? School.Create(_id, _name, _time, catalog, map)
|
||||
: School.Load(_id, _name, _time, _running, _speedIndex, catalog, map);
|
||||
|
||||
_school = school;
|
||||
PublishSnapshot();
|
||||
@@ -298,13 +334,17 @@ internal sealed class SchoolWorker
|
||||
return;
|
||||
}
|
||||
|
||||
_store.Save(new SchoolSave(
|
||||
SchoolStore.CurrentFormat,
|
||||
school.Id,
|
||||
school.Name,
|
||||
school.Clock.Time,
|
||||
school.Clock.IsRunning,
|
||||
school.Clock.SpeedIndex));
|
||||
_store.Save(new SchoolSave
|
||||
{
|
||||
Format = SchoolStore.CurrentFormat,
|
||||
Id = school.Id,
|
||||
Name = school.Name,
|
||||
GameTime = school.Clock.Time,
|
||||
Running = school.Clock.IsRunning,
|
||||
SpeedIndex = school.Clock.SpeedIndex,
|
||||
ModIds = school.Catalog?.PackIds,
|
||||
Map = school.Map,
|
||||
});
|
||||
}
|
||||
|
||||
private void BroadcastClock()
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
|
||||
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
|
||||
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="mods\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -19,6 +19,7 @@ builder.Services
|
||||
.Validate(options => options.GameMinutesPerRealSecond > 0, "Simulation:GameMinutesPerRealSecond must be positive.")
|
||||
.Validate(options => GameClock.IsValidStartDate(options.DefaultStartDate), "Simulation:DefaultStartDate is out of range.")
|
||||
.Validate(options => !string.IsNullOrWhiteSpace(options.SavesDirectory), "Simulation:SavesDirectory must be set.")
|
||||
.Validate(options => !string.IsNullOrWhiteSpace(options.ModsDirectory), "Simulation:ModsDirectory must be set.")
|
||||
.Validate(options => options.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.")
|
||||
.ValidateOnStart();
|
||||
|
||||
@@ -26,6 +27,7 @@ builder.Services.AddSingleton<GameCommandQueue>();
|
||||
builder.Services.AddSingleton<ClientRegistry>();
|
||||
builder.Services.AddSingleton<GameMetrics>();
|
||||
builder.Services.AddSingleton<SchoolStore>();
|
||||
builder.Services.AddSingleton<ModContent>();
|
||||
builder.Services.AddSingleton<GameSocketHandler>();
|
||||
builder.Services.AddSingleton<GameLoopService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"GameMinutesPerRealSecond": 5,
|
||||
"DefaultStartDate": "2012-04-03T06:00:00",
|
||||
"SavesDirectory": "saves",
|
||||
"ModsDirectory": "mods",
|
||||
"SaveIntervalSeconds": 30
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "Sit" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "MainBuilding" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "StandardFloor" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "Principal" }
|
||||
@@ -0,0 +1,2 @@
|
||||
// Empty on purpose: a corridor is a walkable room with no furniture of its own.
|
||||
{ "defName": "Corridor" }
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"defName": "PrincipalsOffice",
|
||||
"slots": [
|
||||
{ "key": "directorChair", "thing": "DirectorsChair" },
|
||||
{ "key": "desk", "thing": "Desk" },
|
||||
{ "key": "guestChair", "thing": "Chair", "count": 2 },
|
||||
],
|
||||
"positions": ["Principal"],
|
||||
"works": ["PrincipalOfficeWork", "TeachLesson", "WalkSchool"],
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "SchoolYard" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "Chair", "actions": ["Sit"] }
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "Desk" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "DirectorsChair", "parent": "Chair" }
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{ "defName": "PrincipalOfficeWork" },
|
||||
{ "defName": "TeachLesson" },
|
||||
{ "defName": "WalkSchool" },
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"Sit": "Sit",
|
||||
"Chair": "Chair",
|
||||
"DirectorsChair": "Principal's chair",
|
||||
"Desk": "Desk",
|
||||
"Principal": "Principal",
|
||||
"PrincipalOfficeWork": "Principal's work",
|
||||
"TeachLesson": "Teach a lesson",
|
||||
"WalkSchool": "Walk the school",
|
||||
"SchoolYard": "Yard",
|
||||
"MainBuilding": "Main building",
|
||||
"StandardFloor": "Floor",
|
||||
"Corridor": "Corridor",
|
||||
"PrincipalsOffice": "Principal's office",
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"Sit": "Сесть",
|
||||
"Chair": "Стул",
|
||||
"DirectorsChair": "Кресло директора",
|
||||
"Desk": "Стол",
|
||||
"Principal": "Директор",
|
||||
"PrincipalOfficeWork": "Работа директора",
|
||||
"TeachLesson": "Урок",
|
||||
"WalkSchool": "Обход школы",
|
||||
"SchoolYard": "Двор",
|
||||
"MainBuilding": "Главный корпус",
|
||||
"StandardFloor": "Этажи",
|
||||
"Corridor": "Коридор",
|
||||
"PrincipalsOffice": "Кабинет директора",
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
// Walkable yard is the tree root and a graph node. Rooms reach it through the porch/corridor.
|
||||
"territory": { "id": "yard", "def": "SchoolYard" },
|
||||
"buildings": [
|
||||
{ "id": "main", "def": "MainBuilding" },
|
||||
],
|
||||
"floors": [
|
||||
{ "id": "floor-1", "def": "StandardFloor", "building": "main", "label": "1" },
|
||||
],
|
||||
"rooms": [
|
||||
{
|
||||
"id": "corridor-1",
|
||||
"def": "Corridor",
|
||||
"building": "main",
|
||||
"floor": "floor-1",
|
||||
},
|
||||
{
|
||||
"id": "principals-office",
|
||||
"def": "PrincipalsOffice",
|
||||
"building": "main",
|
||||
"floor": "floor-1",
|
||||
"slots": [
|
||||
{ "key": "directorChair", "thing": "DirectorsChair" },
|
||||
{ "key": "desk", "thing": "Desk" },
|
||||
{ "key": "guestChair", "thing": "Chair" },
|
||||
],
|
||||
},
|
||||
],
|
||||
"links": [
|
||||
{ "a": "yard", "b": "corridor-1" },
|
||||
{ "a": "corridor-1", "b": "principals-office" },
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user