Add MapClassification.Generated.

Generated maps are created at runtime using a
mod-defined trait. They are embedded directly
into replays and save games as they exist in
memory only, and disappear after the game exits.
This commit is contained in:
Paul Chote
2025-04-17 18:54:38 +01:00
committed by Gustas Kažukauskas
parent 6be947bbf0
commit 979483b63c
15 changed files with 226 additions and 6 deletions

View File

@@ -12,6 +12,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenRA.FileSystem;
using OpenRA.Network;
using OpenRA.Primitives;
@@ -27,6 +28,7 @@ namespace OpenRA
public string MapUid;
public string MapTitle;
public string MapData;
public int FinalGameTick;
/// <summary>Game start timestamp (when the recoding started).</summary>
@@ -40,7 +42,22 @@ namespace OpenRA
public IList<Player> Players { get; }
public HashSet<int> DisabledSpawnPoints = [];
public MapPreview MapPreview => Game.ModData.MapCache[MapUid];
public MapPreview MapPreview
{
get
{
var preview = Game.ModData.MapCache[MapUid];
if (preview.Status != MapStatus.Available && MapData != null)
{
var package = ZipFileLoader.ReadWriteZipFile.FromBase64String(MapData);
preview.UpdateFromMap(package, MapClassification.Generated);
}
return preview;
}
}
public IEnumerable<Player> HumanPlayers { get { return Players.Where(p => p.IsHuman); } }
public bool IsSinglePlayer => HumanPlayers.Count() == 1;

View File

@@ -20,6 +20,7 @@ using OpenRA.FileSystem;
using OpenRA.Graphics;
using OpenRA.Primitives;
using OpenRA.Support;
using OpenRA.Traits;
namespace OpenRA
{
@@ -224,6 +225,45 @@ namespace OpenRA
yield return mapPackage;
}
public void GenerateMap(MapGenerationArgs args)
{
var p = previews[args.Uid];
if (p.Class == MapClassification.Generated)
return;
p.UpdateFromGenerationArgs(args);
Task.Run(() =>
{
try
{
var generator = modData.DefaultRules.Actors[SystemActors.EditorWorld]
.TraitInfos<IMapGeneratorInfo>()
.FirstOrDefault(info => info.Type == args.Generator);
if (generator == null)
throw new Exception($"Unknown map generator type {args.Generator}");
var map = generator.Generate(modData, args);
// Uid is generated when the map is saved
map.Save(new ZipFileLoader.ReadWriteZipFile());
if (map.Uid != args.Uid)
throw new InvalidOperationException("Map generation UID mismatch");
Game.RunAfterTick(() => p.UpdateFromMap(map.Package, MapClassification.Generated));
}
catch (Exception e)
{
Log.Write("debug", "Map generation failed with error:");
Log.Write("debug", e);
p.UpdateFromGenerationArgs(null);
}
});
}
public void QueryRemoteMapDetails(string repositoryUrl, IEnumerable<string> uids,
Action<MapPreview> mapDetailsReceived = null, Action<MapPreview> mapQueryFailed = null)
{

View File

@@ -9,6 +9,7 @@
*/
#endregion
using System.Collections.Generic;
using OpenRA.Primitives;
namespace OpenRA
@@ -39,5 +40,19 @@ namespace OpenRA
{
return yaml.NodeWithKey("Settings").Value;
}
public string Serialize()
{
return new List<MiniYamlNode>()
{
new("Uid", Uid),
new("Generator", Generator),
new("Tileset", Tileset),
new("Size", FieldSaver.FormatValue(Size)),
new("Settings", Settings),
new("Title", Title),
new("Author", Author)
}.WriteToString();
}
}
}

View File

