Files
OpenRA/OpenRA.Game/Map/MapPreview.cs
2020-08-19 18:11:07 +01:00

574 lines
17 KiB
C#

#region Copyright & License Information
/*
* Copyright 2007-2020 The OpenRA Developers (see AUTHORS)
* This file is part of OpenRA, which is free software. It is made
* available to you under the terms of the GNU General Public License
* as published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version. For more
* information, see COPYING.
*/
#endregion
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;
using OpenRA.FileFormats;
using OpenRA.FileSystem;
using OpenRA.Graphics;
using OpenRA.Primitives;
namespace OpenRA
{
public enum MapStatus { Available, Unavailable, Searching, DownloadAvailable, Downloading, DownloadError }
// Used for grouping maps in the UI
[Flags]
public enum MapClassification
{
Unknown = 0,
System = 1,
User = 2,
Remote = 4
}
// Used for verifying map availability in the lobby
public enum MapRuleStatus { Unknown, Cached, Invalid }
[SuppressMessage("StyleCop.CSharp.NamingRules",
"SA1307:AccessibleFieldsMustBeginWithUpperCaseLetter",
Justification = "Fields names must match the with the remote API.")]
[SuppressMessage("StyleCop.CSharp.NamingRules",
"SA1304:NonPrivateReadonlyFieldsMustBeginWithUpperCaseLetter",
Justification = "Fields names must match the with the remote API.")]
[SuppressMessage("StyleCop.CSharp.NamingRules",
"SA1310:FieldNamesMustNotContainUnderscore",
Justification = "Fields names must match the with the remote API.")]
public class RemoteMapData
{
public readonly string title;
public readonly string author;
public readonly string[] categories;
public readonly int players;
public readonly Rectangle bounds;
public readonly short[] spawnpoints = { };
public readonly MapGridType map_grid_type;
public readonly string minimap;
public readonly bool downloading;
public readonly string tileset;
public readonly string rules;
public readonly string players_block;
}
public class MapPreview : IDisposable, IReadOnlyFileSystem
{
/// <summary>Wrapper that enables map data to be replaced in an atomic fashion</summary>
class InnerData
{
public string Title;
public string[] Categories;
public string Author;
public string TileSet;
public MapPlayers Players;
public int PlayerCount;
public CPos[] SpawnPoints;
public MapGridType GridType;
public Rectangle Bounds;
public Png Preview;
public MapStatus Status;
public MapClassification Class;
public MapVisibility Visibility;
Lazy<Ruleset> rules;
public Ruleset Rules { get { return rules != null ? rules.Value : null; } }
public bool InvalidCustomRules { get; private set; }
public bool DefinesUnsafeCustomRules { get; private set; }
public bool RulesLoaded { get; private set; }
public void SetRulesetGenerator(ModData modData, Func<(Ruleset Ruleset, bool DefinesUnsafeCustomRules)> generator)
{
InvalidCustomRules = false;
RulesLoaded = false;
DefinesUnsafeCustomRules = false;
// Note: multiple threads may try to access the value at the same time
// We rely on the thread-safety guarantees given by Lazy<T> to prevent race conitions.
// If you're thinking about replacing this, then you must be careful to keep this safe.
rules = Exts.Lazy(() =>
{
if (generator == null)
return Ruleset.LoadDefaultsForTileSet(modData, TileSet);
try
{
var ret = generator();
DefinesUnsafeCustomRules = ret.DefinesUnsafeCustomRules;
return ret.Ruleset;
}
catch (Exception e)
{
Log.Write("debug", "Failed to load rules for `{0}` with error :{1}", Title, e.Message);
InvalidCustomRules = true;
return Ruleset.LoadDefaultsForTileSet(modData, TileSet);
}
finally
{
RulesLoaded = true;
}
});
}
public InnerData Clone()
{
return (InnerData)MemberwiseClone();
}
}
static readonly CPos[] NoSpawns = new CPos[] { };
readonly MapCache cache;
readonly ModData modData;
public readonly string Uid;
public IReadOnlyPackage Package { get; private set; }
IReadOnlyPackage parentPackage;
volatile InnerData innerData;
public string Title { get { return innerData.Title; } }
public string[] Categories { get { return innerData.Categories; } }
public string Author { get { return innerData.Author; } }
public string TileSet { get { return innerData.TileSet; } }
public MapPlayers Players { get { return innerData.Players; } }
public int PlayerCount { get { return innerData.PlayerCount; } }
public CPos[] SpawnPoints { get { return innerData.SpawnPoints; } }
public MapGridType GridType { get { return innerData.GridType; } }
public Rectangle Bounds { get { return innerData.Bounds; } }
public Png Preview { get { return innerData.Preview; } }
public MapStatus Status { get { return innerData.Status; } }
public MapClassification Class { get { return innerData.Class; } }
public MapVisibility Visibility { get { return innerData.Visibility; } }
public Ruleset Rules { get { return innerData.Rules; } }
public bool InvalidCustomRules { get { return innerData.InvalidCustomRules; } }
public bool RulesLoaded { get { return innerData.RulesLoaded; } }
public bool DefinesUnsafeCustomRules
{
get
{
// Force lazy rules to be evaluated
var force = innerData.Rules;
return innerData.DefinesUnsafeCustomRules;
}
}
Download download;
public long DownloadBytes { get; private set; }
public int DownloadPercentage { get; private set; }
Sprite minimap;
bool generatingMinimap;
public Sprite GetMinimap()
{
if (minimap != null)
return minimap;
if (!generatingMinimap && Status == MapStatus.Available)
{
generatingMinimap = true;
cache.CacheMinimap(this);
}
return null;
}
internal void SetMinimap(Sprite minimap)
{
this.minimap = minimap;
generatingMinimap = false;
}
public MapPreview(ModData modData, string uid, MapGridType gridType, MapCache cache)
{
this.cache = cache;
this.modData = modData;
Uid = uid;
innerData = new InnerData
{
Title = "Unknown Map",
Categories = new[] { "Unknown" },
Author = "Unknown Author",
TileSet = "unknown",
Players = null,
PlayerCount = 0,
SpawnPoints = NoSpawns,
GridType = gridType,
Bounds = Rectangle.Empty,
Preview = null,
Status = MapStatus.Unavailable,
Class = MapClassification.Unknown,
Visibility = MapVisibility.Lobby,
};
}
public void UpdateFromMap(IReadOnlyPackage p, IReadOnlyPackage parent, MapClassification classification, string[] mapCompatibility, MapGridType gridType)
{
Dictionary<string, MiniYaml> yaml;
using (var yamlStream = p.GetStream("map.yaml"))
{
if (yamlStream == null)
throw new FileNotFoundException("Required file map.yaml not present in this map");
yaml = new MiniYaml(null, MiniYaml.FromStream(yamlStream, "map.yaml")).ToDictionary();
}
Package = p;
parentPackage = parent;
var newData = innerData.Clone();
newData.GridType = gridType;
newData.Class = classification;
if (yaml.TryGetValue("MapFormat", out var temp))
{
var format = FieldLoader.GetValue<int>("MapFormat", temp.Value);
if (format != Map.SupportedMapFormat)
throw new InvalidDataException("Map format {0} is not supported.".F(format));
}
if (yaml.TryGetValue("Title", out temp))
newData.Title = temp.Value;
if (yaml.TryGetValue("Categories", out temp))
newData.Categories = FieldLoader.GetValue<string[]>("Categories", temp.Value);
if (yaml.TryGetValue("Tileset", out temp))
newData.TileSet = temp.Value;
if (yaml.TryGetValue("Author", out temp))
newData.Author = temp.Value;
if (yaml.TryGetValue("Bounds", out temp))
newData.Bounds = FieldLoader.GetValue<Rectangle>("Bounds", temp.Value);
if (yaml.TryGetValue("Visibility", out temp))
newData.Visibility = FieldLoader.GetValue<MapVisibility>("Visibility", temp.Value);
string requiresMod = string.Empty;
if (yaml.TryGetValue("RequiresMod", out temp))
requiresMod = temp.Value;
newData.Status = mapCompatibility == null || mapCompatibility.Contains(requiresMod) ?
MapStatus.Available : MapStatus.Unavailable;
try
{
// Actor definitions may change if the map format changes
if (yaml.TryGetValue("Actors", out var actorDefinitions))
{
var spawns = new List<CPos>();
foreach (var kv in actorDefinitions.Nodes.Where(d => d.Value.Value == "mpspawn"))
{
var s = new ActorReference(kv.Value.Value, kv.Value.ToDictionary());
spawns.Add(s.Get<LocationInit>().Value);
}
newData.SpawnPoints = spawns.ToArray();
}
else
newData.SpawnPoints = new CPos[0];
}
catch (Exception)
{
newData.SpawnPoints = new CPos[0];
newData.Status = MapStatus.Unavailable;
}
try
{
// Player definitions may change if the map format changes
if (yaml.TryGetValue("Players", out var playerDefinitions))
{
newData.Players = new MapPlayers(playerDefinitions.Nodes);
newData.PlayerCount = newData.Players.Players.Count(x => x.Value.Playable);
}
}
catch (Exception)
{
newData.Status = MapStatus.Unavailable;
}
newData.SetRulesetGenerator(modData, () =>
{
var ruleDefinitions = LoadRuleSection(yaml, "Rules");
var weaponDefinitions = LoadRuleSection(yaml, "Weapons");
var voiceDefinitions = LoadRuleSection(yaml, "Voices");
var musicDefinitions = LoadRuleSection(yaml, "Music");
var notificationDefinitions = LoadRuleSection(yaml, "Notifications");
var sequenceDefinitions = LoadRuleSection(yaml, "Sequences");
var modelSequenceDefinitions = LoadRuleSection(yaml, "ModelSequences");
var rules = Ruleset.Load(modData, this, TileSet, ruleDefinitions, weaponDefinitions,
voiceDefinitions, notificationDefinitions, musicDefinitions, sequenceDefinitions, modelSequenceDefinitions);
var flagged = Ruleset.DefinesUnsafeCustomRules(modData, this, ruleDefinitions,
weaponDefinitions, voiceDefinitions, notificationDefinitions, sequenceDefinitions);
return (rules, flagged);
});
if (p.Contains("map.png"))
using (var dataStream = p.GetStream("map.png"))
newData.Preview = new Png(dataStream);
// Assign the new data atomically
innerData = newData;
}
MiniYaml LoadRuleSection(Dictionary<string, MiniYaml> yaml, string section)
{
if (!yaml.TryGetValue(section, out var node))
return null;
return node;
}
public void PreloadRules()
{
var unused = Rules;
}
public void UpdateRemoteSearch(MapStatus status, MiniYaml yaml, Action<MapPreview> parseMetadata = null)
{
var newData = innerData.Clone();
newData.Status = status;
newData.Class = MapClassification.Remote;
if (status == MapStatus.DownloadAvailable)
{
try
{
var r = FieldLoader.Load<RemoteMapData>(yaml);
// Map download has been disabled server side
if (!r.downloading)
{
newData.Status = MapStatus.Unavailable;
return;
}
newData.Title = r.title;
newData.Categories = r.categories;
newData.Author = r.author;
newData.PlayerCount = r.players;
newData.Bounds = r.bounds;
newData.TileSet = r.tileset;
var spawns = new CPos[r.spawnpoints.Length / 2];
for (var j = 0; j < r.spawnpoints.Length; j += 2)
spawns[j / 2] = new CPos(r.spawnpoints[j], r.spawnpoints[j + 1]);
newData.SpawnPoints = spawns;
newData.GridType = r.map_grid_type;
try
{
newData.Preview = new Png(new MemoryStream(Convert.FromBase64String(r.minimap)));
}
catch (Exception e)
{
Log.Write("debug", "Failed parsing mapserver minimap response: {0}", e);
newData.Preview = null;
}
var playersString = Encoding.UTF8.GetString(Convert.FromBase64String(r.players_block));
newData.Players = new MapPlayers(MiniYaml.FromString(playersString));
newData.SetRulesetGenerator(modData, () =>
{
var rulesString = Encoding.UTF8.GetString(Convert.FromBase64String(r.rules));
var rulesYaml = new MiniYaml("", MiniYaml.FromString(rulesString)).ToDictionary();
var ruleDefinitions = LoadRuleSection(rulesYaml, "Rules");
var weaponDefinitions = LoadRuleSection(rulesYaml, "Weapons");
var voiceDefinitions = LoadRuleSection(rulesYaml, "Voices");
var musicDefinitions = LoadRuleSection(rulesYaml, "Music");
var notificationDefinitions = LoadRuleSection(rulesYaml, "Notifications");
var sequenceDefinitions = LoadRuleSection(rulesYaml, "Sequences");
var modelSequenceDefinitions = LoadRuleSection(rulesYaml, "ModelSequences");
var rules = Ruleset.Load(modData, this, TileSet, ruleDefinitions, weaponDefinitions,
voiceDefinitions, notificationDefinitions, musicDefinitions, sequenceDefinitions, modelSequenceDefinitions);
var flagged = Ruleset.DefinesUnsafeCustomRules(modData, this, ruleDefinitions,
weaponDefinitions, voiceDefinitions, notificationDefinitions, sequenceDefinitions);
return (rules, flagged);
});
}
catch (Exception e)
{
Log.Write("debug", "Failed parsing mapserver response: {0}", e);
}
// Commit updated data before running the callbacks
innerData = newData;
if (innerData.Preview != null)
cache.CacheMinimap(this);
parseMetadata?.Invoke(this);
}
// Update the status and class unconditionally
innerData = newData;
}
public void Install(string mapRepositoryUrl, Action onSuccess)
{
if ((Status != MapStatus.DownloadError && Status != MapStatus.DownloadAvailable) || !Game.Settings.Game.AllowDownloading)
return;
innerData.Status = MapStatus.Downloading;
var installLocation = cache.MapLocations.FirstOrDefault(p => p.Value == MapClassification.User);
if (installLocation.Key == null || !(installLocation.Key is IReadWritePackage))
{
Log.Write("debug", "Map install directory not found");
innerData.Status = MapStatus.DownloadError;
return;
}
var mapInstallPackage = installLocation.Key as IReadWritePackage;
new Thread(() =>
{
// Request the filename from the server
// Run in a worker thread to avoid network delays
var mapUrl = mapRepositoryUrl + Uid;
var mapFilename = string.Empty;
try
{
var request = WebRequest.Create(mapUrl);
request.Method = "HEAD";
using (var res = request.GetResponse())
{
// Map not found
if (res.Headers["Content-Disposition"] == null)
{
innerData.Status = MapStatus.DownloadError;
return;
}
mapFilename = res.Headers["Content-Disposition"].Replace("attachment; filename = ", "");
}
Action<DownloadProgressChangedEventArgs> onDownloadProgress = i => { DownloadBytes = i.BytesReceived; DownloadPercentage = i.ProgressPercentage; };
Action<DownloadDataCompletedEventArgs> onDownloadComplete = i =>
{
download = null;
if (i.Error != null)
{
Log.Write("debug", "Remote map download failed with error: {0}", Download.FormatErrorMessage(i.Error));
Log.Write("debug", "URL was: {0}", mapUrl);
innerData.Status = MapStatus.DownloadError;
return;
}
mapInstallPackage.Update(mapFilename, i.Result);
Log.Write("debug", "Downloaded map to '{0}'", mapFilename);
Game.RunAfterTick(() =>
{
var package = mapInstallPackage.OpenPackage(mapFilename, modData.ModFiles);
if (package == null)
innerData.Status = MapStatus.DownloadError;
else
{
UpdateFromMap(package, mapInstallPackage, MapClassification.User, null, GridType);
onSuccess();
}
});
};
download = new Download(mapUrl, onDownloadProgress, onDownloadComplete);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
innerData.Status = MapStatus.DownloadError;
}
}).Start();
}
public void CancelInstall()
{
if (download == null)
return;
download.CancelAsync();
download = null;
}
public void Invalidate()
{
innerData.Status = MapStatus.Unavailable;
}
public void Dispose()
{
if (Package != null)
{
Package.Dispose();
Package = null;
}
}
public void Delete()
{
Invalidate();
(parentPackage as IReadWritePackage)?.Delete(Package.Name);
}
Stream IReadOnlyFileSystem.Open(string filename)
{
// Explicit package paths never refer to a map
if (!filename.Contains("|") && Package.Contains(filename))
return Package.GetStream(filename);
return modData.DefaultFileSystem.Open(filename);
}
bool IReadOnlyFileSystem.TryGetPackageContaining(string path, out IReadOnlyPackage package, out string filename)
{
// Packages aren't supported inside maps
return modData.DefaultFileSystem.TryGetPackageContaining(path, out package, out filename);
}
bool IReadOnlyFileSystem.TryOpen(string filename, out Stream s)
{
// Explicit package paths never refer to a map
if (!filename.Contains("|"))
{
s = Package.GetStream(filename);
if (s != null)
return true;
}
return modData.DefaultFileSystem.TryOpen(filename, out s);
}
bool IReadOnlyFileSystem.Exists(string filename)
{
// Explicit package paths never refer to a map
if (!filename.Contains("|") && Package.Contains(filename))
return true;
return modData.DefaultFileSystem.Exists(filename);
}
bool IReadOnlyFileSystem.IsExternalModFile(string filename)
{
// Explicit package paths never refer to a map
if (filename.Contains("|"))
return modData.DefaultFileSystem.IsExternalModFile(filename);
return false;
}
}
}