using HSchool.Content;
using HSchool.Simulation;
using Microsoft.Extensions.Options;
namespace HSchool.Server.Game;
///
/// Reads mods/<id>/ from disk and hands the files to .
/// Content itself never sees these paths.
///
internal sealed class ModContent
{
private readonly CatalogLoader _loader = new();
private readonly ILogger _logger;
public ModContent(IOptions options, IHostEnvironment environment, ILogger 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));
///
/// Pack folder names are identifiers, not paths. Anything that could escape
/// is rejected before it reaches the disk.
///
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 ListPacks(string locale)
{
var packs = new List { 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 NormalizePackIds(IReadOnlyList? extraModIds) =>
CatalogLoader.NormalizePackOrder(extraModIds ?? []);
///
/// Player extras plus core, reordered so each pack's requires load first.
/// Missing dependencies and cycles throw .
///
public IReadOnlyList ResolveSelectedPacks(IReadOnlyList? extraModIds)
{
var selected = NormalizePackIds(extraModIds);
var manifests = new Dictionary(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 ReadDocuments(IReadOnlyList packIds)
{
var documents = new List();
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 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 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 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 Requires);