Give packs an identity so create can refuse missing deps and load in a stable order.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,8 +11,16 @@ internal static class ModEndpoints
|
||||
{
|
||||
public static void MapModEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
builder.MapGet("/api/mods", (ModContent mods) =>
|
||||
new ModsResponse(mods.ListPacks().Select(pack => new ModInfoResponse(pack.Id, pack.Required)).ToArray()))
|
||||
builder.MapGet("/api/mods", (string? lang, ModContent mods) =>
|
||||
{
|
||||
var locale = string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "ru";
|
||||
return new ModsResponse(mods.ListPacks(locale).Select(pack => new ModInfoResponse(
|
||||
pack.Id,
|
||||
pack.Required,
|
||||
pack.Label,
|
||||
pack.Version,
|
||||
pack.Requires)).ToArray());
|
||||
})
|
||||
.WithName("GetMods");
|
||||
|
||||
builder.MapGet("/api/catalog", (string? lang, string? mods, ModContent content) =>
|
||||
@@ -31,7 +39,19 @@ internal static class ModEndpoints
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The core pack is missing.");
|
||||
}
|
||||
|
||||
var packIds = content.NormalizePackIds(extras);
|
||||
IReadOnlyList<string> packIds;
|
||||
try
|
||||
{
|
||||
packIds = content.ResolveSelectedPacks(extras);
|
||||
}
|
||||
catch (PackDependencyException ex)
|
||||
{
|
||||
return PackProblem(ex);
|
||||
}
|
||||
catch (ContentLoadException ex)
|
||||
{
|
||||
return Problem(StatusCodes.Status400BadRequest, "invalid-catalog", ex.Message);
|
||||
}
|
||||
DefCatalog catalog;
|
||||
MapLayout map;
|
||||
try
|
||||
@@ -69,6 +89,17 @@ internal static class ModEndpoints
|
||||
return mods.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static IResult PackProblem(PackDependencyException ex) =>
|
||||
Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: ex.Code,
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["code"] = ex.Code,
|
||||
["missing"] = ex.MissingPackId,
|
||||
});
|
||||
|
||||
private static IResult Problem(int statusCode, string code, string detail) =>
|
||||
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?>
|
||||
{
|
||||
@@ -78,7 +109,12 @@ internal static class ModEndpoints
|
||||
|
||||
internal sealed record ModsResponse(IReadOnlyList<ModInfoResponse> Mods);
|
||||
|
||||
internal sealed record ModInfoResponse(string Id, bool Required);
|
||||
internal sealed record ModInfoResponse(
|
||||
string Id,
|
||||
bool Required,
|
||||
string Label,
|
||||
string Version,
|
||||
IReadOnlyList<string> Requires);
|
||||
|
||||
internal sealed record CatalogResponse(
|
||||
IReadOnlyList<DefInfoResponse> Territories,
|
||||
|
||||
@@ -80,6 +80,14 @@ internal static class SchoolEndpoints
|
||||
Problem(StatusCodes.Status400BadRequest, "unknown-name-set", "The selected name set is not in the catalog."),
|
||||
SchoolCreationError.UnknownNativeLanguage =>
|
||||
Problem(StatusCodes.Status400BadRequest, "unknown-native-language", "The selected native language is not in that name set."),
|
||||
SchoolCreationError.MissingMod =>
|
||||
Problem(
|
||||
StatusCodes.Status400BadRequest,
|
||||
"missing-mod",
|
||||
$"Mod '{outcome.MissingPackId}' is required but was not selected.",
|
||||
missing: outcome.MissingPackId),
|
||||
SchoolCreationError.ModCycle =>
|
||||
Problem(StatusCodes.Status400BadRequest, "mod-cycle", "Selected mods have a cyclic dependency."),
|
||||
_ => Results.Problem("Unknown error."),
|
||||
};
|
||||
})
|
||||
@@ -468,7 +476,12 @@ internal static class SchoolEndpoints
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IResult Problem(int statusCode, string code, string detail, StaffingOutcome? staffing = null)
|
||||
private static IResult Problem(
|
||||
int statusCode,
|
||||
string code,
|
||||
string detail,
|
||||
StaffingOutcome? staffing = null,
|
||||
string? missing = null)
|
||||
{
|
||||
var extensions = new Dictionary<string, object?> { ["code"] = code };
|
||||
if (staffing is not null)
|
||||
@@ -479,6 +492,11 @@ internal static class SchoolEndpoints
|
||||
extensions["attempted"] = staffing.Attempted;
|
||||
}
|
||||
|
||||
if (missing is not null)
|
||||
{
|
||||
extensions["missing"] = missing;
|
||||
}
|
||||
|
||||
return Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: extensions);
|
||||
}
|
||||
}
|
||||
@@ -492,10 +510,16 @@ internal sealed record CreateSchoolRequest(
|
||||
string? NameSetId,
|
||||
string? NativeLanguage);
|
||||
|
||||
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex)
|
||||
internal sealed record SchoolResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
DateTime GameTime,
|
||||
bool Running,
|
||||
byte SpeedIndex,
|
||||
IReadOnlyList<string> ModIds)
|
||||
{
|
||||
public static SchoolResponse From(SchoolState school) =>
|
||||
new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex);
|
||||
new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex, school.ModIds);
|
||||
}
|
||||
|
||||
/// <summary>Everything the main menu needs in one request.</summary>
|
||||
|
||||
@@ -303,7 +303,30 @@ internal sealed class GameLoopService(
|
||||
}
|
||||
}
|
||||
|
||||
var packIds = mods.NormalizePackIds(extras);
|
||||
IReadOnlyList<string> packIds;
|
||||
try
|
||||
{
|
||||
packIds = mods.ResolveSelectedPacks(extras);
|
||||
}
|
||||
catch (PackDependencyException ex) when (ex.Code == PackDependencyException.MissingCode)
|
||||
{
|
||||
command.Result.TrySetResult(new SchoolCreationOutcome(
|
||||
null,
|
||||
SchoolCreationError.MissingMod,
|
||||
ex.MissingPackId));
|
||||
return;
|
||||
}
|
||||
catch (PackDependencyException)
|
||||
{
|
||||
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.ModCycle));
|
||||
return;
|
||||
}
|
||||
catch (ContentLoadException)
|
||||
{
|
||||
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mods.PackExists(CatalogLoader.CorePackId))
|
||||
{
|
||||
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog));
|
||||
|
||||
@@ -51,9 +51,9 @@ internal sealed class ModContent
|
||||
return true;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ModPackInfo> ListPacks()
|
||||
public IReadOnlyList<ModPackInfo> ListPacks(string locale)
|
||||
{
|
||||
var packs = new List<ModPackInfo> { new(CatalogLoader.CorePackId, Required: true) };
|
||||
var packs = new List<ModPackInfo> { Describe(CatalogLoader.CorePackId, required: true, locale) };
|
||||
if (!Directory.Exists(Root))
|
||||
{
|
||||
return packs;
|
||||
@@ -67,7 +67,7 @@ internal sealed class ModContent
|
||||
continue;
|
||||
}
|
||||
|
||||
packs.Add(new ModPackInfo(id, Required: false));
|
||||
packs.Add(Describe(id, required: false, locale));
|
||||
}
|
||||
|
||||
return packs;
|
||||
@@ -76,6 +76,30 @@ internal sealed class ModContent
|
||||
public IReadOnlyList<string> NormalizePackIds(IReadOnlyList<string>? extraModIds) =>
|
||||
CatalogLoader.NormalizePackOrder(extraModIds ?? []);
|
||||
|
||||
/// <summary>
|
||||
/// Player extras plus <c>core</c>, reordered so each pack's <c>requires</c> load first.
|
||||
/// Missing dependencies and cycles throw <see cref="PackDependencyException"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ResolveSelectedPacks(IReadOnlyList<string>? extraModIds)
|
||||
{
|
||||
var selected = NormalizePackIds(extraModIds);
|
||||
var manifests = new Dictionary<string, PackManifest>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var packId in selected)
|
||||
{
|
||||
manifests[packId] = ReadManifest(packId);
|
||||
}
|
||||
|
||||
return PackLoadOrder.Resolve(selected, manifests);
|
||||
}
|
||||
|
||||
public PackManifest ReadManifest(string packId)
|
||||
{
|
||||
var jsonc = Path.Combine(PackPath(packId), "pack.jsonc");
|
||||
var json = Path.Combine(PackPath(packId), "pack.json");
|
||||
var path = File.Exists(jsonc) ? jsonc : File.Exists(json) ? json : null;
|
||||
return path is null ? PackManifest.Empty : PackManifest.Parse(packId, File.ReadAllText(path));
|
||||
}
|
||||
|
||||
public IReadOnlyList<ContentDocument> ReadDocuments(IReadOnlyList<string> packIds)
|
||||
{
|
||||
var documents = new List<ContentDocument>();
|
||||
@@ -136,7 +160,50 @@ internal sealed class ModContent
|
||||
|
||||
public DefCatalog LoadCatalog(IReadOnlyList<string> packIds) => LoadCatalog(packIds, _logger);
|
||||
|
||||
private ModPackInfo Describe(string packId, bool required, string locale)
|
||||
{
|
||||
PackManifest manifest;
|
||||
try
|
||||
{
|
||||
manifest = PackExists(packId) ? ReadManifest(packId) : PackManifest.Empty;
|
||||
}
|
||||
catch (ContentLoadException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not read pack.jsonc for {PackId}.", packId);
|
||||
manifest = PackManifest.Empty;
|
||||
}
|
||||
|
||||
return new ModPackInfo(packId, required, ReadPackLabel(packId, locale), manifest.Version, manifest.Requires);
|
||||
}
|
||||
|
||||
private string ReadPackLabel(string packId, string locale)
|
||||
{
|
||||
var jsonc = Path.Combine(PackPath(packId), "localizations", $"{locale}.jsonc");
|
||||
var json = Path.Combine(PackPath(packId), "localizations", $"{locale}.json");
|
||||
var path = File.Exists(jsonc) ? jsonc : File.Exists(json) ? json : null;
|
||||
if (path is null)
|
||||
{
|
||||
return packId;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var table = PackManifest.ReadLocaleTable(File.ReadAllText(path), $"{packId}:localizations/{locale}");
|
||||
return table.TryGetValue(packId, out var label) ? label : packId;
|
||||
}
|
||||
catch (ContentLoadException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not read pack label for {PackId}.", packId);
|
||||
return packId;
|
||||
}
|
||||
}
|
||||
|
||||
private string PackPath(string packId) => Path.Combine(Root, packId);
|
||||
}
|
||||
|
||||
internal sealed record ModPackInfo(string Id, bool Required);
|
||||
internal sealed record ModPackInfo(
|
||||
string Id,
|
||||
bool Required,
|
||||
string Label,
|
||||
string Version,
|
||||
IReadOnlyList<string> Requires);
|
||||
|
||||
@@ -3,7 +3,10 @@ using HSchool.Simulation;
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>What the supervisor reports back after trying to create a school.</summary>
|
||||
internal readonly record struct SchoolCreationOutcome(SchoolState? School, SchoolCreationError Error)
|
||||
internal readonly record struct SchoolCreationOutcome(
|
||||
SchoolState? School,
|
||||
SchoolCreationError Error,
|
||||
string? MissingPackId = null)
|
||||
{
|
||||
public bool Succeeded => Error == SchoolCreationError.None && School is not null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,13 @@ namespace HSchool.Server.Game;
|
||||
/// Immutable copy of a school, safe to hand to request threads. The live <c>School</c> object
|
||||
/// never leaves its worker thread.
|
||||
/// </summary>
|
||||
internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex);
|
||||
internal sealed record SchoolState(
|
||||
int Id,
|
||||
string Name,
|
||||
DateTime GameTime,
|
||||
bool Running,
|
||||
byte SpeedIndex,
|
||||
IReadOnlyList<string> ModIds);
|
||||
|
||||
/// <summary>Everything the main menu needs in one read.</summary>
|
||||
internal sealed record SchoolsState(int MaxSchools, IReadOnlyList<SchoolState> Schools);
|
||||
|
||||
@@ -94,7 +94,7 @@ internal sealed class SchoolWorker
|
||||
_mods = mods;
|
||||
_onFailed = onFailed;
|
||||
_logger = logger;
|
||||
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex);
|
||||
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex, modIds ?? []);
|
||||
}
|
||||
|
||||
public int Id => _id;
|
||||
@@ -203,6 +203,7 @@ internal sealed class SchoolWorker
|
||||
private void RunLoop(CancellationToken cancellationToken)
|
||||
{
|
||||
var packIds = _mods.NormalizePackIds(_modIds);
|
||||
_logger.LogInformation("School {SchoolId} loading packs [{Packs}].", _id, string.Join(", ", packIds));
|
||||
foreach (var packId in packIds)
|
||||
{
|
||||
if (!_mods.PackExists(packId))
|
||||
@@ -577,7 +578,8 @@ internal sealed class SchoolWorker
|
||||
school.Name,
|
||||
school.Clock.Time,
|
||||
school.Clock.IsRunning,
|
||||
(byte)school.Clock.SpeedIndex));
|
||||
(byte)school.Clock.SpeedIndex,
|
||||
school.Catalog?.PackIds ?? _modIds ?? []));
|
||||
Volatile.Write(ref _rosterSnapshot, school.Roster);
|
||||
Volatile.Write(ref _applicantSnapshot, school.Applicants);
|
||||
Volatile.Write(ref _timetableSnapshot, school.Timetable);
|
||||
|
||||
@@ -73,6 +73,9 @@ if (app.Configuration.GetValue("HSchool:AllowSaveReload", false))
|
||||
|
||||
app.MapGet("/api/dev/saves-directory", (SchoolStore store) => Results.Json(new { path = store.DirectoryPath }))
|
||||
.WithName("GetSavesDirectory");
|
||||
|
||||
app.MapGet("/api/dev/mods-directory", (ModContent mods) => Results.Json(new { path = mods.Root }))
|
||||
.WithName("GetModsDirectory");
|
||||
}
|
||||
|
||||
// The realtime channel: one binary frame per protocol message, see docs/protocol.md.
|
||||
|
||||
@@ -116,4 +116,5 @@
|
||||
"WinterBreak": "Winter break",
|
||||
"SpringBreak": "Spring break",
|
||||
"SummerBreak": "Summer break",
|
||||
"core": "Core",
|
||||
}
|
||||
|
||||
@@ -116,4 +116,5 @@
|
||||
"WinterBreak": "Зимние каникулы",
|
||||
"SpringBreak": "Весенние каникулы",
|
||||
"SummerBreak": "Летние каникулы",
|
||||
"core": "Базовая игра",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"requires": [],
|
||||
}
|
||||
Reference in New Issue
Block a user