210 lines
6.9 KiB
C#
210 lines
6.9 KiB
C#
using HSchool.Content;
|
|
using HSchool.Simulation;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace HSchool.Server.Game;
|
|
|
|
/// <summary>
|
|
/// Reads <c>mods/<id>/</c> from disk and hands the files to <see cref="CatalogLoader"/>.
|
|
/// Content itself never sees these paths.
|
|
/// </summary>
|
|
internal sealed class ModContent
|
|
{
|
|
private readonly CatalogLoader _loader = new();
|
|
private readonly ILogger<ModContent> _logger;
|
|
|
|
public ModContent(IOptions<SimulationOptions> options, IHostEnvironment environment, ILogger<ModContent> logger)
|
|
{
|
|
_logger = logger;
|
|
|
|
var configured = options.Value.ModsDirectory;
|
|
Root = Path.IsPathRooted(configured)
|
|
? configured
|
|
: Path.GetFullPath(Path.Combine(environment.ContentRootPath, configured));
|
|
|
|
logger.LogInformation("Mod packs directory is {Directory}.", Root);
|
|
}
|
|
|
|
public string Root { get; }
|
|
|
|
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(string locale)
|
|
{
|
|
var packs = new List<ModPackInfo> { Describe(CatalogLoader.CorePackId, required: true, locale) };
|
|
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(Describe(id, required: false, locale));
|
|
}
|
|
|
|
return packs;
|
|
}
|
|
|
|
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>();
|
|
foreach (var packId in packIds)
|
|
{
|
|
var packRoot = PackPath(packId);
|
|
if (!Directory.Exists(packRoot))
|
|
{
|
|
throw new SchoolContentUnavailableException($"Mod folder '{packId}' is missing under {Root}.");
|
|
}
|
|
|
|
foreach (var path in Directory.EnumerateFiles(packRoot, "*.*", SearchOption.AllDirectories))
|
|
{
|
|
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
|
|
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var relative = Path.GetRelativePath(packRoot, path).Replace('\\', '/');
|
|
documents.Add(new ContentDocument(packId, relative, File.ReadAllText(path)));
|
|
}
|
|
}
|
|
|
|
return documents;
|
|
}
|
|
|
|
public DefCatalog LoadCatalog(IReadOnlyList<string> packIds, ILogger workerLog)
|
|
{
|
|
var documents = ReadDocuments(packIds);
|
|
try
|
|
{
|
|
return _loader.Load(packIds, documents, new LoggerContentLog(workerLog));
|
|
}
|
|
catch (ContentLoadException ex)
|
|
{
|
|
throw new SchoolContentUnavailableException(ex.Message, ex);
|
|
}
|
|
}
|
|
|
|
public MapLayout LoadMap(IReadOnlyList<string> packIds, MapLayout? saved)
|
|
{
|
|
if (saved is not null)
|
|
{
|
|
return saved;
|
|
}
|
|
|
|
var documents = ReadDocuments(packIds);
|
|
var map = CatalogLoader.LastDefaultMap(packIds, documents);
|
|
if (map is null)
|
|
{
|
|
throw new SchoolContentUnavailableException(
|
|
$"No maps/default.jsonc found for packs [{string.Join(", ", packIds)}].");
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
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,
|
|
string Label,
|
|
string Version,
|
|
IReadOnlyList<string> Requires);
|