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:
Leonid Pershin
2026-08-20 00:20:36 +03:00
co-authored by Cursor
parent e5a0ae5b9d
commit 263c94c55d
28 changed files with 851 additions and 56 deletions
+40 -4
View File
@@ -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,
+27 -3
View File
@@ -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>