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
+4
View File
@@ -40,6 +40,8 @@ const ru = {
errorInvalidStartDate: 'Дата начала вне допустимого диапазона.',
errorInvalidMap: 'Карта должна быть связным графом: двор и хотя бы одна комната.',
errorUnknownMod: 'Выбранный мод не найден.',
errorMissingMod: 'Не выбран обязательный мод «{id}».',
errorModCycle: 'Выбранные моды зависят друг от друга по кругу.',
errorInvalidCatalog: 'Не удалось загрузить выбранные моды.',
errorUnknownNameSet: 'Выбранный набор имён не найден.',
errorUnknownNativeLanguage: 'Выбранный родной язык не входит в этот набор имён.',
@@ -251,6 +253,8 @@ const en: Messages = {
errorInvalidStartDate: 'The start date is outside the allowed range.',
errorInvalidMap: 'The map must be a connected graph: a yard and at least one room.',
errorUnknownMod: 'A selected mod is missing.',
errorMissingMod: 'Required mod “{id}” is not selected.',
errorModCycle: 'The selected mods depend on each other in a cycle.',
errorInvalidCatalog: 'The selected packs could not be loaded.',
errorUnknownNameSet: 'The selected name set is not in the catalog.',
errorUnknownNativeLanguage: 'The selected native language is not in that name set.',
+10 -2
View File
@@ -10,6 +10,7 @@ export interface School {
readonly gameTime: string;
readonly running: boolean;
readonly speedIndex: number;
readonly modIds?: readonly string[];
}
export interface SchoolsResponse {
@@ -30,6 +31,7 @@ export class ApiError extends Error {
readonly payroll?: number,
readonly remaining?: number,
readonly attempted?: number,
readonly missing?: string,
) {
super(message);
}
@@ -75,6 +77,9 @@ export async function createSchool(
export interface ModInfo {
readonly id: string;
readonly required: boolean;
readonly label: string;
readonly version: string;
readonly requires: readonly string[];
}
export interface DefInfo {
@@ -167,8 +172,9 @@ export interface CatalogResponse {
readonly holidays: readonly HolidayInfo[];
}
export async function fetchMods(): Promise<readonly ModInfo[]> {
const response = await request<{ mods: readonly ModInfo[] }>('/api/mods');
export async function fetchMods(lang: string): Promise<readonly ModInfo[]> {
const query = new URLSearchParams({ lang });
const response = await request<{ mods: readonly ModInfo[] }>(`/api/mods?${query.toString()}`);
return response.mods;
}
@@ -528,6 +534,7 @@ async function toApiError(response: Response): Promise<ApiError> {
payroll?: number;
remaining?: number;
attempted?: number;
missing?: string;
};
return new ApiError(
response.status,
@@ -537,6 +544,7 @@ async function toApiError(response: Response): Promise<ApiError> {
problem.payroll,
problem.remaining,
problem.attempted,
problem.missing,
);
} catch {
return new ApiError(response.status, 'unknown', response.statusText);
@@ -194,7 +194,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const paintMods = async (): Promise<void> => {
let packs;
try {
packs = await fetchMods();
packs = await fetchMods(getLocale());
} catch {
showError(t('catalogLoadFailed'));
return;
@@ -223,7 +223,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
'label',
{ class: 'mod-list__item' },
checkbox,
pack.required ? t('coreModLocked', { id: pack.id }) : pack.id,
pack.required ? t('coreModLocked', { id: pack.label }) : pack.label,
);
modsList.append(label);
}
@@ -359,6 +359,10 @@ function describe(reason: unknown): string {
return t('errorInvalidMap');
case 'unknown-mod':
return t('errorUnknownMod');
case 'missing-mod':
return t('errorMissingMod', { id: reason.missing ?? '' });
case 'mod-cycle':
return t('errorModCycle');
case 'invalid-catalog':
return t('errorInvalidCatalog');
case 'unknown-name-set':
+36
View File
@@ -35,6 +35,7 @@ public sealed class CatalogLoader
ApplyPatches(resolved, patches);
var catalog = Materialize(order, resolved, localesRu, localesEn);
ResolveReferences(catalog);
WarnMissingLabels(catalog, log);
return catalog;
}
@@ -491,5 +492,40 @@ public sealed class CatalogLoader
PeopleDefValidator.Validate(catalog);
}
private static void WarnMissingLabels(DefCatalog catalog, IContentLog log)
{
foreach (var def in ConcreteDefs(catalog))
{
if (!catalog.HasText("ru", def.DefName) && !catalog.HasText("en", def.DefName))
{
log.Warning($"Def {DefCatalog.KindOf(def)}:{def.DefName} has no label in the pack locales.");
}
}
}
private static IEnumerable<Def> ConcreteDefs(DefCatalog catalog)
{
return Enumerate(catalog.Actions.Values)
.Concat(Enumerate(catalog.Things.Values))
.Concat(Enumerate(catalog.Positions.Values))
.Concat(Enumerate(catalog.Works.Values))
.Concat(Enumerate(catalog.Rooms.Values))
.Concat(Enumerate(catalog.Buildings.Values))
.Concat(Enumerate(catalog.Floors.Values))
.Concat(Enumerate(catalog.Territories.Values))
.Concat(Enumerate(catalog.Skills.Values))
.Concat(Enumerate(catalog.Traits.Values))
.Concat(Enumerate(catalog.BodyAttributes.Values))
.Concat(Enumerate(catalog.Needs.Values))
.Concat(Enumerate(catalog.NameSets.Values))
.Concat(Enumerate(catalog.Subjects.Values))
.Concat(Enumerate(catalog.Staffing.Values))
.Concat(Enumerate(catalog.DayFrames.Values))
.Concat(Enumerate(catalog.Holidays.Values))
.Concat(Enumerate(catalog.Behavior.Values));
static IEnumerable<Def> Enumerate(IEnumerable<Def> defs) => defs.Where(def => !def.Abstract);
}
private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source);
}
@@ -0,0 +1,32 @@
namespace HSchool.Content;
/// <summary>
/// Selected packs cannot be ordered: a required pack is missing, or they require each other.
/// Distinct from <see cref="ContentLoadException"/> so the create dialog can name the pack.
/// </summary>
public sealed class PackDependencyException : Exception
{
public const string MissingCode = "missing-mod";
public const string CycleCode = "mod-cycle";
private PackDependencyException(string message, string code, string? missingPackId)
: base(message)
{
Code = code;
MissingPackId = missingPackId;
}
public string Code { get; }
public string? MissingPackId { get; }
public static PackDependencyException Missing(string missingPackId, string requiredBy) =>
new(
$"Pack '{requiredBy}' requires '{missingPackId}', which was not selected.",
MissingCode,
missingPackId);
public static PackDependencyException Cycle() =>
new("Selected packs have a cyclic dependency.", CycleCode, missingPackId: null);
}
+93
View File
@@ -0,0 +1,93 @@
namespace HSchool.Content;
/// <summary>
/// Stable topological sort of selected packs. Player order is kept wherever it does not
/// contradict <c>requires</c>. <c>core</c> is expected to already be first in
/// <paramref name="selected"/>.
/// </summary>
public static class PackLoadOrder
{
public static IReadOnlyList<string> Resolve(
IReadOnlyList<string> selected,
IReadOnlyDictionary<string, PackManifest> manifests)
{
var index = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < selected.Count; i++)
{
index[selected[i]] = i;
}
var indegree = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var outgoing = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var packId in selected)
{
indegree[packId] = 0;
outgoing[packId] = [];
}
foreach (var packId in selected)
{
var requires = manifests.TryGetValue(packId, out var manifest)
? manifest.Requires
: PackManifest.Empty.Requires;
foreach (var dependency in requires)
{
if (!indegree.ContainsKey(dependency))
{
throw PackDependencyException.Missing(dependency, packId);
}
outgoing[dependency].Add(packId);
indegree[packId]++;
}
}
var ready = new SortedSet<int>();
for (var i = 0; i < selected.Count; i++)
{
if (indegree[selected[i]] == 0)
{
ready.Add(i);
}
}
var ordered = new List<string>(selected.Count);
while (ready.Count > 0)
{
var next = ready.Min;
ready.Remove(next);
var packId = selected[next];
ordered.Add(packId);
foreach (var dependent in outgoing[packId].OrderBy(id => index[id]))
{
indegree[dependent]--;
if (indegree[dependent] == 0)
{
ready.Add(index[dependent]);
}
}
}
if (ordered.Count != selected.Count)
{
throw PackDependencyException.Cycle();
}
return ordered;
}
public static IReadOnlyDictionary<string, PackManifest> ManifestsFrom(
IReadOnlyList<string> packIds,
IReadOnlyList<ContentDocument> documents)
{
var manifests = new Dictionary<string, PackManifest>(StringComparer.OrdinalIgnoreCase);
foreach (var packId in packIds)
{
manifests[packId] = PackManifest.FromDocuments(packId, documents);
}
return manifests;
}
}
+126
View File
@@ -0,0 +1,126 @@
using System.Text.Json.Nodes;
namespace HSchool.Content;
/// <summary>
/// Identity of one pack from <c>pack.jsonc</c>. A folder without that file is still a pack:
/// empty version, no dependencies, id used as the label until a locale supplies one.
/// </summary>
public sealed record PackManifest(string Version, IReadOnlyList<string> Requires)
{
public static PackManifest Empty { get; } = new(string.Empty, []);
public static PackManifest Parse(string packId, string text)
{
var source = $"{packId}:pack.jsonc";
var node = Jsonc.Parse(text, source);
if (node is not JsonObject obj)
{
throw new ContentLoadException($"{source} must be an object.");
}
var version = ReadVersion(obj, source);
var requires = ReadRequires(obj, source);
return new PackManifest(version, requires);
}
public static PackManifest FromDocuments(string packId, IReadOnlyList<ContentDocument> documents)
{
foreach (var document in documents)
{
if (document.PackId == packId && PackPaths.IsManifest(document.RelativePath))
{
return Parse(packId, document.Text);
}
}
return Empty;
}
/// <summary>
/// Pack title from that pack's own locale table, keyed by pack id. Missing key → the id.
/// </summary>
public static string Label(string packId, string locale, IReadOnlyList<ContentDocument> documents)
{
foreach (var document in documents)
{
if (document.PackId != packId
|| !PackPaths.TryGetLocaleLanguage(document.RelativePath, out var language)
|| !language.Equals(locale, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var table = ReadLocaleTable(document.Text, $"{packId}:{document.RelativePath}");
return table.TryGetValue(packId, out var label) ? label : packId;
}
return packId;
}
public static IReadOnlyDictionary<string, string> ReadLocaleTable(string text, string source)
{
var node = Jsonc.Parse(text, source);
if (node is not JsonObject obj)
{
throw new ContentLoadException($"Localization in {source} must be an object of strings.");
}
var table = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var property in obj)
{
if (property.Value is not JsonValue value || !value.TryGetValue<string>(out var label))
{
throw new ContentLoadException($"Localization key '{property.Key}' in {source} is not a string.");
}
table[property.Key] = label;
}
return table;
}
private static string ReadVersion(JsonObject obj, string source)
{
if (obj["version"] is null)
{
return string.Empty;
}
if (obj["version"] is JsonValue value && value.TryGetValue<string>(out var version))
{
return version ?? string.Empty;
}
throw new ContentLoadException($"{source} version must be a string.");
}
private static IReadOnlyList<string> ReadRequires(JsonObject obj, string source)
{
if (obj["requires"] is null)
{
return [];
}
if (obj["requires"] is not JsonArray array)
{
throw new ContentLoadException($"{source} requires must be an array of pack ids.");
}
var requires = new List<string>();
foreach (var item in array)
{
if (item is not JsonValue value || !value.TryGetValue<string>(out var packId) || string.IsNullOrWhiteSpace(packId))
{
throw new ContentLoadException($"{source} requires entries must be non-empty strings.");
}
if (!requires.Contains(packId, StringComparer.OrdinalIgnoreCase))
{
requires.Add(packId);
}
}
return requires;
}
}
+7
View File
@@ -58,6 +58,13 @@ internal static class PackPaths
Normalize(relativePath).Equals("maps/default.jsonc", StringComparison.OrdinalIgnoreCase)
|| Normalize(relativePath).Equals("maps/default.json", StringComparison.OrdinalIgnoreCase);
public static bool IsManifest(string relativePath)
{
var path = Normalize(relativePath);
return path.Equals("pack.jsonc", StringComparison.OrdinalIgnoreCase)
|| path.Equals("pack.json", StringComparison.OrdinalIgnoreCase);
}
private static bool TryMapFolder(string folder, out DefKind kind)
{
switch (folder.ToLowerInvariant())
+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>
+24 -1
View File
@@ -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));
+71 -4
View File
@@ -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;
}
+7 -1
View File
@@ -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);
+4 -2
View File
@@ -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);
+3
View File
@@ -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": "Базовая игра",
}
+4
View File
@@ -0,0 +1,4 @@
{
"version": "1.0",
"requires": [],
}
@@ -15,4 +15,6 @@ public enum SchoolCreationError
InvalidCatalog,
UnknownNameSet,
UnknownNativeLanguage,
MissingMod,
ModCycle,
}