From 4444b590f016ebecfc113a0dd4584723b0250f41 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 17 Feb 2018 16:34:31 -0500 Subject: add content pack feature (#436) --- src/SMAPI/Framework/ContentPack.cs | 78 +++++++++++ src/SMAPI/Framework/IModMetadata.cs | 18 ++- src/SMAPI/Framework/InternalExtensions.cs | 2 +- src/SMAPI/Framework/ModHelpers/ModHelper.cs | 19 ++- src/SMAPI/Framework/ModLoading/ModMetadata.cs | 29 +++- src/SMAPI/Framework/ModLoading/ModResolver.cs | 151 +++++++++++++++------ src/SMAPI/Framework/ModRegistry.cs | 17 ++- src/SMAPI/Framework/Models/Manifest.cs | 6 +- .../Framework/Models/ManifestContentPackFor.cs | 15 ++ .../ManifestContentPackForConverter.cs | 50 +++++++ src/SMAPI/IContentPack.cs | 42 ++++++ src/SMAPI/IManifest.cs | 5 +- src/SMAPI/IManifestContentPackFor.cs | 12 ++ src/SMAPI/IModHelper.cs | 12 +- src/SMAPI/Program.cs | 90 ++++++++++-- src/SMAPI/StardewModdingAPI.csproj | 7 +- 16 files changed, 485 insertions(+), 68 deletions(-) create mode 100644 src/SMAPI/Framework/ContentPack.cs create mode 100644 src/SMAPI/Framework/Models/ManifestContentPackFor.cs create mode 100644 src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestContentPackForConverter.cs create mode 100644 src/SMAPI/IContentPack.cs create mode 100644 src/SMAPI/IManifestContentPackFor.cs (limited to 'src') diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs new file mode 100644 index 00000000..0a8f223e --- /dev/null +++ b/src/SMAPI/Framework/ContentPack.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Serialisation; +using xTile; + +namespace StardewModdingAPI.Framework +{ + /// Manages access to a content pack's metadata and files. + internal class ContentPack : IContentPack + { + /********* + ** Properties + *********/ + /// Provides an API for loading content assets. + private readonly IContentHelper Content; + + /// Encapsulates SMAPI's JSON file parsing. + private readonly JsonHelper JsonHelper; + + + /********* + ** Accessors + *********/ + /// The full path to the content pack's folder. + public string DirectoryPath { get; } + + /// The content pack's manifest. + public IManifest Manifest { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The full path to the content pack's folder. + /// The content pack's manifest. + /// Provides an API for loading content assets. + /// Encapsulates SMAPI's JSON file parsing. + public ContentPack(string directoryPath, IManifest manifest, IContentHelper content, JsonHelper jsonHelper) + { + this.DirectoryPath = directoryPath; + this.Manifest = manifest; + this.Content = content; + this.JsonHelper = jsonHelper; + } + + /// Read a JSON file from the content pack folder. + /// The model type. + /// The file path relative to the contnet directory. + /// Returns the deserialised model, or null if the file doesn't exist or is empty. + public TModel ReadJsonFile(string path) where TModel : class + { + path = Path.Combine(this.DirectoryPath, path); + return this.JsonHelper.ReadJsonFile(path); + } + + /// Load content from the content pack folder (if not already cached), and return it. When loading a .png file, this must be called outside the game's draw loop. + /// The expected data type. The main supported types are , , and dictionaries; other types may be supported by the game's content pipeline. + /// The local path to a content file relative to the content pack folder. + /// The is empty or contains invalid characters. + /// The content asset couldn't be loaded (e.g. because it doesn't exist). + public T LoadAsset(string key) + { + return this.Content.Load(key, ContentSource.ModFolder); + } + + /// Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists. + /// The the local path to a content file relative to the content pack folder. + /// The is empty or contains invalid characters. + public string GetActualAssetKey(string key) + { + return this.Content.GetActualAssetKey(key, ContentSource.ModFolder); + } + + } +} diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index a91b0a5b..d1e8eb7d 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -18,7 +18,7 @@ namespace StardewModdingAPI.Framework /// The mod manifest. IManifest Manifest { get; } - /// >Metadata about the mod from SMAPI's internal data (if any). + /// Metadata about the mod from SMAPI's internal data (if any). ParsedModDataRecord DataRecord { get; } /// The metadata resolution status. @@ -27,12 +27,21 @@ namespace StardewModdingAPI.Framework /// The reason the metadata is invalid, if any. string Error { get; } - /// The mod instance (if it was loaded). + /// The mod instance (if loaded and is false). IMod Mod { get; } + /// The content pack instance (if loaded and is true). + IContentPack ContentPack { get; } + + /// Writes messages to the console and log file as this mod. + IMonitor Monitor { get; } + /// The mod-provided API (if any). object Api { get; } + /// Whether the mod is a content pack. + bool IsContentPack { get; } + /********* ** Public methods @@ -47,6 +56,11 @@ namespace StardewModdingAPI.Framework /// The mod instance to set. IModMetadata SetMod(IMod mod); + /// Set the mod instance. + /// The contentPack instance to set. + /// Writes messages to the console and log file. + IModMetadata SetMod(IContentPack contentPack, IMonitor monitor); + /// Set the mod-provided API instance. /// The mod-provided API. IModMetadata SetApi(object api); diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs index 0340a92d..71489627 100644 --- a/src/SMAPI/Framework/InternalExtensions.cs +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -94,7 +94,7 @@ namespace StardewModdingAPI.Framework /// The log severity level. public static void LogAsMod(this IModMetadata metadata, string message, LogLevel level = LogLevel.Trace) { - metadata.Mod.Monitor.Log(message, level); + metadata.Monitor.Log(message, level); } /**** diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index 665b9cf4..c73dc307 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -1,5 +1,7 @@ -using System; +using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using StardewModdingAPI.Framework.Serialisation; namespace StardewModdingAPI.Framework.ModHelpers @@ -13,6 +15,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Encapsulates SMAPI's JSON file parsing. private readonly JsonHelper JsonHelper; + /// The content packs loaded for this mod. + private readonly IContentPack[] ContentPacks; + /********* ** Accessors @@ -48,9 +53,10 @@ namespace StardewModdingAPI.Framework.ModHelpers /// an API for fetching metadata about loaded mods. /// An API for accessing private game code. /// An API for reading translations stored in the mod's i18n folder. + /// The content packs loaded for this mod. /// An argument is null or empty. /// The path does not exist on disk. - public ModHelper(string modID, string modDirectory, JsonHelper jsonHelper, IContentHelper contentHelper, ICommandHelper commandHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, ITranslationHelper translationHelper) + public ModHelper(string modID, string modDirectory, JsonHelper jsonHelper, IContentHelper contentHelper, ICommandHelper commandHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, ITranslationHelper translationHelper, IEnumerable contentPacks) : base(modID) { // validate directory @@ -67,6 +73,7 @@ namespace StardewModdingAPI.Framework.ModHelpers this.ConsoleCommands = commandHelper ?? throw new ArgumentNullException(nameof(commandHelper)); this.Reflection = reflectionHelper ?? throw new ArgumentNullException(nameof(reflectionHelper)); this.Translation = translationHelper ?? throw new ArgumentNullException(nameof(translationHelper)); + this.ContentPacks = contentPacks.ToArray(); } /**** @@ -116,6 +123,14 @@ namespace StardewModdingAPI.Framework.ModHelpers this.JsonHelper.WriteJsonFile(path, model); } + /**** + ** Content packs + ****/ + /// Get all content packs loaded for this mod. + public IEnumerable GetContentPacks() + { + return this.ContentPacks; + } /**** ** Disposal diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index 29bb6617..1a0f9994 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -1,3 +1,4 @@ +using System; using StardewModdingAPI.Framework.ModData; namespace StardewModdingAPI.Framework.ModLoading @@ -26,12 +27,21 @@ namespace StardewModdingAPI.Framework.ModLoading /// The reason the metadata is invalid, if any. public string Error { get; private set; } - /// The mod instance (if it was loaded). + /// The mod instance (if loaded and is false). public IMod Mod { get; private set; } + /// The content pack instance (if loaded and is true). + public IContentPack ContentPack { get; private set; } + + /// Writes messages to the console and log file as this mod. + public IMonitor Monitor { get; private set; } + /// The mod-provided API (if any). public object Api { get; private set; } + /// Whether the mod is a content pack. + public bool IsContentPack => this.Manifest?.ContentPackFor != null; + /********* ** Public methods @@ -64,7 +74,24 @@ namespace StardewModdingAPI.Framework.ModLoading /// The mod instance to set. public IModMetadata SetMod(IMod mod) { + if (this.ContentPack != null) + throw new InvalidOperationException("A mod can't be both an assembly mod and content pack."); + this.Mod = mod; + this.Monitor = mod.Monitor; + return this; + } + + /// Set the mod instance. + /// The contentPack instance to set. + /// Writes messages to the console and log file. + public IModMetadata SetMod(IContentPack contentPack, IMonitor monitor) + { + if (this.Mod != null) + throw new InvalidOperationException("A mod can't be both an assembly mod and content pack."); + + this.ContentPack = contentPack; + this.Monitor = monitor; return this; } diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index b46ee117..be73254d 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -30,18 +30,13 @@ namespace StardewModdingAPI.Framework.ModLoading string error = null; try { - // read manifest manifest = jsonHelper.ReadJsonFile(path); - - // validate if (manifest == null) { error = File.Exists(path) ? "its manifest is invalid." : "it doesn't have a manifest."; } - else if (string.IsNullOrWhiteSpace(manifest.EntryDll)) - error = "its manifest doesn't set an entry DLL."; } catch (SParseException ex) { @@ -85,7 +80,7 @@ namespace StardewModdingAPI.Framework.ModLoading if (mod.Status == ModMetadataStatus.Failed) continue; - // validate compatibility + // validate compatibility from internal data switch (mod.DataRecord?.Status) { case ModStatus.Obsolete: @@ -128,24 +123,52 @@ namespace StardewModdingAPI.Framework.ModLoading continue; } - // validate DLL value - if (string.IsNullOrWhiteSpace(mod.Manifest.EntryDll)) - { - mod.SetStatus(ModMetadataStatus.Failed, "its manifest has no EntryDLL field."); - continue; - } - if (mod.Manifest.EntryDll.Intersect(Path.GetInvalidFileNameChars()).Any()) + // validate DLL / content pack fields { - mod.SetStatus(ModMetadataStatus.Failed, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); - continue; - } + bool hasDll = !string.IsNullOrWhiteSpace(mod.Manifest.EntryDll); + bool isContentPack = mod.Manifest.ContentPackFor != null; - // validate DLL path - string assemblyPath = Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll); - if (!File.Exists(assemblyPath)) - { - mod.SetStatus(ModMetadataStatus.Failed, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); - continue; + // validate field presence + if (!hasDll && !isContentPack) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its manifest has no {nameof(IManifest.EntryDll)} or {nameof(IManifest.ContentPackFor)} field; must specify one."); + continue; + } + if (hasDll && isContentPack) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its manifest sets both {nameof(IManifest.EntryDll)} and {nameof(IManifest.ContentPackFor)}, which are mutually exclusive."); + continue; + } + + // validate DLL + if (hasDll) + { + // invalid filename format + if (mod.Manifest.EntryDll.Intersect(Path.GetInvalidFileNameChars()).Any()) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); + continue; + } + + // invalid path + string assemblyPath = Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll); + if (!File.Exists(assemblyPath)) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); + continue; + } + } + + // validate content pack + else + { + // invalid content pack ID + if (string.IsNullOrWhiteSpace(mod.Manifest.ContentPackFor.UniqueID)) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."); + continue; + } + } } // validate required fields @@ -243,30 +266,17 @@ namespace StardewModdingAPI.Framework.ModLoading throw new InvalidModStateException($"Unknown dependency status '{states[mod]}'."); } - // no dependencies, mark sorted - if (mod.Manifest.Dependencies == null || !mod.Manifest.Dependencies.Any()) + // collect dependencies + ModDependency[] dependencies = this.GetDependenciesFrom(mod.Manifest, mods).ToArray(); + + // mark sorted if no dependencies + if (!dependencies.Any()) { sortedMods.Push(mod); return states[mod] = ModDependencyStatus.Sorted; } - // get dependencies - var dependencies = - ( - from entry in mod.Manifest.Dependencies - let dependencyMod = mods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, entry.UniqueID, StringComparison.InvariantCultureIgnoreCase)) - orderby entry.UniqueID - select new - { - ID = entry.UniqueID, - MinVersion = entry.MinimumVersion, - Mod = dependencyMod, - IsRequired = entry.IsRequired - } - ) - .ToArray(); - - // missing required dependencies, mark failed + // mark failed if missing dependencies { string[] failedModNames = ( from entry in dependencies @@ -371,5 +381,64 @@ namespace StardewModdingAPI.Framework.ModLoading yield return directory; } } + + /// Get the dependencies declared in a manifest. + /// The mod manifest. + /// The loaded mods. + private IEnumerable GetDependenciesFrom(IManifest manifest, IModMetadata[] loadedMods) + { + IModMetadata FindMod(string id) => loadedMods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, id, StringComparison.InvariantCultureIgnoreCase)); + + // yield dependencies + if (manifest.Dependencies != null) + { + foreach (var entry in manifest.Dependencies) + yield return new ModDependency(entry.UniqueID, entry.MinimumVersion, FindMod(entry.UniqueID), entry.IsRequired); + } + + // yield content pack parent + if (manifest.ContentPackFor != null) + yield return new ModDependency(manifest.ContentPackFor.UniqueID, manifest.ContentPackFor.MinimumVersion, FindMod(manifest.ContentPackFor.UniqueID), isRequired: true); + } + + + /********* + ** Private models + *********/ + /// Represents a dependency from one mod to another. + private struct ModDependency + { + /********* + ** Accessors + *********/ + /// The unique ID of the required mod. + public string ID { get; } + + /// The minimum required version (if any). + public ISemanticVersion MinVersion { get; } + + /// Whether the mod shouldn't be loaded if the dependency isn't available. + public bool IsRequired { get; } + + /// The loaded mod that fulfills the dependency (if available). + public IModMetadata Mod { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The unique ID of the required mod. + /// The minimum required version (if any). + /// The loaded mod that fulfills the dependency (if available). + /// Whether the mod shouldn't be loaded if the dependency isn't available. + public ModDependency(string id, ISemanticVersion minVersion, IModMetadata mod, bool isRequired) + { + this.ID = id; + this.MinVersion = minVersion; + this.Mod = mod; + this.IsRequired = isRequired; + } + } } } diff --git a/src/SMAPI/Framework/ModRegistry.cs b/src/SMAPI/Framework/ModRegistry.cs index 453d2868..e7d4f89a 100644 --- a/src/SMAPI/Framework/ModRegistry.cs +++ b/src/SMAPI/Framework/ModRegistry.cs @@ -25,18 +25,27 @@ namespace StardewModdingAPI.Framework /********* ** Public methods *********/ - /// Register a mod as a possible source of deprecation warnings. + /// Register a mod. /// The mod metadata. public void Add(IModMetadata metadata) { this.Mods.Add(metadata); - this.ModNamesByAssembly[metadata.Mod.GetType().Assembly.FullName] = metadata; + if (!metadata.IsContentPack) + this.ModNamesByAssembly[metadata.Mod.GetType().Assembly.FullName] = metadata; } /// Get metadata for all loaded mods. - public IEnumerable GetAll() + /// Whether to include SMAPI mods. + /// Whether to include content pack mods. + public IEnumerable GetAll(bool assemblyMods = true, bool contentPacks = true) { - return this.Mods.Select(p => p); + IEnumerable query = this.Mods; + if (!assemblyMods) + query = query.Where(p => p.IsContentPack); + if (!contentPacks) + query = query.Where(p => !p.IsContentPack); + + return query; } /// Get metadata for a loaded mod. diff --git a/src/SMAPI/Framework/Models/Manifest.cs b/src/SMAPI/Framework/Models/Manifest.cs index f9762406..74303cba 100644 --- a/src/SMAPI/Framework/Models/Manifest.cs +++ b/src/SMAPI/Framework/Models/Manifest.cs @@ -27,9 +27,13 @@ namespace StardewModdingAPI.Framework.Models [JsonConverter(typeof(SemanticVersionConverter))] public ISemanticVersion MinimumApiVersion { get; set; } - /// The name of the DLL in the directory that has the method. + /// The name of the DLL in the directory that has the method. Mutually exclusive with . public string EntryDll { get; set; } + /// The mod which will read this as a content pack. Mutually exclusive with . + [JsonConverter(typeof(ManifestContentPackForConverter))] + public IManifestContentPackFor ContentPackFor { get; set; } + /// The other mods that must be loaded before this mod. [JsonConverter(typeof(ManifestDependencyArrayConverter))] public IManifestDependency[] Dependencies { get; set; } diff --git a/src/SMAPI/Framework/Models/ManifestContentPackFor.cs b/src/SMAPI/Framework/Models/ManifestContentPackFor.cs new file mode 100644 index 00000000..7836bbcc --- /dev/null +++ b/src/SMAPI/Framework/Models/ManifestContentPackFor.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// Indicates which mod can read the content pack represented by the containing manifest. + internal class ManifestContentPackFor : IManifestContentPackFor + { + /********* + ** Accessors + *********/ + /// The unique ID of the mod which can read this content pack. + public string UniqueID { get; set; } + + /// The minimum required version (if any). + public ISemanticVersion MinimumVersion { get; set; } + } +} diff --git a/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestContentPackForConverter.cs b/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestContentPackForConverter.cs new file mode 100644 index 00000000..af7558f6 --- /dev/null +++ b/src/SMAPI/Framework/Serialisation/SmapiConverters/ManifestContentPackForConverter.cs @@ -0,0 +1,50 @@ +using System; +using Newtonsoft.Json; +using StardewModdingAPI.Framework.Models; + +namespace StardewModdingAPI.Framework.Serialisation.SmapiConverters +{ + /// Handles deserialisation of arrays. + internal class ManifestContentPackForConverter : JsonConverter + { + /********* + ** Accessors + *********/ + /// Whether this converter can write JSON. + public override bool CanWrite => false; + + + /********* + ** Public methods + *********/ + /// Get whether this instance can convert the specified object type. + /// The object type. + public override bool CanConvert(Type objectType) + { + return objectType == typeof(IManifestContentPackFor[]); + } + + + /********* + ** Protected methods + *********/ + /// Read the JSON representation of the object. + /// The JSON reader. + /// The object type. + /// The object being read. + /// The calling serializer. + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + return serializer.Deserialize(reader); + } + + /// Writes the JSON representation of the object. + /// The JSON writer. + /// The value. + /// The calling serializer. + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); + } + } +} diff --git a/src/SMAPI/IContentPack.cs b/src/SMAPI/IContentPack.cs new file mode 100644 index 00000000..15a2b7dd --- /dev/null +++ b/src/SMAPI/IContentPack.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using xTile; + +namespace StardewModdingAPI +{ + /// An API that provides access to a content pack. + public interface IContentPack + { + /********* + ** Accessors + *********/ + /// The full path to the content pack's folder. + string DirectoryPath { get; } + + /// The content pack's manifest. + IManifest Manifest { get; } + + + /********* + ** Public methods + *********/ + /// Read a JSON file from the content pack folder. + /// The model type. + /// The file path relative to the content pack directory. + /// Returns the deserialised model, or null if the file doesn't exist or is empty. + TModel ReadJsonFile(string path) where TModel : class; + + /// Load content from the content pack folder (if not already cached), and return it. When loading a .png file, this must be called outside the game's draw loop. + /// The expected data type. The main supported types are , , and dictionaries; other types may be supported by the game's content pipeline. + /// The local path to a content file relative to the content pack folder. + /// The is empty or contains invalid characters. + /// The content asset couldn't be loaded (e.g. because it doesn't exist). + T LoadAsset(string key); + + /// Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists. + /// The the local path to a content file relative to the content pack folder. + /// The is empty or contains invalid characters. + string GetActualAssetKey(string key); + } +} diff --git a/src/SMAPI/IManifest.cs b/src/SMAPI/IManifest.cs index 9db1d538..183ac105 100644 --- a/src/SMAPI/IManifest.cs +++ b/src/SMAPI/IManifest.cs @@ -26,9 +26,12 @@ namespace StardewModdingAPI /// The unique mod ID. string UniqueID { get; } - /// The name of the DLL in the directory that has the method. + /// The name of the DLL in the directory that has the method. Mutually exclusive with . string EntryDll { get; } + /// The mod which will read this as a content pack. Mutually exclusive with . + IManifestContentPackFor ContentPackFor { get; } + /// The other mods that must be loaded before this mod. IManifestDependency[] Dependencies { get; } diff --git a/src/SMAPI/IManifestContentPackFor.cs b/src/SMAPI/IManifestContentPackFor.cs new file mode 100644 index 00000000..f05a3873 --- /dev/null +++ b/src/SMAPI/IManifestContentPackFor.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI +{ + /// Indicates which mod can read the content pack represented by the containing manifest. + public interface IManifestContentPackFor + { + /// The unique ID of the mod which can read this content pack. + string UniqueID { get; } + + /// The minimum required version (if any). + ISemanticVersion MinimumVersion { get; } + } +} diff --git a/src/SMAPI/IModHelper.cs b/src/SMAPI/IModHelper.cs index 116e8508..96265c85 100644 --- a/src/SMAPI/IModHelper.cs +++ b/src/SMAPI/IModHelper.cs @@ -1,4 +1,6 @@ -namespace StardewModdingAPI +using System.Collections.Generic; + +namespace StardewModdingAPI { /// Provides simplified APIs for writing mods. public interface IModHelper @@ -54,5 +56,11 @@ /// The file path relative to the mod directory. /// The model to save. void WriteJsonFile(string path, TModel model) where TModel : class; + + /**** + ** Content packs + ****/ + /// Get all content packs loaded for this mod. + IEnumerable GetContentPacks(); } -} \ No newline at end of file +} diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index fd2bb340..e0064714 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -394,7 +394,7 @@ namespace StardewModdingAPI LocalizedContentManager.LanguageCode languageCode = this.ContentManager.GetCurrentLanguage(); // update mod translation helpers - foreach (IModMetadata mod in this.ModRegistry.GetAll()) + foreach (IModMetadata mod in this.ModRegistry.GetAll(contentPacks: false)) (mod.Mod.Helper.Translation as TranslationHelper)?.SetLocale(locale, languageCode); } @@ -652,15 +652,52 @@ namespace StardewModdingAPI { this.Monitor.Log("Loading mods...", LogLevel.Trace); - // load mod assemblies IDictionary skippedMods = new Dictionary(); + void TrackSkip(IModMetadata mod, string reasonPhrase) => skippedMods[mod] = reasonPhrase; + + // load content packs + foreach (IModMetadata metadata in mods.Where(p => p.IsContentPack)) + { + // get basic info + IManifest manifest = metadata.Manifest; + this.Monitor.Log($"Loading {metadata.DisplayName} from {metadata.DirectoryPath.Replace(Constants.ModPath, "").TrimStart(Path.DirectorySeparatorChar)} (content pack)...", LogLevel.Trace); + + // validate status + if (metadata.Status == ModMetadataStatus.Failed) + { + this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace); + TrackSkip(metadata, metadata.Error); + continue; + } + + // load mod as content pack + IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); + IContentHelper contentHelper = new ContentHelper(contentManager, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor); + IContentPack contentPack = new ContentPack(metadata.DirectoryPath, manifest, contentHelper, jsonHelper); + metadata.SetMod(contentPack, monitor); + this.ModRegistry.Add(metadata); + } + IModMetadata[] loadedContentPacks = this.ModRegistry.GetAll(assemblyMods: false).ToArray(); + + // load mods { - void TrackSkip(IModMetadata mod, string reasonPhrase) => skippedMods[mod] = reasonPhrase; + // get content packs by mod ID + IDictionary contentPacksByModID = + loadedContentPacks + .GroupBy(p => p.Manifest.ContentPackFor.UniqueID) + .ToDictionary( + group => group.Key, + group => group.Select(metadata => metadata.ContentPack).ToArray(), + StringComparer.InvariantCultureIgnoreCase + ); + // get assembly loaders AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.TargetPlatform, this.Monitor, this.Settings.DeveloperMode); AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name); InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory(); - foreach (IModMetadata metadata in mods) + + // load from metadata + foreach (IModMetadata metadata in mods.Where(p => !p.IsContentPack)) { // get basic info IManifest manifest = metadata.Manifest; @@ -676,7 +713,7 @@ namespace StardewModdingAPI continue; } - // preprocess & load mod assembly + // load mod string assemblyPath = metadata.Manifest?.EntryDll != null ? Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll) : null; @@ -704,6 +741,10 @@ namespace StardewModdingAPI // initialise mod try { + // get content packs + if (!contentPacksByModID.TryGetValue(manifest.UniqueID, out IContentPack[] contentPacks)) + contentPacks = new IContentPack[0]; + // init mod helpers IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); IModHelper modHelper; @@ -713,7 +754,7 @@ namespace StardewModdingAPI IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection, this.DeprecationManager); IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentManager.GetLocale(), contentManager.GetCurrentLanguage()); - modHelper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, translationHelper); + modHelper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, translationHelper, contentPacks); } // get mod instance @@ -735,7 +776,7 @@ namespace StardewModdingAPI } } } - IModMetadata[] loadedMods = this.ModRegistry.GetAll().ToArray(); + IModMetadata[] loadedMods = this.ModRegistry.GetAll(contentPacks: false).ToArray(); // log skipped mods this.Monitor.Newline(); @@ -757,6 +798,7 @@ namespace StardewModdingAPI // log loaded mods this.Monitor.Log($"Loaded {loadedMods.Length} mods" + (loadedMods.Length > 0 ? ":" : "."), LogLevel.Info); + foreach (IModMetadata metadata in loadedMods.OrderBy(p => p.DisplayName)) { IManifest manifest = metadata.Manifest; @@ -769,10 +811,30 @@ namespace StardewModdingAPI } this.Monitor.Newline(); + // log loaded content packs + if (loadedContentPacks.Any()) + { + string GetModDisplayName(string id) => loadedMods.First(p => id != null && id.Equals(p.Manifest?.UniqueID, StringComparison.InvariantCultureIgnoreCase))?.DisplayName; + + this.Monitor.Log($"Loaded {loadedContentPacks.Length} content packs:", LogLevel.Info); + foreach (IModMetadata metadata in loadedContentPacks.OrderBy(p => p.DisplayName)) + { + IManifest manifest = metadata.Manifest; + this.Monitor.Log( + $" {metadata.DisplayName} {manifest.Version}" + + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") + + (metadata.IsContentPack ? $" | content pack for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}" : "") + + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), + LogLevel.Info + ); + } + this.Monitor.Newline(); + } + // initialise translations - this.ReloadTranslations(); + this.ReloadTranslations(loadedMods); - // initialise loaded mods + // initialise loaded non-content-pack mods foreach (IModMetadata metadata in loadedMods) { // add interceptors @@ -891,11 +953,15 @@ namespace StardewModdingAPI } /// Reload translations for all mods. - private void ReloadTranslations() + /// The mods for which to reload translations. + private void ReloadTranslations(IEnumerable mods) { JsonHelper jsonHelper = new JsonHelper(); - foreach (IModMetadata metadata in this.ModRegistry.GetAll()) + foreach (IModMetadata metadata in mods) { + if (metadata.IsContentPack) + throw new InvalidOperationException("Can't reload translations for a content pack."); + // read translation files IDictionary> translations = new Dictionary>(); DirectoryInfo translationsDir = new DirectoryInfo(Path.Combine(metadata.DirectoryPath, "i18n")); @@ -954,7 +1020,7 @@ namespace StardewModdingAPI break; case "reload_i18n": - this.ReloadTranslations(); + this.ReloadTranslations(this.ModRegistry.GetAll(contentPacks: false)); this.Monitor.Log("Reloaded translation files for all mods. This only affects new translations the mods fetch; if they cached some text, it may not be updated.", LogLevel.Info); break; diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj index eb403309..7cf62a91 100644 --- a/src/SMAPI/StardewModdingAPI.csproj +++ b/src/SMAPI/StardewModdingAPI.csproj @@ -85,6 +85,7 @@ Properties\GlobalAssemblyInfo.cs + @@ -93,6 +94,7 @@ + @@ -114,6 +116,7 @@ + @@ -121,6 +124,8 @@ + + @@ -279,4 +284,4 @@ - \ No newline at end of file + -- cgit