Merge branch 'develop' of https://github.com/Pathoschild/SMAPI.git into android

This commit is contained in:
yangzhi 2020-02-12 01:10:22 +08:00
commit 1648ec1cba
12 changed files with 156 additions and 54 deletions

View File

@ -1,6 +1,26 @@
← [README](README.md)
# Release notes
## Upcoming release
* For players:
* Updated translations. Thanks to xCarloC (added Italian)!
* For the Save Backup mod:
* Fixed warning on MacOS when you have no saves yet.
* Reduced log messages.
* For modders:
* Added support for self-broadcasts through the multiplayer API. (Mods can now send messages to the current machine. That enables simple integrations between mods without needing an API, and lets mods notify a host mod without needing different code depending on whether the current player is the host or a farmhand.)
* Added `helper.Input.GetStatus` method to get the low-level status of a button.
* Eliminated unneeded network messages when broadcasting to a peer who can't handle the message (e.g. because they don't have SMAPI or don't have the target mod).
* Fixed marriage dialogue cleared when propagating dialogue changes.
* For the web UI:
* Updated the JSON validator and Content Patcher schema for `.tmx` support.
* For SMAPI/tool developers:
* The SMAPI log now prefixes the OS name with `Android` on Android.
## 3.2
Released 01 February 2020 for Stardew Valley 1.4.1 or later.

View File

@ -66,29 +66,37 @@ namespace StardewModdingAPI.Mods.SaveBackup
FileInfo targetFile = new FileInfo(Path.Combine(backupFolder.FullName, this.FileName));
DirectoryInfo fallbackDir = new DirectoryInfo(Path.Combine(backupFolder.FullName, this.BackupLabel));
if (targetFile.Exists || fallbackDir.Exists)
{
this.Monitor.Log("Already backed up today.");
return;
}
// copy saves to fallback directory (ignore non-save files/folders)
this.Monitor.Log($"Backing up saves to {fallbackDir.FullName}...", LogLevel.Trace);
DirectoryInfo savesDir = new DirectoryInfo(Constants.SavesPath);
this.RecursiveCopy(savesDir, fallbackDir, entry => this.MatchSaveFolders(savesDir, entry), copyRoot: false);
if (!this.RecursiveCopy(savesDir, fallbackDir, entry => this.MatchSaveFolders(savesDir, entry), copyRoot: false))
{
this.Monitor.Log("No saves found.");
return;
}
// compress backup if possible
this.Monitor.Log("Compressing backup if possible...", LogLevel.Trace);
if (!this.TryCompress(fallbackDir.FullName, targetFile, out Exception compressError))
{
if (Constants.TargetPlatform != GamePlatform.Android) // expected to fail on Android
this.Monitor.Log($"Couldn't compress backup, leaving it uncompressed.\n{compressError}", LogLevel.Trace);
this.Monitor.Log(Constants.TargetPlatform != GamePlatform.Android
? $"Backed up to {fallbackDir.FullName}." // expected to fail on Android
: $"Backed up to {fallbackDir.FullName}. Couldn't compress backup:\n{compressError}"
);
}
else
{
this.Monitor.Log($"Backed up to {targetFile.FullName}.");
fallbackDir.Delete(recursive: true);
this.Monitor.Log("Backup done!", LogLevel.Trace);
}
}
catch (Exception ex)
{
this.Monitor.Log("Couldn't back up save files (see log file for details).", LogLevel.Warn);
this.Monitor.Log(ex.ToString(), LogLevel.Trace);
this.Monitor.Log("Couldn't back up saves (see log file for details).", LogLevel.Warn);
this.Monitor.Log(ex.ToString());
}
}
@ -108,7 +116,7 @@ namespace StardewModdingAPI.Mods.SaveBackup
{
try
{
this.Monitor.Log($"Deleting {entry.Name}...", LogLevel.Trace);
this.Monitor.Log($"Deleting {entry.Name}...");
if (entry is DirectoryInfo folder)
folder.Delete(recursive: true);
else
@ -123,7 +131,7 @@ namespace StardewModdingAPI.Mods.SaveBackup
catch (Exception ex)
{
this.Monitor.Log("Couldn't remove old backups (see log file for details).", LogLevel.Warn);
this.Monitor.Log(ex.ToString(), LogLevel.Trace);
this.Monitor.Log(ex.ToString());
}
}
@ -199,29 +207,33 @@ namespace StardewModdingAPI.Mods.SaveBackup
/// <param name="copyRoot">Whether to copy the root folder itself, or <c>false</c> to only copy its contents.</param>
/// <param name="filter">A filter which matches the files or directories to copy, or <c>null</c> to copy everything.</param>
/// <remarks>Derived from the SMAPI installer code.</remarks>
private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool> filter, bool copyRoot = true)
/// <returns>Returns whether any files were copied.</returns>
private bool RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool> filter, bool copyRoot = true)
{
if (!targetFolder.Exists)
targetFolder.Create();
if (!source.Exists || filter?.Invoke(source) == false)
return false;
if (filter?.Invoke(source) == false)
return;
bool anyCopied = false;
switch (source)
{
case FileInfo sourceFile:
targetFolder.Create();
sourceFile.CopyTo(Path.Combine(targetFolder.FullName, sourceFile.Name));
anyCopied = true;
break;
case DirectoryInfo sourceDir:
DirectoryInfo targetSubfolder = copyRoot ? new DirectoryInfo(Path.Combine(targetFolder.FullName, sourceDir.Name)) : targetFolder;
foreach (var entry in sourceDir.EnumerateFileSystemInfos())
this.RecursiveCopy(entry, targetSubfolder, filter);
anyCopied = this.RecursiveCopy(entry, targetSubfolder, filter) || anyCopied;
break;
default:
throw new NotSupportedException($"Unknown filesystem info type '{source.GetType().FullName}'.");
}
return anyCopied;
}
/// <summary>A copy filter which matches save folders.</summary>

