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);