@@ -26,7 +26,7 @@ using OpenRA.Support;
namespace OpenRA
{
public enum MapStatus { Available, Unavailable, Searching, DownloadAvailable, Downloading, DownloadError }
public enum MapStatus { Available, Unavailable, Searching, DownloadAvailable, Downloading, DownloadError, Generating }
// Used for grouping maps in the UI
[Flags]
@@ -35,7 +35,8 @@ namespace OpenRA
Unknown = 0,
System = 1,
User = 2,
Remote = 4
Remote = 4,
Generated = 8
}
[SuppressMessage("StyleCop.CSharp.NamingRules",
@@ -218,6 +219,19 @@ namespace OpenRA
return new Map(modData, package);
}
public string ToBase64String()
{
LoadPackage();
if (package is not ZipFileLoader.ReadWriteZipFile p)
{
var map = new Map(modData, package);
p = new ZipFileLoader.ReadWriteZipFile();
map.Save(p);
}
return p.ToBase64String();
}
IReadOnlyPackage parentPackage;
volatile InnerData innerData;
@@ -450,7 +464,7 @@ namespace OpenRA
using (var dataStream = p.GetStream("map.png"))
newData.Preview = new Png(dataStream);
newData.ModifiedDate = File.GetLastWriteTime(p.Name);
newData.ModifiedDate = p.Name != null ? File.GetLastWriteTime(p.Name) : DateTime.Now;
// Assign the new data atomically
// Local maps have higher precedence than remote/generated maps,
@@ -459,6 +473,23 @@ namespace OpenRA
innerData = newData;
}
public void UpdateFromGenerationArgs(MapGenerationArgs args)
{
var newData = innerData.Clone();
newData.Class = MapClassification.Generated;
if (args != null)
{
newData.Status = MapStatus.Generating;
newData.Title = args.Title;
newData.Author = args.Author;
}
else
newData.Status = MapStatus.Unavailable;
lock (syncRoot)
innerData = newData;
}
public void BeginRemoteSearch()
{
var newData = innerData.Clone();

View File

@@ -93,6 +93,7 @@ namespace OpenRA.Network
public Dictionary<string, Session.Slot> Slots { get; private set; }
public Dictionary<string, SlotClient> SlotClients { get; private set; }
public Dictionary<int, MiniYaml> TraitData = [];
public string MapData;
// Set on game start
int[] clientsBySlotIndex = [];
@@ -141,6 +142,8 @@ namespace OpenRA.Network
SlotClients.Add(slotClient.Slot, slotClient);
}
MapData = rs.ReadLengthPrefixedString(Encoding.UTF8, Connection.MaxOrderLength);
if (rs.Position != traitDataOffset || rs.ReadInt32() != TraitDataMarker)
throw new InvalidDataException("Invalid orasav file");
@@ -155,6 +158,9 @@ namespace OpenRA.Network
public void StartGame(Session lobbyInfo, MapPreview map)
{
if (map.Class == MapClassification.Generated)
MapData = map.ToBase64String();
// Game orders are mapped from a client index to the slot that they occupy
// Orders from spectators are ignored, which is not a problem in practice
// because all immediate orders are also ignored
@@ -306,6 +312,8 @@ namespace OpenRA.Network
.ToList();
file.WriteLengthPrefixedString(Encoding.UTF8, slotClientNodes.WriteToString());
file.WriteLengthPrefixedString(Encoding.UTF8, MapData);
var traitDataOffset = file.Length;
file.Write(TraitDataMarker);

View File

@@ -389,6 +389,13 @@ namespace OpenRA.Network
break;
}
case "GenerateMap":
{
var yaml = new MiniYaml(order.OrderString, MiniYaml.FromString(order.TargetString, order.OrderString));
Game.ModData.MapCache.GenerateMap(FieldLoader.Load<MapGenerationArgs>(yaml));
break;
}
default:
{
if (world == null)

View File

@@ -21,7 +21,6 @@ using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using OpenRA;
using OpenRA.FileFormats;
using OpenRA.Network;
using OpenRA.Primitives;
@@ -127,6 +126,7 @@ namespace OpenRA.Server
public ServerSettings Settings;
public ModData ModData;
public List<string> TempBans = [];
public string GeneratedMapData;
// Managed by LobbyCommands
public MapPreview Map;
@@ -577,6 +577,10 @@ namespace OpenRA.Server
foreach (var t in serverTraits.WithInterface<IClientJoined>())
t.ClientJoined(this, newConn);
var p = ModData.MapCache[LobbyInfo.GlobalSettings.Map];
if (p.Class == MapClassification.Generated && !string.IsNullOrEmpty(GeneratedMapData))
SendOrderTo(newConn, "GenerateMap", GeneratedMapData);
SyncLobbyInfo();
Log.Write("server", $"{client.Name} ({newConn.EndPoint}) has joined the game.");
@@ -1119,6 +1123,31 @@ namespace OpenRA.Server
break;
}
case "GenerateMap":
{
if (!GetClient(conn).IsAdmin || State >= ServerState.GameStarted)
break;
try
{
var yaml = new MiniYaml(o.OrderString, MiniYaml.FromString(o.TargetString, o.OrderString));
var args = FieldLoader.Load<MapGenerationArgs>(yaml);
var preview = ModData.MapCache[args.Uid];
if (preview.Status != MapStatus.Available)
ModData.MapCache.GenerateMap(args);
GeneratedMapData = o.TargetString;
DispatchServerOrdersToClients(Order.FromTargetString("GenerateMap", o.TargetString, true));
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
break;
}
}
}
}
@@ -1333,6 +1362,9 @@ namespace OpenRA.Server
StartTimeUtc = DateTime.UtcNow,
};
if (Map.Class == MapClassification.Generated)
gameInfo.MapData = Map.ToBase64String();
// Replay metadata should only include the playable players
foreach (var p in worldPlayers)
if (p != null)

View File

@@ -239,6 +239,10 @@ namespace OpenRA
MapTitle = Map.Title
};
var preview = modData.MapCache[Map.Uid];
if (preview.Class == MapClassification.Generated)
gameInfo.MapData = preview.ToBase64String();
RulesContainTemporaryBlocker = Map.Rules.Actors.Any(a => a.Value.HasTraitInfo<ITemporaryBlockerInfo>());
gameSettings = Game.Settings.Game;
}

View File