View File

@ -53,7 +53,19 @@ namespace StardewModdingAPI.Toolkit.Utilities
}
catch { }
#endif
return (platform == Platform.Mac ? "MacOS " : "") + Environment.OSVersion;
string name = Environment.OSVersion.ToString();
switch (platform)
{
case Platform.Android:
name = $"Android {name}";
break;
case Platform.Mac:
name = $"MacOS {name}";
break;
}
return name;
}
/// <summary>Get the name of the Stardew Valley executable.</summary>

View File

@ -142,7 +142,7 @@
},
"FromFile": {
"title": "Source file",
"description": "The relative file path in your content pack folder to load instead (like 'assets/dinosaur.png'). This can be a .json (data), .png (image), .tbin (map), or .xnb file. This field supports tokens and capitalization doesn't matter.",
"description": "The relative file path in your content pack folder to load instead (like 'assets/dinosaur.png'). This can be a .json (data), .png (image), .tbin or .tmx (map), or .xnb file. This field supports tokens and capitalization doesn't matter.",
"type": "string",
"allOf": [
{
@ -151,12 +151,12 @@
}
},
{
"pattern": "\\.(json|png|tbin|xnb) *$"
"pattern": "\\.(json|png|tbin|tmx|xnb) *$"
}
],
"@errorMessages": {
"allOf:indexes: 0": "Invalid value; must not contain directory climbing (like '../').",
"allOf:indexes: 1": "Invalid value; must be a file path ending with .json, .png, .tbin, or .xnb."
"allOf:indexes: 1": "Invalid value; must be a file path ending with .json, .png, .tbin, .tmx, or .xnb."
}
},
"FromArea": {
@ -325,7 +325,7 @@
"then": {
"properties": {
"FromFile": {
"description": "The relative path to the map in your content pack folder from which to copy (like assets/town.tbin). This can be a .tbin or .xnb file. This field supports tokens and capitalization doesn't matter.\nContent Patcher will handle tilesheets referenced by the FromFile map for you:\n - If a tilesheet isn't referenced by the target map, Content Patcher will add it for you (with a z_ ID prefix to avoid conflicts with hardcoded game logic). If the source map has a custom version of a tilesheet that's already referenced, it'll be added as a separate tilesheet only used by your tiles.\n - If you include the tilesheet file in your mod folder, Content Patcher will use that one automatically; otherwise it will be loaded from the game's Content/Maps folder."
"description": "The relative path to the map in your content pack folder from which to copy (like assets/town.tbin). This can be a .tbin, .tmx, or .xnb file. This field supports tokens and capitalization doesn't matter.\nContent Patcher will handle tilesheets referenced by the FromFile map for you:\n - If a tilesheet isn't referenced by the target map, Content Patcher will add it for you (with a z_ ID prefix to avoid conflicts with hardcoded game logic). If the source map has a custom version of a tilesheet that's already referenced, it'll be added as a separate tilesheet only used by your tiles.\n - If you include the tilesheet file in your mod folder, Content Patcher will use that one automatically; otherwise it will be loaded from the game's Content/Maps folder."
},
"FromArea": {
"description": "The part of the source map to copy. Defaults to the whole source map."

View File

@ -169,6 +169,13 @@ namespace StardewModdingAPI.Framework.Input
return buttons.Any(button => this.IsDown(button.ToSButton()));
}
/// <summary>Get the status of a button.</summary>
/// <param name="button">The button to check.</param>
public InputStatus GetStatus(SButton button)
{
return this.GetStatus(this.ActiveButtons, button);
}
/*********
** Private methods

View File

@ -50,5 +50,12 @@ namespace StardewModdingAPI.Framework.ModHelpers
{
this.InputState.SuppressButtons.Add(button);
}
/// <summary>Get the status of a button.</summary>
/// <param name="button">The button to check.</param>
public InputStatus GetStatus(SButton button)
{
return this.InputState.GetStatus(button);
}
}
}

View File

@ -21,7 +21,7 @@ namespace StardewModdingAPI.Framework.Networking
/****
** Destination
****/
/// <summary>The players who should receive the message, or <c>null</c> for all players.</summary>
/// <summary>The players who should receive the message.</summary>
public long[] ToPlayerIDs { get; set; }
/// <summary>The mods which should receive the message, or <c>null</c> for all mods.</summary>

View File

@ -338,30 +338,49 @@ namespace StardewModdingAPI.Framework
/// <param name="toPlayerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param>
public void BroadcastModMessage<TMessage>(TMessage message, string messageType, string fromModID, string[] toModIDs, long[] toPlayerIDs)
{
// validate
// validate input
if (message == null)
throw new ArgumentNullException(nameof(message));
if (string.IsNullOrWhiteSpace(messageType))
throw new ArgumentNullException(nameof(messageType));
if (string.IsNullOrWhiteSpace(fromModID))
throw new ArgumentNullException(nameof(fromModID));
if (!this.Peers.Any())
// get target players
long curPlayerId = Game1.player.UniqueMultiplayerID;
bool sendToSelf = false;
List<MultiplayerPeer> sendToPeers = new List<MultiplayerPeer>();
if (toPlayerIDs == null)
{
this.Monitor.VerboseLog($"Ignored '{messageType}' broadcast from mod {fromModID}: not connected to any players.");
return;
sendToSelf = true;
sendToPeers.AddRange(this.Peers.Values);
}
else
{
foreach (long id in toPlayerIDs.Distinct())
{
if (id == curPlayerId)
sendToSelf = true;
else if (this.Peers.TryGetValue(id, out MultiplayerPeer peer) && peer.HasSmapi)
sendToPeers.Add(peer);
}
}
// filter player IDs
HashSet<long> playerIDs = null;
if (toPlayerIDs != null && toPlayerIDs.Any())
// filter by mod ID
if (toModIDs != null)
{
playerIDs = new HashSet<long>(toPlayerIDs);
playerIDs.RemoveWhere(id => !this.Peers.ContainsKey(id));
if (!playerIDs.Any())
{
this.Monitor.VerboseLog($"Ignored '{messageType}' broadcast from mod {fromModID}: none of the specified player IDs are connected.");
return;
}
HashSet<string> sendToMods = new HashSet<string>(toModIDs, StringComparer.InvariantCultureIgnoreCase);
if (sendToSelf && toModIDs.All(id => this.ModRegistry.Get(id) == null))
sendToSelf = false;
sendToPeers.RemoveAll(peer => peer.Mods.All(mod => !sendToMods.Contains(mod.ID)));
}
// validate recipients
if (!sendToSelf && !sendToPeers.Any())
{
this.Monitor.VerboseLog($"Ignored '{messageType}' broadcast from mod {fromModID}: none of the specified player IDs can receive this message.");
return;
}
// get data to send
@ -369,33 +388,44 @@ namespace StardewModdingAPI.Framework
fromPlayerID: Game1.player.UniqueMultiplayerID,
fromModID: fromModID,
toModIDs: toModIDs,
toPlayerIDs: playerIDs?.ToArray(),
toPlayerIDs: sendToPeers.Select(p => p.PlayerID).ToArray(),
type: messageType,
data: JToken.FromObject(message)
);
string data = JsonConvert.SerializeObject(model, Formatting.None);
// log message
if (this.LogNetworkTraffic)
this.Monitor.Log($"Broadcasting '{messageType}' message: {data}.", LogLevel.Trace);
// send message
if (Context.IsMainPlayer)
// send self-message
if (sendToSelf)
{
foreach (MultiplayerPeer peer in this.Peers.Values)
if (this.LogNetworkTraffic)
this.Monitor.Log($"Broadcasting '{messageType}' message to self: {data}.", LogLevel.Trace);
this.OnModMessageReceived(model);
}
// send message to peers
if (sendToPeers.Any())
{
if (Context.IsMainPlayer)
{
if (playerIDs == null || playerIDs.Contains(peer.PlayerID))
foreach (MultiplayerPeer peer in sendToPeers)
{
model.ToPlayerIDs = new[] { peer.PlayerID };
if (this.LogNetworkTraffic)
this.Monitor.Log($"Broadcasting '{messageType}' message to farmhand {peer.PlayerID}: {data}.", LogLevel.Trace);
peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, data));
}
}
}
else if (this.HostPeer != null && this.HostPeer.HasSmapi)
this.HostPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, this.HostPeer.PlayerID, data));
else
this.Monitor.VerboseLog(" Can't send message because no valid connections were found.");
else if (this.HostPeer?.HasSmapi == true)
{
if (this.LogNetworkTraffic)
this.Monitor.Log($"Broadcasting '{messageType}' message to host {this.HostPeer.PlayerID}: {data}.", LogLevel.Trace);
this.HostPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, this.HostPeer.PlayerID, data));
}
else
this.Monitor.VerboseLog(" Can't send message because no valid connections were found.");
}
}

