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:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user