Merge branch 'revamp-mod-build-logic' into develop

This commit is contained in:
Jesse Plamondon-Willard 2017-10-08 21:22:08 -04:00
commit 65e8997fdb
8 changed files with 467 additions and 264 deletions

View File

@ -1,17 +1,17 @@
**Stardew.ModBuildConfig** is an open-source NuGet package which automates the build configuration The **mod build package** is an open-source NuGet package which automates the MSBuild configuration
for [Stardew Valley](http://stardewvalley.net/) [SMAPI](https://github.com/Pathoschild/SMAPI) mods. for SMAPI mods.
The package... The package...
* lets you write your mod once, and compile it on any computer. It detects the current platform * lets your code compile on any computer (Linux/Mac/Windows) without needing to change the assembly
(Linux, Mac, or Windows) and game install path, and injects the right references automatically. 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 * configures Visual Studio so you can debug into the mod code when the game is running (_Windows
only_). only_).
* packages the mod automatically into the game's mod folder when you build the code (_optional_).
## Contents ## Contents
* [Install](#install) * [Install](#install)
* [Simplify mod development](#simplify-mod-development) * [Configure](#configure)
* [Troubleshoot](#troubleshoot) * [Troubleshoot](#troubleshoot)
* [Release notes](#release-notes) * [Release notes](#release-notes)
@ -20,7 +20,7 @@ The package...
1. Create an empty library project. 1. Create an empty library project.
2. Reference the [`Pathoschild.Stardew.ModBuildConfig` NuGet package](https://www.nuget.org/packages/Pathoschild.Stardew.ModBuildConfig). 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. 4. Compile on any platform.
**When migrating an existing mod:** **When migrating an existing mod:**
@ -30,59 +30,61 @@ The package...
2. Reference the [`Pathoschild.Stardew.ModBuildConfig` NuGet package](https://www.nuget.org/packages/Pathoschild.Stardew.ModBuildConfig). 2. Reference the [`Pathoschild.Stardew.ModBuildConfig` NuGet package](https://www.nuget.org/packages/Pathoschild.Stardew.ModBuildConfig).
3. Compile on any platform. 3. Compile on any platform.
## Simplify mod development ## Configure
### Package your mod into the game folder automatically ### Deploy files into the `Mods` folder
You can copy your mod files into the `Mods` folder automatically each time you build, so you don't By default, your mod will be copied into the game's `Mods` folder (with a subfolder matching your
need to do it manually: 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. To add custom files to the mod folder, just [add them to the build output](https://stackoverflow.com/a/10828462/262123).
2. Add this block above the first `</PropertyGroup>` line: (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 You can change the mod's folder name by adding this above the first `</PropertyGroup>` in your
<DeployModFolderName>$(MSBuildProjectName)</DeployModFolderName> `.csproj`:
``` ```xml
<ModFolderName>YourModName</ModFolderName>
```
That's it! Each time you build, the files in `<game path>\Mods\<mod name>` will be updated with If you don't want to deploy the mod automatically, you can add this:
your `manifest.json`, build output, and any `i18n` files. ```xml
<EnableModDeploy>False</EnableModDeploy>
```
Notes: ### Create release zip
* To add custom files, just [add them to the build output](https://stackoverflow.com/a/10828462/262123). By default, a zip file will be created in the build output when you rebuild the code. This zip file
* To customise the folder name, just replace `$(MSBuildProjectName)` with the folder name you want. contains all the files needed to share your mod in the recommended format for uploading to Nexus
* 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). Mods or other sites.
### Debug into the mod code (Windows-only) You can change the zipped folder name (and zip name) by adding this above the first
Stepping into your mod code when the game is running is straightforward, since this package injects `</PropertyGroup>` in your `.csproj`:
the configuration automatically. To do it: ```xml
<ModFolderName>YourModName</ModFolderName>
```
1. [Package your mod into the game folder automatically](#package-your-mod-into-the-game-folder-automatically). You can change the folder path where the zip is created like this:
2. Launch the project with debugging in Visual Studio or MonoDevelop. ```xml
<ModZipPath>$(SolutionDir)\_releases</ModZipPath>
```
This will deploy your mod files into the game folder, launch SMAPI, and attach a debugger Finally, you can disable the zip creation with this:
automatically. Now you can step through your code, set breakpoints, etc. ```xml
<EnableModZip>False</EnableModZip>
```
### Create release zips automatically (Windows-only) Or only create it in release builds with this:
You can create the mod package automatically when you build: ```xml
<EnableModZip Condition="$(Configuration) != 'Release'">False</EnableModZip>
```
1. Edit your mod's `.csproj` file. ### Game path
2. Add this block above the first `</PropertyGroup>` line: 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:
```xml * **Option 1: global game path (recommended).**
<DeployModZipTo>$(SolutionDir)\_releases</DeployModZipTo> _This will apply to every project that uses the package._
```
That's it! Each time you build, the mod files will be zipped into `_releases\<mod name>.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._
1. Get the full folder path containing the Stardew Valley executable. 1. Get the full folder path containing the Stardew Valley executable.
2. Create this file path: 2. Create this file:
platform | path platform | path
--------- | ---- --------- | ----
@ -99,10 +101,11 @@ the game location yourself. There's two ways to do that:
</Project> </Project>
``` ```
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. 1. Get the folder path containing the Stardew Valley `.exe` file.
2. Add this to your `.csproj` file under the `<Project` line: 2. Add this to your `.csproj` file under the `<Project` line:
@ -117,9 +120,18 @@ the game location yourself. There's two ways to do that:
The configuration will check your custom path first, then fall back to the default paths (so it'll The configuration will check your custom path first, then fall back to the default paths (so it'll
still compile on a different computer). 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 can specify the game path yourself; see
_[Game path](#game-path)_ above.
## Release notes ## Release notes
### 1.8 ### 2.0
* 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. * Added mod's version to release zip filename.
* Improved errors to simplify troubleshooting.
* Fixed release zip not having a mod folder. * Fixed release zip not having a mod folder.
* Fixed release zip failing if mod name contains characters that aren't valid in a filename. * Fixed release zip failing if mod name contains characters that aren't valid in a filename.

View File

@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using StardewModdingAPI.ModBuildConfig.Framework;
namespace StardewModdingAPI.ModBuildConfig
{
/// <summary>A build task which deploys the mod files and prepares a release zip.</summary>
public class DeployModTask : Task
{
/*********
** Properties
*********/
/// <summary>The MSBuild platforms recognised by the build configuration.</summary>
private readonly HashSet<string> ValidPlatforms = new HashSet<string>(new[] { "OSX", "Unix", "Windows_NT" }, StringComparer.InvariantCultureIgnoreCase);
/// <summary>The name of the game's main executable file.</summary>
private string GameExeName => this.Platform == "Windows_NT"
? "Stardew Valley.exe"
: "StardewValley.exe";
/// <summary>The name of SMAPI's main executable file.</summary>
private readonly string SmapiExeName = "StardewModdingAPI.exe";
/*********
** Accessors
*********/
/// <summary>The name of the mod folder.</summary>
[Required]
public string ModFolderName { get; set; }
/// <summary>The absolute or relative path to the folder which should contain the generated zip file.</summary>
[Required]
public string ModZipPath { get; set; }
/// <summary>The folder containing the project files.</summary>
[Required]
public string ProjectDir { get; set; }
/// <summary>The folder containing the build output.</summary>
[Required]
public string TargetDir { get; set; }
/// <summary>The folder containing the game files.</summary>
[Required]
public string GameDir { get; set; }
/// <summary>The MSBuild OS value.</summary>
[Required]
public string Platform { get; set; }
/// <summary>Whether to enable copying the mod files into the game's Mods folder.</summary>
[Required]
public bool EnableModDeploy { get; set; }
/// <summary>Whether to enable the release zip.</summary>
[Required]
public bool EnableModZip { get; set; }
/*********
** Public methods
*********/
/// <summary>When overridden in a derived class, executes the task.</summary>
/// <returns>true if the task successfully executed; otherwise, false.</returns>
public override bool Execute()
{
if (!this.EnableModDeploy && !this.EnableModZip)
return true; // nothing to do
try
{
// 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 (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
*********/
/// <summary>Copy the mod files into the game's mod folder.</summary>
/// <param name="files">The files to include.</param>
/// <param name="modFolderPath">The folder path to create with the mod files.</param>
private void CreateModFolder(IDictionary<string, FileInfo> 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);
}
}
/// <summary>Create a release zip in the recommended format for uploading to mod sites.</summary>
/// <param name="files">The files to include.</param>
/// <param name="modName">The name of the mod.</param>
/// <param name="modVersion">The mod version string.</param>
/// <param name="outputFolderPath">The absolute or relative path to the folder which should contain the generated zip file.</param>
private void CreateReleaseZip(IDictionary<string, FileInfo> 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 (var fileEntry in files)
{
string relativePath = fileEntry.Key;
FileInfo file = fileEntry.Value;
// get file info
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);
}
}
}
/// <summary>Get a copy of a filename with all invalid filename characters substituted.</summary>
/// <param name="name">The filename.</param>
private string EscapeInvalidFilenameCharacters(string name)
{
foreach (char invalidChar in Path.GetInvalidFileNameChars())
name = name.Replace(invalidChar, '.');
return name;
}
}
}

View File

@ -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
{
/// <summary>Manages the files that are part of a mod package.</summary>
internal class ModFileManager
{
/*********
** Properties
*********/
/// <summary>The name of the manifest file.</summary>
private readonly string ManifestFileName = "manifest.json";
/// <summary>The files that are part of the package.</summary>
private readonly IDictionary<string, FileInfo> Files;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="projectDir">The folder containing the project files.</param>
/// <param name="targetDir">The folder containing the build output.</param>
/// <exception cref="UserErrorException">The mod package isn't valid.</exception>
public ModFileManager(string projectDir, string targetDir)
{
this.Files = new Dictionary<string, FileInfo>(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.");
}
/// <summary>Get the files in the mod package.</summary>
public IDictionary<string, FileInfo> GetFiles()
{
return new Dictionary<string, FileInfo>(this.Files, StringComparer.InvariantCultureIgnoreCase);
}
/// <summary>Get a semantic version from the mod manifest.</summary>
/// <exception cref="UserErrorException">The manifest is missing or invalid.</exception>
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<string, object> 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<string, object> 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
*********/
/// <summary>Get a case-insensitive dictionary matching the given JSON.</summary>
/// <param name="json">The JSON to parse.</param>
private IDictionary<string, object> Parse(string json)
{
IDictionary<string, object> MakeCaseInsensitive(IDictionary<string, object> dict)
{
foreach (var field in dict.ToArray())
{
if (field.Value is IDictionary<string, object> value)
dict[field.Key] = MakeCaseInsensitive(value);
}
return new Dictionary<string, object>(dict, StringComparer.InvariantCultureIgnoreCase);
}
IDictionary<string, object> data = (IDictionary<string, object>)new JavaScriptSerializer().DeserializeObject(json);
return MakeCaseInsensitive(data);
}
}
}

View File

@ -0,0 +1,16 @@
using System;
namespace StardewModdingAPI.ModBuildConfig.Framework
{
/// <summary>A user error whose message can be displayed to the user.</summary>
internal class UserErrorException : Exception
{
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="message">The error message.</param>
public UserErrorException(string message)
: base(message) { }
}
}

View File

@ -38,7 +38,9 @@
<Reference Include="System.Web.Extensions" /> <Reference Include="System.Web.Extensions" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="Tasks\CreateModReleaseZip.cs" /> <Compile Include="DeployModTask.cs" />
<Compile Include="Framework\UserErrorException.cs" />
<Compile Include="Framework\ModFileManager.cs" />
<Compile Include="Properties\AssemblyInfo.cs" /> <Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -1,153 +0,0 @@
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;
namespace StardewModdingAPI.ModBuildConfig.Tasks
{
/// <summary>A build task which packs mod files into a conventional release zip.</summary>
public class CreateModReleaseZip : Task
{
/*********
** Properties
*********/
/// <summary>The name of the manifest file.</summary>
private readonly string ManifestFileName = "manifest.json";
/*********
** Accessors
*********/
/// <summary>The mod files to pack.</summary>
[Required]
public ITaskItem[] Files { get; set; }
/// <summary>The name of the mod.</summary>
[Required]
public string ModName { get; set; }
/// <summary>The absolute or relative path to the folder which should contain the generated zip file.</summary>
[Required]
public string OutputFolderPath { get; set; }
/*********
** Public methods
*********/
/// <summary>When overridden in a derived class, executes the task.</summary>
/// <returns>true if the task successfully executed; otherwise, false.</returns>
public override bool Execute()
{
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);
}
}
}
return true;
}
catch (Exception ex)
{
this.Log.LogErrorFromException(ex);
return false;
}
}
/// <summary>Get a semantic version from the mod manifest (if available).</summary>
/// <exception cref="InvalidOperationException">The manifest file wasn't found or is invalid.</exception>
public 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<string, object> 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<string, object> 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+
}
/// <summary>Get a case-insensitive dictionary matching the given JSON.</summary>
/// <param name="json">The JSON to parse.</param>
private IDictionary<string, object> Parse(string json)
{
IDictionary<string, object> MakeCaseInsensitive(IDictionary<string, object> dict)
{
foreach (var field in dict.ToArray())
{
if (field.Value is IDictionary<string, object> value)
dict[field.Key] = MakeCaseInsensitive(value);
}
return new Dictionary<string, object>(dict, StringComparer.InvariantCultureIgnoreCase);
}
IDictionary<string, object> data = (IDictionary<string, object>)new JavaScriptSerializer().DeserializeObject(json);
return MakeCaseInsensitive(data);
}
/// <summary>Get a copy of a filename with all invalid filename characters substituted.</summary>
/// <param name="name">The filename.</param>
private string EscapeInvalidFilenameCharacters(string name)
{
foreach (char invalidChar in Path.GetInvalidFileNameChars())
name = name.Replace(invalidChar, '.');
return name;
}
}
}

View File

@ -2,20 +2,29 @@
<!--********************************************* <!--*********************************************
** Import build tasks ** Import build tasks
**********************************************--> **********************************************-->
<UsingTask TaskName="CreateModReleaseZip" AssemblyFile="StardewModdingAPI.ModBuildConfig.dll" /> <UsingTask TaskName="DeployModTask" AssemblyFile="StardewModdingAPI.ModBuildConfig.dll" />
<!--********************************************* <!--*********************************************
** Find the basic mod metadata ** Find the basic mod metadata
**********************************************--> **********************************************-->
<!--###### <!-- import developer's custom settings (if any) -->
## import developer's custom settings (if any)
#######-->
<Import Condition="$(OS) != 'Windows_NT' AND Exists('$(HOME)\stardewvalley.targets')" Project="$(HOME)\stardewvalley.targets" /> <Import Condition="$(OS) != 'Windows_NT' AND Exists('$(HOME)\stardewvalley.targets')" Project="$(HOME)\stardewvalley.targets" />
<Import Condition="$(OS) == 'Windows_NT' AND Exists('$(USERPROFILE)\stardewvalley.targets')" Project="$(USERPROFILE)\stardewvalley.targets" /> <Import Condition="$(OS) == 'Windows_NT' AND Exists('$(USERPROFILE)\stardewvalley.targets')" Project="$(USERPROFILE)\stardewvalley.targets" />
<!--###### <!-- set setting defaults -->
## find platform + game path <PropertyGroup>
#######--> <!-- map legacy settings -->
<ModFolderName Condition="'$(ModFolderName)' == '' AND '$(DeployModFolderName)' != ''">$(DeployModFolderName)</ModFolderName>
<ModZipPath Condition="'$(ModZipPath)' == '' AND '$(DeployModZipTo)' != ''">$(DeployModZipTo)</ModZipPath>
<!-- set default settings -->
<ModFolderName Condition="'$(ModFolderName)' == ''">$(MSBuildProjectName)</ModFolderName>
<ModZipPath Condition="'$(ModZipPath)' == ''">$(TargetDir)</ModZipPath>
<EnableModDeploy Condition="'$(EnableModDeploy)' == ''">True</EnableModDeploy>
<EnableModZip Condition="'$(EnableModZip)' == ''">True</EnableModZip>
</PropertyGroup>
<!-- find platform + game path -->
<Choose> <Choose>
<When Condition="$(OS) == 'Unix' OR $(OS) == 'OSX'"> <When Condition="$(OS) == 'Unix' OR $(OS) == 'OSX'">
<PropertyGroup> <PropertyGroup>
@ -106,52 +115,21 @@
<!--********************************************* <!--*********************************************
** Perform build logic ** Deploy mod files & create release zip after build
**********************************************--> **********************************************-->
<!--###### <Target Name="AfterBuild">
## validate metadata before build <DeployModTask
#######--> ModFolderName="$(ModFolderName)"
<Target Name="BeforeBuild"> ModZipPath="$(ModZipPath)"
<!-- show error for unknown platform -->
<Error Condition="'$(OS)' != 'OSX' AND '$(OS)' != 'Unix' AND '$(OS)' != 'Windows_NT'" Text="The build config package doesn't recognise OS type '$(OS)'." />
<!-- if game path is invalid, show one user-friendly error instead of a slew of reference errors --> EnableModDeploy="$(EnableModDeploy)"
<Error Condition="!Exists('$(GamePath)')" Text="Failed to find the game install path. See https://github.com/Pathoschild/Stardew.ModBuildConfig#troubleshoot for help." /> EnableModZip="$(EnableModZip)"
<Error Condition="'$(OS)' == 'Windows_NT' AND !Exists('$(GamePath)\Stardew Valley.exe')" Text="Found a game folder at $(GamePath), but it doesn't contain Stardew Valley. You should delete this folder if it's empty." />
<Error Condition="'$(OS)' != 'Windows_NT' AND !Exists('$(GamePath)\StardewValley.exe')" Text="Found a game folder at $(GamePath), but it doesn't contain Stardew Valley. You should delete this folder if it's empty." />
<Error Condition="!Exists('$(GamePath)\StardewModdingAPI.exe')" Text="Found a game folder at $(GamePath), but it doesn't contain SMAPI." />
</Target>
<!--###### ProjectDir="$(ProjectDir)"
## Deploy files after build TargetDir="$(TargetDir)"
#######--> GameDir="$(GamePath)"
<Target Name="AfterBuild" Condition="'$(DeployModFolderName)' != '' OR '$(DeployModZipTo)' != ''">
<!--collect file paths-->
<PropertyGroup>
<ModDeployPath>$(GamePath)\Mods\$(DeployModFolderName)</ModDeployPath>
<DeployModZipTo Condition="'$(OS)' != 'Windows_NT'"><!--disable on Linux/Mac where CodeTaskFactory doesn't seem to be available--></DeployModZipTo>
</PropertyGroup>
<ItemGroup>
<BuildFiles Include="$(TargetDir)\**\*.*" Exclude="$(TargetDir)\manifest.json;$(TargetDir)\i18n\**\*.*" />
<BuildFiles Include="$(ProjectDir)\manifest.json" Condition="'@(BuildFiles)' != ''" /> Platform="$(OS)"
<BuildFiles Include="$(TargetDir)\manifest.json" Condition="'@(BuildFiles)' != '' AND !EXISTS('$(ProjectDir)\manifest.json')" /> />
<I18nFiles Include="$(ProjectDir)\i18n\*.json" Condition="'@(BuildFiles)' != ''" />
<I18nFiles Include="$(TargetDir)\i18n\*.json" Condition="'@(BuildFiles)' != '' AND !EXISTS('$(ProjectDir)\i18n')" />
</ItemGroup>
<!--validate paths-->
<Error Text="Could not deploy mod automatically because no build output was found." Condition="'@(BuildFiles)' == ''" />
<Error Text="Could not deploy mod automatically because no manifest.json was found in the project or build output." Condition="!Exists('$(TargetDir)\manifest.json') AND !Exists('$(ProjectDir)\manifest.json')" />
<!-- copy mod files into mod folder if <DeployModFolderName> property is set -->
<Message Text="Deploying mod to $(ModDeployPath)..." Importance="high" Condition="'$(DeployModFolderName)' != ''" />
<Copy SourceFiles="@(BuildFiles)" DestinationFolder="$(ModDeployPath)\%(RecursiveDir)" SkipUnchangedFiles="true" Condition="'$(DeployModFolderName)' != ''" />
<Copy SourceFiles="@(I18nFiles)" DestinationFolder="$(ModDeployPath)\i18n" SkipUnchangedFiles="true" Condition="'$(DeployModFolderName)' != ''" />
<!-- create release zip if <DeployModZipTo> property is set -->
<Message Text="Generating mod release at $(DeployModZipTo)\$(MSBuildProjectName).zip..." Importance="high" Condition="'$(DeployModZipTo)' != ''" />
<CreateModReleaseZip ModName="$(MSBuildProjectName)" Files="@(BuildFiles);@(I18nFiles)" OutputFolderPath="$(DeployModZipTo)" Condition="'$(DeployModZipTo)' != ''" />
</Target> </Target>
</Project> </Project>

View File

@ -2,22 +2,26 @@
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"> <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata> <metadata>
<id>Pathoschild.Stardew.ModBuildConfig</id> <id>Pathoschild.Stardew.ModBuildConfig</id>
<version>1.7.1</version> <version>2.0-alpha</version>
<title>MSBuild config for Stardew Valley mods</title> <title>Build package for SMAPI mods</title>
<authors>Pathoschild</authors> <authors>Pathoschild</authors>
<owners>Pathoschild</owners> <owners>Pathoschild</owners>
<requireLicenseAcceptance>false</requireLicenseAcceptance> <requireLicenseAcceptance>false</requireLicenseAcceptance>
<licenseUrl>https://github.com/Pathoschild/Stardew.ModBuildConfig/blob/1.7.1/LICENSE.txt</licenseUrl> <licenseUrl>https://github.com/Pathoschild/SMAPI/blob/develop/LICENSE.txt</licenseUrl>
<projectUrl>https://github.com/Pathoschild/Stardew.ModBuildConfig#readme</projectUrl> <projectUrl>https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#readme</projectUrl>
<iconUrl>https://raw.githubusercontent.com/Pathoschild/Stardew.ModBuildConfig/1.7.1/assets/nuget-icon.png</iconUrl> <iconUrl>https://raw.githubusercontent.com/Pathoschild/SMAPI/develop/src/SMAPI.ModBuildConfig/assets/nuget-icon.png</iconUrl>
<description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods.</description> <description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods.</description>
<releaseNotes> <releaseNotes>
1.7 added an option to create release zips on build and added a reference to XNA's XACT library for audio-related mods. - Added: mods are now copied into the `Mods` folder automatically (configurable).
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.</releaseNotes> - 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.
</releaseNotes>
</metadata> </metadata>
<files> <files>
<file src="build/smapi.targets" target="build/Pathoschild.Stardew.ModBuildConfig.targets" /> <file src="build/smapi.targets" target="build/Pathoschild.Stardew.ModBuildConfig.targets" />
<file src="bin/StardewModdingAPI.ModBuildConfig.dll" target="build/StardewModdingAPI.ModBuildConfig.dll" /> <file src="bin/StardewModdingAPI.ModBuildConfig.dll" target="build/StardewModdingAPI.ModBuildConfig.dll" />
<file src="readme.md" />
</files> </files>
</package> </package>