@@ -667,6 +667,21 @@ namespace OpenRA.Mods.Common.Server
var m = server.ModData.MapCache[s];
if (m.Status == MapStatus.Available || m.Status == MapStatus.DownloadAvailable)
SelectMap(m);
else if (m.Class == MapClassification.Generated)
{
if (m.Status == MapStatus.Generating)
{
// Wait up to 5 seconds for the map to be generated
var stopwatch = Stopwatch.StartNew();
while (m.Status == MapStatus.Generating && stopwatch.ElapsedMilliseconds < 5000)
Thread.Sleep(100);
}
if (m.Status == MapStatus.Available)
SelectMap(m);
else
QueryFailed();
}
else if (server.Settings.QueryMapRepository)
{
server.SendFluentMessageTo(conn, SearchingMap);

View File

@@ -14,6 +14,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using OpenRA.FileSystem;
using OpenRA.Network;
using OpenRA.Widgets;
@@ -336,7 +337,15 @@ namespace OpenRA.Mods.Common.Widgets.Logic
// Parse the save to find the map UID
var save = new GameSave(selectedSave);
var map = modData.MapCache[save.GlobalSettings.Map];
var map = Game.ModData.MapCache[save.GlobalSettings.Map];
if (map.Status != MapStatus.Available && save.MapData != null)
{
// Add to the MapCache so the server will accept the map
var package = ZipFileLoader.ReadWriteZipFile.FromBase64String(save.MapData);
map.UpdateFromMap(package, MapClassification.Generated);
}
if (map.Status != MapStatus.Available)
return;

View File

@@ -48,6 +48,7 @@ namespace OpenRA.Mods.Common.Widgets.Logic
Playable,
Incompatible,
Validating,
Generating,
DownloadAvailable,
Searching,
Downloading,
@@ -199,6 +200,8 @@ namespace OpenRA.Mods.Common.Widgets.Logic
[previewLarge, widget.Get("MAP_INCOMPATIBLE")];
previewWidgets[PreviewStatus.Validating] =
[previewSmall, widget.Get("MAP_VALIDATING")];
previewWidgets[PreviewStatus.Generating] =
[previewSmall, widget.Get("MAP_GENERATING")];
previewWidgets[PreviewStatus.UpdateAvailable] =
[previewSmall, widget.Get("MAP_UPDATE_AVAILABLE"), updateButton];
previewWidgets[PreviewStatus.DownloadAvailable] =
@@ -271,6 +274,9 @@ namespace OpenRA.Mods.Common.Widgets.Logic
case MapStatus.Searching:
status = PreviewStatus.Searching;
break;
case MapStatus.Generating:
status = PreviewStatus.Generating;
break;
case MapStatus.Unavailable:
if (mapUpdateAvailable)
status = PreviewStatus.UpdateAvailable;

View File

@@ -102,6 +102,23 @@ Container@MAP_PREVIEW:
Width: PARENT_WIDTH
Height: 25
Indeterminate: True
Container@MAP_GENERATING:
Width: PARENT_WIDTH
Height: PARENT_HEIGHT
Children:
Label@MAP_STATUS_GENERATING:
Y: 174
Width: PARENT_WIDTH
Height: 25
Font: Tiny
Align: Center
Text: label-map-generating-status
IgnoreMouseOver: true
ProgressBar@MAP_GENERATING_BAR:
Y: 194
Width: PARENT_WIDTH
Height: 25
Indeterminate: True
Container@MAP_DOWNLOAD_AVAILABLE:
Width: PARENT_WIDTH
Height: PARENT_HEIGHT

View File

@@ -389,6 +389,7 @@ button-force-start-dialog-start = Start
label-map-incompatible-status-a = This map is not compatible
label-map-incompatible-status-b = with this version of OpenRA
label-map-validating-status = Validating...
label-map-generating-status = Generating...
button-map-download-available-install = Install Map
button-map-preview-update = Update Map
button-map-update-download-available-install = Install Map

View File

@@ -102,6 +102,23 @@ Container@MAP_PREVIEW:
Width: PARENT_WIDTH
Height: 25
Indeterminate: True
Container@MAP_GENERATING:
Width: PARENT_WIDTH
Height: PARENT_HEIGHT
Children:
Label@MAP_STATUS_GENERATING:
Y: 174
Width: PARENT_WIDTH
Height: 25
Font: Tiny
Align: Center
Text: label-map-generating-status
IgnoreMouseOver: true
ProgressBar@MAP_GENERATING_BAR:
Y: 194
Width: PARENT_WIDTH
Height: 25
Indeterminate: True
Container@MAP_DOWNLOAD_AVAILABLE:
Width: PARENT_WIDTH
Height: PARENT_HEIGHT

View File

@@ -204,6 +204,7 @@ button-force-start-dialog-start = Start
label-map-incompatible-status-a = This map is not compatible
label-map-incompatible-status-b = with this version of OpenRA
label-map-validating-status = Validating...
label-map-generating-status = Generating...
button-map-download-available-install = Install Map
button-map-preview-update = Update Map
button-map-update-download-available-install = Install Map