Enhance school creation and map management features by updating the API to support mod packs and map layouts. Introduce a new map snapshot protocol for efficient data handling during school sessions. Revise documentation to reflect these changes, including updates to the protocol and architecture documents. Improve UI components for mod selection and map editing, ensuring a better user experience. Update tests to validate new functionalities and ensure robustness.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.Simulation;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
@@ -11,6 +12,8 @@ internal abstract record GameCommand
|
||||
internal sealed record CreateSchool(
|
||||
string Name,
|
||||
DateTime StartDate,
|
||||
IReadOnlyList<string>? ExtraModIds,
|
||||
MapLayout? Map,
|
||||
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
|
||||
|
||||
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
|
||||
|
||||
@@ -169,10 +169,27 @@ internal sealed class GameLoopService(
|
||||
return;
|
||||
}
|
||||
|
||||
var extras = command.ExtraModIds ?? [];
|
||||
foreach (var packId in extras)
|
||||
{
|
||||
if (!ModContent.IsSafePackId(packId) || !mods.PackExists(packId))
|
||||
{
|
||||
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownMod));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var packIds = mods.NormalizePackIds(extras);
|
||||
if (!mods.PackExists(CatalogLoader.CorePackId))
|
||||
{
|
||||
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog));
|
||||
return;
|
||||
}
|
||||
|
||||
var id = _nextId++;
|
||||
store.WriteNextId(_nextId);
|
||||
|
||||
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, modIds: null, map: null);
|
||||
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map);
|
||||
Track(worker);
|
||||
worker.Start();
|
||||
|
||||
@@ -180,6 +197,13 @@ internal sealed class GameLoopService(
|
||||
{
|
||||
await worker.Started.ConfigureAwait(false);
|
||||
}
|
||||
catch (SchoolContentUnavailableException ex)
|
||||
{
|
||||
Untrack(id);
|
||||
await worker.StopAsync(persist: false).ConfigureAwait(false);
|
||||
command.Result.TrySetResult(new SchoolCreationOutcome(null, ContentError(ex)));
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Untrack(id);
|
||||
@@ -427,6 +451,14 @@ internal sealed class GameLoopService(
|
||||
client.TrySend(frame.AsMemory(0, length));
|
||||
}
|
||||
|
||||
private static SchoolCreationError ContentError(SchoolContentUnavailableException ex) =>
|
||||
ex.InnerException switch
|
||||
{
|
||||
MapValidationException => SchoolCreationError.InvalidMap,
|
||||
ContentLoadException => SchoolCreationError.InvalidCatalog,
|
||||
_ => SchoolCreationError.InvalidCatalog,
|
||||
};
|
||||
|
||||
/// <summary>Runs work for a waiting request thread without letting an exception kill the supervisor.</summary>
|
||||
private static void Complete<T>(TaskCompletionSource<T> completion, Func<T> work)
|
||||
{
|
||||
|
||||
@@ -29,6 +29,50 @@ internal sealed class ModContent
|
||||
|
||||
public bool PackExists(string packId) => Directory.Exists(PackPath(packId));
|
||||
|
||||
/// <summary>
|
||||
/// Pack folder names are identifiers, not paths. Anything that could escape <see cref="Root"/>
|
||||
/// is rejected before it reaches the disk.
|
||||
/// </summary>
|
||||
public static bool IsSafePackId(string packId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packId) || packId.Length > 64)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var ch in packId)
|
||||
{
|
||||
if (!char.IsAsciiLetterOrDigit(ch) && ch is not '-' and not '_')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ModPackInfo> ListPacks()
|
||||
{
|
||||
var packs = new List<ModPackInfo> { new(CatalogLoader.CorePackId, Required: true) };
|
||||
if (!Directory.Exists(Root))
|
||||
{
|
||||
return packs;
|
||||
}
|
||||
|
||||
foreach (var directory in Directory.GetDirectories(Root).OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var id = Path.GetFileName(directory);
|
||||
if (id.Equals(CatalogLoader.CorePackId, StringComparison.OrdinalIgnoreCase) || !IsSafePackId(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
packs.Add(new ModPackInfo(id, Required: false));
|
||||
}
|
||||
|
||||
return packs;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> NormalizePackIds(IReadOnlyList<string>? extraModIds) =>
|
||||
CatalogLoader.NormalizePackOrder(extraModIds ?? []);
|
||||
|
||||
@@ -90,5 +134,9 @@ internal sealed class ModContent
|
||||
return map;
|
||||
}
|
||||
|
||||
public DefCatalog LoadCatalog(IReadOnlyList<string> packIds) => LoadCatalog(packIds, _logger);
|
||||
|
||||
private string PackPath(string packId) => Path.Combine(Root, packId);
|
||||
}
|
||||
|
||||
internal sealed record ModPackInfo(string Id, bool Required);
|
||||
|
||||
@@ -276,6 +276,7 @@ internal sealed class SchoolWorker
|
||||
{
|
||||
case WorkerCommand.Open open:
|
||||
open.Client.OpenSchoolId = _id;
|
||||
SendMapSnapshot(open.Client, school);
|
||||
BroadcastClockTo(open.Client, school);
|
||||
break;
|
||||
|
||||
@@ -364,6 +365,33 @@ internal sealed class SchoolWorker
|
||||
}
|
||||
}
|
||||
|
||||
private void SendMapSnapshot(GameClient client, School school)
|
||||
{
|
||||
if (school.Catalog is null || school.Map is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var locale = ProtocolConstants.CatalogLocale(client.Locale);
|
||||
var view = MapView.Build(school.Catalog, school.Map, locale);
|
||||
var nodes = new MapSnapshotNode[view.Count];
|
||||
for (var i = 0; i < view.Count; i++)
|
||||
{
|
||||
var node = view[i];
|
||||
nodes[i] = new MapSnapshotNode(
|
||||
(byte)node.Kind,
|
||||
node.Id,
|
||||
node.ParentId,
|
||||
node.Name,
|
||||
node.Items,
|
||||
node.Positions);
|
||||
}
|
||||
|
||||
var frame = new byte[ProtocolConstants.MaxMessageSize];
|
||||
var length = ProtocolCodec.WriteMapSnapshot(frame, new ServerMapSnapshotMessage(school.Id, nodes));
|
||||
client.TrySendReliable(frame.AsMemory(0, length));
|
||||
}
|
||||
|
||||
private static void BroadcastClockTo(GameClient client, School school)
|
||||
{
|
||||
var frame = new byte[ProtocolCodec.MaxFrameSize];
|
||||
|
||||
Reference in New Issue
Block a user