rewrite mod build package per new docs

This commit is contained in:
Jesse Plamondon-Willard 2017-10-08 18:05:47 -04:00
parent cd93382c64
commit 475efa12fe
6 changed files with 313 additions and 134 deletions

View File

@ -71,6 +71,11 @@ Finally, you can disable the zip creation with this:
<EnableModZip>False</EnableModZip>
```
Or only create it in release builds with this:
```xml
<EnableModZip Condition="$(Configuration) != 'Release'">False</EnableModZip>
```
### 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.

View File

@ -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
*********/
/// <summary>The name of the manifest file.</summary>
private readonly string ManifestFileName = "manifest.json";
/// <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 mod files to pack.</summary>
/// <summary>The name of the mod folder.</summary>
[Required]
public ITaskItem[] Files { get; set; }
/// <summary>The name of the mod.</summary>
[Required]
public string ModName { get; set; }
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
@ -43,33 +69,80 @@ namespace StardewModdingAPI.ModBuildConfig
/// <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
{
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
*********/
/// <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(ITaskItem[] files, string modName, string modVersion, string outputFolderPath)
private void CreateReleaseZip(IDictionary<string, FileInfo> 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);
}
}
}
}
/// <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>
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<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)

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

@ -39,12 +39,15 @@
</ItemGroup>
<ItemGroup>
<Compile Include="DeployModTask.cs" />
<Compile Include="Framework\UserErrorException.cs" />
<Compile Include="Framework\ModFileManager.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>
<None Include="assets\nuget-icon.pdn" />
<None Include="build\smapi.targets">
<SubType>Designer</SubType>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="package.nuspec">
<SubType>Designer</SubType>

View File

@ -7,15 +7,24 @@
<!--*********************************************
** 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('$(USERPROFILE)\stardewvalley.targets')" Project="$(USERPROFILE)\stardewvalley.targets" />
<!--######
## find platform + game path
#######-->
<!-- set setting defaults -->
<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>
<When Condition="$(OS) == 'Unix' OR $(OS) == 'OSX'">
<PropertyGroup>
@ -106,52 +115,21 @@
<!--*********************************************
** Perform build logic
** Deploy mod files & create release zip after build
**********************************************-->
<!--######
## validate metadata before build
#######-->
<Target Name="BeforeBuild">
<!-- 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)'." />
<Target Name="AfterBuild">
<DeployModTask
ModFolderName="$(ModFolderName)"
ModZipPath="$(ModZipPath)"
<!-- if game path is invalid, show one user-friendly error instead of a slew of reference errors -->
<Error Condition="!Exists('$(GamePath)')" Text="Failed to find the game install path. See https://github.com/Pathoschild/Stardew.ModBuildConfig#troubleshoot for help." />
<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>
EnableModDeploy="$(EnableModDeploy)"
EnableModZip="$(EnableModZip)"
<!--######
## Deploy files after build
#######-->
<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\**\*.*" />
ProjectDir="$(ProjectDir)"
TargetDir="$(TargetDir)"
GameDir="$(GamePath)"
<BuildFiles Include="$(ProjectDir)\manifest.json" Condition="'@(BuildFiles)' != ''" />
<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)' != ''" />
<DeployModTask ModName="$(MSBuildProjectName)" Files="@(BuildFiles);@(I18nFiles)" ModZipPath="$(ModZipPath)" Condition="'$(DeployModZipTo)' != ''" />
Platform="$(OS)"
/>
</Target>
</Project>