From 5161ceae5285a1fb66e66ba9bedf36d371516193 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 8 Oct 2017 13:15:20 -0400 Subject: [PATCH 1/5] rewrite mod build package docs for proposed changes --- docs/mod-build-config.md | 112 +++++++++++++++++++++------------------ 1 file changed, 59 insertions(+), 53 deletions(-) diff --git a/docs/mod-build-config.md b/docs/mod-build-config.md index f76a69ad..b091c2c0 100644 --- a/docs/mod-build-config.md +++ b/docs/mod-build-config.md @@ -1,17 +1,17 @@ -**Stardew.ModBuildConfig** is an open-source NuGet package which automates the build configuration -for [Stardew Valley](http://stardewvalley.net/) [SMAPI](https://github.com/Pathoschild/SMAPI) mods. +The **mod build package** is an open-source NuGet package which automates the MSBuild configuration +for SMAPI mods. The package... -* lets you write your mod once, and compile it on any computer. It detects the current platform - (Linux, Mac, or Windows) and game install path, and injects the right references automatically. +* lets your code compile on any computer (Linux/Mac/Windows) without needing to change the assembly + references or game path. +* packages the mod into the game's `Mods` folder when you rebuild the code (configurable). * configures Visual Studio so you can debug into the mod code when the game is running (_Windows only_). -* packages the mod automatically into the game's mod folder when you build the code (_optional_). ## Contents * [Install](#install) -* [Simplify mod development](#simplify-mod-development) +* [Configure](#configure) * [Troubleshoot](#troubleshoot) * [Release notes](#release-notes) @@ -20,7 +20,7 @@ The package... 1. Create an empty library project. 2. Reference the [`Pathoschild.Stardew.ModBuildConfig` NuGet package](https://www.nuget.org/packages/Pathoschild.Stardew.ModBuildConfig). -3. [Write your code](http://canimod.com/guides/creating-a-smapi-mod). +3. [Write your code](https://stardewvalleywiki.com/Modding:Creating_a_SMAPI_mod). 4. Compile on any platform. **When migrating an existing mod:** @@ -30,59 +30,56 @@ The package... 2. Reference the [`Pathoschild.Stardew.ModBuildConfig` NuGet package](https://www.nuget.org/packages/Pathoschild.Stardew.ModBuildConfig). 3. Compile on any platform. -## Simplify mod development -### Package your mod into the game folder automatically -You can copy your mod files into the `Mods` folder automatically each time you build, so you don't -need to do it manually: +## Configure +### Deploy files into the `Mods` folder +By default, your mod will be copied into the game's `Mods` folder (with a subfolder matching your +project name) when you rebuild the code. The package will automatically include your +`manifest.json`, any `i18n` files, and the build output. -1. Edit your mod's `.csproj` file. -2. Add this block above the first `` line: +To add custom files to the mod folder, just [add them to the build output](https://stackoverflow.com/a/10828462/262123). +(If your project references another mod, make sure the reference is [_not_ marked 'copy local'](https://msdn.microsoft.com/en-us/library/t1zz5y8c(v=vs.100).aspx).) - ```xml - $(MSBuildProjectName) - ``` +You can change the mod's folder name by adding this above the first `` in your +`.csproj`: +```xml +YourModName +``` -That's it! Each time you build, the files in `\Mods\` will be updated with -your `manifest.json`, build output, and any `i18n` files. +If you don't want to deploy the mod automatically, you can add this: +```xml +False +``` -Notes: -* To add custom files, just [add them to the build output](https://stackoverflow.com/a/10828462/262123). -* To customise the folder name, just replace `$(MSBuildProjectName)` with the folder name you want. -* If your project references another mod, make sure the reference is [_not_ marked 'copy local'](https://msdn.microsoft.com/en-us/library/t1zz5y8c(v=vs.100).aspx). +### Create release zip +By default, a zip file will be created in the build output when you rebuild the code. This zip file +contains all the files needed to share your mod in the recommended format for uploading to Nexus +Mods or other sites. -### Debug into the mod code (Windows-only) -Stepping into your mod code when the game is running is straightforward, since this package injects -the configuration automatically. To do it: +You can change the zipped folder name (and zip name) by adding this above the first +`` in your `.csproj`: +```xml +YourModName +``` -1. [Package your mod into the game folder automatically](#package-your-mod-into-the-game-folder-automatically). -2. Launch the project with debugging in Visual Studio or MonoDevelop. +You can change the folder path where the zip is created like this: +```xml +$(SolutionDir)\_releases +``` -This will deploy your mod files into the game folder, launch SMAPI, and attach a debugger -automatically. Now you can step through your code, set breakpoints, etc. +Finally, you can disable the zip creation with this: +```xml +False +``` -### Create release zips automatically (Windows-only) -You can create the mod package automatically when you build: +### Game path +The package usually detects where your game is installed automatically. If it can't find your game +or you have multiple installs, you can specify the path yourself. There's two ways to do that: -1. Edit your mod's `.csproj` file. -2. Add this block above the first `` line: - - ```xml - $(SolutionDir)\_releases - ``` - -That's it! Each time you build, the mod files will be zipped into `_releases\.zip`. (You -can change the value to save the zips somewhere else.) - -## Troubleshoot -### "Failed to find the game install path" -That error means the package couldn't figure out where the game is installed. You need to specify -the game location yourself. There's two ways to do that: - -* **Option 1: set the path globally.** - _This will apply to every project that uses version 1.5+ of package._ +* **Option 1: global game path (recommended).** + _This will apply to every project that uses the package._ 1. Get the full folder path containing the Stardew Valley executable. - 2. Create this file path: + 2. Create this file: platform | path --------- | ---- @@ -99,10 +96,11 @@ the game location yourself. There's two ways to do that: ``` - 4. Replace `PATH_HERE` with your custom game install path. + 4. Replace `PATH_HERE` with your game path. + +* **Option 2: path in the project file.** + _You'll need to do this for each project that uses the package._ -* **Option 2: set the path in the project file.** - _(You'll need to do it for every project that uses the package.)_ 1. Get the folder path containing the Stardew Valley `.exe` file. 2. Add this to your `.csproj` file under the ` Date: Sun, 8 Oct 2017 14:50:04 -0400 Subject: [PATCH 2/5] rename build task for broader use --- .../{Tasks/CreateModReleaseZip.cs => DeployModTask.cs} | 6 +++--- .../StardewModdingAPI.ModBuildConfig.csproj | 2 +- src/SMAPI.ModBuildConfig/build/smapi.targets | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) rename src/SMAPI.ModBuildConfig/{Tasks/CreateModReleaseZip.cs => DeployModTask.cs} (97%) diff --git a/src/SMAPI.ModBuildConfig/Tasks/CreateModReleaseZip.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs similarity index 97% rename from src/SMAPI.ModBuildConfig/Tasks/CreateModReleaseZip.cs rename to src/SMAPI.ModBuildConfig/DeployModTask.cs index b9460b39..0483f651 100644 --- a/src/SMAPI.ModBuildConfig/Tasks/CreateModReleaseZip.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -8,10 +8,10 @@ using Microsoft.Build.Framework; using Microsoft.Build.Utilities; using StardewModdingAPI.Common; -namespace StardewModdingAPI.ModBuildConfig.Tasks +namespace StardewModdingAPI.ModBuildConfig { - /// A build task which packs mod files into a conventional release zip. - public class CreateModReleaseZip : Task + /// A build task which deploys the mod files and prepares a release zip. + public class DeployModTask : Task { /********* ** Properties diff --git a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj index 3ca3cca8..f943bc97 100644 --- a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj @@ -38,7 +38,7 @@ - + diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index b4bc8d8b..61bf96ac 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -2,7 +2,7 @@ - + - + From cd93382c645da3c6d3ce4e532307f42704ba4c76 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 8 Oct 2017 15:03:31 -0400 Subject: [PATCH 3/5] move zip logic into method --- src/SMAPI.ModBuildConfig/DeployModTask.cs | 79 ++++++++++++-------- src/SMAPI.ModBuildConfig/build/smapi.targets | 2 +- 2 files changed, 48 insertions(+), 33 deletions(-) diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 0483f651..2018ab06 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -33,7 +33,7 @@ namespace StardewModdingAPI.ModBuildConfig /// The absolute or relative path to the folder which should contain the generated zip file. [Required] - public string OutputFolderPath { get; set; } + public string ModZipPath { get; set; } /********* @@ -45,32 +45,8 @@ namespace StardewModdingAPI.ModBuildConfig { try { - // get names - string zipName = this.EscapeInvalidFilenameCharacters($"{this.ModName}-{this.GetManifestVersion()}.zip"); - string folderName = this.EscapeInvalidFilenameCharacters(this.ModName); - string zipPath = Path.Combine(this.OutputFolderPath, zipName); - - // create zip file - Directory.CreateDirectory(this.OutputFolderPath); - using (Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write)) - using (ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create)) - { - foreach (ITaskItem file in this.Files) - { - // get file info - string filePath = file.ItemSpec; - string entryName = folderName + '/' + file.GetMetadata("RecursiveDir") + file.GetMetadata("Filename") + file.GetMetadata("Extension"); - if (new FileInfo(filePath).Directory.Name.Equals("i18n", StringComparison.InvariantCultureIgnoreCase)) - entryName = Path.Combine("i18n", entryName); - - // add to zip - using (Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) - using (Stream fileStreamInZip = archive.CreateEntry(entryName).Open()) - { - fileStream.CopyTo(fileStreamInZip); - } - } - } + string modVersion = this.GetManifestVersion(); + this.CreateReleaseZip(this.Files, this.ModName, modVersion, this.ModZipPath); return true; } @@ -81,9 +57,48 @@ namespace StardewModdingAPI.ModBuildConfig } } + + /********* + ** Private methods + *********/ + /// Create a release zip in the recommended format for uploading to mod sites. + /// The files to include. + /// The name of the mod. + /// The mod version string. + /// The absolute or relative path to the folder which should contain the generated zip file. + private void CreateReleaseZip(ITaskItem[] files, string modName, string modVersion, string outputFolderPath) + { + // get names + string zipName = this.EscapeInvalidFilenameCharacters($"{modName}-{modVersion}.zip"); + string folderName = this.EscapeInvalidFilenameCharacters(modName); + string zipPath = Path.Combine(outputFolderPath, zipName); + + // create zip file + Directory.CreateDirectory(outputFolderPath); + using (Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write)) + using (ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create)) + { + foreach (ITaskItem file in files) + { + // get file info + string filePath = file.ItemSpec; + string entryName = folderName + '/' + file.GetMetadata("RecursiveDir") + file.GetMetadata("Filename") + file.GetMetadata("Extension"); + if (new FileInfo(filePath).Directory.Name.Equals("i18n", StringComparison.InvariantCultureIgnoreCase)) + entryName = Path.Combine("i18n", entryName); + + // add to zip + using (Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) + using (Stream fileStreamInZip = archive.CreateEntry(entryName).Open()) + { + fileStream.CopyTo(fileStreamInZip); + } + } + } + } + /// Get a semantic version from the mod manifest (if available). /// The manifest file wasn't found or is invalid. - public string GetManifestVersion() + private string GetManifestVersion() { // find manifest file ITaskItem file = this.Files.FirstOrDefault(p => this.ManifestFileName.Equals(Path.GetFileName(p.ItemSpec), StringComparison.InvariantCultureIgnoreCase)); @@ -114,10 +129,10 @@ namespace StardewModdingAPI.ModBuildConfig // get version string if (versionObj is IDictionary versionFields) // SMAPI 1.x { - int major = versionFields.ContainsKey("MajorVersion") ? (int) versionFields["MajorVersion"] : 0; - int minor = versionFields.ContainsKey("MinorVersion") ? (int) versionFields["MinorVersion"] : 0; - int patch = versionFields.ContainsKey("PatchVersion") ? (int) versionFields["PatchVersion"] : 0; - string tag = versionFields.ContainsKey("Build") ? (string) versionFields["Build"] : null; + int major = versionFields.ContainsKey("MajorVersion") ? (int)versionFields["MajorVersion"] : 0; + int minor = versionFields.ContainsKey("MinorVersion") ? (int)versionFields["MinorVersion"] : 0; + int patch = versionFields.ContainsKey("PatchVersion") ? (int)versionFields["PatchVersion"] : 0; + string tag = versionFields.ContainsKey("Build") ? (string)versionFields["Build"] : null; return new SemanticVersionImpl(major, minor, patch, tag).ToString(); } return new SemanticVersionImpl(versionObj.ToString()).ToString(); // SMAPI 2.0+ diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index 61bf96ac..46e8428d 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -152,6 +152,6 @@ - + From 475efa12febcb1f1f0976cb6c84e445a263daed9 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 8 Oct 2017 18:05:47 -0400 Subject: [PATCH 4/5] rewrite mod build package per new docs --- docs/mod-build-config.md | 14 +- src/SMAPI.ModBuildConfig/DeployModTask.cs | 176 ++++++++++-------- .../Framework/ModFileManager.cs | 162 ++++++++++++++++ .../Framework/UserErrorException.cs | 16 ++ .../StardewModdingAPI.ModBuildConfig.csproj | 3 + src/SMAPI.ModBuildConfig/build/smapi.targets | 76 +++----- 6 files changed, 313 insertions(+), 134 deletions(-) create mode 100644 src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs create mode 100644 src/SMAPI.ModBuildConfig/Framework/UserErrorException.cs diff --git a/docs/mod-build-config.md b/docs/mod-build-config.md index b091c2c0..f02981b9 100644 --- a/docs/mod-build-config.md +++ b/docs/mod-build-config.md @@ -71,6 +71,11 @@ Finally, you can disable the zip creation with this: False ``` +Or only create it in release builds with this: +```xml +False +``` + ### Game path The package usually detects where your game is installed automatically. If it can't find your game or you have multiple installs, you can specify the path yourself. There's two ways to do that: @@ -118,14 +123,15 @@ still compile on a different computer). ## Troubleshoot ### "Failed to find the game install path" -That error means the package couldn't find your game. You need to specify the game path yourself; -see _[Game path](#game-path)_ above. +That error means the package couldn't find your game. You can specify the game path yourself; see +_[Game path](#game-path)_ above. ## Release notes ### 2.0 -* Mods are now copied into the `Mods` folder automatically (configurable). -* The release zip is now created automatically in your build output folder (configurable). +* Added: mods are now copied into the `Mods` folder automatically (configurable). +* Added: release zips are now created automatically in your build output folder (configurable). * Added mod's version to release zip filename. +* Improved errors to simplify troubleshooting. * Fixed release zip not having a mod folder. * Fixed release zip failing if mod name contains characters that aren't valid in a filename. diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 2018ab06..a693fe32 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -2,11 +2,9 @@ using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; -using System.Linq; -using System.Web.Script.Serialization; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; -using StardewModdingAPI.Common; +using StardewModdingAPI.ModBuildConfig.Framework; namespace StardewModdingAPI.ModBuildConfig { @@ -16,25 +14,53 @@ namespace StardewModdingAPI.ModBuildConfig /********* ** Properties *********/ - /// The name of the manifest file. - private readonly string ManifestFileName = "manifest.json"; + /// The MSBuild platforms recognised by the build configuration. + private readonly HashSet ValidPlatforms = new HashSet(new[] { "OSX", "Unix", "Windows_NT" }, StringComparer.InvariantCultureIgnoreCase); + + /// The name of the game's main executable file. + private string GameExeName => this.Platform == "Windows_NT" + ? "Stardew Valley.exe" + : "StardewValley.exe"; + + /// The name of SMAPI's main executable file. + private readonly string SmapiExeName = "StardewModdingAPI.exe"; /********* ** Accessors *********/ - /// The mod files to pack. + /// The name of the mod folder. [Required] - public ITaskItem[] Files { get; set; } - - /// The name of the mod. - [Required] - public string ModName { get; set; } + public string ModFolderName { get; set; } /// The absolute or relative path to the folder which should contain the generated zip file. [Required] public string ModZipPath { get; set; } + /// The folder containing the project files. + [Required] + public string ProjectDir { get; set; } + + /// The folder containing the build output. + [Required] + public string TargetDir { get; set; } + + /// The folder containing the game files. + [Required] + public string GameDir { get; set; } + + /// The MSBuild OS value. + [Required] + public string Platform { get; set; } + + /// Whether to enable copying the mod files into the game's Mods folder. + [Required] + public bool EnableModDeploy { get; set; } + + /// Whether to enable the release zip. + [Required] + public bool EnableModZip { get; set; } + /********* ** Public methods @@ -43,33 +69,80 @@ namespace StardewModdingAPI.ModBuildConfig /// true if the task successfully executed; otherwise, false. public override bool Execute() { + if (!this.EnableModDeploy && !this.EnableModZip) + return true; // nothing to do + try { - string modVersion = this.GetManifestVersion(); - this.CreateReleaseZip(this.Files, this.ModName, modVersion, this.ModZipPath); + // validate context + if (!this.ValidPlatforms.Contains(this.Platform)) + throw new UserErrorException($"The mod build package doesn't recognise OS type '{this.Platform}'."); + if (!Directory.Exists(this.GameDir)) + throw new UserErrorException("The mod build package can't find your game path. See https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md for help specifying it."); + if (!File.Exists(Path.Combine(this.GameDir, this.GameExeName))) + throw new UserErrorException($"The mod build package found a game folder at {this.GameDir}, but it doesn't contain the {this.GameExeName} file. If this folder is invalid, delete it and the package will autodetect another game install path."); + if (!File.Exists(Path.Combine(this.GameDir, this.SmapiExeName))) + throw new UserErrorException($"The mod build package found a game folder at {this.GameDir}, but it doesn't contain SMAPI. You need to install SMAPI before building the mod."); + + // get mod info + ModFileManager package = new ModFileManager(this.ProjectDir, this.TargetDir); + + // deploy mod files + if (this.EnableModDeploy) + { + string outputPath = Path.Combine(this.GameDir, "Mods", this.EscapeInvalidFilenameCharacters(this.ModFolderName)); + this.Log.LogMessage(MessageImportance.High, $"The mod build package is copying the mod files to {outputPath}..."); + this.CreateModFolder(package.GetFiles(), outputPath); + } + + // create release zip + if (this.EnableModZip) + { + this.Log.LogMessage(MessageImportance.High, $"The mod build package is generating a release zip at {this.ModZipPath} for {this.ModFolderName}..."); + this.CreateReleaseZip(package.GetFiles(), this.ModFolderName, package.GetManifestVersion(), this.ModZipPath); + } return true; } - catch (Exception ex) + catch (UserErrorException ex) { this.Log.LogErrorFromException(ex); return false; } + catch (Exception ex) + { + this.Log.LogError($"The mod build package failed trying to deploy the mod.\n{ex}"); + return false; + } } /********* ** Private methods *********/ + /// Copy the mod files into the game's mod folder. + /// The files to include. + /// The folder path to create with the mod files. + private void CreateModFolder(IDictionary files, string modFolderPath) + { + Directory.CreateDirectory(modFolderPath); + foreach (var entry in files) + { + string fromPath = entry.Value.FullName; + string toPath = Path.Combine(modFolderPath, entry.Key); + File.Copy(fromPath, toPath, overwrite: true); + } + } + /// Create a release zip in the recommended format for uploading to mod sites. /// The files to include. /// The name of the mod. /// The mod version string. /// The absolute or relative path to the folder which should contain the generated zip file. - private void CreateReleaseZip(ITaskItem[] files, string modName, string modVersion, string outputFolderPath) + private void CreateReleaseZip(IDictionary files, string modName, string modVersion, string outputFolderPath) { // get names - string zipName = this.EscapeInvalidFilenameCharacters($"{modName}-{modVersion}.zip"); + string zipName = this.EscapeInvalidFilenameCharacters($"{modName} {modVersion}.zip"); string folderName = this.EscapeInvalidFilenameCharacters(modName); string zipPath = Path.Combine(outputFolderPath, zipName); @@ -78,84 +151,25 @@ namespace StardewModdingAPI.ModBuildConfig using (Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write)) using (ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create)) { - foreach (ITaskItem file in files) + foreach (var fileEntry in files) { + string relativePath = fileEntry.Key; + FileInfo file = fileEntry.Value; + // get file info - string filePath = file.ItemSpec; - string entryName = folderName + '/' + file.GetMetadata("RecursiveDir") + file.GetMetadata("Filename") + file.GetMetadata("Extension"); + string filePath = file.FullName; + string entryName = folderName + '/' + relativePath.Replace(Path.DirectorySeparatorChar, '/'); if (new FileInfo(filePath).Directory.Name.Equals("i18n", StringComparison.InvariantCultureIgnoreCase)) entryName = Path.Combine("i18n", entryName); // add to zip using (Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) using (Stream fileStreamInZip = archive.CreateEntry(entryName).Open()) - { fileStream.CopyTo(fileStreamInZip); - } } } } - /// Get a semantic version from the mod manifest (if available). - /// The manifest file wasn't found or is invalid. - private string GetManifestVersion() - { - // find manifest file - ITaskItem file = this.Files.FirstOrDefault(p => this.ManifestFileName.Equals(Path.GetFileName(p.ItemSpec), StringComparison.InvariantCultureIgnoreCase)); - if (file == null) - throw new InvalidOperationException($"The mod must include a {this.ManifestFileName} file."); - - // read content - string json = File.ReadAllText(file.ItemSpec); - if (string.IsNullOrWhiteSpace(json)) - throw new InvalidOperationException($"The mod's {this.ManifestFileName} file must not be empty."); - - // parse JSON - IDictionary data; - try - { - data = this.Parse(json); - } - catch (Exception ex) - { - throw new InvalidOperationException($"The mod's {this.ManifestFileName} couldn't be parsed. It doesn't seem to be valid JSON.", ex); - } - - // get version field - object versionObj = data.ContainsKey("Version") ? data["Version"] : null; - if (versionObj == null) - throw new InvalidOperationException($"The mod's {this.ManifestFileName} must have a version field."); - - // get version string - if (versionObj is IDictionary versionFields) // SMAPI 1.x - { - int major = versionFields.ContainsKey("MajorVersion") ? (int)versionFields["MajorVersion"] : 0; - int minor = versionFields.ContainsKey("MinorVersion") ? (int)versionFields["MinorVersion"] : 0; - int patch = versionFields.ContainsKey("PatchVersion") ? (int)versionFields["PatchVersion"] : 0; - string tag = versionFields.ContainsKey("Build") ? (string)versionFields["Build"] : null; - return new SemanticVersionImpl(major, minor, patch, tag).ToString(); - } - return new SemanticVersionImpl(versionObj.ToString()).ToString(); // SMAPI 2.0+ - } - - /// Get a case-insensitive dictionary matching the given JSON. - /// The JSON to parse. - private IDictionary Parse(string json) - { - IDictionary MakeCaseInsensitive(IDictionary dict) - { - foreach (var field in dict.ToArray()) - { - if (field.Value is IDictionary value) - dict[field.Key] = MakeCaseInsensitive(value); - } - return new Dictionary(dict, StringComparer.InvariantCultureIgnoreCase); - } - - IDictionary data = (IDictionary)new JavaScriptSerializer().DeserializeObject(json); - return MakeCaseInsensitive(data); - } - /// Get a copy of a filename with all invalid filename characters substituted. /// The filename. private string EscapeInvalidFilenameCharacters(string name) diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs new file mode 100644 index 00000000..9d9054f1 --- /dev/null +++ b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Web.Script.Serialization; +using StardewModdingAPI.Common; + +namespace StardewModdingAPI.ModBuildConfig.Framework +{ + /// Manages the files that are part of a mod package. + internal class ModFileManager + { + /********* + ** Properties + *********/ + /// The name of the manifest file. + private readonly string ManifestFileName = "manifest.json"; + + /// The files that are part of the package. + private readonly IDictionary Files; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The folder containing the project files. + /// The folder containing the build output. + /// The mod package isn't valid. + public ModFileManager(string projectDir, string targetDir) + { + this.Files = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + + // validate paths + if (!Directory.Exists(projectDir)) + throw new UserErrorException("Could not create mod package because the project folder wasn't found."); + if (!Directory.Exists(targetDir)) + throw new UserErrorException("Could not create mod package because no build output was found."); + + // project manifest + bool hasProjectManifest = false; + { + FileInfo manifest = new FileInfo(Path.Combine(projectDir, "manifest.json")); + if (manifest.Exists) + { + this.Files[this.ManifestFileName] = manifest; + hasProjectManifest = true; + } + } + + // project i18n files + bool hasProjectTranslations = false; + DirectoryInfo translationsFolder = new DirectoryInfo(Path.Combine(projectDir, "i18n")); + if (translationsFolder.Exists) + { + foreach (FileInfo file in translationsFolder.EnumerateFiles()) + this.Files[Path.Combine("i18n", file.Name)] = file; + hasProjectTranslations = true; + } + + // build output + DirectoryInfo buildFolder = new DirectoryInfo(targetDir); + foreach (FileInfo file in buildFolder.EnumerateFiles("*", SearchOption.AllDirectories)) + { + // get relative paths + string relativePath = file.FullName.Replace(buildFolder.FullName, ""); + string relativeDirPath = file.Directory.FullName.Replace(buildFolder.FullName, ""); + + // prefer project manifest/i18n files + if (hasProjectManifest && relativePath.Equals(this.ManifestFileName, StringComparison.InvariantCultureIgnoreCase)) + continue; + if (hasProjectTranslations && relativeDirPath.Equals("i18n", StringComparison.InvariantCultureIgnoreCase)) + continue; + + // ignore release zips + if (file.Extension.Equals("zip", StringComparison.InvariantCultureIgnoreCase)) + continue; + + // add file + this.Files[relativePath] = file; + } + + // check for missing manifest + if (!this.Files.ContainsKey(this.ManifestFileName)) + throw new UserErrorException($"Could not create mod package because no {this.ManifestFileName} was found in the project or build output."); + + // check for missing DLL + // ReSharper disable once SimplifyLinqExpression + if (!this.Files.Any(p => !p.Key.EndsWith(".dll"))) + throw new UserErrorException("Could not create mod package because no .dll file was found in the project or build output."); + } + + /// Get the files in the mod package. + public IDictionary GetFiles() + { + return new Dictionary(this.Files, StringComparer.InvariantCultureIgnoreCase); + } + + /// Get a semantic version from the mod manifest. + /// The manifest is missing or invalid. + public string GetManifestVersion() + { + // get manifest file + if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile)) + throw new InvalidOperationException($"The mod does not have a {this.ManifestFileName} file."); // shouldn't happen since we validate in constructor + + // read content + string json = File.ReadAllText(manifestFile.FullName); + if (string.IsNullOrWhiteSpace(json)) + throw new UserErrorException("The mod's manifest must not be empty."); + + // parse JSON + IDictionary data; + try + { + data = this.Parse(json); + } + catch (Exception ex) + { + throw new UserErrorException($"The mod's manifest couldn't be parsed. It doesn't seem to be valid JSON.\n{ex}"); + } + + // get version field + object versionObj = data.ContainsKey("Version") ? data["Version"] : null; + if (versionObj == null) + throw new UserErrorException("The mod's manifest must have a version field."); + + // get version string + if (versionObj is IDictionary versionFields) // SMAPI 1.x + { + int major = versionFields.ContainsKey("MajorVersion") ? (int)versionFields["MajorVersion"] : 0; + int minor = versionFields.ContainsKey("MinorVersion") ? (int)versionFields["MinorVersion"] : 0; + int patch = versionFields.ContainsKey("PatchVersion") ? (int)versionFields["PatchVersion"] : 0; + string tag = versionFields.ContainsKey("Build") ? (string)versionFields["Build"] : null; + return new SemanticVersionImpl(major, minor, patch, tag).ToString(); + } + return new SemanticVersionImpl(versionObj.ToString()).ToString(); // SMAPI 2.0+ + } + + + /********* + ** Private methods + *********/ + /// Get a case-insensitive dictionary matching the given JSON. + /// The JSON to parse. + private IDictionary Parse(string json) + { + IDictionary MakeCaseInsensitive(IDictionary dict) + { + foreach (var field in dict.ToArray()) + { + if (field.Value is IDictionary value) + dict[field.Key] = MakeCaseInsensitive(value); + } + return new Dictionary(dict, StringComparer.InvariantCultureIgnoreCase); + } + + IDictionary data = (IDictionary)new JavaScriptSerializer().DeserializeObject(json); + return MakeCaseInsensitive(data); + } + } +} diff --git a/src/SMAPI.ModBuildConfig/Framework/UserErrorException.cs b/src/SMAPI.ModBuildConfig/Framework/UserErrorException.cs new file mode 100644 index 00000000..64e31c29 --- /dev/null +++ b/src/SMAPI.ModBuildConfig/Framework/UserErrorException.cs @@ -0,0 +1,16 @@ +using System; + +namespace StardewModdingAPI.ModBuildConfig.Framework +{ + /// A user error whose message can be displayed to the user. + internal class UserErrorException : Exception + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The error message. + public UserErrorException(string message) + : base(message) { } + } +} diff --git a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj index f943bc97..2a445f72 100644 --- a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj @@ -39,12 +39,15 @@ + + Designer + PreserveNewest Designer diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index 46e8428d..0010d8ff 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -7,15 +7,24 @@ - + - + + + + $(DeployModFolderName) + $(DeployModZipTo) + + + $(MSBuildProjectName) + $(TargetDir) + True + True + + + @@ -106,52 +115,21 @@ - - - - + + - - - - - + EnableModDeploy="$(EnableModDeploy)" + EnableModZip="$(EnableModZip)" - - - - - $(GamePath)\Mods\$(DeployModFolderName) - - - - + ProjectDir="$(ProjectDir)" + TargetDir="$(TargetDir)" + GameDir="$(GamePath)" - - - - - - - - - - - - - - - - - - - + Platform="$(OS)" + /> From d47105a27841bcae80fcaa2351a2a658cd3d7fdb Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 8 Oct 2017 21:21:11 -0400 Subject: [PATCH 5/5] update mod build package nuspec --- .../Framework/ModFileManager.cs | 2 +- .../StardewModdingAPI.ModBuildConfig.csproj | 1 - src/SMAPI.ModBuildConfig/package.nuspec | 20 +++++++++++-------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs index 9d9054f1..10c55d4c 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs @@ -73,7 +73,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework continue; // ignore release zips - if (file.Extension.Equals("zip", StringComparison.InvariantCultureIgnoreCase)) + if (file.Extension.Equals(".zip", StringComparison.InvariantCultureIgnoreCase)) continue; // add file diff --git a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj index 2a445f72..e04f09a7 100644 --- a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj @@ -47,7 +47,6 @@ Designer - PreserveNewest Designer diff --git a/src/SMAPI.ModBuildConfig/package.nuspec b/src/SMAPI.ModBuildConfig/package.nuspec index 9d547e28..4242489e 100644 --- a/src/SMAPI.ModBuildConfig/package.nuspec +++ b/src/SMAPI.ModBuildConfig/package.nuspec @@ -2,22 +2,26 @@ Pathoschild.Stardew.ModBuildConfig - 1.7.1 - MSBuild config for Stardew Valley mods + 2.0-alpha + Build package for SMAPI mods Pathoschild Pathoschild false - https://github.com/Pathoschild/Stardew.ModBuildConfig/blob/1.7.1/LICENSE.txt - https://github.com/Pathoschild/Stardew.ModBuildConfig#readme - https://raw.githubusercontent.com/Pathoschild/Stardew.ModBuildConfig/1.7.1/assets/nuget-icon.png + https://github.com/Pathoschild/SMAPI/blob/develop/LICENSE.txt + https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#readme + https://raw.githubusercontent.com/Pathoschild/SMAPI/develop/src/SMAPI.ModBuildConfig/assets/nuget-icon.png Automates the build configuration for crossplatform Stardew Valley SMAPI mods. - 1.7 added an option to create release zips on build and added a reference to XNA's XACT library for audio-related mods. - 1.7.1 fixed an issue where i18n folders were flattened, and ensures that the manifest/i18n files in the project take precedence over those in the build output if both are present. + - Added: mods are now copied into the `Mods` folder automatically (configurable). + - Added: release zips are now created automatically in your build output folder (configurable). + - Added mod's version to release zip filename. + - Improved errors to simplify troubleshooting. + - Fixed release zip not having a mod folder. + - Fixed release zip failing if mod name contains characters that aren't valid in a filename. + -