From 979483b63cd8bde23da73774e7c29072715f8f54 Mon Sep 17 00:00:00 2001 From: Paul Chote Date: Thu, 17 Apr 2025 18:54:38 +0100 Subject: [PATCH] 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. --- OpenRA.Game/GameInformation.cs | 19 ++++++++- OpenRA.Game/Map/MapCache.cs | 40 +++++++++++++++++++ OpenRA.Game/Map/MapGenerationArgs.cs | 15 +++++++ OpenRA.Game/Map/MapPreview.cs | 37 +++++++++++++++-- OpenRA.Game/Network/GameSave.cs | 8 ++++ OpenRA.Game/Network/UnitOrders.cs | 7 ++++ OpenRA.Game/Server/Server.cs | 34 +++++++++++++++- OpenRA.Game/World.cs | 4 ++ .../ServerTraits/LobbyCommands.cs | 15 +++++++ .../Widgets/Logic/GameSaveBrowserLogic.cs | 11 ++++- .../Widgets/Logic/Lobby/MapPreviewLogic.cs | 6 +++ mods/cnc/chrome/lobby-mappreview.yaml | 17 ++++++++ mods/cnc/fluent/chrome.ftl | 1 + mods/common/chrome/lobby-mappreview.yaml | 17 ++++++++ mods/common/fluent/chrome.ftl | 1 + 15 files changed, 226 insertions(+), 6 deletions(-) diff --git a/OpenRA.Game/GameInformation.cs b/OpenRA.Game/GameInformation.cs index 7017fa3d7c..aee285be3a 100644 --- a/OpenRA.Game/GameInformation.cs +++ b/OpenRA.Game/GameInformation.cs @@ -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; /// Game start timestamp (when the recoding started). @@ -40,7 +42,22 @@ namespace OpenRA public IList Players { get; } public HashSet 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 HumanPlayers { get { return Players.Where(p => p.IsHuman); } } public bool IsSinglePlayer => HumanPlayers.Count() == 1; diff --git a/OpenRA.Game/Map/MapCache.cs b/OpenRA.Game/Map/MapCache.cs index 64703a9cbd..05905642ed 100644 --- a/OpenRA.Game/Map/MapCache.cs +++ b/OpenRA.Game/Map/MapCache.cs @@ -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() + .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 uids, Action mapDetailsReceived = null, Action mapQueryFailed = null) { diff --git a/OpenRA.Game/Map/MapGenerationArgs.cs b/OpenRA.Game/Map/MapGenerationArgs.cs index 93562694fe..0d6e6b1d39 100644 --- a/OpenRA.Game/Map/MapGenerationArgs.cs +++ b/OpenRA.Game/Map/MapGenerationArgs.cs @@ -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() + { + new("Uid", Uid), + new("Generator", Generator), + new("Tileset", Tileset), + new("Size", FieldSaver.FormatValue(Size)), + new("Settings", Settings), + new("Title", Title), + new("Author", Author) + }.WriteToString(); + } } } diff --git a/OpenRA.Game/Map/MapPreview.cs b/OpenRA.Game/Map/MapPreview.cs index f703283945..7aa37788f6 100644 --- a/OpenRA.Game/Map/MapPreview.cs +++ b/OpenRA.Game/Map/MapPreview.cs @@ -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(); diff --git a/OpenRA.Game/Network/GameSave.cs b/OpenRA.Game/Network/GameSave.cs index 633593d820..d4c58f940e 100644 --- a/OpenRA.Game/Network/GameSave.cs +++ b/OpenRA.Game/Network/GameSave.cs @@ -93,6 +93,7 @@ namespace OpenRA.Network public Dictionary Slots { get; private set; } public Dictionary SlotClients { get; private set; } public Dictionary 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); diff --git a/OpenRA.Game/Network/UnitOrders.cs b/OpenRA.Game/Network/UnitOrders.cs index a125e7e375..c8f7edc97e 100644 --- a/OpenRA.Game/Network/UnitOrders.cs +++ b/OpenRA.Game/Network/UnitOrders.cs @@ -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(yaml)); + break; + } + default: { if (world == null) diff --git a/OpenRA.Game/Server/Server.cs b/OpenRA.Game/Server/Server.cs index 6247d2a731..1e52bfdbad 100644 --- a/OpenRA.Game/Server/Server.cs +++ b/OpenRA.Game/Server/Server.cs @@ -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 TempBans = []; + public string GeneratedMapData; // Managed by LobbyCommands public MapPreview Map; @@ -577,6 +577,10 @@ namespace OpenRA.Server foreach (var t in serverTraits.WithInterface()) 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(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) diff --git a/OpenRA.Game/World.cs b/OpenRA.Game/World.cs index a159f5496b..11739801a0 100644 --- a/OpenRA.Game/World.cs +++ b/OpenRA.Game/World.cs @@ -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()); gameSettings = Game.Settings.Game; } diff --git a/OpenRA.Mods.Common/ServerTraits/LobbyCommands.cs b/OpenRA.Mods.Common/ServerTraits/LobbyCommands.cs index 4270ac5cf0..2e2191a0c6 100644 --- a/OpenRA.Mods.Common/ServerTraits/LobbyCommands.cs +++ b/OpenRA.Mods.Common/ServerTraits/LobbyCommands.cs @@ -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); diff --git a/OpenRA.Mods.Common/Widgets/Logic/GameSaveBrowserLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/GameSaveBrowserLogic.cs index bd860b42ba..b886e3fee6 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/GameSaveBrowserLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/GameSaveBrowserLogic.cs @@ -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; diff --git a/OpenRA.Mods.Common/Widgets/Logic/Lobby/MapPreviewLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/Lobby/MapPreviewLogic.cs index 6f6a7b52de..9583c02dff 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/Lobby/MapPreviewLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/Lobby/MapPreviewLogic.cs @@ -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; diff --git a/mods/cnc/chrome/lobby-mappreview.yaml b/mods/cnc/chrome/lobby-mappreview.yaml index 5768b052f1..35246ee17e 100644 --- a/mods/cnc/chrome/lobby-mappreview.yaml +++ b/mods/cnc/chrome/lobby-mappreview.yaml @@ -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 diff --git a/mods/cnc/fluent/chrome.ftl b/mods/cnc/fluent/chrome.ftl index adedae06e2..ffefefae2b 100644 --- a/mods/cnc/fluent/chrome.ftl +++ b/mods/cnc/fluent/chrome.ftl @@ -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 diff --git a/mods/common/chrome/lobby-mappreview.yaml b/mods/common/chrome/lobby-mappreview.yaml index 34be3de644..80dc30f092 100644 --- a/mods/common/chrome/lobby-mappreview.yaml +++ b/mods/common/chrome/lobby-mappreview.yaml @@ -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 diff --git a/mods/common/fluent/chrome.ftl b/mods/common/fluent/chrome.ftl index 9c1920f7c6..f1cc937a49 100644 --- a/mods/common/fluent/chrome.ftl +++ b/mods/common/fluent/chrome.ftl @@ -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