View File

@ -17,5 +17,9 @@ namespace StardewModdingAPI
/// <summary>Prevent the game from handling a button press. This doesn't prevent other mods from receiving the event.</summary>
/// <param name="button">The button to suppress.</param>
void Suppress(SButton button);
/// <summary>Get the status of a button.</summary>
/// <param name="button">The button to check.</param>
InputStatus GetStatus(SButton button);
}
}

View File

@ -1,7 +1,7 @@
namespace StardewModdingAPI.Framework.Input
namespace StardewModdingAPI
{
/// <summary>The input status for a button during an update frame.</summary>
internal enum InputStatus
public enum InputStatus
{
/// <summary>The button was neither pressed, held, nor released.</summary>
None,

View File

@ -887,10 +887,17 @@ namespace StardewModdingAPI.Metadata
return false;
// update dialogue
// Note that marriage dialogue isn't reloaded after reset, but it doesn't need to be
// propagated anyway since marriage dialogue keys can't be added/removed and the field
// doesn't store the text itself.
foreach (NPC villager in villagers)
{
MarriageDialogueReference[] marriageDialogue = villager.currentMarriageDialogue.ToArray();
villager.resetSeasonalDialogue(); // doesn't only affect seasonal dialogue
villager.resetCurrentDialogue();
villager.currentMarriageDialogue.Set(marriageDialogue);
}
return true;

3
src/SMAPI/i18n/it.json Normal file
View File

@ -0,0 +1,3 @@
{
"warn.invalid-content-removed": "Contenuto non valido rimosso per prevenire un crash (Guarda la console di SMAPI per maggiori informazioni)."
}