From e11fb2bb00314b1c3587a72d2b31d6fed9bf65cd Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sat, 1 Aug 2026 13:26:47 +0300 Subject: [PATCH 01/27] Add proof-of-concept initial MCP implementation --- src/TSMapEditor/AI/GameThreadDispatcher.cs | 65 ++++++++++ src/TSMapEditor/AI/MCPServer.cs | 77 +++++++++++ src/TSMapEditor/AI/MapFacade.cs | 129 +++++++++++++++++++ src/TSMapEditor/AI/MapTools.cs | 64 +++++++++ src/TSMapEditor/Mutations/MutationManager.cs | 5 + src/TSMapEditor/TSMapEditor.csproj | 3 +- src/TSMapEditor/UI/UIManager.cs | 38 ++++++ 7 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 src/TSMapEditor/AI/GameThreadDispatcher.cs create mode 100644 src/TSMapEditor/AI/MCPServer.cs create mode 100644 src/TSMapEditor/AI/MapFacade.cs create mode 100644 src/TSMapEditor/AI/MapTools.cs diff --git a/src/TSMapEditor/AI/GameThreadDispatcher.cs b/src/TSMapEditor/AI/GameThreadDispatcher.cs new file mode 100644 index 000000000..3ec623f37 --- /dev/null +++ b/src/TSMapEditor/AI/GameThreadDispatcher.cs @@ -0,0 +1,65 @@ +using Rampastring.Tools; +using Rampastring.XNAUI; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace TSMapEditor.AI; + +/// +/// Schedules operations from MCP request threads to run on the game's main thread. +/// +public sealed class GameThreadDispatcher +{ + public GameThreadDispatcher(WindowManager windowManager, CancellationToken shutdownCancellationToken) + { + this.windowManager = windowManager; + this.shutdownCancellationToken = shutdownCancellationToken; + } + + private readonly WindowManager windowManager; + private readonly CancellationToken shutdownCancellationToken; + + public async Task InvokeAsync(Func operation, CancellationToken cancellationToken = default) + { + Logger.Log("GameThreadDispatcher: Adding WindowManager callback."); + + ArgumentNullException.ThrowIfNull(operation); + + using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + shutdownCancellationToken); + CancellationToken linkedCancellationToken = linkedCancellationTokenSource.Token; + + var taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using CancellationTokenRegistration cancellationTokenRegistration = linkedCancellationToken.Register( + () => taskCompletionSource.TrySetCanceled(linkedCancellationToken)); + + try + { + windowManager.AddCallback(new Action(() => + { + if (linkedCancellationToken.IsCancellationRequested) + { + taskCompletionSource.TrySetCanceled(linkedCancellationToken); + return; + } + + try + { + taskCompletionSource.TrySetResult(operation()); + } + catch (Exception ex) + { + taskCompletionSource.TrySetException(ex); + } + })); + } + catch (Exception ex) + { + taskCompletionSource.TrySetException(ex); + } + + return await taskCompletionSource.Task.ConfigureAwait(false); + } +} diff --git a/src/TSMapEditor/AI/MCPServer.cs b/src/TSMapEditor/AI/MCPServer.cs new file mode 100644 index 000000000..a891247a6 --- /dev/null +++ b/src/TSMapEditor/AI/MCPServer.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Server; +using Rampastring.XNAUI; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace TSMapEditor.AI; + +public sealed class MCPServer : IDisposable +{ + public const string ServerUrl = "http://127.0.0.1:32123"; + public const string MCPPath = "/mcp"; + + public MCPServer(WindowManager windowManager, MapFacade mapFacade) + { + this.windowManager = windowManager; + this.mapFacade = mapFacade; + } + + private readonly WindowManager windowManager; + private readonly MapFacade mapFacade; + private readonly CancellationTokenSource shutdownCancellationTokenSource = new CancellationTokenSource(); + + private WebApplication application; + private bool disposed; + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(disposed, this); + + if (application != null) + return; + + var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions + { + Args = Array.Empty(), + ApplicationName = typeof(MCPServer).Assembly.FullName + }); + + builder.WebHost.UseUrls(ServerUrl); + builder.Configuration["AllowedHosts"] = "localhost;127.0.0.1;[::1]"; + + builder.Services.AddSingleton(mapFacade); + builder.Services.AddSingleton(new GameThreadDispatcher(windowManager, shutdownCancellationTokenSource.Token)); + builder.Services + .AddMcpServer() + .WithHttpTransport(options => options.Stateless = true) + .WithTools(); + + application = builder.Build(); + application.MapMcp(MCPPath); + + await application.StartAsync(cancellationToken); + } + + public void Dispose() + { + if (disposed) + return; + + disposed = true; + shutdownCancellationTokenSource.Cancel(); + + if (application != null) + { + application.StopAsync().GetAwaiter().GetResult(); + application.DisposeAsync().AsTask().GetAwaiter().GetResult(); + application = null; + } + + shutdownCancellationTokenSource.Dispose(); + } +} diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs new file mode 100644 index 000000000..95b43853b --- /dev/null +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -0,0 +1,129 @@ +using Microsoft.Xna.Framework; +using System.Collections.Generic; +using TSMapEditor.GameMath; +using TSMapEditor.Models; +using TSMapEditor.Mutations; +using TSMapEditor.Rendering; + +namespace TSMapEditor.AI; + +public class MapObjectInfo +{ + public string RTTI { get; } + public int X { get; } + public int Y { get; } + public string ININame { get; } + + public MapObjectInfo(string rtti, int x, int y, string iniName) + { + RTTI = rtti; + X = x; + Y = y; + ININame = iniName; + } +} + +public class MapOverlayInfo : MapObjectInfo +{ + public MapOverlayInfo(int x, int y, string iniName, int frameId) : base(RTTIType.Overlay.ToString(), x, y, iniName) + { + FrameID = frameId; + } + + public int FrameID { get; } +} + +public class CellInfo +{ + public CellInfo(int x, int y, string tileSetName, int tileIndexInTileSet, int height, MapObjectInfo terrainObjectInfo, MapOverlayInfo overlayInfo) + { + X = x; + Y = y; + TileSetName = tileSetName; + TileIndexInTileSet = tileIndexInTileSet; + Height = height; + TerrainObjectInfo = terrainObjectInfo; + OverlayInfo = overlayInfo; + } + + public int X { get; } + public int Y { get; } + public string TileSetName { get; } + public int TileIndexInTileSet { get; } + public int Height { get; } + public MapObjectInfo TerrainObjectInfo { get; } + public MapOverlayInfo OverlayInfo { get; } + + public static CellInfo FromMapCell(ITheater theater, MapTile mapTile) + { + int tileSetIndex = theater.GetTileSetId(mapTile.TileIndex); + var tileSet = theater.Theater.TileSets[tileSetIndex]; + + var terrainObjectInfo = mapTile.TerrainObject == null ? null : new MapObjectInfo(RTTIType.Terrain.ToString(), mapTile.TerrainObject.Position.X, mapTile.TerrainObject.Position.Y, mapTile.TerrainObject.TerrainType.ININame); + var overlayInfo = mapTile.Overlay == null ? null : new MapOverlayInfo(mapTile.Overlay.Position.X, mapTile.Overlay.Position.Y, mapTile.Overlay.OverlayType.ININame, mapTile.Overlay.FrameIndex); + + return new CellInfo(mapTile.X, mapTile.Y, tileSet.SetName, mapTile.TileIndex - tileSet.StartTileIndex, mapTile.Level, terrainObjectInfo, overlayInfo); + } +} + +public class MapInfo +{ + public MapInfo(string theaterName, int width, int height) + { + TheaterName = theaterName; + Width = width; + Height = height; + } + + public string TheaterName { get; } + public int Width { get; } + public int Height { get; } +} + +/// +/// Facade that performs operations on the map for the Model Context Protocol component. +/// +public class MapFacade +{ + public MapFacade(Map map, MutationManager mutationManager) + { + this.map = map; + this.mutationManager = mutationManager; + } + + private readonly Map map; + private readonly MutationManager mutationManager; + + public MapInfo GetMapInfo() + { + return new MapInfo(map.LoadedTheaterName, map.Size.X, map.Size.Y); + } + + public int GetMapRevision() + { + return mutationManager.Revision; + } + + public List InspectRegion(Rectangle rectangle) + { + var returnValue = new List(); + + for (int y = rectangle.Y; y < rectangle.Bottom; y++) + { + for (int x = rectangle.X; x < rectangle.Right; x++) + { + Point2D coords = new Point2D(x, y); + if (!map.IsCoordWithinMap(coords)) + { + continue; + } + + var mapCell = map.GetTile(coords); + + returnValue.Add(CellInfo.FromMapCell(map.TheaterInstance, mapCell)); + } + } + + return returnValue; + } +} diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs new file mode 100644 index 000000000..3c50484e6 --- /dev/null +++ b/src/TSMapEditor/AI/MapTools.cs @@ -0,0 +1,64 @@ +using Microsoft.Xna.Framework; +using ModelContextProtocol; +using ModelContextProtocol.Server; +using Rampastring.Tools; +using System.Collections.Generic; +using System.ComponentModel; +using System.Threading; +using System.Threading.Tasks; + +namespace TSMapEditor.AI; + +[McpServerToolType] +public sealed class MapTools +{ + private const int MaxRegionDimension = 256; + private const int MaxRegionCellCount = 10_000; + + public MapTools(MapFacade mapFacade, GameThreadDispatcher gameThreadDispatcher) + { + this.mapFacade = mapFacade; + this.gameThreadDispatcher = gameThreadDispatcher; + } + + private readonly MapFacade mapFacade; + private readonly GameThreadDispatcher gameThreadDispatcher; + + [McpServerTool(Name = "get_map_info", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns basic information about the map currently open in the World-Altering Editor.")] + public Task GetMapInfo(CancellationToken cancellationToken) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetMapInfo)}"); + return gameThreadDispatcher.InvokeAsync(mapFacade.GetMapInfo, cancellationToken); + } + + [McpServerTool(Name = "get_map_revision", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns the revision number of the map currently open in the World-Altering Editor.")] + public Task GetMapRevision(CancellationToken cancellationToken) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetMapRevision)}"); + return gameThreadDispatcher.InvokeAsync(mapFacade.GetMapRevision, cancellationToken); + } + + [McpServerTool(Name = "inspect_map_region", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns terrain, terrain objects, and overlays from a rectangular region of the open map.")] + public Task> InspectMapRegion( + [Description("X coordinate of the region's top-left cell.")] int x, + [Description("Y coordinate of the region's top-left cell.")] int y, + [Description("Width of the region in cells.")] int width, + [Description("Height of the region in cells.")] int height, + CancellationToken cancellationToken) + { + Logger.Log($"{nameof(MapTools)}.{nameof(InspectMapRegion)}"); + + if (width <= 0 || height <= 0) + throw new McpException("The region width and height must both be greater than zero."); + + if (width > MaxRegionDimension || height > MaxRegionDimension || (long)width * height > MaxRegionCellCount) + throw new McpException($"The requested region is too large. Each dimension may be at most {MaxRegionDimension} cells and the total area may be at most {MaxRegionCellCount} cells."); + + return gameThreadDispatcher.InvokeAsync( + () => mapFacade.InspectRegion(new Rectangle(x, y, width, height)), + cancellationToken); + } +} diff --git a/src/TSMapEditor/Mutations/MutationManager.cs b/src/TSMapEditor/Mutations/MutationManager.cs index 9a47c8ba5..8fd7da2c4 100644 --- a/src/TSMapEditor/Mutations/MutationManager.cs +++ b/src/TSMapEditor/Mutations/MutationManager.cs @@ -10,6 +10,8 @@ public class MutationManager public List UndoList { get; } = new List(); public List RedoList { get; } = new List(); + public int Revision { get; private set; } + /// /// Performs a new mutation on the map. /// @@ -19,6 +21,7 @@ public void PerformMutation(IMutation mutation) mutation.Perform(); RedoList.Clear(); UndoList.Add(mutation); + Revision++; } public bool CanUndo() => UndoList.Count > 0; @@ -63,6 +66,7 @@ public void UndoOne() UndoList[lastUndoIndex].Undo(); RedoList.Add(UndoList[lastUndoIndex]); UndoList.RemoveAt(lastUndoIndex); + Revision++; } public bool CanRedo() => RedoList.Count > 0; @@ -79,6 +83,7 @@ public void Redo() RedoList[lastRedoIndex].Perform(); UndoList.Add(RedoList[lastRedoIndex]); RedoList.RemoveAt(lastRedoIndex); + Revision++; } public void ClearUndoAndRedoLists() diff --git a/src/TSMapEditor/TSMapEditor.csproj b/src/TSMapEditor/TSMapEditor.csproj index 42c88f27d..636ce0f5c 100644 --- a/src/TSMapEditor/TSMapEditor.csproj +++ b/src/TSMapEditor/TSMapEditor.csproj @@ -486,10 +486,11 @@ + - + diff --git a/src/TSMapEditor/UI/UIManager.cs b/src/TSMapEditor/UI/UIManager.cs index 55d290ead..a6df874ee 100644 --- a/src/TSMapEditor/UI/UIManager.cs +++ b/src/TSMapEditor/UI/UIManager.cs @@ -4,6 +4,7 @@ using Rampastring.XNAUI.XNAControls; using System; using System.Linq; +using TSMapEditor.AI; using TSMapEditor.Misc; using TSMapEditor.Models; using TSMapEditor.Mutations; @@ -78,6 +79,7 @@ public UIManager(WindowManager windowManager, Map map, TheaterGraphics theaterGr private WindowController windowController; private MutationManager mutationManager; + private MCPServer mcpServer; private NotificationManager notificationManager; @@ -203,6 +205,39 @@ public override void Initialize() KeyboardCommands.Instance.ToggleFullscreen.Triggered += ToggleFullscreen_Triggered; SetInitialDisplayMode(); + StartMCPServer(); + } + + private void StartMCPServer() + { + try + { + mcpServer = new MCPServer(WindowManager, new MapFacade(map, mutationManager)); + mcpServer.StartAsync().GetAwaiter().GetResult(); + Logger.Log($"MCP server listening at {MCPServer.ServerUrl}{MCPServer.MCPPath}"); + } + catch (Exception ex) + { + Logger.Log("Failed to start the MCP server. Returned error: " + ex.Message); + StopMCPServer(); + } + } + + private void StopMCPServer() + { + if (mcpServer == null) + return; + + try + { + mcpServer.Dispose(); + } + catch (Exception ex) + { + Logger.Log("Failed to stop the MCP server cleanly. Returned error: " + ex.Message); + } + + mcpServer = null; } private void SetInitialDisplayMode() @@ -319,6 +354,7 @@ private void WindowManager_WindowSizeChangedByUser(object sender, EventArgs e) private void WindowManager_GameClosing(object sender, EventArgs e) { + StopMCPServer(); mapFileWatcher.StopWatching(); TranslatorSetup.DumpMissingValues(); } @@ -515,6 +551,8 @@ private void ClearResources() { // We need to free memory of everything that we've ever created + StopMCPServer(); + map.Rules.TutorialLines.ShutdownFSW(); windowController.OpenMapWindow.OnFileSelected -= OpenMapWindow_OnFileSelected; windowController.CreateNewMapWindow.OnCreateNewMap -= CreateNewMapWindow_OnCreateNewMap; From 92a5d70d9afd14171449b5da2eaecd56cfbb6a7f Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sat, 1 Aug 2026 14:06:59 +0300 Subject: [PATCH 02/27] Add techno information to MCP cell information --- src/TSMapEditor/AI/MapFacade.cs | 65 ++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index 95b43853b..eb1b6f90e 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -1,5 +1,6 @@ using Microsoft.Xna.Framework; using System.Collections.Generic; +using System.Linq; using TSMapEditor.GameMath; using TSMapEditor.Models; using TSMapEditor.Mutations; @@ -33,9 +34,61 @@ public MapOverlayInfo(int x, int y, string iniName, int frameId) : base(RTTIType public int FrameID { get; } } +public class MapTechnoInfo : MapObjectInfo +{ + public MapTechnoInfo(string rtti, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag) : base(rtti, x, y, iniName) + { + Owner = owner; + Facing = facing; + HP = hp; + AttachedTag = attachedTag; + } + + public int HP { get; } + public string AttachedTag { get; } + public string Owner { get; } + public byte Facing { get; } +} + +public class MapBuildingInfo : MapTechnoInfo +{ + public MapBuildingInfo(string rtti, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag, bool powered, bool aiRepairable) + : base(rtti, x, y, iniName, owner, facing, hp, attachedTag) + { + Powered = powered; + AIRepairable = aiRepairable; + } + + public bool Powered { get; } + public bool AIRepairable { get; } +} + +public class MapFootInfo : MapTechnoInfo +{ + public MapFootInfo(string rtti, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag, string mission, + bool onBridge, int veterancy, int group, bool autocreateNoRecruitable, bool autocreateYesRecruitable) + : base(rtti, x, y, iniName, owner, facing, hp, attachedTag) + { + Mission = mission; + OnBridge = onBridge; + Veterancy = veterancy; + Group = group; + AutocreateNoRecruitable = autocreateNoRecruitable; + AutocreateYesRecruitable = autocreateYesRecruitable; + } + + public string Mission { get; } + public bool OnBridge { get; } + public int Veterancy { get; } + public int Group { get; } + public bool AutocreateNoRecruitable { get; } + public bool AutocreateYesRecruitable { get; } +} + public class CellInfo { - public CellInfo(int x, int y, string tileSetName, int tileIndexInTileSet, int height, MapObjectInfo terrainObjectInfo, MapOverlayInfo overlayInfo) + public CellInfo(int x, int y, string tileSetName, int tileIndexInTileSet, int height, MapObjectInfo terrainObjectInfo, MapOverlayInfo overlayInfo, + List buildingInfos, List footInfos) { X = x; Y = y; @@ -44,6 +97,8 @@ public CellInfo(int x, int y, string tileSetName, int tileIndexInTileSet, int he Height = height; TerrainObjectInfo = terrainObjectInfo; OverlayInfo = overlayInfo; + BuildingInfos = buildingInfos; + FootInfos = footInfos; } public int X { get; } @@ -53,6 +108,8 @@ public CellInfo(int x, int y, string tileSetName, int tileIndexInTileSet, int he public int Height { get; } public MapObjectInfo TerrainObjectInfo { get; } public MapOverlayInfo OverlayInfo { get; } + public List BuildingInfos { get; } + public List FootInfos { get; } public static CellInfo FromMapCell(ITheater theater, MapTile mapTile) { @@ -61,8 +118,12 @@ public static CellInfo FromMapCell(ITheater theater, MapTile mapTile) var terrainObjectInfo = mapTile.TerrainObject == null ? null : new MapObjectInfo(RTTIType.Terrain.ToString(), mapTile.TerrainObject.Position.X, mapTile.TerrainObject.Position.Y, mapTile.TerrainObject.TerrainType.ININame); var overlayInfo = mapTile.Overlay == null ? null : new MapOverlayInfo(mapTile.Overlay.Position.X, mapTile.Overlay.Position.Y, mapTile.Overlay.OverlayType.ININame, mapTile.Overlay.FrameIndex); + var buildingInfos = mapTile.Structures.Select(s => new MapBuildingInfo(s.WhatAmI().ToString(), s.Position.X, s.Position.Y, s.ObjectType.ININame, s.Owner.ININame, s.Facing, s.HP, s.AttachedTag?.Name, s.Powered, s.AIRepairable)).ToList(); + var vehicleInfos = mapTile.Vehicles.Select(v => new MapFootInfo(v.WhatAmI().ToString(), v.Position.X, v.Position.Y, v.ObjectType.ININame, v.Owner.ININame, v.Facing, v.HP, v.AttachedTag?.Name, v.Mission, v.High, v.Veterancy, v.Group, v.AutocreateNoRecruitable, v.AutocreateYesRecruitable)); + var infantryInfos = mapTile.Infantry.Where(i => i != null).Select(i => new MapFootInfo(i.WhatAmI().ToString(), i.Position.X, i.Position.Y, i.ObjectType.ININame, i.Owner.ININame, i.Facing, i.HP, i.AttachedTag?.Name, i.Mission, i.High, i.Veterancy, i.Group, i.AutocreateNoRecruitable, i.AutocreateYesRecruitable)); + var aircraftInfos = mapTile.Aircraft.Select(a => new MapFootInfo(a.WhatAmI().ToString(), a.Position.X, a.Position.Y, a.ObjectType.ININame, a.Owner.ININame, a.Facing, a.HP, a.AttachedTag?.Name, a.Mission, a.High, a.Veterancy, a.Group, a.AutocreateNoRecruitable, a.AutocreateYesRecruitable)); - return new CellInfo(mapTile.X, mapTile.Y, tileSet.SetName, mapTile.TileIndex - tileSet.StartTileIndex, mapTile.Level, terrainObjectInfo, overlayInfo); + return new CellInfo(mapTile.X, mapTile.Y, tileSet.SetName, mapTile.TileIndex - tileSet.StartTileIndex, mapTile.Level, terrainObjectInfo, overlayInfo, buildingInfos, vehicleInfos.Concat(infantryInfos).Concat(aircraftInfos).ToList()); } } From 9f939290f41faf3f4db8fd995ea03766f7db8860 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sat, 1 Aug 2026 14:07:20 +0300 Subject: [PATCH 03/27] Shutdown MCP server on separate thread --- src/TSMapEditor/AI/MCPServer.cs | 45 +++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/src/TSMapEditor/AI/MCPServer.cs b/src/TSMapEditor/AI/MCPServer.cs index a891247a6..91fd3634c 100644 --- a/src/TSMapEditor/AI/MCPServer.cs +++ b/src/TSMapEditor/AI/MCPServer.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Server; +using Rampastring.Tools; using Rampastring.XNAUI; using System; using System.Threading; @@ -15,6 +16,8 @@ public sealed class MCPServer : IDisposable public const string ServerUrl = "http://127.0.0.1:32123"; public const string MCPPath = "/mcp"; + private static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(5.0); + public MCPServer(WindowManager windowManager, MapFacade mapFacade) { this.windowManager = windowManager; @@ -65,13 +68,45 @@ public void Dispose() disposed = true; shutdownCancellationTokenSource.Cancel(); - if (application != null) + WebApplication applicationToDispose = application; + application = null; + + if (applicationToDispose == null) + { + shutdownCancellationTokenSource.Dispose(); + return; + } + + _ = Task.Run(() => StopAndDisposeAsync(applicationToDispose)); + } + + private async Task StopAndDisposeAsync(WebApplication applicationToDispose) + { + try { - application.StopAsync().GetAwaiter().GetResult(); - application.DisposeAsync().AsTask().GetAwaiter().GetResult(); - application = null; + using var timeoutCancellationTokenSource = new CancellationTokenSource(ShutdownTimeout); + await applicationToDispose.StopAsync(timeoutCancellationTokenSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + Logger.Log($"MCP server did not stop gracefully within {ShutdownTimeout.TotalSeconds} seconds."); + } + catch (Exception ex) + { + Logger.Log("Failed to stop the MCP server cleanly. Returned error: " + ex.Message); } - shutdownCancellationTokenSource.Dispose(); + try + { + await applicationToDispose.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Log("Failed to dispose the MCP server cleanly. Returned error: " + ex.Message); + } + finally + { + shutdownCancellationTokenSource.Dispose(); + } } } From 4898fc711e64545756decc3ba4a3198710c57fd4 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sat, 1 Aug 2026 14:49:57 +0300 Subject: [PATCH 04/27] Add terrain object placement capabilities to MCP --- src/TSMapEditor/AI/MapFacade.cs | 107 +++++++++++++++++++++++++++++++- src/TSMapEditor/AI/MapTools.cs | 32 ++++++++++ src/TSMapEditor/UI/UIManager.cs | 2 +- 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index eb1b6f90e..d82293fb6 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -1,10 +1,13 @@ using Microsoft.Xna.Framework; +using System; using System.Collections.Generic; using System.Linq; using TSMapEditor.GameMath; using TSMapEditor.Models; using TSMapEditor.Mutations; +using TSMapEditor.Mutations.Classes; using TSMapEditor.Rendering; +using TSMapEditor.UI; namespace TSMapEditor.AI; @@ -141,19 +144,54 @@ public MapInfo(string theaterName, int width, int height) public int Height { get; } } +public class MapObjectTypeInfo +{ + public MapObjectTypeInfo(string iniName, string uiName, string editorCategory) + { + ININame = iniName; + UIName = uiName; + EditorCategory = editorCategory; + } + + public string ININame { get; } + public string UIName { get; } + public string EditorCategory { get; } +} + +public class MapEditResult +{ + public MapEditResult(int revision, List affectedCells) + { + Revision = revision; + AffectedCells = affectedCells; + } + + public int Revision { get; } + public List AffectedCells { get; } +} + +public sealed class MapFacadeValidationException : Exception +{ + public MapFacadeValidationException(string message) : base(message) + { + } +} + /// /// Facade that performs operations on the map for the Model Context Protocol component. /// public class MapFacade { - public MapFacade(Map map, MutationManager mutationManager) + public MapFacade(Map map, MutationManager mutationManager, IMutationTarget mutationTarget) { this.map = map; this.mutationManager = mutationManager; + this.mutationTarget = mutationTarget; } private readonly Map map; private readonly MutationManager mutationManager; + private readonly IMutationTarget mutationTarget; public MapInfo GetMapInfo() { @@ -165,6 +203,26 @@ public int GetMapRevision() return mutationManager.Revision; } + public List GetTerrainTypes(string nameFilter = null) + { + string normalizedFilter = nameFilter?.Trim(); + + return map.Rules.TerrainTypes + .Where(terrainType => terrainType.EditorVisible && terrainType.IsValidForTheater(map.LoadedTheaterName)) + .Select(terrainType => new MapObjectTypeInfo( + terrainType.ININame, + terrainType.GetEditorDisplayName(), + terrainType.EditorCategory)) + .Where(typeInfo => string.IsNullOrWhiteSpace(normalizedFilter) || + ContainsIgnoringCase(typeInfo.ININame, normalizedFilter) || + ContainsIgnoringCase(typeInfo.UIName, normalizedFilter) || + ContainsIgnoringCase(typeInfo.EditorCategory, normalizedFilter)) + .OrderBy(typeInfo => typeInfo.EditorCategory) + .ThenBy(typeInfo => typeInfo.UIName) + .ThenBy(typeInfo => typeInfo.ININame) + .ToList(); + } + public List InspectRegion(Rectangle rectangle) { var returnValue = new List(); @@ -175,11 +233,11 @@ public List InspectRegion(Rectangle rectangle) { Point2D coords = new Point2D(x, y); if (!map.IsCoordWithinMap(coords)) - { continue; - } var mapCell = map.GetTile(coords); + if (mapCell == null) + continue; returnValue.Add(CellInfo.FromMapCell(map.TheaterInstance, mapCell)); } @@ -187,4 +245,47 @@ public List InspectRegion(Rectangle rectangle) return returnValue; } + + public MapEditResult PlaceTerrainObject(string terrainTypeName, int x, int y) + { + if (string.IsNullOrWhiteSpace(terrainTypeName)) + throw new MapFacadeValidationException("A terrain object type INI name must be provided."); + + var cellCoords = new Point2D(x, y); + if (!map.IsCoordWithinMap(cellCoords)) + throw new MapFacadeValidationException($"Cell ({x}, {y}) is outside the map."); + + var mapTile = map.GetTile(cellCoords); + if (mapTile == null) + throw new MapFacadeValidationException($"Cell ({x}, {y}) is outside the map."); + + var terrainType = map.Rules.TerrainTypes.Find( + tt => string.Equals(tt.ININame, terrainTypeName, StringComparison.OrdinalIgnoreCase)); + if (terrainType == null) + throw new MapFacadeValidationException($"Terrain object type '{terrainTypeName}' does not exist in the loaded rules."); + + if (!terrainType.EditorVisible) + throw new MapFacadeValidationException($"Terrain object type '{terrainType.ININame}' is not available for placement in the editor."); + + if (!terrainType.IsValidForTheater(map.LoadedTheaterName)) + throw new MapFacadeValidationException($"Terrain object type '{terrainType.ININame}' is not valid for theater '{map.LoadedTheaterName}'."); + + if (mapTile.TerrainObject != null) + throw new MapFacadeValidationException($"Cell ({x}, {y}) already contains terrain object '{mapTile.TerrainObject.TerrainType.ININame}'."); + + var mutation = new PlaceTerrainObjectMutation(mutationTarget, terrainType, cellCoords); + if (!mutation.ShouldPerform()) + throw new MapFacadeValidationException($"Terrain object '{terrainType.ININame}' cannot be placed at ({x}, {y})."); + + mutationManager.PerformMutation(mutation); + + return new MapEditResult( + mutationManager.Revision, + new List { CellInfo.FromMapCell(map.TheaterInstance, mapTile) }); + } + + private static bool ContainsIgnoringCase(string value, string searchValue) + { + return value?.IndexOf(searchValue, StringComparison.OrdinalIgnoreCase) >= 0; + } } diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index 3c50484e6..8fc45c1d6 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -40,6 +40,16 @@ public Task GetMapRevision(CancellationToken cancellationToken) return gameThreadDispatcher.InvokeAsync(mapFacade.GetMapRevision, cancellationToken); } + [McpServerTool(Name = "get_terrain_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns terrain object types that are visible in the editor and valid for the current map's theater.")] + public Task> GetTerrainTypes( + [Description("Optional case-insensitive filter matched against INI name, UI name, and editor category.")] string nameFilter = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetTerrainTypes)}"); + return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetTerrainTypes(nameFilter), cancellationToken); + } + [McpServerTool(Name = "inspect_map_region", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] [Description("Returns terrain, terrain objects, and overlays from a rectangular region of the open map.")] public Task> InspectMapRegion( @@ -61,4 +71,26 @@ public Task> InspectMapRegion( () => mapFacade.InspectRegion(new Rectangle(x, y, width, height)), cancellationToken); } + + [McpServerTool(Name = "place_terrain_object", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Places one terrain object, such as a tree, on an empty map cell. The placement is added to the editor's undo history.")] + public async Task PlaceTerrainObject( + [Description("INI name of the terrain object type to place.")] string terrainTypeName, + [Description("X coordinate of the destination cell.")] int x, + [Description("Y coordinate of the destination cell.")] int y, + CancellationToken cancellationToken) + { + Logger.Log($"{nameof(MapTools)}.{nameof(PlaceTerrainObject)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.PlaceTerrainObject(terrainTypeName, x, y), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } } diff --git a/src/TSMapEditor/UI/UIManager.cs b/src/TSMapEditor/UI/UIManager.cs index a6df874ee..54c2d3e8d 100644 --- a/src/TSMapEditor/UI/UIManager.cs +++ b/src/TSMapEditor/UI/UIManager.cs @@ -212,7 +212,7 @@ private void StartMCPServer() { try { - mcpServer = new MCPServer(WindowManager, new MapFacade(map, mutationManager)); + mcpServer = new MCPServer(WindowManager, new MapFacade(map, mutationManager, mapUI.MutationTarget)); mcpServer.StartAsync().GetAwaiter().GetResult(); Logger.Log($"MCP server listening at {MCPServer.ServerUrl}{MCPServer.MCPPath}"); } From 7767acc998959f637cda19aeac55dba5ee152351 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sat, 1 Aug 2026 15:19:49 +0300 Subject: [PATCH 05/27] Implement terrain placement through MCP server --- src/TSMapEditor/AI/MapFacade.cs | 187 +++++++++++++++++- src/TSMapEditor/AI/MapTools.cs | 59 ++++++ .../AIMutations/SetCellTerrainMutation.cs | 63 ++++++ .../Classes/PlaceTerrainTileMutation.cs | 20 +- src/TSMapEditor/Mutations/Mutation.cs | 2 +- 5 files changed, 321 insertions(+), 10 deletions(-) create mode 100644 src/TSMapEditor/Mutations/Classes/AIMutations/SetCellTerrainMutation.cs diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index d82293fb6..ca0293a4e 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -2,10 +2,13 @@ using System; using System.Collections.Generic; using System.Linq; +using TSMapEditor.CCEngine; +using TSMapEditor.CCEngine.TileData; using TSMapEditor.GameMath; using TSMapEditor.Models; using TSMapEditor.Mutations; using TSMapEditor.Mutations.Classes; +using TSMapEditor.Mutations.Classes.AIMutations; using TSMapEditor.Rendering; using TSMapEditor.UI; @@ -90,13 +93,15 @@ public MapFootInfo(string rtti, int x, int y, string iniName, string owner, byte public class CellInfo { - public CellInfo(int x, int y, string tileSetName, int tileIndexInTileSet, int height, MapObjectInfo terrainObjectInfo, MapOverlayInfo overlayInfo, - List buildingInfos, List footInfos) + public CellInfo(int x, int y, string tileSetName, int tileIndex, int tileIndexInTileSet, int subTileIndex, int height, + MapObjectInfo terrainObjectInfo, MapOverlayInfo overlayInfo, List buildingInfos, List footInfos) { X = x; Y = y; TileSetName = tileSetName; + TileIndex = tileIndex; TileIndexInTileSet = tileIndexInTileSet; + SubTileIndex = subTileIndex; Height = height; TerrainObjectInfo = terrainObjectInfo; OverlayInfo = overlayInfo; @@ -107,7 +112,9 @@ public CellInfo(int x, int y, string tileSetName, int tileIndexInTileSet, int he public int X { get; } public int Y { get; } public string TileSetName { get; } + public int TileIndex { get; } public int TileIndexInTileSet { get; } + public int SubTileIndex { get; } public int Height { get; } public MapObjectInfo TerrainObjectInfo { get; } public MapOverlayInfo OverlayInfo { get; } @@ -126,7 +133,9 @@ public static CellInfo FromMapCell(ITheater theater, MapTile mapTile) var infantryInfos = mapTile.Infantry.Where(i => i != null).Select(i => new MapFootInfo(i.WhatAmI().ToString(), i.Position.X, i.Position.Y, i.ObjectType.ININame, i.Owner.ININame, i.Facing, i.HP, i.AttachedTag?.Name, i.Mission, i.High, i.Veterancy, i.Group, i.AutocreateNoRecruitable, i.AutocreateYesRecruitable)); var aircraftInfos = mapTile.Aircraft.Select(a => new MapFootInfo(a.WhatAmI().ToString(), a.Position.X, a.Position.Y, a.ObjectType.ININame, a.Owner.ININame, a.Facing, a.HP, a.AttachedTag?.Name, a.Mission, a.High, a.Veterancy, a.Group, a.AutocreateNoRecruitable, a.AutocreateYesRecruitable)); - return new CellInfo(mapTile.X, mapTile.Y, tileSet.SetName, mapTile.TileIndex - tileSet.StartTileIndex, mapTile.Level, terrainObjectInfo, overlayInfo, buildingInfos, vehicleInfos.Concat(infantryInfos).Concat(aircraftInfos).ToList()); + return new CellInfo(mapTile.X, mapTile.Y, tileSet.SetName, mapTile.TileIndex, mapTile.TileIndex - tileSet.StartTileIndex, + mapTile.SubTileIndex, mapTile.Level, terrainObjectInfo, overlayInfo, buildingInfos, + vehicleInfos.Concat(infantryInfos).Concat(aircraftInfos).ToList()); } } @@ -158,6 +167,26 @@ public MapObjectTypeInfo(string iniName, string uiName, string editorCategory) public string EditorCategory { get; } } +public class MapTileSetInfo +{ + public MapTileSetInfo(int index, string setName, string uiName, int startTileIndex, int tileCount, bool only1x1) + { + Index = index; + SetName = setName; + UIName = uiName; + StartTileIndex = startTileIndex; + TileCount = tileCount; + Only1x1 = only1x1; + } + + public int Index { get; } + public string SetName { get; } + public string UIName { get; } + public int StartTileIndex { get; } + public int TileCount { get; } + public bool Only1x1 { get; } +} + public class MapEditResult { public MapEditResult(int revision, List affectedCells) @@ -223,6 +252,27 @@ public List GetTerrainTypes(string nameFilter = null) .ToList(); } + public List GetTileSets(string nameFilter = null) + { + string normalizedFilter = nameFilter?.Trim(); + + return map.TheaterInstance.Theater.TileSets + .Where(IsTileSetPlaceable) + .Select(tileSet => new MapTileSetInfo( + tileSet.Index, + tileSet.SetName, + tileSet.TranslatedName, + tileSet.StartTileIndex, + tileSet.LoadedTileCount, + tileSet.Only1x1)) + .Where(tileSetInfo => string.IsNullOrWhiteSpace(normalizedFilter) || + ContainsIgnoringCase(tileSetInfo.SetName, normalizedFilter) || + ContainsIgnoringCase(tileSetInfo.UIName, normalizedFilter)) + .OrderBy(tileSetInfo => tileSetInfo.UIName) + .ThenBy(tileSetInfo => tileSetInfo.SetName) + .ToList(); + } + public List InspectRegion(Rectangle rectangle) { var returnValue = new List(); @@ -284,6 +334,137 @@ public MapEditResult PlaceTerrainObject(string terrainTypeName, int x, int y) new List { CellInfo.FromMapCell(map.TheaterInstance, mapTile) }); } + public MapEditResult PlaceTerrainTile(string tileSetName, int tileIndexInTileSet, int x, int y, + int brushWidth, int brushHeight, bool autoLAT) + { + if (string.IsNullOrWhiteSpace(tileSetName)) + throw new MapFacadeValidationException("A tile set name must be provided."); + + var tileSet = map.TheaterInstance.Theater.TileSets.Find(ts => ts.AllowToPlace && string.Equals(ts.SetName, tileSetName, StringComparison.OrdinalIgnoreCase)); + if (tileSet == null) + throw new MapFacadeValidationException($"Tile set '{tileSetName}' does not exist in the loaded theater."); + + if (!IsTileSetPlaceable(tileSet)) + throw new MapFacadeValidationException($"Tile set '{tileSet.SetName}' is not available for placement in the editor."); + + if (tileIndexInTileSet < 0 || tileIndexInTileSet >= tileSet.LoadedTileCount) + { + throw new MapFacadeValidationException( + $"Tile index {tileIndexInTileSet} is outside tile set '{tileSet.SetName}', which contains {tileSet.LoadedTileCount} tiles."); + } + + var brushSize = map.EditorConfig.BrushSizes.Find(bs => bs.Width == brushWidth && bs.Height == brushHeight); + if (brushSize == null) + throw new MapFacadeValidationException($"Brush size {brushWidth}x{brushHeight} is not configured in the editor."); + + if (tileSet.Only1x1 && (brushSize.Width != 1 || brushSize.Height != 1)) + throw new MapFacadeValidationException($"Tile set '{tileSet.SetName}' only supports a 1x1 brush."); + + int tileIndex = tileSet.StartTileIndex + tileIndexInTileSet; + if (tileIndex < 0 || tileIndex >= mutationTarget.TheaterGraphics.TileCount) + throw new MapFacadeValidationException($"Absolute tile index {tileIndex} is not loaded."); + + ITileImage tile = map.TheaterInstance.GetTile(tileIndex); + if (tile == null || tile.Width <= 0 || tile.Height <= 0 || tile.SubTileCount <= 0) + { + throw new MapFacadeValidationException( + $"Tile {tileIndexInTileSet} from tile set '{tileSet.SetName}' has no usable tile graphics."); + } + + var cellCoords = new Point2D(x, y); + ValidateTerrainTileFootprint(tile, cellCoords, brushSize); + + var mutation = new PlaceTerrainTileMutation( + mutationTarget, + cellCoords, + tile, + 0, + brushSize, + autoLAT, + false); + + if (!mutation.ShouldPerform()) + { + throw new MapFacadeValidationException( + $"Tile {tileIndexInTileSet} from tile set '{tileSet.SetName}' cannot be placed at ({x}, {y})."); + } + + mutationManager.PerformMutation(mutation); + + int footprintWidth = tile.Width * brushSize.Width; + int footprintHeight = tile.Height * brushSize.Height; + var affectedArea = autoLAT + ? new Rectangle(x - 1, y - 1, footprintWidth + 3, footprintHeight + 3) + : new Rectangle(x, y, footprintWidth, footprintHeight); + + return new MapEditResult(mutationManager.Revision, InspectRegion(affectedArea)); + } + + public MapEditResult SetCellTerrain(int x, int y, int tileIndex, int subTileIndex) + { + var cellCoords = new Point2D(x, y); + var mapTile = map.IsCoordWithinMap(cellCoords) ? map.GetTile(cellCoords) : null; + if (mapTile == null) + throw new MapFacadeValidationException($"Cell ({x}, {y}) is outside the map."); + + if (tileIndex < 0 || tileIndex >= mutationTarget.TheaterGraphics.TileCount) + throw new MapFacadeValidationException($"Absolute tile index {tileIndex} is not loaded."); + + TileImage tile = mutationTarget.TheaterGraphics.GetTileImage(tileIndex); + if (tile == null || tile.SubTileCount <= 0) + throw new MapFacadeValidationException($"Absolute tile index {tileIndex} has no usable tile graphics."); + + if (subTileIndex < 0 || subTileIndex >= tile.SubTileCount || subTileIndex > byte.MaxValue || tile.GetSubTile(subTileIndex) == null) + { + throw new MapFacadeValidationException( + $"Sub-tile index {subTileIndex} is not valid for absolute tile index {tileIndex}."); + } + + var mutation = new SetCellTerrainMutation(mutationTarget, cellCoords, tileIndex, (byte)subTileIndex); + if (!mutation.ShouldPerform()) + { + throw new MapFacadeValidationException( + $"Cell ({x}, {y}) already uses absolute tile index {tileIndex} and sub-tile index {subTileIndex}."); + } + + mutationManager.PerformMutation(mutation); + + return new MapEditResult( + mutationManager.Revision, + new List { CellInfo.FromMapCell(map.TheaterInstance, mapTile) }); + } + + private void ValidateTerrainTileFootprint(ITileImage tile, Point2D cellCoords, BrushSize brushSize) + { + for (int brushY = 0; brushY < brushSize.Height; brushY++) + { + for (int brushX = 0; brushX < brushSize.Width; brushX++) + { + for (int subTileIndex = 0; subTileIndex < tile.SubTileCount; subTileIndex++) + { + Point2D? subTileOffset = tile.GetSubTileCoordOffset(subTileIndex); + if (subTileOffset == null) + continue; + + var targetCoords = cellCoords + + new Point2D(brushX * tile.Width, brushY * tile.Height) + + subTileOffset.Value; + + if (!map.IsCoordWithinMap(targetCoords) || map.GetTile(targetCoords) == null) + { + throw new MapFacadeValidationException( + $"The terrain placement footprint extends outside the map at ({targetCoords.X}, {targetCoords.Y})."); + } + } + } + } + } + + private static bool IsTileSetPlaceable(TileSet tileSet) + { + return tileSet.AllowToPlace && tileSet.LoadedTileCount > 0 && tileSet.NonMarbleMadness < 0; + } + private static bool ContainsIgnoringCase(string value, string searchValue) { return value?.IndexOf(searchValue, StringComparison.OrdinalIgnoreCase) >= 0; diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index 8fc45c1d6..5daa68b9a 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -50,6 +50,16 @@ public Task> GetTerrainTypes( return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetTerrainTypes(nameFilter), cancellationToken); } + [McpServerTool(Name = "get_tile_sets", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns tile sets that are available for placement in the current map's theater.")] + public Task> GetTileSets( + [Description("Optional case-insensitive filter matched against tile set name and UI name.")] string nameFilter = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetTileSets)}"); + return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetTileSets(nameFilter), cancellationToken); + } + [McpServerTool(Name = "inspect_map_region", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] [Description("Returns terrain, terrain objects, and overlays from a rectangular region of the open map.")] public Task> InspectMapRegion( @@ -93,4 +103,53 @@ public async Task PlaceTerrainObject( throw new McpException(ex.Message); } } + + [McpServerTool(Name = "place_terrain_tile", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Places a full terrain tile by tile set and tile-set-relative index. Supports configured brush sizes and optional AutoLAT. Existing terrain in the footprint may be replaced, and the edit is added to undo history.")] + public async Task PlaceTerrainTile( + [Description("Internal name of the tile set returned by get_tile_sets.")] string tileSetName, + [Description("Zero-based tile index relative to the start of the tile set.")] int tileIndexInTileSet, + [Description("X coordinate of the placement's top-left cell.")] int x, + [Description("Y coordinate of the placement's top-left cell.")] int y, + [Description("Width of the configured brush in repeated full tiles. Defaults to 1.")] int brushWidth = 1, + [Description("Height of the configured brush in repeated full tiles. Defaults to 1.")] int brushHeight = 1, + [Description("Whether to automatically create LAT transitions around the placed terrain. Defaults to true.")] bool autoLAT = true, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(PlaceTerrainTile)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.PlaceTerrainTile(tileSetName, tileIndexInTileSet, x, y, brushWidth, brushHeight, autoLAT), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + + [McpServerTool(Name = "set_cell_terrain", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Directly sets the absolute tile index and sub-tile index of one cell. This is a low-level operation that does not apply a brush or AutoLAT, and it is added to undo history.")] + public async Task SetCellTerrain( + [Description("X coordinate of the cell.")] int x, + [Description("Y coordinate of the cell.")] int y, + [Description("Absolute tile index in the loaded theater.")] int tileIndex, + [Description("Sub-tile index within the selected full tile.")] int subTileIndex, + CancellationToken cancellationToken) + { + Logger.Log($"{nameof(MapTools)}.{nameof(SetCellTerrain)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.SetCellTerrain(x, y, tileIndex, subTileIndex), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } } diff --git a/src/TSMapEditor/Mutations/Classes/AIMutations/SetCellTerrainMutation.cs b/src/TSMapEditor/Mutations/Classes/AIMutations/SetCellTerrainMutation.cs new file mode 100644 index 000000000..5a8b3f60b --- /dev/null +++ b/src/TSMapEditor/Mutations/Classes/AIMutations/SetCellTerrainMutation.cs @@ -0,0 +1,63 @@ +using System; +using TSMapEditor.GameMath; +using TSMapEditor.UI; + +namespace TSMapEditor.Mutations.Classes.AIMutations +{ + /// + /// Directly changes the absolute tile and sub-tile index of one map cell. + /// + public sealed class SetCellTerrainMutation : Mutation, ICheckableMutation + { + public SetCellTerrainMutation(IMutationTarget mutationTarget, Point2D cellCoords, int tileIndex, byte subTileIndex) + : base(mutationTarget) + { + this.cellCoords = cellCoords; + this.tileIndex = tileIndex; + this.subTileIndex = subTileIndex; + } + + private readonly Point2D cellCoords; + private readonly int tileIndex; + private readonly byte subTileIndex; + + private int originalTileIndex; + private byte originalSubTileIndex; + + public bool ShouldPerform() + { + var mapTile = Map.GetTile(cellCoords); + return mapTile != null && (mapTile.TileIndex != tileIndex || mapTile.SubTileIndex != subTileIndex); + } + + public override string GetDisplayString() + { + return $"Set terrain at {cellCoords} to tile {tileIndex}, sub-tile {subTileIndex}"; + } + + public override void Perform() + { + var mapTile = Map.GetTile(cellCoords); + if (mapTile == null) + throw new InvalidOperationException($"Cell {cellCoords} does not exist."); + + originalTileIndex = mapTile.TileIndex; + originalSubTileIndex = mapTile.SubTileIndex; + + mapTile.ChangeTileIndex(tileIndex, subTileIndex); + RefreshCellLighting(mapTile); + MutationTarget.AddRefreshPoint(cellCoords); + } + + public override void Undo() + { + var mapTile = Map.GetTile(cellCoords); + if (mapTile == null) + throw new InvalidOperationException($"Cell {cellCoords} does not exist."); + + mapTile.ChangeTileIndex(originalTileIndex, originalSubTileIndex); + RefreshCellLighting(mapTile); + MutationTarget.AddRefreshPoint(cellCoords); + } + } +} diff --git a/src/TSMapEditor/Mutations/Classes/PlaceTerrainTileMutation.cs b/src/TSMapEditor/Mutations/Classes/PlaceTerrainTileMutation.cs index 3a2f62c79..8ae9af646 100644 --- a/src/TSMapEditor/Mutations/Classes/PlaceTerrainTileMutation.cs +++ b/src/TSMapEditor/Mutations/Classes/PlaceTerrainTileMutation.cs @@ -12,19 +12,27 @@ namespace TSMapEditor.Mutations.Classes /// public class PlaceTerrainTileMutation : Mutation, ICheckableMutation { - public PlaceTerrainTileMutation(IMutationTarget mutationTarget, Point2D targetCellCoords, TileImage tile, int heightOffset) : base(mutationTarget) + public PlaceTerrainTileMutation(IMutationTarget mutationTarget, Point2D targetCellCoords, ITileImage tile, int heightOffset) + : this(mutationTarget, targetCellCoords, tile, heightOffset, mutationTarget.BrushSize, mutationTarget.AutoLATEnabled, mutationTarget.OnlyPaintOnClearGround) + { + } + + public PlaceTerrainTileMutation(IMutationTarget mutationTarget, Point2D targetCellCoords, ITileImage tile, int heightOffset, + BrushSize brushSize, bool autoLATEnabled, bool onlyPaintOnClearGround) : base(mutationTarget) { TargetCellCoords = targetCellCoords; Tile = tile; HeightOffset = heightOffset; - BrushSize = mutationTarget.BrushSize; - OnlyPaintOnClearGround = mutationTarget.OnlyPaintOnClearGround; + BrushSize = brushSize; + AutoLATEnabled = autoLATEnabled; + OnlyPaintOnClearGround = onlyPaintOnClearGround; } public Point2D TargetCellCoords { get; } - public TileImage Tile { get; } + public ITileImage Tile { get; } public int HeightOffset { get; } public BrushSize BrushSize { get; } + public bool AutoLATEnabled { get; } public bool OnlyPaintOnClearGround { get; } private List undoData; @@ -68,7 +76,7 @@ public override void Perform() int totalHeight = Tile.Height * BrushSize.Height; // Get un-do data - DoForArea(AddUndoDataForTile, MutationTarget.AutoLATEnabled); + DoForArea(AddUndoDataForTile, AutoLATEnabled); MapTile originCell = MutationTarget.Map.GetTile(TargetCellCoords); int originLevel = -1; @@ -128,7 +136,7 @@ public override void Perform() }); // Apply autoLAT if necessary - if (MutationTarget.AutoLATEnabled) + if (AutoLATEnabled) { ApplyAutoLATForTilePlacement(Tile, BrushSize, TargetCellCoords); } diff --git a/src/TSMapEditor/Mutations/Mutation.cs b/src/TSMapEditor/Mutations/Mutation.cs index 44e7d2472..a710222eb 100644 --- a/src/TSMapEditor/Mutations/Mutation.cs +++ b/src/TSMapEditor/Mutations/Mutation.cs @@ -218,7 +218,7 @@ public static (TileSet baseTileSet, TileSet altBaseTileSet) GetBaseTileSetsForTi return (baseTileSet, altBaseTileSet); } - protected void ApplyAutoLATForTilePlacement(TileImage tile, BrushSize brushSize, Point2D targetCellCoords) + protected void ApplyAutoLATForTilePlacement(ITileImage tile, BrushSize brushSize, Point2D targetCellCoords) { // Get potential base tilesets of the placed LAT (if we're placing LAT) // This allows placing certain LATs on top of other LATs (example: snowy dirt on snow, when snow is also placed on grass) From 7ecb937ea584acfbe541fe879b6edec90721049a Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sat, 1 Aug 2026 16:56:38 +0300 Subject: [PATCH 06/27] Make it possible to place buildings through the MCP --- src/TSMapEditor/AI/MapFacade.cs | 150 ++++++++++++++++++ src/TSMapEditor/AI/MapTools.cs | 46 +++++- .../Classes/PlaceBuildingMutation.cs | 12 +- 3 files changed, 205 insertions(+), 3 deletions(-) diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index ca0293a4e..dbbc802ba 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -167,6 +167,36 @@ public MapObjectTypeInfo(string iniName, string uiName, string editorCategory) public string EditorCategory { get; } } +public class MapBuildingTypeInfo : MapObjectTypeInfo +{ + public MapBuildingTypeInfo(string iniName, string uiName, string editorCategory, int foundationWidth, + int foundationHeight, int foundationCellCount) + : base(iniName, uiName, editorCategory) + { + FoundationWidth = foundationWidth; + FoundationHeight = foundationHeight; + FoundationCellCount = foundationCellCount; + } + + public int FoundationWidth { get; } + public int FoundationHeight { get; } + public int FoundationCellCount { get; } +} + +public class MapHouseInfo +{ + public MapHouseInfo(string iniName, string houseTypeName, string color) + { + ININame = iniName; + HouseTypeName = houseTypeName; + Color = color; + } + + public string ININame { get; } + public string HouseTypeName { get; } + public string Color { get; } +} + public class MapTileSetInfo { public MapTileSetInfo(int index, string setName, string uiName, int startTileIndex, int tileCount, bool only1x1) @@ -252,6 +282,49 @@ public List GetTerrainTypes(string nameFilter = null) .ToList(); } + public List GetBuildingTypes(string nameFilter = null) + { + string normalizedFilter = nameFilter?.Trim(); + + return map.Rules.BuildingTypes + .Where(buildingType => buildingType.EditorVisible && buildingType.IsValidForTheater(map.LoadedTheaterName)) + .Select(buildingType => + { + var foundation = buildingType.ArtConfig.Foundation; + bool usesOriginOnly = foundation.Width == 0 || foundation.Height == 0; + + return new MapBuildingTypeInfo( + buildingType.ININame, + buildingType.GetEditorDisplayName(), + GetEffectiveEditorCategory(buildingType), + usesOriginOnly ? 1 : foundation.Width, + usesOriginOnly ? 1 : foundation.Height, + usesOriginOnly ? 1 : (foundation.FoundationCells?.Length ?? 0)); + }) + .Where(typeInfo => string.IsNullOrWhiteSpace(normalizedFilter) || + ContainsIgnoringCase(typeInfo.ININame, normalizedFilter) || + ContainsIgnoringCase(typeInfo.UIName, normalizedFilter) || + ContainsIgnoringCase(typeInfo.EditorCategory, normalizedFilter)) + .OrderBy(typeInfo => typeInfo.EditorCategory) + .ThenBy(typeInfo => typeInfo.UIName) + .ThenBy(typeInfo => typeInfo.ININame) + .ToList(); + } + + public List GetHouses(string nameFilter = null) + { + string normalizedFilter = nameFilter?.Trim(); + + return map.GetHouses() + .Select(house => new MapHouseInfo(house.ININame, house.HouseType?.ININame, house.Color)) + .Where(houseInfo => string.IsNullOrWhiteSpace(normalizedFilter) || + ContainsIgnoringCase(houseInfo.ININame, normalizedFilter) || + ContainsIgnoringCase(houseInfo.HouseTypeName, normalizedFilter) || + ContainsIgnoringCase(houseInfo.Color, normalizedFilter)) + .OrderBy(houseInfo => houseInfo.ININame) + .ToList(); + } + public List GetTileSets(string nameFilter = null) { string normalizedFilter = nameFilter?.Trim(); @@ -334,6 +407,74 @@ public MapEditResult PlaceTerrainObject(string terrainTypeName, int x, int y) new List { CellInfo.FromMapCell(map.TheaterInstance, mapTile) }); } + public MapEditResult PlaceBuilding(string buildingTypeName, string ownerName, int x, int y, bool allowOverlap) + { + if (string.IsNullOrWhiteSpace(buildingTypeName)) + throw new MapFacadeValidationException("A building type INI name must be provided."); + + if (string.IsNullOrWhiteSpace(ownerName)) + throw new MapFacadeValidationException("An owner house name must be provided."); + + var buildingType = map.Rules.BuildingTypes.Find( + bt => string.Equals(bt.ININame, buildingTypeName, StringComparison.OrdinalIgnoreCase)); + if (buildingType == null) + throw new MapFacadeValidationException($"Building type '{buildingTypeName}' does not exist in the loaded rules."); + + if (!buildingType.EditorVisible) + throw new MapFacadeValidationException($"Building type '{buildingType.ININame}' is not available for placement in the editor."); + + if (!buildingType.IsValidForTheater(map.LoadedTheaterName)) + throw new MapFacadeValidationException($"Building type '{buildingType.ININame}' is not valid for theater '{map.LoadedTheaterName}'."); + + var owner = map.GetHouses().Find( + house => string.Equals(house.ININame, ownerName, StringComparison.OrdinalIgnoreCase)); + if (owner == null) + throw new MapFacadeValidationException($"House '{ownerName}' does not exist on the map."); + + var cellCoords = new Point2D(x, y); + if (!map.IsCoordWithinMap(cellCoords) || map.GetTile(cellCoords) == null) + throw new MapFacadeValidationException($"Cell ({x}, {y}) is outside the map."); + + var structure = new Structure(buildingType) + { + Owner = owner, + Position = cellCoords + }; + + var foundationCells = new List(); + Point2D? invalidFoundationCoords = null; + buildingType.ArtConfig.DoForFoundationCoordsOrOrigin(offset => + { + var foundationCoords = cellCoords + offset; + var foundationCell = map.IsCoordWithinMap(foundationCoords) ? map.GetTile(foundationCoords) : null; + if (foundationCell == null) + { + invalidFoundationCoords ??= foundationCoords; + return; + } + + foundationCells.Add(foundationCell); + }); + + if (invalidFoundationCoords.HasValue) + { + throw new MapFacadeValidationException( + $"The building foundation extends outside the map at ({invalidFoundationCoords.Value.X}, {invalidFoundationCoords.Value.Y})."); + } + + if (!map.CanPlaceObjectAt(structure, cellCoords, false, allowOverlap)) + { + throw new MapFacadeValidationException( + $"Building '{buildingType.ININame}' cannot be placed at ({x}, {y}) because its foundation overlaps another building."); + } + + mutationManager.PerformMutation(new PlaceBuildingMutation(mutationTarget, buildingType, cellCoords, owner)); + + return new MapEditResult( + mutationManager.Revision, + foundationCells.Select(cell => CellInfo.FromMapCell(map.TheaterInstance, cell)).ToList()); + } + public MapEditResult PlaceTerrainTile(string tileSetName, int tileIndexInTileSet, int x, int y, int brushWidth, int brushHeight, bool autoLAT) { @@ -465,6 +606,15 @@ private static bool IsTileSetPlaceable(TileSet tileSet) return tileSet.AllowToPlace && tileSet.LoadedTileCount > 0 && tileSet.NonMarbleMadness < 0; } + private static string GetEffectiveEditorCategory(GameObjectType gameObjectType) + { + string editorCategory = gameObjectType.EditorCategory; + if (string.IsNullOrWhiteSpace(editorCategory) && gameObjectType is TechnoType technoType) + editorCategory = technoType.Owner; + + return string.IsNullOrWhiteSpace(editorCategory) ? "Uncategorized" : editorCategory; + } + private static bool ContainsIgnoringCase(string value, string searchValue) { return value?.IndexOf(searchValue, StringComparison.OrdinalIgnoreCase) >= 0; diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index 5daa68b9a..e7700cc3a 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -50,6 +50,26 @@ public Task> GetTerrainTypes( return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetTerrainTypes(nameFilter), cancellationToken); } + [McpServerTool(Name = "get_building_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns building types that are visible in the editor and valid for the current map's theater, including foundation dimensions.")] + public Task> GetBuildingTypes( + [Description("Optional case-insensitive filter matched against INI name, UI name, and editor category.")] string nameFilter = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetBuildingTypes)}"); + return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetBuildingTypes(nameFilter), cancellationToken); + } + + [McpServerTool(Name = "get_houses", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns houses that can own player-controllable objects on the current map.")] + public Task> GetHouses( + [Description("Optional case-insensitive filter matched against house name, house type, and color.")] string nameFilter = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetHouses)}"); + return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetHouses(nameFilter), cancellationToken); + } + [McpServerTool(Name = "get_tile_sets", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] [Description("Returns tile sets that are available for placement in the current map's theater.")] public Task> GetTileSets( @@ -61,7 +81,7 @@ public Task> GetTileSets( } [McpServerTool(Name = "inspect_map_region", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] - [Description("Returns terrain, terrain objects, and overlays from a rectangular region of the open map.")] + [Description("Returns terrain, overlays, and placed map objects from a rectangular region of the open map.")] public Task> InspectMapRegion( [Description("X coordinate of the region's top-left cell.")] int x, [Description("Y coordinate of the region's top-left cell.")] int y, @@ -104,6 +124,30 @@ public async Task PlaceTerrainObject( } } + [McpServerTool(Name = "place_building", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Places a building with an explicit owner at the given foundation origin. By default, placement fails if its foundation overlaps another building. The edit is added to undo history.")] + public async Task PlaceBuilding( + [Description("INI name of the building type returned by get_building_types.")] string buildingTypeName, + [Description("INI name of the owner returned by get_houses.")] string ownerName, + [Description("X coordinate of the building foundation origin.")] int x, + [Description("Y coordinate of the building foundation origin.")] int y, + [Description("Whether to allow the building foundation to overlap other buildings. Defaults to false.")] bool allowOverlap = false, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(PlaceBuilding)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.PlaceBuilding(buildingTypeName, ownerName, x, y, allowOverlap), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + [McpServerTool(Name = "place_terrain_tile", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] [Description("Places a full terrain tile by tile set and tile-set-relative index. Supports configured brush sizes and optional AutoLAT. Existing terrain in the footprint may be replaced, and the edit is added to undo history.")] public async Task PlaceTerrainTile( diff --git a/src/TSMapEditor/Mutations/Classes/PlaceBuildingMutation.cs b/src/TSMapEditor/Mutations/Classes/PlaceBuildingMutation.cs index b97721109..7fc1cb88c 100644 --- a/src/TSMapEditor/Mutations/Classes/PlaceBuildingMutation.cs +++ b/src/TSMapEditor/Mutations/Classes/PlaceBuildingMutation.cs @@ -1,5 +1,6 @@ using TSMapEditor.GameMath; using TSMapEditor.Models; +using System; using TSMapEditor.UI; namespace TSMapEditor.Mutations.Classes @@ -10,14 +11,21 @@ namespace TSMapEditor.Mutations.Classes /// public class PlaceBuildingMutation : Mutation { - public PlaceBuildingMutation(IMutationTarget mutationTarget, BuildingType buildingType, Point2D cellCoords) : base(mutationTarget) + public PlaceBuildingMutation(IMutationTarget mutationTarget, BuildingType buildingType, Point2D cellCoords) + : this(mutationTarget, buildingType, cellCoords, mutationTarget.ObjectOwner) + { + } + + public PlaceBuildingMutation(IMutationTarget mutationTarget, BuildingType buildingType, Point2D cellCoords, House owner) : base(mutationTarget) { this.buildingType = buildingType; this.cellCoords = cellCoords; + this.owner = owner ?? throw new ArgumentNullException(nameof(owner)); } private readonly BuildingType buildingType; private readonly Point2D cellCoords; + private readonly House owner; private Structure placedBuilding; @@ -33,7 +41,7 @@ public override void Perform() var cell = MutationTarget.Map.GetTileOrFail(cellCoords); var structure = new Structure(buildingType); - structure.Owner = MutationTarget.ObjectOwner; + structure.Owner = owner; structure.Position = cellCoords; structure.AIRepairable = structure.ObjectType.Repairable && structure.Owner.DefaultRepairableStructures; MutationTarget.Map.PlaceBuilding(structure); From edc3f17c678dee127f16de50f5991c4768ee8013 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sat, 1 Aug 2026 22:17:24 +0300 Subject: [PATCH 07/27] Make it possible to place vehicles, infantry and aircraft through the MCP server --- .../AI/MapBuildingPlacementProperties.cs | 33 ++ src/TSMapEditor/AI/MapFacade.cs | 403 +++++++++++++++++- .../AI/MapFootPlacementProperties.cs | 36 ++ src/TSMapEditor/AI/MapTools.cs | 109 ++++- .../Classes/PlaceAircraftMutation.cs | 26 +- .../Classes/PlaceBuildingMutation.cs | 19 +- .../Classes/PlaceInfantryMutation.cs | 29 +- .../Mutations/Classes/PlaceVehicleMutation.cs | 26 +- 8 files changed, 641 insertions(+), 40 deletions(-) create mode 100644 src/TSMapEditor/AI/MapBuildingPlacementProperties.cs create mode 100644 src/TSMapEditor/AI/MapFootPlacementProperties.cs diff --git a/src/TSMapEditor/AI/MapBuildingPlacementProperties.cs b/src/TSMapEditor/AI/MapBuildingPlacementProperties.cs new file mode 100644 index 000000000..5283a9cf3 --- /dev/null +++ b/src/TSMapEditor/AI/MapBuildingPlacementProperties.cs @@ -0,0 +1,33 @@ +using System.ComponentModel; + +namespace TSMapEditor.AI; + +public class MapBuildingPlacementProperties +{ + [Description("Initial health from 1 through 256. Omit to use full health.")] + public int? Health { get; set; } + + [Description("Initial facing from 0 through 255. Omit to use 0.")] + public int? Facing { get; set; } + + [Description("Tag ID or unique tag name to attach to the building.")] + public string AttachedTag { get; set; } + + public bool? AISellable { get; set; } + public bool? AIRebuildable { get; set; } + public bool? Powered { get; set; } + public bool? AIRepairable { get; set; } + public bool? Nominal { get; set; } + + [Description("Spotlight mode: 0 for none, 1 for reciprocating, or 2 for loop.")] + public int? Spotlight { get; set; } + + [Description("INI name of the first building upgrade.")] + public string Upgrade1 { get; set; } + + [Description("INI name of the second building upgrade.")] + public string Upgrade2 { get; set; } + + [Description("INI name of the third building upgrade.")] + public string Upgrade3 { get; set; } +} diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index dbbc802ba..775483b14 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -42,38 +42,51 @@ public MapOverlayInfo(int x, int y, string iniName, int frameId) : base(RTTIType public class MapTechnoInfo : MapObjectInfo { - public MapTechnoInfo(string rtti, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag) : base(rtti, x, y, iniName) + public MapTechnoInfo(string rtti, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag, string attachedTagID) : base(rtti, x, y, iniName) { Owner = owner; Facing = facing; HP = hp; AttachedTag = attachedTag; + AttachedTagID = attachedTagID; } public int HP { get; } public string AttachedTag { get; } + public string AttachedTagID { get; } public string Owner { get; } public byte Facing { get; } } public class MapBuildingInfo : MapTechnoInfo { - public MapBuildingInfo(string rtti, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag, bool powered, bool aiRepairable) - : base(rtti, x, y, iniName, owner, facing, hp, attachedTag) + public MapBuildingInfo(string rtti, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag, string attachedTagID, + bool aiSellable, bool aiRebuildable, bool powered, bool aiRepairable, bool nominal, int spotlight, List upgrades) + : base(rtti, x, y, iniName, owner, facing, hp, attachedTag, attachedTagID) { + AISellable = aiSellable; + AIRebuildable = aiRebuildable; Powered = powered; AIRepairable = aiRepairable; + Nominal = nominal; + Spotlight = spotlight; + Upgrades = upgrades; } + public bool AISellable { get; } + public bool AIRebuildable { get; } public bool Powered { get; } public bool AIRepairable { get; } + public bool Nominal { get; } + public int Spotlight { get; } + public List Upgrades { get; } } public class MapFootInfo : MapTechnoInfo { - public MapFootInfo(string rtti, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag, string mission, - bool onBridge, int veterancy, int group, bool autocreateNoRecruitable, bool autocreateYesRecruitable) - : base(rtti, x, y, iniName, owner, facing, hp, attachedTag) + public MapFootInfo(string rtti, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag, string attachedTagID, string mission, + bool onBridge, int veterancy, int group, bool autocreateNoRecruitable, bool autocreateYesRecruitable, int? subCell) + : base(rtti, x, y, iniName, owner, facing, hp, attachedTag, attachedTagID) { Mission = mission; OnBridge = onBridge; @@ -81,6 +94,7 @@ public MapFootInfo(string rtti, int x, int y, string iniName, string owner, byte Group = group; AutocreateNoRecruitable = autocreateNoRecruitable; AutocreateYesRecruitable = autocreateYesRecruitable; + SubCell = subCell; } public string Mission { get; } @@ -89,6 +103,7 @@ public MapFootInfo(string rtti, int x, int y, string iniName, string owner, byte public int Group { get; } public bool AutocreateNoRecruitable { get; } public bool AutocreateYesRecruitable { get; } + public int? SubCell { get; } } public class CellInfo @@ -128,10 +143,15 @@ public static CellInfo FromMapCell(ITheater theater, MapTile mapTile) var terrainObjectInfo = mapTile.TerrainObject == null ? null : new MapObjectInfo(RTTIType.Terrain.ToString(), mapTile.TerrainObject.Position.X, mapTile.TerrainObject.Position.Y, mapTile.TerrainObject.TerrainType.ININame); var overlayInfo = mapTile.Overlay == null ? null : new MapOverlayInfo(mapTile.Overlay.Position.X, mapTile.Overlay.Position.Y, mapTile.Overlay.OverlayType.ININame, mapTile.Overlay.FrameIndex); - var buildingInfos = mapTile.Structures.Select(s => new MapBuildingInfo(s.WhatAmI().ToString(), s.Position.X, s.Position.Y, s.ObjectType.ININame, s.Owner.ININame, s.Facing, s.HP, s.AttachedTag?.Name, s.Powered, s.AIRepairable)).ToList(); - var vehicleInfos = mapTile.Vehicles.Select(v => new MapFootInfo(v.WhatAmI().ToString(), v.Position.X, v.Position.Y, v.ObjectType.ININame, v.Owner.ININame, v.Facing, v.HP, v.AttachedTag?.Name, v.Mission, v.High, v.Veterancy, v.Group, v.AutocreateNoRecruitable, v.AutocreateYesRecruitable)); - var infantryInfos = mapTile.Infantry.Where(i => i != null).Select(i => new MapFootInfo(i.WhatAmI().ToString(), i.Position.X, i.Position.Y, i.ObjectType.ININame, i.Owner.ININame, i.Facing, i.HP, i.AttachedTag?.Name, i.Mission, i.High, i.Veterancy, i.Group, i.AutocreateNoRecruitable, i.AutocreateYesRecruitable)); - var aircraftInfos = mapTile.Aircraft.Select(a => new MapFootInfo(a.WhatAmI().ToString(), a.Position.X, a.Position.Y, a.ObjectType.ININame, a.Owner.ININame, a.Facing, a.HP, a.AttachedTag?.Name, a.Mission, a.High, a.Veterancy, a.Group, a.AutocreateNoRecruitable, a.AutocreateYesRecruitable)); + var buildingInfos = mapTile.Structures.Select(s => new MapBuildingInfo(s.WhatAmI().ToString(), s.Position.X, s.Position.Y, s.ObjectType.ININame, s.Owner.ININame, + s.Facing, s.HP, s.AttachedTag?.Name, s.AttachedTag?.ID, s.AISellable, s.AIRebuildable, s.Powered, s.AIRepairable, s.Nominal, (int)s.Spotlight, + s.Upgrades.Select(upgrade => upgrade?.ININame).ToList())).ToList(); + var vehicleInfos = mapTile.Vehicles.Select(v => new MapFootInfo(v.WhatAmI().ToString(), v.Position.X, v.Position.Y, v.ObjectType.ININame, v.Owner.ININame, + v.Facing, v.HP, v.AttachedTag?.Name, v.AttachedTag?.ID, v.Mission, v.High, v.Veterancy, v.Group, v.AutocreateNoRecruitable, v.AutocreateYesRecruitable, null)); + var infantryInfos = mapTile.Infantry.Where(i => i != null).Select(i => new MapFootInfo(i.WhatAmI().ToString(), i.Position.X, i.Position.Y, i.ObjectType.ININame, i.Owner.ININame, + i.Facing, i.HP, i.AttachedTag?.Name, i.AttachedTag?.ID, i.Mission, i.High, i.Veterancy, i.Group, i.AutocreateNoRecruitable, i.AutocreateYesRecruitable, (int)i.SubCell)); + var aircraftInfos = mapTile.Aircraft.Select(a => new MapFootInfo(a.WhatAmI().ToString(), a.Position.X, a.Position.Y, a.ObjectType.ININame, a.Owner.ININame, + a.Facing, a.HP, a.AttachedTag?.Name, a.AttachedTag?.ID, a.Mission, a.High, a.Veterancy, a.Group, a.AutocreateNoRecruitable, a.AutocreateYesRecruitable, null)); return new CellInfo(mapTile.X, mapTile.Y, tileSet.SetName, mapTile.TileIndex, mapTile.TileIndex - tileSet.StartTileIndex, mapTile.SubTileIndex, mapTile.Level, terrainObjectInfo, overlayInfo, buildingInfos, @@ -241,6 +261,14 @@ public MapFacadeValidationException(string message) : base(message) /// public class MapFacade { + private static readonly string[] ValidMissions = new[] + { + "Ambush", "Area Guard", "Attack", "Capture", "Construction", "Enter", "Guard", "Harmless", "Harvest", "Hunt", "Missile", "Move", "Open", + "Patrol", "QMove", "Repair", "Rescue", "Retreat", "Return", "Sabotage", "Selling", "Sleep", "Sticky", "Stop", "Unload" + }; + + private static readonly int[] ValidVeterancyLevels = new[] { 0, 50, 100, 150, 200 }; + public MapFacade(Map map, MutationManager mutationManager, IMutationTarget mutationTarget) { this.map = map; @@ -311,6 +339,41 @@ public List GetBuildingTypes(string nameFilter = null) .ToList(); } + private List GetTechnoTypes(IEnumerable technoTypes, string nameFilter) + { + string normalizedFilter = nameFilter?.Trim(); + + return technoTypes + .Where(technoType => technoType.EditorVisible && technoType.IsValidForTheater(map.LoadedTheaterName)) + .Select(technoType => new MapObjectTypeInfo( + technoType.ININame, + technoType.GetEditorDisplayName(), + GetEffectiveEditorCategory(technoType))) + .Where(typeInfo => string.IsNullOrWhiteSpace(normalizedFilter) || + ContainsIgnoringCase(typeInfo.ININame, normalizedFilter) || + ContainsIgnoringCase(typeInfo.UIName, normalizedFilter) || + ContainsIgnoringCase(typeInfo.EditorCategory, normalizedFilter)) + .OrderBy(typeInfo => typeInfo.EditorCategory) + .ThenBy(typeInfo => typeInfo.UIName) + .ThenBy(typeInfo => typeInfo.ININame) + .ToList(); + } + + public List GetAircraftTypes(string nameFilter = null) + { + return GetTechnoTypes(map.Rules.AircraftTypes, nameFilter); + } + + public List GetInfantryTypes(string nameFilter = null) + { + return GetTechnoTypes(map.Rules.InfantryTypes, nameFilter); + } + + public List GetVehicleTypes(string nameFilter = null) + { + return GetTechnoTypes(map.Rules.UnitTypes, nameFilter); + } + public List GetHouses(string nameFilter = null) { string normalizedFilter = nameFilter?.Trim(); @@ -382,8 +445,7 @@ public MapEditResult PlaceTerrainObject(string terrainTypeName, int x, int y) if (mapTile == null) throw new MapFacadeValidationException($"Cell ({x}, {y}) is outside the map."); - var terrainType = map.Rules.TerrainTypes.Find( - tt => string.Equals(tt.ININame, terrainTypeName, StringComparison.OrdinalIgnoreCase)); + var terrainType = map.Rules.TerrainTypes.Find(tt => string.Equals(tt.ININame, terrainTypeName, StringComparison.OrdinalIgnoreCase)); if (terrainType == null) throw new MapFacadeValidationException($"Terrain object type '{terrainTypeName}' does not exist in the loaded rules."); @@ -407,7 +469,7 @@ public MapEditResult PlaceTerrainObject(string terrainTypeName, int x, int y) new List { CellInfo.FromMapCell(map.TheaterInstance, mapTile) }); } - public MapEditResult PlaceBuilding(string buildingTypeName, string ownerName, int x, int y, bool allowOverlap) + public MapEditResult PlaceBuilding(string buildingTypeName, string ownerName, int x, int y, bool allowOverlap, MapBuildingPlacementProperties properties) { if (string.IsNullOrWhiteSpace(buildingTypeName)) throw new MapFacadeValidationException("A building type INI name must be provided."); @@ -415,8 +477,7 @@ public MapEditResult PlaceBuilding(string buildingTypeName, string ownerName, in if (string.IsNullOrWhiteSpace(ownerName)) throw new MapFacadeValidationException("An owner house name must be provided."); - var buildingType = map.Rules.BuildingTypes.Find( - bt => string.Equals(bt.ININame, buildingTypeName, StringComparison.OrdinalIgnoreCase)); + var buildingType = map.Rules.BuildingTypes.Find(bt => string.Equals(bt.ININame, buildingTypeName, StringComparison.OrdinalIgnoreCase)); if (buildingType == null) throw new MapFacadeValidationException($"Building type '{buildingTypeName}' does not exist in the loaded rules."); @@ -438,8 +499,10 @@ public MapEditResult PlaceBuilding(string buildingTypeName, string ownerName, in var structure = new Structure(buildingType) { Owner = owner, - Position = cellCoords + Position = cellCoords, + AIRepairable = buildingType.Repairable && owner.DefaultRepairableStructures }; + ApplyBuildingPlacementProperties(structure, properties); var foundationCells = new List(); Point2D? invalidFoundationCoords = null; @@ -468,13 +531,152 @@ public MapEditResult PlaceBuilding(string buildingTypeName, string ownerName, in $"Building '{buildingType.ININame}' cannot be placed at ({x}, {y}) because its foundation overlaps another building."); } - mutationManager.PerformMutation(new PlaceBuildingMutation(mutationTarget, buildingType, cellCoords, owner)); + mutationManager.PerformMutation(new PlaceBuildingMutation(mutationTarget, structure)); return new MapEditResult( mutationManager.Revision, foundationCells.Select(cell => CellInfo.FromMapCell(map.TheaterInstance, cell)).ToList()); } + public MapEditResult PlaceAircraft(string aircraftTypeName, string ownerName, int x, int y, bool allowOverlap, MapFootPlacementProperties properties) + { + if (string.IsNullOrWhiteSpace(aircraftTypeName)) + throw new MapFacadeValidationException("An aircraft type INI name must be provided."); + + if (string.IsNullOrWhiteSpace(ownerName)) + throw new MapFacadeValidationException("An owner house name must be provided."); + + var aircraftType = map.Rules.AircraftTypes.Find( + at => string.Equals(at.ININame, aircraftTypeName, StringComparison.OrdinalIgnoreCase)); + if (aircraftType == null) + throw new MapFacadeValidationException($"Aircraft type '{aircraftTypeName}' does not exist in the loaded rules."); + + if (!aircraftType.EditorVisible) + throw new MapFacadeValidationException($"Aircraft type '{aircraftType.ININame}' is not available for placement in the editor."); + + if (!aircraftType.IsValidForTheater(map.LoadedTheaterName)) + throw new MapFacadeValidationException($"Aircraft type '{aircraftType.ININame}' is not valid for theater '{map.LoadedTheaterName}'."); + + var owner = map.GetHouses().Find(house => string.Equals(house.ININame, ownerName, StringComparison.OrdinalIgnoreCase)); + if (owner == null) + throw new MapFacadeValidationException($"House '{ownerName}' does not exist on the map."); + + var cellCoords = new Point2D(x, y); + if (!map.IsCoordWithinMap(cellCoords) || map.GetTile(cellCoords) == null) + throw new MapFacadeValidationException($"Cell ({x}, {y}) is outside the map."); + + var aircraft = new Aircraft(aircraftType) + { + Owner = owner, + Position = cellCoords + }; + ApplyFootPlacementProperties(aircraft, properties, false, false); + + if (!map.CanPlaceObjectAt(aircraft, cellCoords, false, allowOverlap)) + { + throw new MapFacadeValidationException($"Aircraft '{aircraftType.ININame}' cannot be placed at ({x}, {y}) because the cell already contains aircraft."); + } + + mutationManager.PerformMutation(new PlaceAircraftMutation(mutationTarget, aircraft)); + + return new MapEditResult( + mutationManager.Revision, + new List { CellInfo.FromMapCell(map.TheaterInstance, map.GetTile(cellCoords)) }); + } + + public MapEditResult PlaceInfantry(string infantryTypeName, string ownerName, int x, int y, MapFootPlacementProperties properties) + { + if (string.IsNullOrWhiteSpace(infantryTypeName)) + throw new MapFacadeValidationException("An infantry type INI name must be provided."); + + if (string.IsNullOrWhiteSpace(ownerName)) + throw new MapFacadeValidationException("An owner house name must be provided."); + + var infantryType = map.Rules.InfantryTypes.Find(it => string.Equals(it.ININame, infantryTypeName, StringComparison.OrdinalIgnoreCase)); + if (infantryType == null) + throw new MapFacadeValidationException($"Infantry type '{infantryTypeName}' does not exist in the loaded rules."); + + if (!infantryType.EditorVisible) + throw new MapFacadeValidationException($"Infantry type '{infantryType.ININame}' is not available for placement in the editor."); + + if (!infantryType.IsValidForTheater(map.LoadedTheaterName)) + throw new MapFacadeValidationException($"Infantry type '{infantryType.ININame}' is not valid for theater '{map.LoadedTheaterName}'."); + + var owner = map.GetHouses().Find(house => string.Equals(house.ININame, ownerName, StringComparison.OrdinalIgnoreCase)); + if (owner == null) + throw new MapFacadeValidationException($"House '{ownerName}' does not exist on the map."); + + var cellCoords = new Point2D(x, y); + var mapTile = map.IsCoordWithinMap(cellCoords) ? map.GetTile(cellCoords) : null; + if (mapTile == null) + throw new MapFacadeValidationException($"Cell ({x}, {y}) is outside the map."); + + SubCell freeSubCell = GetInfantryPlacementSubCell(mapTile, properties?.SubCell); + if (freeSubCell == SubCell.None) + { + throw new MapFacadeValidationException($"Infantry '{infantryType.ININame}' cannot be placed at ({x}, {y}) because all usable infantry subcells are occupied."); + } + + var infantry = new Infantry(infantryType) + { + Owner = owner, + Position = cellCoords, + SubCell = freeSubCell + }; + ApplyFootPlacementProperties(infantry, properties, true, true); + + mutationManager.PerformMutation(new PlaceInfantryMutation(mutationTarget, infantry)); + + return new MapEditResult( + mutationManager.Revision, + new List { CellInfo.FromMapCell(map.TheaterInstance, mapTile) }); + } + + public MapEditResult PlaceVehicle(string vehicleTypeName, string ownerName, int x, int y, bool allowOverlap, MapFootPlacementProperties properties) + { + if (string.IsNullOrWhiteSpace(vehicleTypeName)) + throw new MapFacadeValidationException("A vehicle type INI name must be provided."); + + if (string.IsNullOrWhiteSpace(ownerName)) + throw new MapFacadeValidationException("An owner house name must be provided."); + + var vehicleType = map.Rules.UnitTypes.Find(ut => string.Equals(ut.ININame, vehicleTypeName, StringComparison.OrdinalIgnoreCase)); + if (vehicleType == null) + throw new MapFacadeValidationException($"Vehicle type '{vehicleTypeName}' does not exist in the loaded rules."); + + if (!vehicleType.EditorVisible) + throw new MapFacadeValidationException($"Vehicle type '{vehicleType.ININame}' is not available for placement in the editor."); + + if (!vehicleType.IsValidForTheater(map.LoadedTheaterName)) + throw new MapFacadeValidationException($"Vehicle type '{vehicleType.ININame}' is not valid for theater '{map.LoadedTheaterName}'."); + + var owner = map.GetHouses().Find(house => string.Equals(house.ININame, ownerName, StringComparison.OrdinalIgnoreCase)); + if (owner == null) + throw new MapFacadeValidationException($"House '{ownerName}' does not exist on the map."); + + var cellCoords = new Point2D(x, y); + if (!map.IsCoordWithinMap(cellCoords) || map.GetTile(cellCoords) == null) + throw new MapFacadeValidationException($"Cell ({x}, {y}) is outside the map."); + + var vehicle = new Unit(vehicleType) + { + Owner = owner, + Position = cellCoords + }; + ApplyFootPlacementProperties(vehicle, properties, true, false); + + if (!map.CanPlaceObjectAt(vehicle, cellCoords, false, allowOverlap)) + { + throw new MapFacadeValidationException($"Vehicle '{vehicleType.ININame}' cannot be placed at ({x}, {y}) because the cell already contains a vehicle."); + } + + mutationManager.PerformMutation(new PlaceVehicleMutation(mutationTarget, vehicle)); + + return new MapEditResult( + mutationManager.Revision, + new List { CellInfo.FromMapCell(map.TheaterInstance, map.GetTile(cellCoords)) }); + } + public MapEditResult PlaceTerrainTile(string tileSetName, int tileIndexInTileSet, int x, int y, int brushWidth, int brushHeight, bool autoLAT) { @@ -508,8 +710,7 @@ public MapEditResult PlaceTerrainTile(string tileSetName, int tileIndexInTileSet ITileImage tile = map.TheaterInstance.GetTile(tileIndex); if (tile == null || tile.Width <= 0 || tile.Height <= 0 || tile.SubTileCount <= 0) { - throw new MapFacadeValidationException( - $"Tile {tileIndexInTileSet} from tile set '{tileSet.SetName}' has no usable tile graphics."); + throw new MapFacadeValidationException($"Tile {tileIndexInTileSet} from tile set '{tileSet.SetName}' has no usable tile graphics."); } var cellCoords = new Point2D(x, y); @@ -526,8 +727,7 @@ public MapEditResult PlaceTerrainTile(string tileSetName, int tileIndexInTileSet if (!mutation.ShouldPerform()) { - throw new MapFacadeValidationException( - $"Tile {tileIndexInTileSet} from tile set '{tileSet.SetName}' cannot be placed at ({x}, {y})."); + throw new MapFacadeValidationException($"Tile {tileIndexInTileSet} from tile set '{tileSet.SetName}' cannot be placed at ({x}, {y})."); } mutationManager.PerformMutation(mutation); @@ -575,6 +775,167 @@ public MapEditResult SetCellTerrain(int x, int y, int tileIndex, int subTileInde new List { CellInfo.FromMapCell(map.TheaterInstance, mapTile) }); } + private void ApplyBuildingPlacementProperties(Structure structure, MapBuildingPlacementProperties properties) + { + if (properties == null) + return; + + ApplyTechnoPlacementProperties(structure, properties.Health, properties.Facing, properties.AttachedTag); + + if (properties.AISellable.HasValue) + structure.AISellable = properties.AISellable.Value; + if (properties.AIRebuildable.HasValue) + structure.AIRebuildable = properties.AIRebuildable.Value; + if (properties.Powered.HasValue) + structure.Powered = properties.Powered.Value; + if (properties.AIRepairable.HasValue) + structure.AIRepairable = properties.AIRepairable.Value; + if (properties.Nominal.HasValue) + structure.Nominal = properties.Nominal.Value; + + if (properties.Spotlight.HasValue) + { + if (!Enum.IsDefined(typeof(SpotlightType), properties.Spotlight.Value)) + throw new MapFacadeValidationException("Spotlight must be 0, 1, or 2."); + + structure.Spotlight = (SpotlightType)properties.Spotlight.Value; + } + + bool upgradesChanged = false; + upgradesChanged |= ApplyBuildingUpgrade(structure, properties.Upgrade1, 0); + upgradesChanged |= ApplyBuildingUpgrade(structure, properties.Upgrade2, 1); + upgradesChanged |= ApplyBuildingUpgrade(structure, properties.Upgrade3, 2); + if (upgradesChanged) + structure.UpdatePowerUpAnims(); + } + + private bool ApplyBuildingUpgrade(Structure structure, string upgradeName, int upgradeIndex) + { + if (upgradeName == null) + return false; + + if (string.IsNullOrWhiteSpace(upgradeName)) + throw new MapFacadeValidationException($"Building upgrade #{upgradeIndex + 1} cannot be empty."); + + if (upgradeIndex >= structure.ObjectType.Upgrades) + { + throw new MapFacadeValidationException( + $"Building type '{structure.ObjectType.ININame}' does not support upgrade slot #{upgradeIndex + 1}."); + } + + var upgrade = map.Rules.BuildingTypes.Find(bt => string.Equals(bt.ININame, upgradeName, StringComparison.OrdinalIgnoreCase)); + if (upgrade == null) + throw new MapFacadeValidationException($"Building upgrade type '{upgradeName}' does not exist in the loaded rules."); + + if (!string.Equals(upgrade.PowersUpBuilding, structure.ObjectType.ININame, StringComparison.OrdinalIgnoreCase)) + { + throw new MapFacadeValidationException( + $"Building type '{upgrade.ININame}' is not a valid upgrade for '{structure.ObjectType.ININame}'."); + } + + structure.Upgrades[upgradeIndex] = upgrade; + return true; + } + + private void ApplyFootPlacementProperties(Foot foot, MapFootPlacementProperties properties, bool supportsOnBridge, bool supportsSubCell) + where T : TechnoType + { + if (properties == null) + return; + + if (properties.OnBridge.HasValue && !supportsOnBridge) + throw new MapFacadeValidationException("The onBridge property is not valid for aircraft."); + + if (properties.SubCell.HasValue && !supportsSubCell) + throw new MapFacadeValidationException("The subCell property is only valid for infantry."); + + ApplyTechnoPlacementProperties(foot, properties.Health, properties.Facing, properties.AttachedTag); + + if (properties.Mission != null) + { + string mission = Array.Find(ValidMissions, validMission => string.Equals(validMission, properties.Mission, StringComparison.OrdinalIgnoreCase)); + if (mission == null) + throw new MapFacadeValidationException($"Mission '{properties.Mission}' is not available in the editor."); + + foot.Mission = mission; + } + + if (properties.Veterancy.HasValue) + { + if (Array.IndexOf(ValidVeterancyLevels, properties.Veterancy.Value) < 0) + throw new MapFacadeValidationException("Veterancy must be 0, 50, 100, 150, or 200."); + + foot.Veterancy = properties.Veterancy.Value; + } + + if (properties.Group.HasValue) + foot.Group = properties.Group.Value; + if (properties.OnBridge.HasValue) + foot.High = properties.OnBridge.Value; + if (properties.AutocreateNoRecruitable.HasValue) + foot.AutocreateNoRecruitable = properties.AutocreateNoRecruitable.Value; + if (properties.AutocreateYesRecruitable.HasValue) + foot.AutocreateYesRecruitable = properties.AutocreateYesRecruitable.Value; + } + + private void ApplyTechnoPlacementProperties(TechnoBase techno, int? health, int? facing, string attachedTag) + { + if (health.HasValue) + { + if (health.Value < 1 || health.Value > Constants.ObjectHealthMax) + throw new MapFacadeValidationException($"Health must be from 1 through {Constants.ObjectHealthMax}."); + + techno.HP = health.Value; + } + + if (facing.HasValue) + { + if (facing.Value < 0 || facing.Value > Constants.FacingMax) + throw new MapFacadeValidationException($"Facing must be from 0 through {Constants.FacingMax}."); + + techno.Facing = (byte)facing.Value; + } + + if (attachedTag != null) + techno.AttachedTag = ResolveAttachedTag(attachedTag); + } + + private Tag ResolveAttachedTag(string tagNameOrID) + { + if (string.IsNullOrWhiteSpace(tagNameOrID)) + throw new MapFacadeValidationException("An attached tag cannot be empty."); + + var tagByID = map.Tags.Find(tag => string.Equals(tag.ID, tagNameOrID, StringComparison.OrdinalIgnoreCase)); + if (tagByID != null) + return tagByID; + + var tagsByName = map.Tags.FindAll(tag => string.Equals(tag.Name, tagNameOrID, StringComparison.OrdinalIgnoreCase)); + if (tagsByName.Count == 0) + throw new MapFacadeValidationException($"Tag '{tagNameOrID}' does not exist on the map."); + if (tagsByName.Count > 1) + throw new MapFacadeValidationException($"Multiple tags are named '{tagNameOrID}'; use the tag ID instead."); + + return tagsByName[0]; + } + + private static SubCell GetInfantryPlacementSubCell(MapTile mapTile, int? requestedSubCell) + { + if (!requestedSubCell.HasValue) + return mapTile.GetFreeSubCellSpot(); + + if (requestedSubCell.Value != (int)SubCell.Right && requestedSubCell.Value != (int)SubCell.Left && requestedSubCell.Value != (int)SubCell.Bottom) + throw new MapFacadeValidationException("An infantry subcell must be 2 (Right), 3 (Left), or 4 (Bottom)."); + + var subCell = (SubCell)requestedSubCell.Value; + if (mapTile.Infantry[(int)subCell] != null) + { + throw new MapFacadeValidationException( + $"Infantry cannot be placed at ({mapTile.X}, {mapTile.Y}) because subcell {requestedSubCell.Value} ({subCell}) is occupied."); + } + + return subCell; + } + private void ValidateTerrainTileFootprint(ITileImage tile, Point2D cellCoords, BrushSize brushSize) { for (int brushY = 0; brushY < brushSize.Height; brushY++) diff --git a/src/TSMapEditor/AI/MapFootPlacementProperties.cs b/src/TSMapEditor/AI/MapFootPlacementProperties.cs new file mode 100644 index 000000000..f3e624c54 --- /dev/null +++ b/src/TSMapEditor/AI/MapFootPlacementProperties.cs @@ -0,0 +1,36 @@ +using System.ComponentModel; + +namespace TSMapEditor.AI; + +public class MapFootPlacementProperties +{ + [Description("Initial health from 1 through 256. Omit to use full health.")] + public int? Health { get; set; } + + [Description("Initial facing from 0 through 255. Omit to use 0.")] + public int? Facing { get; set; } + + [Description("Initial mission. Valid values are Ambush, Area Guard, Attack, Capture, Construction, Enter, Guard, Harmless, Harvest, Hunt, Missile, Move, Open, Patrol, QMove, Repair, Rescue, Retreat, Return, Sabotage, Selling, Sleep, Sticky, Stop, and Unload.")] + public string Mission { get; set; } + + [Description("Initial veterancy. Valid values are 0, 50, 100, 150, and 200.")] + public int? Veterancy { get; set; } + + [Description("Initial group number. Omit to use -1.")] + public int? Group { get; set; } + + [Description("Whether a vehicle or infantry object is on a bridge. Not valid for aircraft.")] + public bool? OnBridge { get; set; } + + [Description("Whether the object can be recruited when Autocreate is disabled.")] + public bool? AutocreateNoRecruitable { get; set; } + + [Description("Whether the object can be recruited when Autocreate is enabled.")] + public bool? AutocreateYesRecruitable { get; set; } + + [Description("Tag ID or unique tag name to attach to the object.")] + public string AttachedTag { get; set; } + + [Description("Infantry subcell: 2 for Right, 3 for Left, or 4 for Bottom. Not valid for aircraft or vehicles. Omit to use the first free usable subcell.")] + public int? SubCell { get; set; } +} diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index e7700cc3a..86e61da13 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -60,6 +60,36 @@ public Task> GetBuildingTypes( return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetBuildingTypes(nameFilter), cancellationToken); } + [McpServerTool(Name = "get_aircraft_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns aircraft types that are visible in the editor and valid for the current map's theater.")] + public Task> GetAircraftTypes( + [Description("Optional case-insensitive filter matched against INI name, UI name, and editor category.")] string nameFilter = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetAircraftTypes)}"); + return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetAircraftTypes(nameFilter), cancellationToken); + } + + [McpServerTool(Name = "get_infantry_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns infantry types that are visible in the editor and valid for the current map's theater.")] + public Task> GetInfantryTypes( + [Description("Optional case-insensitive filter matched against INI name, UI name, and editor category.")] string nameFilter = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetInfantryTypes)}"); + return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetInfantryTypes(nameFilter), cancellationToken); + } + + [McpServerTool(Name = "get_vehicle_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns vehicle types that are visible in the editor and valid for the current map's theater.")] + public Task> GetVehicleTypes( + [Description("Optional case-insensitive filter matched against INI name, UI name, and editor category.")] string nameFilter = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetVehicleTypes)}"); + return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetVehicleTypes(nameFilter), cancellationToken); + } + [McpServerTool(Name = "get_houses", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] [Description("Returns houses that can own player-controllable objects on the current map.")] public Task> GetHouses( @@ -125,13 +155,14 @@ public async Task PlaceTerrainObject( } [McpServerTool(Name = "place_building", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] - [Description("Places a building with an explicit owner at the given foundation origin. By default, placement fails if its foundation overlaps another building. The edit is added to undo history.")] + [Description("Places a building with an explicit owner and optional initial properties at the given foundation origin. By default, placement fails if its foundation overlaps another building. The edit is added to undo history.")] public async Task PlaceBuilding( [Description("INI name of the building type returned by get_building_types.")] string buildingTypeName, [Description("INI name of the owner returned by get_houses.")] string ownerName, [Description("X coordinate of the building foundation origin.")] int x, [Description("Y coordinate of the building foundation origin.")] int y, [Description("Whether to allow the building foundation to overlap other buildings. Defaults to false.")] bool allowOverlap = false, + [Description("Optional initial building properties. Omitted properties retain the editor's placement defaults.")] MapBuildingPlacementProperties properties = null, CancellationToken cancellationToken = default) { Logger.Log($"{nameof(MapTools)}.{nameof(PlaceBuilding)}"); @@ -139,7 +170,81 @@ public async Task PlaceBuilding( try { return await gameThreadDispatcher.InvokeAsync( - () => mapFacade.PlaceBuilding(buildingTypeName, ownerName, x, y, allowOverlap), + () => mapFacade.PlaceBuilding(buildingTypeName, ownerName, x, y, allowOverlap, properties), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + + [McpServerTool(Name = "place_aircraft", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Places aircraft with an explicit owner and optional initial properties at the given cell. By default, placement fails if the cell already contains aircraft. The edit is added to undo history.")] + public async Task PlaceAircraft( + [Description("INI name of the aircraft type returned by get_aircraft_types.")] string aircraftTypeName, + [Description("INI name of the owner returned by get_houses.")] string ownerName, + [Description("X coordinate of the destination cell.")] int x, + [Description("Y coordinate of the destination cell.")] int y, + [Description("Whether to allow the aircraft to overlap other aircraft. Defaults to false.")] bool allowOverlap = false, + [Description("Optional initial aircraft properties. onBridge and subCell are not valid for aircraft. Omitted properties retain the editor's placement defaults.")] MapFootPlacementProperties properties = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(PlaceAircraft)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.PlaceAircraft(aircraftTypeName, ownerName, x, y, allowOverlap, properties), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + + [McpServerTool(Name = "place_infantry", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Places infantry with an explicit owner and optional initial properties at the given cell. A specific usable subcell can be requested; otherwise the first free one is used. The edit is added to undo history.")] + public async Task PlaceInfantry( + [Description("INI name of the infantry type returned by get_infantry_types.")] string infantryTypeName, + [Description("INI name of the owner returned by get_houses.")] string ownerName, + [Description("X coordinate of the destination cell.")] int x, + [Description("Y coordinate of the destination cell.")] int y, + [Description("Optional initial infantry properties. Omitted properties retain the editor's placement defaults.")] MapFootPlacementProperties properties = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(PlaceInfantry)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.PlaceInfantry(infantryTypeName, ownerName, x, y, properties), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + + [McpServerTool(Name = "place_vehicle", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Places a vehicle with an explicit owner and optional initial properties at the given cell. By default, placement fails if the cell already contains a vehicle. The edit is added to undo history.")] + public async Task PlaceVehicle( + [Description("INI name of the vehicle type returned by get_vehicle_types.")] string vehicleTypeName, + [Description("INI name of the owner returned by get_houses.")] string ownerName, + [Description("X coordinate of the destination cell.")] int x, + [Description("Y coordinate of the destination cell.")] int y, + [Description("Whether to allow the vehicle to overlap other vehicles. Defaults to false.")] bool allowOverlap = false, + [Description("Optional initial vehicle properties. subCell is not valid for vehicles. Omitted properties retain the editor's placement defaults.")] MapFootPlacementProperties properties = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(PlaceVehicle)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.PlaceVehicle(vehicleTypeName, ownerName, x, y, allowOverlap, properties), cancellationToken); } catch (MapFacadeValidationException ex) diff --git a/src/TSMapEditor/Mutations/Classes/PlaceAircraftMutation.cs b/src/TSMapEditor/Mutations/Classes/PlaceAircraftMutation.cs index 322b07447..b24df6692 100644 --- a/src/TSMapEditor/Mutations/Classes/PlaceAircraftMutation.cs +++ b/src/TSMapEditor/Mutations/Classes/PlaceAircraftMutation.cs @@ -9,14 +9,30 @@ namespace TSMapEditor.Mutations.Classes /// public class PlaceAircraftMutation : Mutation { - public PlaceAircraftMutation(IMutationTarget mutationTarget, AircraftType aircraftType, Point2D cellCoords) : base(mutationTarget) + public PlaceAircraftMutation(IMutationTarget mutationTarget, AircraftType aircraftType, Point2D cellCoords) + : this(mutationTarget, aircraftType, cellCoords, mutationTarget.ObjectOwner) + { + } + + public PlaceAircraftMutation(IMutationTarget mutationTarget, AircraftType aircraftType, Point2D cellCoords, House owner) : base(mutationTarget) { this.aircraftType = aircraftType; this.cellCoords = cellCoords; + this.owner = owner ?? throw new System.ArgumentNullException(nameof(owner)); + } + + public PlaceAircraftMutation(IMutationTarget mutationTarget, Aircraft aircraft) : base(mutationTarget) + { + preconfiguredAircraft = aircraft ?? throw new System.ArgumentNullException(nameof(aircraft)); + aircraftType = aircraft.ObjectType; + cellCoords = aircraft.Position; + owner = aircraft.Owner ?? throw new System.ArgumentException("The aircraft must have an owner.", nameof(aircraft)); } private readonly AircraftType aircraftType; private readonly Point2D cellCoords; + private readonly House owner; + private readonly Aircraft preconfiguredAircraft; private Aircraft aircraft; public override string GetDisplayString() @@ -32,9 +48,11 @@ public override void Perform() if (cell == null) return; - aircraft = new Aircraft(aircraftType); - aircraft.Owner = MutationTarget.ObjectOwner; - aircraft.Position = cellCoords; + aircraft = preconfiguredAircraft ?? new Aircraft(aircraftType) + { + Owner = owner, + Position = cellCoords + }; MutationTarget.Map.PlaceAircraft(aircraft); MutationTarget.AddRefreshPoint(cellCoords); } diff --git a/src/TSMapEditor/Mutations/Classes/PlaceBuildingMutation.cs b/src/TSMapEditor/Mutations/Classes/PlaceBuildingMutation.cs index 7fc1cb88c..f5e3fb11f 100644 --- a/src/TSMapEditor/Mutations/Classes/PlaceBuildingMutation.cs +++ b/src/TSMapEditor/Mutations/Classes/PlaceBuildingMutation.cs @@ -23,9 +23,18 @@ public PlaceBuildingMutation(IMutationTarget mutationTarget, BuildingType buildi this.owner = owner ?? throw new ArgumentNullException(nameof(owner)); } + public PlaceBuildingMutation(IMutationTarget mutationTarget, Structure structure) : base(mutationTarget) + { + preconfiguredStructure = structure ?? throw new ArgumentNullException(nameof(structure)); + buildingType = structure.ObjectType; + cellCoords = structure.Position; + owner = structure.Owner ?? throw new ArgumentException("The building must have an owner.", nameof(structure)); + } + private readonly BuildingType buildingType; private readonly Point2D cellCoords; private readonly House owner; + private readonly Structure preconfiguredStructure; private Structure placedBuilding; @@ -40,10 +49,12 @@ public override void Perform() { var cell = MutationTarget.Map.GetTileOrFail(cellCoords); - var structure = new Structure(buildingType); - structure.Owner = owner; - structure.Position = cellCoords; - structure.AIRepairable = structure.ObjectType.Repairable && structure.Owner.DefaultRepairableStructures; + var structure = preconfiguredStructure ?? new Structure(buildingType) + { + Owner = owner, + Position = cellCoords, + AIRepairable = buildingType.Repairable && owner.DefaultRepairableStructures + }; MutationTarget.Map.PlaceBuilding(structure); MutationTarget.AddRefreshPoint(cellCoords); diff --git a/src/TSMapEditor/Mutations/Classes/PlaceInfantryMutation.cs b/src/TSMapEditor/Mutations/Classes/PlaceInfantryMutation.cs index 41c11162a..81780bb94 100644 --- a/src/TSMapEditor/Mutations/Classes/PlaceInfantryMutation.cs +++ b/src/TSMapEditor/Mutations/Classes/PlaceInfantryMutation.cs @@ -10,16 +10,33 @@ namespace TSMapEditor.Mutations.Classes /// public class PlaceInfantryMutation : Mutation { - public PlaceInfantryMutation(IMutationTarget mutationTarget, InfantryType infantryType, Point2D cellCoords, SubCell subCell) : base(mutationTarget) + public PlaceInfantryMutation(IMutationTarget mutationTarget, InfantryType infantryType, Point2D cellCoords, SubCell subCell) + : this(mutationTarget, infantryType, cellCoords, subCell, mutationTarget.ObjectOwner) + { + } + + public PlaceInfantryMutation(IMutationTarget mutationTarget, InfantryType infantryType, Point2D cellCoords, SubCell subCell, House owner) : base(mutationTarget) { this.infantryType = infantryType; this.cellCoords = cellCoords; this.subCell = subCell; + this.owner = owner ?? throw new ArgumentNullException(nameof(owner)); + } + + public PlaceInfantryMutation(IMutationTarget mutationTarget, Infantry infantry) : base(mutationTarget) + { + preconfiguredInfantry = infantry ?? throw new ArgumentNullException(nameof(infantry)); + infantryType = infantry.ObjectType; + cellCoords = infantry.Position; + subCell = infantry.SubCell; + owner = infantry.Owner ?? throw new ArgumentException("The infantry must have an owner.", nameof(infantry)); } private readonly InfantryType infantryType; private readonly Point2D cellCoords; private readonly SubCell subCell; + private readonly House owner; + private readonly Infantry preconfiguredInfantry; private Infantry placedInfantry; @@ -39,10 +56,12 @@ public override void Perform() if (cell.Infantry[(int)subCell] != null) throw new InvalidOperationException(nameof(PlaceInfantryMutation) + ": cannot place infantry on an occupied sub-cell spot!"); - var infantry = new Infantry(infantryType); - infantry.Owner = MutationTarget.ObjectOwner; - infantry.Position = cellCoords; - infantry.SubCell = subCell; + var infantry = preconfiguredInfantry ?? new Infantry(infantryType) + { + Owner = owner, + Position = cellCoords, + SubCell = subCell + }; placedInfantry = infantry; MutationTarget.Map.PlaceInfantry(infantry); diff --git a/src/TSMapEditor/Mutations/Classes/PlaceVehicleMutation.cs b/src/TSMapEditor/Mutations/Classes/PlaceVehicleMutation.cs index 75935c84b..ad416b96e 100644 --- a/src/TSMapEditor/Mutations/Classes/PlaceVehicleMutation.cs +++ b/src/TSMapEditor/Mutations/Classes/PlaceVehicleMutation.cs @@ -9,14 +9,30 @@ namespace TSMapEditor.Mutations.Classes /// public class PlaceVehicleMutation : Mutation { - public PlaceVehicleMutation(IMutationTarget mutationTarget, UnitType unitType, Point2D cellCoords) : base(mutationTarget) + public PlaceVehicleMutation(IMutationTarget mutationTarget, UnitType unitType, Point2D cellCoords) + : this(mutationTarget, unitType, cellCoords, mutationTarget.ObjectOwner) + { + } + + public PlaceVehicleMutation(IMutationTarget mutationTarget, UnitType unitType, Point2D cellCoords, House owner) : base(mutationTarget) { this.unitType = unitType; this.cellCoords = cellCoords; + this.owner = owner ?? throw new System.ArgumentNullException(nameof(owner)); + } + + public PlaceVehicleMutation(IMutationTarget mutationTarget, Unit unit) : base(mutationTarget) + { + preconfiguredUnit = unit ?? throw new System.ArgumentNullException(nameof(unit)); + unitType = unit.ObjectType; + cellCoords = unit.Position; + owner = unit.Owner ?? throw new System.ArgumentException("The vehicle must have an owner.", nameof(unit)); } private readonly UnitType unitType; private readonly Point2D cellCoords; + private readonly House owner; + private readonly Unit preconfiguredUnit; private Unit unit; public override string GetDisplayString() @@ -30,9 +46,11 @@ public override void Perform() { var cell = MutationTarget.Map.GetTileOrFail(cellCoords); - unit = new Unit(unitType); - unit.Owner = MutationTarget.ObjectOwner; - unit.Position = cellCoords; + unit = preconfiguredUnit ?? new Unit(unitType) + { + Owner = owner, + Position = cellCoords + }; MutationTarget.Map.PlaceUnit(unit); MutationTarget.AddRefreshPoint(cellCoords); } From 2404aa4ab0f6d525cf8bd63d1155d45015b1f0bf Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sun, 2 Aug 2026 00:48:21 +0300 Subject: [PATCH 08/27] Add techno property modification capabilities to MCP server --- src/TSMapEditor/AI/MapFacade.cs | 344 ++++++++++++++++-- .../AI/MapTechnoModificationProperties.cs | 71 ++++ src/TSMapEditor/AI/MapTools.cs | 62 ++++ .../Config/Translations/en/Translation_en.ini | 1 + src/TSMapEditor/Constants.cs | 2 +- src/TSMapEditor/Helpers.cs | 5 + src/TSMapEditor/Initialization/IMap.cs | 5 +- src/TSMapEditor/Initialization/MapLoader.cs | 64 ++-- src/TSMapEditor/Models/Map.cs | 37 +- src/TSMapEditor/Models/Structure.cs | 2 +- src/TSMapEditor/Models/Techno.cs | 15 + .../AIMutations/ModifyTechnosMutation.cs | 191 ++++++++++ .../UI/Windows/InfantryOptionsWindow.cs | 2 +- 13 files changed, 716 insertions(+), 85 deletions(-) create mode 100644 src/TSMapEditor/AI/MapTechnoModificationProperties.cs create mode 100644 src/TSMapEditor/Mutations/Classes/AIMutations/ModifyTechnosMutation.cs diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index 775483b14..eee95691f 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -42,8 +42,10 @@ public MapOverlayInfo(int x, int y, string iniName, int frameId) : base(RTTIType public class MapTechnoInfo : MapObjectInfo { - public MapTechnoInfo(string rtti, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag, string attachedTagID) : base(rtti, x, y, iniName) + public MapTechnoInfo(string rtti, int objectId, int index, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag, string attachedTagID) : base(rtti, x, y, iniName) { + ObjectId = objectId; + Index = index; Owner = owner; Facing = facing; HP = hp; @@ -51,6 +53,8 @@ public MapTechnoInfo(string rtti, int x, int y, string iniName, string owner, by AttachedTagID = attachedTagID; } + public int ObjectId { get; } + public int Index { get; } public int HP { get; } public string AttachedTag { get; } public string AttachedTagID { get; } @@ -60,9 +64,9 @@ public MapTechnoInfo(string rtti, int x, int y, string iniName, string owner, by public class MapBuildingInfo : MapTechnoInfo { - public MapBuildingInfo(string rtti, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag, string attachedTagID, + public MapBuildingInfo(string rtti, int objectId, int index, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag, string attachedTagID, bool aiSellable, bool aiRebuildable, bool powered, bool aiRepairable, bool nominal, int spotlight, List upgrades) - : base(rtti, x, y, iniName, owner, facing, hp, attachedTag, attachedTagID) + : base(rtti, objectId, index, x, y, iniName, owner, facing, hp, attachedTag, attachedTagID) { AISellable = aiSellable; AIRebuildable = aiRebuildable; @@ -84,9 +88,9 @@ public MapBuildingInfo(string rtti, int x, int y, string iniName, string owner, public class MapFootInfo : MapTechnoInfo { - public MapFootInfo(string rtti, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag, string attachedTagID, string mission, + public MapFootInfo(string rtti, int objectId, int index, int x, int y, string iniName, string owner, byte facing, int hp, string attachedTag, string attachedTagID, string mission, bool onBridge, int veterancy, int group, bool autocreateNoRecruitable, bool autocreateYesRecruitable, int? subCell) - : base(rtti, x, y, iniName, owner, facing, hp, attachedTag, attachedTagID) + : base(rtti, objectId, index, x, y, iniName, owner, facing, hp, attachedTag, attachedTagID) { Mission = mission; OnBridge = onBridge; @@ -136,27 +140,58 @@ public CellInfo(int x, int y, string tileSetName, int tileIndex, int tileIndexIn public List BuildingInfos { get; } public List FootInfos { get; } - public static CellInfo FromMapCell(ITheater theater, MapTile mapTile) + public static CellInfo FromMapCell(Map map, MapTile mapTile) { + ITheater theater = map.TheaterInstance; int tileSetIndex = theater.GetTileSetId(mapTile.TileIndex); var tileSet = theater.Theater.TileSets[tileSetIndex]; var terrainObjectInfo = mapTile.TerrainObject == null ? null : new MapObjectInfo(RTTIType.Terrain.ToString(), mapTile.TerrainObject.Position.X, mapTile.TerrainObject.Position.Y, mapTile.TerrainObject.TerrainType.ININame); var overlayInfo = mapTile.Overlay == null ? null : new MapOverlayInfo(mapTile.Overlay.Position.X, mapTile.Overlay.Position.Y, mapTile.Overlay.OverlayType.ININame, mapTile.Overlay.FrameIndex); - var buildingInfos = mapTile.Structures.Select(s => new MapBuildingInfo(s.WhatAmI().ToString(), s.Position.X, s.Position.Y, s.ObjectType.ININame, s.Owner.ININame, - s.Facing, s.HP, s.AttachedTag?.Name, s.AttachedTag?.ID, s.AISellable, s.AIRebuildable, s.Powered, s.AIRepairable, s.Nominal, (int)s.Spotlight, - s.Upgrades.Select(upgrade => upgrade?.ININame).ToList())).ToList(); - var vehicleInfos = mapTile.Vehicles.Select(v => new MapFootInfo(v.WhatAmI().ToString(), v.Position.X, v.Position.Y, v.ObjectType.ININame, v.Owner.ININame, - v.Facing, v.HP, v.AttachedTag?.Name, v.AttachedTag?.ID, v.Mission, v.High, v.Veterancy, v.Group, v.AutocreateNoRecruitable, v.AutocreateYesRecruitable, null)); - var infantryInfos = mapTile.Infantry.Where(i => i != null).Select(i => new MapFootInfo(i.WhatAmI().ToString(), i.Position.X, i.Position.Y, i.ObjectType.ININame, i.Owner.ININame, - i.Facing, i.HP, i.AttachedTag?.Name, i.AttachedTag?.ID, i.Mission, i.High, i.Veterancy, i.Group, i.AutocreateNoRecruitable, i.AutocreateYesRecruitable, (int)i.SubCell)); - var aircraftInfos = mapTile.Aircraft.Select(a => new MapFootInfo(a.WhatAmI().ToString(), a.Position.X, a.Position.Y, a.ObjectType.ININame, a.Owner.ININame, - a.Facing, a.HP, a.AttachedTag?.Name, a.AttachedTag?.ID, a.Mission, a.High, a.Veterancy, a.Group, a.AutocreateNoRecruitable, a.AutocreateYesRecruitable, null)); + var buildingInfos = mapTile.Structures.Select(s => (MapBuildingInfo)FromTechno(map, s)).ToList(); + var vehicleInfos = mapTile.Vehicles.Select(v => (MapFootInfo)FromTechno(map, v)); + var infantryInfos = mapTile.Infantry.Where(i => i != null).Select(i => (MapFootInfo)FromTechno(map, i)); + var aircraftInfos = mapTile.Aircraft.Select(a => (MapFootInfo)FromTechno(map, a)); return new CellInfo(mapTile.X, mapTile.Y, tileSet.SetName, mapTile.TileIndex, mapTile.TileIndex - tileSet.StartTileIndex, mapTile.SubTileIndex, mapTile.Level, terrainObjectInfo, overlayInfo, buildingInfos, vehicleInfos.Concat(infantryInfos).Concat(aircraftInfos).ToList()); } + + public static MapTechnoInfo FromTechno(Map map, TechnoBase techno) + { + if (techno is Structure structure) + { + return new MapBuildingInfo(structure.WhatAmI().ToString(), structure.ObjectId, map.Structures.IndexOf(structure), structure.Position.X, structure.Position.Y, + structure.ObjectType.ININame, structure.Owner.ININame, structure.Facing, structure.HP, structure.AttachedTag?.Name, structure.AttachedTag?.ID, + structure.AISellable, structure.AIRebuildable, structure.Powered, structure.AIRepairable, structure.Nominal, (int)structure.Spotlight, + structure.Upgrades.Select(upgrade => upgrade?.ININame).ToList()); + } + + if (techno is Unit unit) + { + return new MapFootInfo(unit.WhatAmI().ToString(), unit.ObjectId, map.Units.IndexOf(unit), unit.Position.X, unit.Position.Y, unit.ObjectType.ININame, unit.Owner.ININame, + unit.Facing, unit.HP, unit.AttachedTag?.Name, unit.AttachedTag?.ID, unit.Mission, unit.High, unit.Veterancy, unit.Group, + unit.AutocreateNoRecruitable, unit.AutocreateYesRecruitable, null); + } + + if (techno is Infantry infantry) + { + return new MapFootInfo(infantry.WhatAmI().ToString(), infantry.ObjectId, map.Infantry.IndexOf(infantry), infantry.Position.X, infantry.Position.Y, + infantry.ObjectType.ININame, infantry.Owner.ININame, infantry.Facing, infantry.HP, infantry.AttachedTag?.Name, infantry.AttachedTag?.ID, + infantry.Mission, infantry.High, infantry.Veterancy, infantry.Group, infantry.AutocreateNoRecruitable, infantry.AutocreateYesRecruitable, + (int)infantry.SubCell); + } + + if (techno is Aircraft aircraft) + { + return new MapFootInfo(aircraft.WhatAmI().ToString(), aircraft.ObjectId, map.Aircraft.IndexOf(aircraft), aircraft.Position.X, aircraft.Position.Y, + aircraft.ObjectType.ININame, aircraft.Owner.ININame, aircraft.Facing, aircraft.HP, aircraft.AttachedTag?.Name, aircraft.AttachedTag?.ID, + aircraft.Mission, aircraft.High, aircraft.Veterancy, aircraft.Group, aircraft.AutocreateNoRecruitable, aircraft.AutocreateYesRecruitable, null); + } + + throw new ArgumentException($"Unsupported techno type {techno.WhatAmI()}.", nameof(techno)); + } } public class MapInfo @@ -261,6 +296,8 @@ public MapFacadeValidationException(string message) : base(message) /// public class MapFacade { + private const int MaxTechnoQueryResults = 1_000; + private static readonly string[] ValidMissions = new[] { "Ambush", "Area Guard", "Attack", "Capture", "Construction", "Enter", "Guard", "Harmless", "Harvest", "Hunt", "Missile", "Move", "Open", @@ -374,6 +411,33 @@ public List GetVehicleTypes(string nameFilter = null) return GetTechnoTypes(map.Rules.UnitTypes, nameFilter); } + public MapTechnoQueryResult GetTechnos(string rttiFilter, string typeNameFilter, string ownerNameFilter, Rectangle? area) + { + string normalizedRTTI = NormalizeTechnoRTTI(rttiFilter); + IEnumerable technos = map.Structures.Cast() + .Concat(map.Units) + .Concat(map.Infantry) + .Concat(map.Aircraft); + + if (normalizedRTTI != null) + technos = technos.Where(techno => techno.WhatAmI().ToString() == normalizedRTTI); + if (!string.IsNullOrWhiteSpace(typeNameFilter)) + technos = technos.Where(techno => ContainsIgnoringCase(techno.GetObjectType().ININame, typeNameFilter.Trim())); + if (!string.IsNullOrWhiteSpace(ownerNameFilter)) + technos = technos.Where(techno => ContainsIgnoringCase(techno.Owner?.ININame, ownerNameFilter.Trim())); + if (area.HasValue) + technos = technos.Where(techno => area.Value.Contains(techno.Position.X, techno.Position.Y)); + + var result = technos.Select(techno => CellInfo.FromTechno(map, techno)).ToList(); + if (result.Count > MaxTechnoQueryResults) + { + throw new MapFacadeValidationException( + $"The query matched {result.Count} technos, exceeding the limit of {MaxTechnoQueryResults}. Add type, owner, kind, or area filters."); + } + + return new MapTechnoQueryResult(mutationManager.Revision, result); + } + public List GetHouses(string nameFilter = null) { string normalizedFilter = nameFilter?.Trim(); @@ -425,13 +489,60 @@ public List InspectRegion(Rectangle rectangle) if (mapCell == null) continue; - returnValue.Add(CellInfo.FromMapCell(map.TheaterInstance, mapCell)); + returnValue.Add(CellInfo.FromMapCell(map, mapCell)); } } return returnValue; } + public MapEditResult ModifyTechnos(List technoReferences, MapTechnoModificationProperties properties, int? expectedRevision) + { + if (expectedRevision.HasValue && expectedRevision.Value != mutationManager.Revision) + { + throw new MapFacadeValidationException( + $"The map revision changed from {expectedRevision.Value} to {mutationManager.Revision}. Query the technos again before modifying them, or omit expectedRevision to allow concurrent edits."); + } + + if (technoReferences == null || technoReferences.Count == 0) + throw new MapFacadeValidationException("At least one techno reference must be provided."); + if (technoReferences.Count > MaxTechnoQueryResults) + throw new MapFacadeValidationException($"At most {MaxTechnoQueryResults} technos can be modified in one call."); + if (properties == null || !HasAnyModification(properties)) + throw new MapFacadeValidationException("At least one property modification must be provided."); + + ValidateCommonModificationProperties(properties); + + var technos = technoReferences.Select(ResolveTechnoReference).ToList(); + if (technos.Distinct().Count() != technos.Count) + throw new MapFacadeValidationException("The techno reference list contains duplicates."); + + var changes = new List(); + foreach (var techno in technos) + { + var oldProperties = TechnoPropertiesSnapshot.Capture(techno); + var newProperties = TechnoPropertiesSnapshot.Capture(techno); + ApplyModificationProperties(techno, newProperties, properties); + + if (!oldProperties.HasSameValuesAs(newProperties)) + changes.Add(new TechnoPropertyChange(techno, oldProperties, newProperties)); + } + + if (changes.Count == 0) + throw new MapFacadeValidationException("All selected technos already have the requested property values."); + + mutationManager.PerformMutation(new ModifyTechnosMutation(mutationTarget, changes)); + + var affectedCells = technos + .Select(techno => map.GetTile(techno.Position)) + .Where(mapTile => mapTile != null) + .Distinct() + .Select(mapTile => CellInfo.FromMapCell(map, mapTile)) + .ToList(); + + return new MapEditResult(mutationManager.Revision, affectedCells); + } + public MapEditResult PlaceTerrainObject(string terrainTypeName, int x, int y) { if (string.IsNullOrWhiteSpace(terrainTypeName)) @@ -466,7 +577,7 @@ public MapEditResult PlaceTerrainObject(string terrainTypeName, int x, int y) return new MapEditResult( mutationManager.Revision, - new List { CellInfo.FromMapCell(map.TheaterInstance, mapTile) }); + new List { CellInfo.FromMapCell(map, mapTile) }); } public MapEditResult PlaceBuilding(string buildingTypeName, string ownerName, int x, int y, bool allowOverlap, MapBuildingPlacementProperties properties) @@ -535,7 +646,7 @@ public MapEditResult PlaceBuilding(string buildingTypeName, string ownerName, in return new MapEditResult( mutationManager.Revision, - foundationCells.Select(cell => CellInfo.FromMapCell(map.TheaterInstance, cell)).ToList()); + foundationCells.Select(cell => CellInfo.FromMapCell(map, cell)).ToList()); } public MapEditResult PlaceAircraft(string aircraftTypeName, string ownerName, int x, int y, bool allowOverlap, MapFootPlacementProperties properties) @@ -581,7 +692,7 @@ public MapEditResult PlaceAircraft(string aircraftTypeName, string ownerName, in return new MapEditResult( mutationManager.Revision, - new List { CellInfo.FromMapCell(map.TheaterInstance, map.GetTile(cellCoords)) }); + new List { CellInfo.FromMapCell(map, map.GetTile(cellCoords)) }); } public MapEditResult PlaceInfantry(string infantryTypeName, string ownerName, int x, int y, MapFootPlacementProperties properties) @@ -629,7 +740,7 @@ public MapEditResult PlaceInfantry(string infantryTypeName, string ownerName, in return new MapEditResult( mutationManager.Revision, - new List { CellInfo.FromMapCell(map.TheaterInstance, mapTile) }); + new List { CellInfo.FromMapCell(map, mapTile) }); } public MapEditResult PlaceVehicle(string vehicleTypeName, string ownerName, int x, int y, bool allowOverlap, MapFootPlacementProperties properties) @@ -674,7 +785,7 @@ public MapEditResult PlaceVehicle(string vehicleTypeName, string ownerName, int return new MapEditResult( mutationManager.Revision, - new List { CellInfo.FromMapCell(map.TheaterInstance, map.GetTile(cellCoords)) }); + new List { CellInfo.FromMapCell(map, map.GetTile(cellCoords)) }); } public MapEditResult PlaceTerrainTile(string tileSetName, int tileIndexInTileSet, int x, int y, @@ -772,7 +883,159 @@ public MapEditResult SetCellTerrain(int x, int y, int tileIndex, int subTileInde return new MapEditResult( mutationManager.Revision, - new List { CellInfo.FromMapCell(map.TheaterInstance, mapTile) }); + new List { CellInfo.FromMapCell(map, mapTile) }); + } + + private void ApplyModificationProperties(TechnoBase techno, TechnoPropertiesSnapshot snapshot, MapTechnoModificationProperties properties) + { + if (properties.Owner != null) + snapshot.Owner = ResolveHouse(properties.Owner); + if (properties.Health.HasValue) + snapshot.Health = properties.Health.Value; + if (properties.Facing.HasValue) + snapshot.Facing = (byte)properties.Facing.Value; + if (properties.AttachedTag != null) + snapshot.AttachedTag = ResolveAttachedTag(properties.AttachedTag); + else if (properties.ClearAttachedTag) + snapshot.AttachedTag = null; + + if (techno is Structure structure) + { + if (HasFootModification(properties)) + throw new MapFacadeValidationException("Mobile techno properties cannot be applied to buildings."); + + ApplyBuildingModificationProperties(structure, snapshot, properties); + return; + } + + if (HasBuildingModification(properties)) + throw new MapFacadeValidationException("Building properties cannot be applied to Unit, Infantry, or Aircraft objects."); + if (techno is Aircraft && properties.OnBridge.HasValue) + throw new MapFacadeValidationException("The onBridge property is not valid for aircraft."); + + if (properties.Mission != null) + snapshot.Mission = ResolveMission(properties.Mission); + if (properties.Veterancy.HasValue) + snapshot.Veterancy = properties.Veterancy.Value; + if (properties.Group.HasValue) + snapshot.Group = properties.Group.Value; + if (properties.OnBridge.HasValue) + snapshot.OnBridge = properties.OnBridge.Value; + if (properties.AutocreateNoRecruitable.HasValue) + snapshot.AutocreateNoRecruitable = properties.AutocreateNoRecruitable.Value; + if (properties.AutocreateYesRecruitable.HasValue) + snapshot.AutocreateYesRecruitable = properties.AutocreateYesRecruitable.Value; + } + + private void ApplyBuildingModificationProperties(Structure structure, TechnoPropertiesSnapshot snapshot, MapTechnoModificationProperties properties) + { + if (properties.AISellable.HasValue) + snapshot.AISellable = properties.AISellable.Value; + if (properties.AIRebuildable.HasValue) + snapshot.AIRebuildable = properties.AIRebuildable.Value; + if (properties.Powered.HasValue) + snapshot.Powered = properties.Powered.Value; + if (properties.AIRepairable.HasValue) + snapshot.AIRepairable = properties.AIRepairable.Value; + if (properties.Nominal.HasValue) + snapshot.Nominal = properties.Nominal.Value; + if (properties.Spotlight.HasValue) + snapshot.Spotlight = (SpotlightType)properties.Spotlight.Value; + + ApplyBuildingUpgradeModification(structure, snapshot, properties.Upgrade1, properties.ClearUpgrade1, 0); + ApplyBuildingUpgradeModification(structure, snapshot, properties.Upgrade2, properties.ClearUpgrade2, 1); + ApplyBuildingUpgradeModification(structure, snapshot, properties.Upgrade3, properties.ClearUpgrade3, 2); + } + + private void ApplyBuildingUpgradeModification(Structure structure, TechnoPropertiesSnapshot snapshot, string upgradeName, bool clearUpgrade, int upgradeIndex) + { + if (upgradeName == null && !clearUpgrade) + return; + + if (upgradeIndex >= structure.ObjectType.Upgrades) + { + throw new MapFacadeValidationException( + $"Building type '{structure.ObjectType.ININame}' does not support upgrade slot #{upgradeIndex + 1}."); + } + + snapshot.Upgrades[upgradeIndex] = clearUpgrade ? null : ResolveBuildingUpgrade(structure.ObjectType, upgradeName, upgradeIndex); + } + + private void ValidateCommonModificationProperties(MapTechnoModificationProperties properties) + { + if (properties.Health.HasValue && (properties.Health.Value < 1 || properties.Health.Value > Constants.ObjectHealthMax)) + throw new MapFacadeValidationException($"Health must be from 1 through {Constants.ObjectHealthMax}."); + if (properties.Facing.HasValue && (properties.Facing.Value < 0 || properties.Facing.Value > Constants.FacingMax)) + throw new MapFacadeValidationException($"Facing must be from 0 through {Constants.FacingMax}."); + if (properties.AttachedTag != null && properties.ClearAttachedTag) + throw new MapFacadeValidationException("attachedTag and clearAttachedTag cannot be used together."); + if (properties.Veterancy.HasValue && Array.IndexOf(ValidVeterancyLevels, properties.Veterancy.Value) < 0) + throw new MapFacadeValidationException("Veterancy must be 0, 50, 100, 150, or 200."); + if (properties.Mission != null) + ResolveMission(properties.Mission); + if (properties.Spotlight.HasValue && !Enum.IsDefined(typeof(SpotlightType), properties.Spotlight.Value)) + throw new MapFacadeValidationException("Spotlight must be 0, 1, or 2."); + if (properties.Upgrade1 != null && properties.ClearUpgrade1) + throw new MapFacadeValidationException("upgrade1 and clearUpgrade1 cannot be used together."); + if (properties.Upgrade2 != null && properties.ClearUpgrade2) + throw new MapFacadeValidationException("upgrade2 and clearUpgrade2 cannot be used together."); + if (properties.Upgrade3 != null && properties.ClearUpgrade3) + throw new MapFacadeValidationException("upgrade3 and clearUpgrade3 cannot be used together."); + + if (properties.Owner != null) + ResolveHouse(properties.Owner); + if (properties.AttachedTag != null) + ResolveAttachedTag(properties.AttachedTag); + } + + private TechnoBase ResolveTechnoReference(MapTechnoReference technoReference) + { + if (technoReference == null) + throw new MapFacadeValidationException("A techno reference cannot be null."); + if (technoReference.ObjectId <= 0) + throw new MapFacadeValidationException("A techno reference objectId must be greater than zero."); + + TechnoBase techno = map.Structures.Cast() + .Concat(map.Units) + .Concat(map.Infantry) + .Concat(map.Aircraft) + .FirstOrDefault(candidate => candidate.ObjectId == technoReference.ObjectId); + + return techno ?? throw new MapFacadeValidationException($"Techno objectId {technoReference.ObjectId} does not exist on the current map."); + } + + private House ResolveHouse(string ownerName) + { + if (string.IsNullOrWhiteSpace(ownerName)) + throw new MapFacadeValidationException("An owner house name cannot be empty."); + + return map.GetHouses().Find(house => string.Equals(house.ININame, ownerName, StringComparison.OrdinalIgnoreCase)) ?? + throw new MapFacadeValidationException($"House '{ownerName}' does not exist on the map."); + } + + private static string ResolveMission(string missionName) + { + string mission = Array.Find(ValidMissions, validMission => string.Equals(validMission, missionName, StringComparison.OrdinalIgnoreCase)); + return mission ?? throw new MapFacadeValidationException($"Mission '{missionName}' is not available in the editor."); + } + + private static bool HasAnyModification(MapTechnoModificationProperties properties) + { + return properties.Owner != null || properties.Health.HasValue || properties.Facing.HasValue || properties.AttachedTag != null || properties.ClearAttachedTag || + HasFootModification(properties) || HasBuildingModification(properties); + } + + private static bool HasFootModification(MapTechnoModificationProperties properties) + { + return properties.Mission != null || properties.Veterancy.HasValue || properties.Group.HasValue || properties.OnBridge.HasValue || + properties.AutocreateNoRecruitable.HasValue || properties.AutocreateYesRecruitable.HasValue; + } + + private static bool HasBuildingModification(MapTechnoModificationProperties properties) + { + return properties.AISellable.HasValue || properties.AIRebuildable.HasValue || properties.Powered.HasValue || properties.AIRepairable.HasValue || + properties.Nominal.HasValue || properties.Spotlight.HasValue || properties.Upgrade1 != null || properties.Upgrade2 != null || properties.Upgrade3 != null || + properties.ClearUpgrade1 || properties.ClearUpgrade2 || properties.ClearUpgrade3; } private void ApplyBuildingPlacementProperties(Structure structure, MapBuildingPlacementProperties properties) @@ -814,27 +1077,32 @@ private bool ApplyBuildingUpgrade(Structure structure, string upgradeName, int u if (upgradeName == null) return false; + structure.Upgrades[upgradeIndex] = ResolveBuildingUpgrade(structure.ObjectType, upgradeName, upgradeIndex); + return true; + } + + private BuildingType ResolveBuildingUpgrade(BuildingType buildingType, string upgradeName, int upgradeIndex) + { if (string.IsNullOrWhiteSpace(upgradeName)) throw new MapFacadeValidationException($"Building upgrade #{upgradeIndex + 1} cannot be empty."); - if (upgradeIndex >= structure.ObjectType.Upgrades) + if (upgradeIndex >= buildingType.Upgrades) { throw new MapFacadeValidationException( - $"Building type '{structure.ObjectType.ININame}' does not support upgrade slot #{upgradeIndex + 1}."); + $"Building type '{buildingType.ININame}' does not support upgrade slot #{upgradeIndex + 1}."); } var upgrade = map.Rules.BuildingTypes.Find(bt => string.Equals(bt.ININame, upgradeName, StringComparison.OrdinalIgnoreCase)); if (upgrade == null) throw new MapFacadeValidationException($"Building upgrade type '{upgradeName}' does not exist in the loaded rules."); - if (!string.Equals(upgrade.PowersUpBuilding, structure.ObjectType.ININame, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(upgrade.PowersUpBuilding, buildingType.ININame, StringComparison.OrdinalIgnoreCase)) { throw new MapFacadeValidationException( - $"Building type '{upgrade.ININame}' is not a valid upgrade for '{structure.ObjectType.ININame}'."); + $"Building type '{upgrade.ININame}' is not a valid upgrade for '{buildingType.ININame}'."); } - structure.Upgrades[upgradeIndex] = upgrade; - return true; + return upgrade; } private void ApplyFootPlacementProperties(Foot foot, MapFootPlacementProperties properties, bool supportsOnBridge, bool supportsSubCell) @@ -976,6 +1244,26 @@ private static string GetEffectiveEditorCategory(GameObjectType gameObjectType) return string.IsNullOrWhiteSpace(editorCategory) ? "Uncategorized" : editorCategory; } + private static string NormalizeTechnoRTTI(string rtti) + { + if (string.IsNullOrWhiteSpace(rtti)) + return null; + + string normalizedRTTI = rtti.Trim(); + if (string.Equals(normalizedRTTI, nameof(RTTIType.Building), StringComparison.OrdinalIgnoreCase) || + string.Equals(normalizedRTTI, "Structure", StringComparison.OrdinalIgnoreCase)) + return nameof(RTTIType.Building); + if (string.Equals(normalizedRTTI, nameof(RTTIType.Unit), StringComparison.OrdinalIgnoreCase) || + string.Equals(normalizedRTTI, "Vehicle", StringComparison.OrdinalIgnoreCase)) + return nameof(RTTIType.Unit); + if (string.Equals(normalizedRTTI, nameof(RTTIType.Infantry), StringComparison.OrdinalIgnoreCase)) + return nameof(RTTIType.Infantry); + if (string.Equals(normalizedRTTI, nameof(RTTIType.Aircraft), StringComparison.OrdinalIgnoreCase)) + return nameof(RTTIType.Aircraft); + + throw new MapFacadeValidationException($"RTTI '{rtti}' is not a techno kind. Use Building, Unit, Infantry, or Aircraft."); + } + private static bool ContainsIgnoringCase(string value, string searchValue) { return value?.IndexOf(searchValue, StringComparison.OrdinalIgnoreCase) >= 0; diff --git a/src/TSMapEditor/AI/MapTechnoModificationProperties.cs b/src/TSMapEditor/AI/MapTechnoModificationProperties.cs new file mode 100644 index 000000000..c933076c7 --- /dev/null +++ b/src/TSMapEditor/AI/MapTechnoModificationProperties.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.ComponentModel; + +namespace TSMapEditor.AI; + +public class MapTechnoReference +{ + [Description("Stable ID of a techno currently present on the open map, returned by get_technos or inspect_map_region.")] + public int ObjectId { get; set; } +} + +public class MapTechnoModificationProperties +{ + [Description("INI name of a house returned by get_houses.")] + public string Owner { get; set; } + + [Description("Health from 1 through 256.")] + public int? Health { get; set; } + + [Description("Facing from 0 through 255.")] + public int? Facing { get; set; } + + [Description("Tag ID or unique tag name to attach.")] + public string AttachedTag { get; set; } + + [Description("Whether to remove the currently attached tag. Cannot be combined with attachedTag.")] + public bool ClearAttachedTag { get; set; } + + [Description("Mission for Unit, Infantry, or Aircraft objects.")] + public string Mission { get; set; } + + [Description("Veterancy for Unit, Infantry, or Aircraft objects. Valid values are 0, 50, 100, 150, and 200.")] + public int? Veterancy { get; set; } + + [Description("Group number for Unit, Infantry, or Aircraft objects.")] + public int? Group { get; set; } + + [Description("On-bridge state for Unit or Infantry objects. Not valid for Aircraft.")] + public bool? OnBridge { get; set; } + + public bool? AutocreateNoRecruitable { get; set; } + public bool? AutocreateYesRecruitable { get; set; } + + public bool? AISellable { get; set; } + public bool? AIRebuildable { get; set; } + public bool? Powered { get; set; } + public bool? AIRepairable { get; set; } + public bool? Nominal { get; set; } + + [Description("Building spotlight mode: 0 for none, 1 for reciprocating, or 2 for loop.")] + public int? Spotlight { get; set; } + + public string Upgrade1 { get; set; } + public string Upgrade2 { get; set; } + public string Upgrade3 { get; set; } + public bool ClearUpgrade1 { get; set; } + public bool ClearUpgrade2 { get; set; } + public bool ClearUpgrade3 { get; set; } +} + +public class MapTechnoQueryResult +{ + public MapTechnoQueryResult(int revision, List technos) + { + Revision = revision; + Technos = technos; + } + + public int Revision { get; } + public List Technos { get; } +} diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index 86e61da13..3f220adbb 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -110,6 +110,46 @@ public Task> GetTileSets( return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetTileSets(nameFilter), cancellationToken); } + [McpServerTool(Name = "get_technos", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns unique buildings, vehicles, infantry, and aircraft from the current map with stable object IDs for use with modify_technos.")] + public async Task GetTechnos( + [Description("Optional kind filter: Building, Unit or Vehicle, Infantry, or Aircraft.")] string rtti = null, + [Description("Optional case-insensitive partial INI type-name filter.")] string typeNameFilter = null, + [Description("Optional case-insensitive partial owner-name filter.")] string ownerNameFilter = null, + [Description("Optional X coordinate of a rectangular query area. Must be supplied together with y, width, and height.")] int? x = null, + [Description("Optional Y coordinate of a rectangular query area. Must be supplied together with x, width, and height.")] int? y = null, + [Description("Optional query-area width. Must be supplied together with x, y, and height.")] int? width = null, + [Description("Optional query-area height. Must be supplied together with x, y, and width.")] int? height = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetTechnos)}"); + + Rectangle? area = null; + bool hasAnyAreaValue = x.HasValue || y.HasValue || width.HasValue || height.HasValue; + if (hasAnyAreaValue) + { + if (!x.HasValue || !y.HasValue || !width.HasValue || !height.HasValue) + throw new McpException("x, y, width, and height must all be supplied when filtering technos by area."); + if (width.Value <= 0 || height.Value <= 0) + throw new McpException("The techno query area width and height must both be greater than zero."); + if (width.Value > MaxRegionDimension || height.Value > MaxRegionDimension || (long)width.Value * height.Value > MaxRegionCellCount) + throw new McpException($"The techno query area is too large. Each dimension may be at most {MaxRegionDimension} cells and the total area may be at most {MaxRegionCellCount} cells."); + + area = new Rectangle(x.Value, y.Value, width.Value, height.Value); + } + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.GetTechnos(rtti, typeNameFilter, ownerNameFilter, area), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + [McpServerTool(Name = "inspect_map_region", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] [Description("Returns terrain, overlays, and placed map objects from a rectangular region of the open map.")] public Task> InspectMapRegion( @@ -132,6 +172,28 @@ public Task> InspectMapRegion( cancellationToken); } + [McpServerTool(Name = "modify_technos", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Atomically modifies properties of explicitly referenced technos. The entire batch is one undo entry and one revision bump.")] + public async Task ModifyTechnos( + [Description("One or more object ID references returned by get_technos or inspect_map_region.")] List technos, + [Description("Properties to apply to every selected techno. Properties that do not apply to every selected kind cause the entire call to fail.")] MapTechnoModificationProperties properties, + [Description("Optional map revision returned by get_technos. When supplied, modification fails if the map has changed; omit it to allow concurrent human edits.")] int? expectedRevision = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(ModifyTechnos)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.ModifyTechnos(technos, properties, expectedRevision), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + [McpServerTool(Name = "place_terrain_object", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] [Description("Places one terrain object, such as a tree, on an empty map cell. The placement is added to the editor's undo history.")] public async Task PlaceTerrainObject( diff --git a/src/TSMapEditor/Config/Translations/en/Translation_en.ini b/src/TSMapEditor/Config/Translations/en/Translation_en.ini index d858b7789..1ccd4d36d 100644 --- a/src/TSMapEditor/Config/Translations/en/Translation_en.ini +++ b/src/TSMapEditor/Config/Translations/en/Translation_en.ini @@ -82,6 +82,7 @@ MapLoader.ReadUnits.UnitTypeNotFound=Unable to find unit type {0} - skipping add MapLoader.ReadUnits.UnitOutsideOfMap=Warning: The map has a unit "{0}" ({1}) placed outside of the valid map area at {2}, {3}. MapLoader.ReadInfantry.InfantryTypeNotFound=Unable to find infantry type {0} - skipping adding it to map. MapLoader.ReadInfantry.InfantryOutsideOfMap=Warning: The map has an infantry "{0}" ({1}) placed outside of the valid map area at {2}, {3}. +MapLoader.ReadInfantry.MultipleInfantryOnSameSubCell=The map had multiple infantry on the same subcell "{0}" at {1}, {2}. Infantry of type "{3}" ({4}) was not placed on the map. MapLoader.ReadSmudges.InvalidSmudgeSyntax=Invalid syntax in smudge defined in map: {0} MapLoader.ReadSmudges.InvalidSmudgeSyntaxAtPosition=Invalid syntax in smudge at {0},{1}: {2} MapLoader.ReadSmudges.SmudgeTypeNotFound=Cell at {0},{1} contains a smudge '{2}' that does not exist in Rules.ini. Ignoring it. diff --git a/src/TSMapEditor/Constants.cs b/src/TSMapEditor/Constants.cs index 3201370fb..d042bca70 100644 --- a/src/TSMapEditor/Constants.cs +++ b/src/TSMapEditor/Constants.cs @@ -4,7 +4,7 @@ namespace TSMapEditor { public static class Constants { - public const string ReleaseVersion = "1.8.10"; + public const string ReleaseVersion = "1.9.0"; public static int CellSizeX = 48; public static int CellSizeY = 24; diff --git a/src/TSMapEditor/Helpers.cs b/src/TSMapEditor/Helpers.cs index 91a93cdd9..cce8f1f68 100644 --- a/src/TSMapEditor/Helpers.cs +++ b/src/TSMapEditor/Helpers.cs @@ -848,6 +848,11 @@ public static string DifficultyToTranslatedString(Difficulty difficulty) } } + public static string SubCellToTranslatedString(SubCell subCell) + { + return Translate("SubCell." + subCell.ToString(), subCell.ToString()); + } + /// /// Shared helper function used by Triggers, TaskForces, Scripts, TeamTypes, and AI Triggers. /// Used to generate the name of the instance during the cloning process of those entities. diff --git a/src/TSMapEditor/Initialization/IMap.cs b/src/TSMapEditor/Initialization/IMap.cs index b6cdd22da..2f0fe313e 100644 --- a/src/TSMapEditor/Initialization/IMap.cs +++ b/src/TSMapEditor/Initialization/IMap.cs @@ -62,7 +62,10 @@ public interface IMap void AddScript(Script script); void AddTeamType(TeamType teamType); - void PlaceUnit(Unit unit); + void PlaceBuilding(Structure structure); + void PlaceUnit(Unit unit, bool allowOutOfBounds = false); + void PlaceInfantry(Infantry infantry, bool allowOutOfBounds = false); + void PlaceAircraft(Aircraft aircraft, bool allowOutOfBounds = false); void RemoveUnit(Unit unit); void DoForAllValidTiles(Action action); diff --git a/src/TSMapEditor/Initialization/MapLoader.cs b/src/TSMapEditor/Initialization/MapLoader.cs index 1cf099d4d..d927a0fb7 100644 --- a/src/TSMapEditor/Initialization/MapLoader.cs +++ b/src/TSMapEditor/Initialization/MapLoader.cs @@ -396,29 +396,8 @@ public static void ReadBuildings(IMap map, IniFile mapIni) FindAttachedTag(map, building, attachedTag); - bool isClear = false; - - void ApplyFoundationCell(Point2D cellCoords) - { - var tile = map.GetTile(cellCoords); - if (tile != null) - { - tile.Structures.Add(building); - } - } - - bool IsFoundationCellOnMap(Point2D cellCoords) => map.GetTile(cellCoords) != null; - // Check that the building's origin cell is within the map. If it is not, we should not add the building to the map at all. - if (IsFoundationCellOnMap(building.Position)) - { - isClear = true; - - // Go through foundation cells and register the building to all tiles that are valid on its foundation. - buildingType.ArtConfig.DoForFoundationCoordsOrOrigin(offset => ApplyFoundationCell(building.Position + offset)); - } - - if (!isClear) + if (map.GetTile(building.Position) == null) { AddMapLoadError(string.Format(Translate("MapLoader.CheckFoundationCell.TileOutOfBounds", "Building {0} has been placed outside of the map at {1}. Skipping adding it to map."), @@ -426,9 +405,7 @@ void ApplyFoundationCell(Point2D cellCoords) continue; } - map.Structures.Add(building); - - building.LightTiles(map.Tiles); + map.PlaceBuilding(building); } map.Structures.ForEach(s => s.UpdatePowerUpAnims()); @@ -489,13 +466,9 @@ public static void ReadAircraft(IMap map, IniFile mapIni) FindAttachedTag(map, aircraft, attachedTag); - map.Aircraft.Add(aircraft); var tile = map.GetTile(x, y); - if (tile != null) - { - tile.Aircraft.Add(aircraft); - } - else + map.PlaceAircraft(aircraft, allowOutOfBounds: true); + if (tile == null) { AddMapLoadError(string.Format(Translate("MapLoader.ReadAircraft.AircraftOutsideOfMap", "Warning: The map has an aircraft \"{0}\" ({1}) placed outside of the valid map area at {2}, {3}."), @@ -563,13 +536,9 @@ public static void ReadUnits(IMap map, IniFile mapIni) FindAttachedTag(map, unit, attachedTag); - map.Units.Add(unit); var tile = map.GetTile(x, y); - if (tile != null) - { - tile.Vehicles.Add(unit); - } - else + map.PlaceUnit(unit, allowOutOfBounds: true); + if (tile == null) { AddMapLoadError(string.Format(Translate("MapLoader.ReadUnits.UnitOutsideOfMap", "Warning: The map has a unit \"{0}\" ({1}) placed outside of the valid map area at {2}, {3}."), @@ -646,18 +615,27 @@ public static void ReadInfantry(IMap map, IniFile mapIni) FindAttachedTag(map, infantry, attachedTag); - map.Infantry.Add(infantry); var tile = map.GetTile(x, y); - if (tile != null) - { - tile.Infantry[(int)subCell] = infantry; - } - else + + if (tile == null) { AddMapLoadError(string.Format(Translate("MapLoader.ReadInfantry.InfantryOutsideOfMap", "Warning: The map has an infantry \"{0}\" ({1}) placed outside of the valid map area at {2}, {3}."), infantryType.GetEditorDisplayName(), infantryTypeId, x, y)); } + else + { + if (tile.Infantry[(int)subCell] != null) + { + AddMapLoadError(string.Format(Translate("MapLoader.ReadInfantry.MultipleInfantryOnSameSubCell", + "The map had multiple infantry on the same subcell \"{0}\" at {1}, {2}. Infantry of type \"{3}\" ({4}) was not placed on the map."), + Helpers.SubCellToTranslatedString(subCell), x, y, infantry.ObjectType.GetEditorDisplayName(), infantry.ObjectType.ININame)); + continue; + } + } + + map.PlaceInfantry(infantry, allowOutOfBounds: true); + } Logger.Log("Infantry read successfully."); diff --git a/src/TSMapEditor/Models/Map.cs b/src/TSMapEditor/Models/Map.cs index c2e4db3dd..81a46adb1 100644 --- a/src/TSMapEditor/Models/Map.cs +++ b/src/TSMapEditor/Models/Map.cs @@ -116,6 +116,8 @@ public MapTile GetTile(int x, int y) public List Units { get; private set; } = new List(); public List Structures { get; private set; } = new List(); + private int nextTechnoObjectId = 1; + /// /// The list of standard house types loaded from EditorRules.ini, or Rules.ini as a fallback. /// Relevant only when the map itself has no house types specified. @@ -948,6 +950,8 @@ public void HouseColorUpdated(House house) public void PlaceBuilding(Structure structure) { + EnsureTechnoObjectId(structure); + structure.ObjectType.ArtConfig.DoForFoundationCoordsOrOrigin(offset => { var cell = GetTile(structure.Position + offset); @@ -1002,11 +1006,12 @@ public void MoveBuilding(Structure structure, Point2D newCoords) PlaceBuilding(structure); } - public void PlaceUnit(Unit unit) + public void PlaceUnit(Unit unit, bool allowOutOfBounds = false) { - var cell = GetTile(unit.Position); + var cell = allowOutOfBounds ? GetTile(unit.Position) : GetTileOrFail(unit.Position); - cell.Vehicles.Add(unit); + EnsureTechnoObjectId(unit); + cell?.Vehicles.Add(unit); Units.Add(unit); } @@ -1032,13 +1037,15 @@ public void MoveUnit(Unit unit, Point2D newCoords) PlaceUnit(unit); } - public void PlaceInfantry(Infantry infantry) + public void PlaceInfantry(Infantry infantry, bool allowOutOfBounds = false) { - var cell = GetTile(infantry.Position); - if (cell.Infantry[(int)infantry.SubCell] != null) + var cell = allowOutOfBounds ? GetTile(infantry.Position) : GetTileOrFail(infantry.Position); + if (cell != null && cell.Infantry[(int)infantry.SubCell] != null) throw new InvalidOperationException("Cannot place infantry on an occupied sub-cell spot!"); - cell.Infantry[(int)infantry.SubCell] = infantry; + EnsureTechnoObjectId(infantry); + if (cell != null) + cell.Infantry[(int)infantry.SubCell] = infantry; Infantry.Add(infantry); } @@ -1067,11 +1074,12 @@ public void MoveInfantry(Infantry infantry, Point2D newCoords) PlaceInfantry(infantry); } - public void PlaceAircraft(Aircraft aircraft) + public void PlaceAircraft(Aircraft aircraft, bool allowOutOfBounds = false) { - var cell = GetTile(aircraft.Position); + var cell = allowOutOfBounds ? GetTile(aircraft.Position) : GetTileOrFail(aircraft.Position); - cell.Aircraft.Add(aircraft); + EnsureTechnoObjectId(aircraft); + cell?.Aircraft.Add(aircraft); Aircraft.Add(aircraft); } @@ -1097,6 +1105,15 @@ public void MoveAircraft(Aircraft aircraft, Point2D newCoords) PlaceAircraft(aircraft); } + private void EnsureTechnoObjectId(TechnoBase techno) + { + if (techno.ObjectId == 0) + { + techno.ObjectId = nextTechnoObjectId + 1; + nextTechnoObjectId++; + } + } + public void AddTerrainObject(TerrainObject terrainObject) { var cell = GetTile(terrainObject.Position); diff --git a/src/TSMapEditor/Models/Structure.cs b/src/TSMapEditor/Models/Structure.cs index 451089de5..42c262a21 100644 --- a/src/TSMapEditor/Models/Structure.cs +++ b/src/TSMapEditor/Models/Structure.cs @@ -321,7 +321,7 @@ public Point2D GetSouthernmostFoundationCell() public override Structure Clone() { - var clone = MemberwiseClone() as Structure; + var clone = (Structure)base.Clone(); clone.Upgrades = Upgrades.ToArray(); diff --git a/src/TSMapEditor/Models/Techno.cs b/src/TSMapEditor/Models/Techno.cs index 1b963e99f..f5f7df28c 100644 --- a/src/TSMapEditor/Models/Techno.cs +++ b/src/TSMapEditor/Models/Techno.cs @@ -42,10 +42,25 @@ public TechnoBase() } public virtual House Owner { get; set; } + + /// + /// Unique object ID for this instance. + /// Assigned when the object is placed on map. + /// When zero or negative, this object should not be considered as placed on the map. + /// + public int ObjectId { get; set; } + public int HP { get; set; } public virtual byte Facing { get; set; } public Tag AttachedTag { get; set; } + public override AbstractObject Clone() + { + var clone = (TechnoBase)base.Clone(); + clone.ObjectId = 0; + return clone; + } + public abstract double GetWeaponRange(); public abstract double GetGuardRange(); public abstract double GetGapGeneratorRange(); diff --git a/src/TSMapEditor/Mutations/Classes/AIMutations/ModifyTechnosMutation.cs b/src/TSMapEditor/Mutations/Classes/AIMutations/ModifyTechnosMutation.cs new file mode 100644 index 000000000..0901c7f37 --- /dev/null +++ b/src/TSMapEditor/Mutations/Classes/AIMutations/ModifyTechnosMutation.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using TSMapEditor.Models; +using TSMapEditor.UI; + +namespace TSMapEditor.Mutations.Classes.AIMutations; + +public sealed class TechnoPropertiesSnapshot +{ + public static TechnoPropertiesSnapshot Capture(TechnoBase techno) + { + var snapshot = new TechnoPropertiesSnapshot + { + RTTI = techno.WhatAmI(), + Owner = techno.Owner, + Health = techno.HP, + Facing = techno.Facing, + AttachedTag = techno.AttachedTag + }; + + switch (techno) + { + case Unit unit: + snapshot.CaptureFoot(unit); + break; + case Infantry infantry: + snapshot.CaptureFoot(infantry); + break; + case Aircraft aircraft: + snapshot.CaptureFoot(aircraft); + break; + case Structure structure: + snapshot.AISellable = structure.AISellable; + snapshot.AIRebuildable = structure.AIRebuildable; + snapshot.Powered = structure.Powered; + snapshot.AIRepairable = structure.AIRepairable; + snapshot.Nominal = structure.Nominal; + snapshot.Spotlight = structure.Spotlight; + snapshot.Upgrades = structure.Upgrades.ToArray(); + break; + default: + throw new ArgumentException($"Unsupported techno type {techno.WhatAmI()}.", nameof(techno)); + } + + return snapshot; + } + + public RTTIType RTTI { get; private set; } + public House Owner { get; set; } + public int Health { get; set; } + public byte Facing { get; set; } + public Tag AttachedTag { get; set; } + + public string Mission { get; set; } + public int Veterancy { get; set; } + public int Group { get; set; } + public bool OnBridge { get; set; } + public bool AutocreateNoRecruitable { get; set; } + public bool AutocreateYesRecruitable { get; set; } + + public bool AISellable { get; set; } + public bool AIRebuildable { get; set; } + public bool Powered { get; set; } + public bool AIRepairable { get; set; } + public bool Nominal { get; set; } + public SpotlightType Spotlight { get; set; } + public BuildingType[] Upgrades { get; set; } + + public void ApplyTo(TechnoBase techno) + { + if (techno.WhatAmI() != RTTI) + throw new InvalidOperationException($"Cannot apply {RTTI} properties to {techno.WhatAmI()}."); + + techno.Owner = Owner; + techno.HP = Health; + techno.Facing = Facing; + techno.AttachedTag = AttachedTag; + + switch (techno) + { + case Unit unit: + ApplyFoot(unit); + break; + case Infantry infantry: + ApplyFoot(infantry); + break; + case Aircraft aircraft: + ApplyFoot(aircraft); + break; + case Structure structure: + structure.AISellable = AISellable; + structure.AIRebuildable = AIRebuildable; + structure.Powered = Powered; + structure.AIRepairable = AIRepairable; + structure.Nominal = Nominal; + structure.Spotlight = Spotlight; + Array.Copy(Upgrades, structure.Upgrades, structure.Upgrades.Length); + structure.UpdatePowerUpAnims(); + break; + } + } + + public bool HasSameValuesAs(TechnoPropertiesSnapshot other) + { + if (other == null || RTTI != other.RTTI || Owner != other.Owner || Health != other.Health || Facing != other.Facing || AttachedTag != other.AttachedTag) + return false; + + if (RTTI == RTTIType.Building) + { + return AISellable == other.AISellable && AIRebuildable == other.AIRebuildable && Powered == other.Powered && + AIRepairable == other.AIRepairable && Nominal == other.Nominal && Spotlight == other.Spotlight && Upgrades.SequenceEqual(other.Upgrades); + } + + return Mission == other.Mission && Veterancy == other.Veterancy && Group == other.Group && OnBridge == other.OnBridge && + AutocreateNoRecruitable == other.AutocreateNoRecruitable && AutocreateYesRecruitable == other.AutocreateYesRecruitable; + } + + private void CaptureFoot(Foot foot) where T : TechnoType + { + Mission = foot.Mission; + Veterancy = foot.Veterancy; + Group = foot.Group; + OnBridge = foot.High; + AutocreateNoRecruitable = foot.AutocreateNoRecruitable; + AutocreateYesRecruitable = foot.AutocreateYesRecruitable; + } + + private void ApplyFoot(Foot foot) where T : TechnoType + { + foot.Mission = Mission; + foot.Veterancy = Veterancy; + foot.Group = Group; + foot.High = OnBridge; + foot.AutocreateNoRecruitable = AutocreateNoRecruitable; + foot.AutocreateYesRecruitable = AutocreateYesRecruitable; + } +} + +public sealed class TechnoPropertyChange +{ + public TechnoPropertyChange(TechnoBase techno, TechnoPropertiesSnapshot oldProperties, TechnoPropertiesSnapshot newProperties) + { + Techno = techno; + OldProperties = oldProperties; + NewProperties = newProperties; + } + + public TechnoBase Techno { get; } + public TechnoPropertiesSnapshot OldProperties { get; } + public TechnoPropertiesSnapshot NewProperties { get; } +} + +public sealed class ModifyTechnosMutation : Mutation +{ + public ModifyTechnosMutation(IMutationTarget mutationTarget, List changes) : base(mutationTarget) + { + this.changes = changes ?? throw new ArgumentNullException(nameof(changes)); + if (changes.Count == 0) + throw new ArgumentException("At least one techno property change must be provided.", nameof(changes)); + } + + private readonly List changes; + + public override string GetDisplayString() + { + return $"Modify properties of {changes.Count} techno object(s)"; + } + + public override void Perform() + { + foreach (var change in changes) + ApplyProperties(change.Techno, change.NewProperties); + } + + public override void Undo() + { + for (int i = changes.Count - 1; i >= 0; i--) + ApplyProperties(changes[i].Techno, changes[i].OldProperties); + } + + private void ApplyProperties(TechnoBase techno, TechnoPropertiesSnapshot properties) + { + bool refreshLighting = techno is Structure structure && structure.Powered != properties.Powered && structure.ObjectType.LightIntensity != 0.0; + properties.ApplyTo(techno); + MutationTarget.AddRefreshPoint(techno.Position); + + if (refreshLighting) + Map.RefreshCellLighting(MutationTarget.LightingPreviewState, MutationTarget.LightDisabledLightSources, ((Structure)techno).LitTiles); + } +} diff --git a/src/TSMapEditor/UI/Windows/InfantryOptionsWindow.cs b/src/TSMapEditor/UI/Windows/InfantryOptionsWindow.cs index a5df433c9..4196bd7aa 100644 --- a/src/TSMapEditor/UI/Windows/InfantryOptionsWindow.cs +++ b/src/TSMapEditor/UI/Windows/InfantryOptionsWindow.cs @@ -108,7 +108,7 @@ public void Open(Infantry infantry) private void RefreshValues() { - string subCellString = Translate("SubCell." + infantry.SubCell.ToString(), infantry.SubCell.ToString()); + string subCellString = Helpers.SubCellToTranslatedString(infantry.SubCell); lblSelectedInfantryValue.Text = string.Format(Translate(this, nameof(lblSelectedInfantryValue) + ".Format", "{0}, sub cell: {1}"), infantry.ObjectType.GetEditorDisplayName(), subCellString); trbStrength.Value = infantry.HP; From 19e1a55f399aebf6b64052e16c0a5b311ecaec4c Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sun, 2 Aug 2026 01:18:02 +0300 Subject: [PATCH 09/27] Add map object deletion capabilities to MCP server --- src/TSMapEditor/AI/MapFacade.cs | 147 ++++++++++++++++++ .../AI/MapTerrainObjectReference.cs | 15 ++ src/TSMapEditor/AI/MapTools.cs | 44 ++++++ .../AIMutations/DeleteMapObjectsMutation.cs | 89 +++++++++++ .../Mutations/Classes/PlaceOverlayMutation.cs | 9 +- 5 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 src/TSMapEditor/AI/MapTerrainObjectReference.cs create mode 100644 src/TSMapEditor/Mutations/Classes/AIMutations/DeleteMapObjectsMutation.cs diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index eee95691f..b100b5ed6 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -297,6 +297,9 @@ public MapFacadeValidationException(string message) : base(message) public class MapFacade { private const int MaxTechnoQueryResults = 1_000; + private const int MaxObjectDeletionCount = 1_000; + private const int MaxMapOperationDimension = 256; + private const int MaxMapOperationCellCount = 10_000; private static readonly string[] ValidMissions = new[] { @@ -543,6 +546,85 @@ public MapEditResult ModifyTechnos(List technoReferences, Ma return new MapEditResult(mutationManager.Revision, affectedCells); } + public MapEditResult DeleteObjects(List technoReferences, List terrainObjectReferences) + { + int technoReferenceCount = technoReferences?.Count ?? 0; + int terrainObjectReferenceCount = terrainObjectReferences?.Count ?? 0; + int totalReferenceCount = technoReferenceCount + terrainObjectReferenceCount; + + if (totalReferenceCount == 0) + throw new MapFacadeValidationException("At least one techno or terrain object reference must be provided."); + if (totalReferenceCount > MaxObjectDeletionCount) + throw new MapFacadeValidationException($"At most {MaxObjectDeletionCount} objects can be deleted in one call."); + + var objects = new List(totalReferenceCount); + if (technoReferences != null) + { + foreach (var technoReference in technoReferences) + { + var techno = ResolveTechnoReference(technoReference); + if (map.GetTile(techno.Position) == null) + throw new MapFacadeValidationException($"Techno objectId {techno.ObjectId} is outside the valid map area and cannot be deleted through the MCP server."); + + objects.Add(techno); + } + } + + if (terrainObjectReferences != null) + { + foreach (var terrainObjectReference in terrainObjectReferences) + objects.Add(ResolveTerrainObjectReference(terrainObjectReference)); + } + + if (objects.Distinct().Count() != objects.Count) + throw new MapFacadeValidationException("The object reference lists contain duplicates."); + + var affectedMapTiles = GetAffectedMapTiles(objects); + mutationManager.PerformMutation(new DeleteMapObjectsMutation(mutationTarget, objects)); + + return new MapEditResult( + mutationManager.Revision, + affectedMapTiles.Select(mapTile => CellInfo.FromMapCell(map, mapTile)).ToList()); + } + + public MapEditResult EraseOverlay(int x, int y, int width, int height) + { + if (width <= 0 || height <= 0) + throw new MapFacadeValidationException("The overlay erasure width and height must both be greater than zero."); + + if (width > MaxMapOperationDimension || height > MaxMapOperationDimension || (long)width * height > MaxMapOperationCellCount) + { + throw new MapFacadeValidationException( + $"The overlay erasure area is too large. Each dimension may be at most {MaxMapOperationDimension} cells and the total area may be at most {MaxMapOperationCellCount} cells."); + } + + var overlayTiles = new List(); + for (int yOffset = 0; yOffset < height; yOffset++) + { + for (int xOffset = 0; xOffset < width; xOffset++) + { + var mapTile = map.GetTile(x + xOffset, y + yOffset); + if (mapTile?.Overlay != null) + overlayTiles.Add(mapTile); + } + } + + if (overlayTiles.Count == 0) + throw new MapFacadeValidationException($"The requested {width}x{height} area at ({x}, {y}) contains no overlay."); + + var affectedMapTiles = overlayTiles + .SelectMany(mapTile => GetMapTileAndSurroundings(mapTile.CoordsToPoint())) + .Distinct() + .ToList(); + + var mutation = new PlaceOverlayMutation(mutationTarget, null, null, new Point2D(x, y), new BrushSize(width, height)); + mutationManager.PerformMutation(mutation); + + return new MapEditResult( + mutationManager.Revision, + affectedMapTiles.Select(mapTile => CellInfo.FromMapCell(map, mapTile)).ToList()); + } + public MapEditResult PlaceTerrainObject(string terrainTypeName, int x, int y) { if (string.IsNullOrWhiteSpace(terrainTypeName)) @@ -734,6 +816,7 @@ public MapEditResult PlaceInfantry(string infantryTypeName, string ownerName, in Position = cellCoords, SubCell = freeSubCell }; + ApplyFootPlacementProperties(infantry, properties, true, true); mutationManager.PerformMutation(new PlaceInfantryMutation(mutationTarget, infantry)); @@ -774,6 +857,7 @@ public MapEditResult PlaceVehicle(string vehicleTypeName, string ownerName, int Owner = owner, Position = cellCoords }; + ApplyFootPlacementProperties(vehicle, properties, true, false); if (!map.CanPlaceObjectAt(vehicle, cellCoords, false, allowOverlap)) @@ -1004,6 +1088,69 @@ private TechnoBase ResolveTechnoReference(MapTechnoReference technoReference) return techno ?? throw new MapFacadeValidationException($"Techno objectId {technoReference.ObjectId} does not exist on the current map."); } + private TerrainObject ResolveTerrainObjectReference(MapTerrainObjectReference terrainObjectReference) + { + if (terrainObjectReference == null) + throw new MapFacadeValidationException("A terrain object reference cannot be null."); + + if (string.IsNullOrWhiteSpace(terrainObjectReference.ININame)) + throw new MapFacadeValidationException("A terrain object reference must include an INI name."); + + var mapTile = map.GetTile(terrainObjectReference.X, terrainObjectReference.Y); + if (mapTile == null) + throw new MapFacadeValidationException($"Cell ({terrainObjectReference.X}, {terrainObjectReference.Y}) is outside the map."); + + var terrainObject = mapTile.TerrainObject; + if (terrainObject == null) + throw new MapFacadeValidationException($"Cell ({terrainObjectReference.X}, {terrainObjectReference.Y}) does not contain a terrain object."); + + if (!string.Equals(terrainObject.TerrainType.ININame, terrainObjectReference.ININame, StringComparison.OrdinalIgnoreCase)) + { + throw new MapFacadeValidationException( + $"Cell ({terrainObjectReference.X}, {terrainObjectReference.Y}) contains terrain object '{terrainObject.TerrainType.ININame}', not '{terrainObjectReference.ININame}'."); + } + + return terrainObject; + } + + private List GetAffectedMapTiles(List objects) + { + var affectedMapTiles = new List(); + foreach (var mapObject in objects) + { + if (mapObject is Structure structure) + { + structure.ObjectType.ArtConfig.DoForFoundationCoordsOrOrigin(offset => + { + var mapTile = map.GetTile(structure.Position + offset); + if (mapTile != null) + affectedMapTiles.Add(mapTile); + }); + } + else + { + var mapTile = map.GetTile(mapObject.Position); + if (mapTile != null) + affectedMapTiles.Add(mapTile); + } + } + + return affectedMapTiles.Distinct().ToList(); + } + + private IEnumerable GetMapTileAndSurroundings(Point2D cellCoords) + { + for (int yOffset = -1; yOffset <= 1; yOffset++) + { + for (int xOffset = -1; xOffset <= 1; xOffset++) + { + var mapTile = map.GetTile(cellCoords + new Point2D(xOffset, yOffset)); + if (mapTile != null) + yield return mapTile; + } + } + } + private House ResolveHouse(string ownerName) { if (string.IsNullOrWhiteSpace(ownerName)) diff --git a/src/TSMapEditor/AI/MapTerrainObjectReference.cs b/src/TSMapEditor/AI/MapTerrainObjectReference.cs new file mode 100644 index 000000000..1e4e861b2 --- /dev/null +++ b/src/TSMapEditor/AI/MapTerrainObjectReference.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace TSMapEditor.AI; + +public class MapTerrainObjectReference +{ + [Description("X coordinate returned for the terrain object by inspect_map_region.")] + public int X { get; set; } + + [Description("Y coordinate returned for the terrain object by inspect_map_region.")] + public int Y { get; set; } + + [Description("INI name returned for the terrain object by inspect_map_region. Prevents deleting a different terrain object type at the same cell.")] + public string ININame { get; set; } +} diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index 3f220adbb..b68202fde 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -194,6 +194,50 @@ public async Task ModifyTechnos( } } + [McpServerTool(Name = "delete_objects", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Atomically deletes explicitly referenced technos and terrain objects. The entire batch is one undo entry and one revision bump.")] + public async Task DeleteObjects( + [Description("Optional techno object ID references returned by get_technos or inspect_map_region.")] List technos = null, + [Description("Optional terrain object coordinate and INI-name references returned by inspect_map_region.")] List terrainObjects = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(DeleteObjects)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.DeleteObjects(technos, terrainObjects), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + + [McpServerTool(Name = "erase_overlay", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Erases all overlay from a rectangular map area. The operation is one undo entry and also updates neighboring Tiberium frames.")] + public async Task EraseOverlay( + [Description("X coordinate of the area's top-left cell.")] int x, + [Description("Y coordinate of the area's top-left cell.")] int y, + [Description("Area width in cells. Defaults to 1.")] int width = 1, + [Description("Area height in cells. Defaults to 1.")] int height = 1, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(EraseOverlay)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.EraseOverlay(x, y, width, height), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + [McpServerTool(Name = "place_terrain_object", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] [Description("Places one terrain object, such as a tree, on an empty map cell. The placement is added to the editor's undo history.")] public async Task PlaceTerrainObject( diff --git a/src/TSMapEditor/Mutations/Classes/AIMutations/DeleteMapObjectsMutation.cs b/src/TSMapEditor/Mutations/Classes/AIMutations/DeleteMapObjectsMutation.cs new file mode 100644 index 000000000..92016f7f9 --- /dev/null +++ b/src/TSMapEditor/Mutations/Classes/AIMutations/DeleteMapObjectsMutation.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using TSMapEditor.Models; +using TSMapEditor.UI; + +namespace TSMapEditor.Mutations.Classes.AIMutations; + +public sealed class DeleteMapObjectsMutation : Mutation +{ + public DeleteMapObjectsMutation(IMutationTarget mutationTarget, List objects) : base(mutationTarget) + { + this.objects = objects ?? throw new ArgumentNullException(nameof(objects)); + if (objects.Count == 0) + throw new ArgumentException("At least one map object must be provided.", nameof(objects)); + } + + private readonly List objects; + + public override string GetDisplayString() + { + return $"Delete {objects.Count} map object(s)"; + } + + public override void Perform() + { + foreach (var mapObject in objects) + { + RemoveObject(mapObject); + MutationTarget.AddRefreshPoint(mapObject.Position); + } + } + + public override void Undo() + { + for (int i = objects.Count - 1; i >= 0; i--) + { + RestoreObject(objects[i]); + MutationTarget.AddRefreshPoint(objects[i].Position); + } + } + + private void RemoveObject(GameObject mapObject) + { + switch (mapObject) + { + case Structure structure: + Map.RemoveBuilding(structure); + break; + case Unit unit: + Map.RemoveUnit(unit); + break; + case Infantry infantry: + Map.RemoveInfantry(infantry); + break; + case Aircraft aircraft: + Map.RemoveAircraft(aircraft); + break; + case TerrainObject terrainObject: + Map.RemoveTerrainObject(terrainObject); + break; + default: + throw new InvalidOperationException($"Cannot delete map object of type {mapObject.WhatAmI()}."); + } + } + + private void RestoreObject(GameObject mapObject) + { + switch (mapObject) + { + case Structure structure: + Map.PlaceBuilding(structure); + break; + case Unit unit: + Map.PlaceUnit(unit); + break; + case Infantry infantry: + Map.PlaceInfantry(infantry); + break; + case Aircraft aircraft: + Map.PlaceAircraft(aircraft); + break; + case TerrainObject terrainObject: + Map.AddTerrainObject(terrainObject); + break; + default: + throw new InvalidOperationException($"Cannot restore map object of type {mapObject.WhatAmI()}."); + } + } +} diff --git a/src/TSMapEditor/Mutations/Classes/PlaceOverlayMutation.cs b/src/TSMapEditor/Mutations/Classes/PlaceOverlayMutation.cs index 14f045f46..d8dc0cd6b 100644 --- a/src/TSMapEditor/Mutations/Classes/PlaceOverlayMutation.cs +++ b/src/TSMapEditor/Mutations/Classes/PlaceOverlayMutation.cs @@ -11,12 +11,17 @@ namespace TSMapEditor.Mutations.Classes /// class PlaceOverlayMutation : Mutation, ICheckableMutation { - public PlaceOverlayMutation(IMutationTarget mutationTarget, OverlayType overlayType, int? forcedFrameIndex, Point2D cellCoords) : base(mutationTarget) + public PlaceOverlayMutation(IMutationTarget mutationTarget, OverlayType overlayType, int? forcedFrameIndex, Point2D cellCoords) + : this(mutationTarget, overlayType, forcedFrameIndex, cellCoords, mutationTarget.BrushSize) + { + } + + public PlaceOverlayMutation(IMutationTarget mutationTarget, OverlayType overlayType, int? forcedFrameIndex, Point2D cellCoords, BrushSize brush) : base(mutationTarget) { this.overlayType = overlayType; this.forcedFrameIndex = forcedFrameIndex; this.cellCoords = cellCoords; - brush = mutationTarget.BrushSize; + this.brush = brush ?? throw new ArgumentNullException(nameof(brush)); } private readonly OverlayType overlayType; From 3ce711b3368dafd296ff63c4caf93d80ce7e6db3 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sun, 2 Aug 2026 01:50:55 +0300 Subject: [PATCH 10/27] Add MCP capabilities for placing overlay --- src/TSMapEditor/AI/MapFacade.cs | 181 ++++++++++++++++++ src/TSMapEditor/AI/MapOverlayTypeInfo.cs | 65 +++++++ src/TSMapEditor/AI/MapTools.cs | 69 +++++++ .../Classes/PlaceConnectedOverlayMutation.cs | 10 +- 4 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 src/TSMapEditor/AI/MapOverlayTypeInfo.cs diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index b100b5ed6..2ffdf6008 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -350,6 +350,62 @@ public List GetTerrainTypes(string nameFilter = null) .ToList(); } + public List GetOverlayTypes(string nameFilter = null) + { + string normalizedFilter = nameFilter?.Trim(); + + return map.Rules.OverlayTypes + .Where(overlayType => overlayType.EditorVisible && overlayType.IsValidForTheater(map.LoadedTheaterName)) + .Select(overlayType => new MapOverlayTypeInfo( + overlayType.ININame, + overlayType.GetEditorDisplayName(), + GetEffectiveEditorCategory(overlayType), + GetPlaceableOverlayFrameCount(overlayType), + overlayType.Tiberium, + overlayType.Wall, + overlayType.WaterBound, + overlayType.IsVeins, + overlayType.IsVeinholeMonster, + map.EditorConfig.ConnectedOverlays + .Where(connectedOverlay => connectedOverlay.Frames.TrueForAll(frame => frame.OverlayType.IsValidForTheater(map.LoadedTheaterName))) + .Where(connectedOverlay => connectedOverlay.Frames.Exists(frame => frame.OverlayType == overlayType)) + .Select(connectedOverlay => connectedOverlay.Name) + .ToList())) + .Where(typeInfo => string.IsNullOrWhiteSpace(normalizedFilter) || + ContainsIgnoringCase(typeInfo.ININame, normalizedFilter) || + ContainsIgnoringCase(typeInfo.UIName, normalizedFilter) || + ContainsIgnoringCase(typeInfo.EditorCategory, normalizedFilter) || + typeInfo.ConnectedOverlayNames.Exists(name => ContainsIgnoringCase(name, normalizedFilter))) + .OrderBy(typeInfo => typeInfo.EditorCategory) + .ThenBy(typeInfo => typeInfo.UIName) + .ThenBy(typeInfo => typeInfo.ININame) + .ToList(); + } + + public List GetConnectedOverlayTypes(string nameFilter = null) + { + string normalizedFilter = nameFilter?.Trim(); + + return map.EditorConfig.ConnectedOverlays + .Where(connectedOverlay => connectedOverlay.Frames.TrueForAll(frame => frame.OverlayType.IsValidForTheater(map.LoadedTheaterName))) + .Select(connectedOverlay => new MapConnectedOverlayTypeInfo( + connectedOverlay.Name, + connectedOverlay.UIName, + connectedOverlay.ConnectionMask, + connectedOverlay.RelatedOverlays.Select(relatedOverlay => relatedOverlay.Name).ToList(), + connectedOverlay.Frames + .Select(frame => new MapConnectedOverlayFrameInfo(frame.OverlayType.ININame, frame.FrameIndex, frame.ConnectsTo)) + .ToList())) + .Where(typeInfo => string.IsNullOrWhiteSpace(normalizedFilter) || + ContainsIgnoringCase(typeInfo.Name, normalizedFilter) || + ContainsIgnoringCase(typeInfo.UIName, normalizedFilter) || + typeInfo.RelatedOverlayNames.Exists(name => ContainsIgnoringCase(name, normalizedFilter)) || + typeInfo.Frames.Exists(frame => ContainsIgnoringCase(frame.OverlayININame, normalizedFilter))) + .OrderBy(typeInfo => typeInfo.UIName) + .ThenBy(typeInfo => typeInfo.Name) + .ToList(); + } + public List GetBuildingTypes(string nameFilter = null) { string normalizedFilter = nameFilter?.Trim(); @@ -625,6 +681,73 @@ public MapEditResult EraseOverlay(int x, int y, int width, int height) affectedMapTiles.Select(mapTile => CellInfo.FromMapCell(map, mapTile)).ToList()); } + public MapEditResult PlaceOverlay(string overlayTypeName, int x, int y, int width, int height, int? frameIndex) + { + if (string.IsNullOrWhiteSpace(overlayTypeName)) + throw new MapFacadeValidationException("An overlay type INI name must be provided."); + + var overlayType = map.Rules.OverlayTypes.Find(candidate => string.Equals(candidate.ININame, overlayTypeName, StringComparison.OrdinalIgnoreCase)); + + if (overlayType == null) + throw new MapFacadeValidationException($"Overlay type '{overlayTypeName}' does not exist in the loaded rules."); + if (!overlayType.EditorVisible) + throw new MapFacadeValidationException($"Overlay type '{overlayType.ININame}' is not available for placement in the editor."); + if (!overlayType.IsValidForTheater(map.LoadedTheaterName)) + throw new MapFacadeValidationException($"Overlay type '{overlayType.ININame}' is not valid for theater '{map.LoadedTheaterName}'."); + + ValidateOverlayFrame(overlayType, frameIndex ?? 0); + + var targetMapTiles = GetValidatedMapTilesInArea(x, y, width, height, "overlay placement"); + + var affectedMapTiles = targetMapTiles + .SelectMany(mapTile => GetMapTileAndSurroundings(mapTile.CoordsToPoint())) + .Distinct() + .ToList(); + + var mutation = new PlaceOverlayMutation(mutationTarget, overlayType, frameIndex, new Point2D(x, y), new BrushSize(width, height)); + + if (!mutation.ShouldPerform()) + throw new MapFacadeValidationException($"The requested area already contains overlay '{overlayType.ININame}' with the requested frame settings."); + + mutationManager.PerformMutation(mutation); + + return new MapEditResult( + mutationManager.Revision, + affectedMapTiles.Select(mapTile => CellInfo.FromMapCell(map, mapTile)).ToList()); + } + + public MapEditResult PlaceConnectedOverlay(string connectedOverlayName, int x, int y, int width, int height) + { + if (string.IsNullOrWhiteSpace(connectedOverlayName)) + throw new MapFacadeValidationException("A connected overlay type name must be provided."); + + var connectedOverlay = map.EditorConfig.ConnectedOverlays.Find( + candidate => string.Equals(candidate.Name, connectedOverlayName, StringComparison.OrdinalIgnoreCase)); + if (connectedOverlay == null) + throw new MapFacadeValidationException($"Connected overlay type '{connectedOverlayName}' does not exist in the editor configuration."); + if (!connectedOverlay.Frames.TrueForAll(frame => frame.OverlayType.IsValidForTheater(map.LoadedTheaterName))) + throw new MapFacadeValidationException($"Connected overlay type '{connectedOverlay.Name}' is not valid for theater '{map.LoadedTheaterName}'."); + + foreach (var connectedOverlayFrame in connectedOverlay.Frames) + ValidateOverlayFrame(connectedOverlayFrame.OverlayType, connectedOverlayFrame.FrameIndex); + + var targetMapTiles = GetValidatedMapTilesInArea(x, y, width, height, "connected overlay placement"); + var affectedMapTiles = targetMapTiles + .SelectMany(mapTile => GetMapTileAndSurroundings(mapTile.CoordsToPoint())) + .Distinct() + .ToList(); + + mutationManager.PerformMutation(new PlaceConnectedOverlayMutation( + mutationTarget, + connectedOverlay, + new Point2D(x, y), + new BrushSize(width, height))); + + return new MapEditResult( + mutationManager.Revision, + affectedMapTiles.Select(mapTile => CellInfo.FromMapCell(map, mapTile)).ToList()); + } + public MapEditResult PlaceTerrainObject(string terrainTypeName, int x, int y) { if (string.IsNullOrWhiteSpace(terrainTypeName)) @@ -1151,6 +1274,64 @@ private IEnumerable GetMapTileAndSurroundings(Point2D cellCoords) } } + private List GetValidatedMapTilesInArea(int x, int y, int width, int height, string operationName) + { + if (width <= 0 || height <= 0) + throw new MapFacadeValidationException($"The {operationName} width and height must both be greater than zero."); + + if (width > MaxMapOperationDimension || height > MaxMapOperationDimension || (long)width * height > MaxMapOperationCellCount) + { + throw new MapFacadeValidationException( + $"The {operationName} area is too large. Each dimension may be at most {MaxMapOperationDimension} cells and the total area may be at most {MaxMapOperationCellCount} cells."); + } + + var mapTiles = new List(width * height); + for (int yOffset = 0; yOffset < height; yOffset++) + { + for (int xOffset = 0; xOffset < width; xOffset++) + { + int targetX = x + xOffset; + int targetY = y + yOffset; + var mapTile = map.GetTile(targetX, targetY); + if (mapTile == null) + throw new MapFacadeValidationException($"The {operationName} area includes cell ({targetX}, {targetY}), which is outside the map."); + + mapTiles.Add(mapTile); + } + } + + return mapTiles; + } + + private int GetPlaceableOverlayFrameCount(OverlayType overlayType) + { + var overlayTextures = mutationTarget.TheaterGraphics.OverlayTextures; + if (overlayTextures == null || overlayType.Index < 0 || overlayType.Index >= overlayTextures.Length) + return 0; + + if (overlayTextures[overlayType.Index] == null) + return 0; + + return mutationTarget.TheaterGraphics.GetOverlayFrameCount(overlayType); + } + + private void ValidateOverlayFrame(OverlayType overlayType, int frameIndex) + { + int placeableFrameCount = GetPlaceableOverlayFrameCount(overlayType); + if (placeableFrameCount == 0) + throw new MapFacadeValidationException($"Overlay type '{overlayType.ININame}' has no graphics for the current theater."); + + if (frameIndex < 0 || frameIndex >= placeableFrameCount) + { + throw new MapFacadeValidationException( + $"Overlay type '{overlayType.ININame}' has {placeableFrameCount} placeable frames; frameIndex must be from 0 through {placeableFrameCount - 1}. " + + "Higher raw SHP frame indexes are reserved for engine-managed shadows and cannot be placed directly."); + } + + if (mutationTarget.TheaterGraphics.OverlayTextures[overlayType.Index].GetFrame(frameIndex) == null) + throw new MapFacadeValidationException($"Frame {frameIndex} of overlay type '{overlayType.ININame}' has no valid graphics."); + } + private House ResolveHouse(string ownerName) { if (string.IsNullOrWhiteSpace(ownerName)) diff --git a/src/TSMapEditor/AI/MapOverlayTypeInfo.cs b/src/TSMapEditor/AI/MapOverlayTypeInfo.cs new file mode 100644 index 000000000..79e2ab64c --- /dev/null +++ b/src/TSMapEditor/AI/MapOverlayTypeInfo.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; + +namespace TSMapEditor.AI; + +public class MapOverlayTypeInfo +{ + public MapOverlayTypeInfo(string iniName, string uiName, string editorCategory, int frameCount, bool tiberium, bool wall, bool waterBound, + bool isVeins, bool isVeinholeMonster, List connectedOverlayNames) + { + ININame = iniName; + UIName = uiName; + EditorCategory = editorCategory; + FrameCount = frameCount; + Tiberium = tiberium; + Wall = wall; + WaterBound = waterBound; + IsVeins = isVeins; + IsVeinholeMonster = isVeinholeMonster; + ConnectedOverlayNames = connectedOverlayNames; + } + + public string ININame { get; } + public string UIName { get; } + public string EditorCategory { get; } + public int FrameCount { get; } + public bool Tiberium { get; } + public bool Wall { get; } + public bool WaterBound { get; } + public bool IsVeins { get; } + public bool IsVeinholeMonster { get; } + public List ConnectedOverlayNames { get; } +} + +public class MapConnectedOverlayFrameInfo +{ + public MapConnectedOverlayFrameInfo(string overlayININame, int frameIndex, int connectsTo) + { + OverlayININame = overlayININame; + FrameIndex = frameIndex; + ConnectsTo = connectsTo; + } + + public string OverlayININame { get; } + public int FrameIndex { get; } + public int ConnectsTo { get; } +} + +public class MapConnectedOverlayTypeInfo +{ + public MapConnectedOverlayTypeInfo(string name, string uiName, int connectionMask, List relatedOverlayNames, + List frames) + { + Name = name; + UIName = uiName; + ConnectionMask = connectionMask; + RelatedOverlayNames = relatedOverlayNames; + Frames = frames; + } + + public string Name { get; } + public string UIName { get; } + public int ConnectionMask { get; } + public List RelatedOverlayNames { get; } + public List Frames { get; } +} diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index b68202fde..557307c43 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -50,6 +50,26 @@ public Task> GetTerrainTypes( return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetTerrainTypes(nameFilter), cancellationToken); } + [McpServerTool(Name = "get_overlay_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns regular overlay types that are visible in the editor and valid for the current map's theater, including placeable frame counts and connected-overlay memberships. frameCount counts placeable artwork only; higher raw SHP frames, conventionally the upper half, are engine-managed shadow data.")] + public Task> GetOverlayTypes( + [Description("Optional case-insensitive filter matched against INI name, UI name, editor category, and connected-overlay name.")] string nameFilter = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetOverlayTypes)}"); + return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetOverlayTypes(nameFilter), cancellationToken); + } + + [McpServerTool(Name = "get_connected_overlay_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns WAE connected-overlay configurations valid for the current map's theater, including their connection masks and underlying overlay frames. Use place_connected_overlay for automatic connections, or place_overlay with this frame data for exact manual placement.")] + public Task> GetConnectedOverlayTypes( + [Description("Optional case-insensitive filter matched against configuration name, UI name, related configuration names, and underlying overlay INI names.")] string nameFilter = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetConnectedOverlayTypes)}"); + return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetConnectedOverlayTypes(nameFilter), cancellationToken); + } + [McpServerTool(Name = "get_building_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] [Description("Returns building types that are visible in the editor and valid for the current map's theater, including foundation dimensions.")] public Task> GetBuildingTypes( @@ -238,6 +258,55 @@ public async Task EraseOverlay( } } + [McpServerTool(Name = "place_overlay", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Places a regular overlay in a rectangular map area, replacing existing overlay. Omit frameIndex to use frame 0 and let WAE automatically smooth Tiberium; specify a placeable artwork frame for exact manual placement. Raw SHP frames at or above the frameCount returned by get_overlay_types are engine-managed shadows and are rejected. The operation is one undo entry and one revision bump.")] + public async Task PlaceOverlay( + [Description("INI name of an overlay type returned by get_overlay_types.")] string overlayTypeName, + [Description("X coordinate of the area's top-left cell.")] int x, + [Description("Y coordinate of the area's top-left cell.")] int y, + [Description("Area width in cells. Defaults to 1.")] int width = 1, + [Description("Area height in cells. Defaults to 1.")] int height = 1, + [Description("Optional zero-based placeable artwork frame index, which must be lower than frameCount from get_overlay_types. The upper raw SHP half contains engine-managed shadow frames and cannot be placed. Omit it for WAE's normal placement behavior and automatic Tiberium smoothing.")] int? frameIndex = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(PlaceOverlay)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.PlaceOverlay(overlayTypeName, x, y, width, height, frameIndex), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + + [McpServerTool(Name = "place_connected_overlay", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Places a WAE connected-overlay configuration in a rectangular map area, replacing existing overlay and automatically selecting frames and reconnecting neighboring members. The operation is one undo entry and one revision bump.")] + public async Task PlaceConnectedOverlay( + [Description("Connected-overlay configuration name returned by get_connected_overlay_types.")] string connectedOverlayName, + [Description("X coordinate of the area's top-left cell.")] int x, + [Description("Y coordinate of the area's top-left cell.")] int y, + [Description("Area width in cells. Defaults to 1.")] int width = 1, + [Description("Area height in cells. Defaults to 1.")] int height = 1, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(PlaceConnectedOverlay)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.PlaceConnectedOverlay(connectedOverlayName, x, y, width, height), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + [McpServerTool(Name = "place_terrain_object", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] [Description("Places one terrain object, such as a tree, on an empty map cell. The placement is added to the editor's undo history.")] public async Task PlaceTerrainObject( diff --git a/src/TSMapEditor/Mutations/Classes/PlaceConnectedOverlayMutation.cs b/src/TSMapEditor/Mutations/Classes/PlaceConnectedOverlayMutation.cs index eb46f29d3..66e968222 100644 --- a/src/TSMapEditor/Mutations/Classes/PlaceConnectedOverlayMutation.cs +++ b/src/TSMapEditor/Mutations/Classes/PlaceConnectedOverlayMutation.cs @@ -54,10 +54,16 @@ protected void PlaceConnectedOverlay(MapTile tile) /// class PlaceConnectedOverlayMutation : ConnectedOverlayMutationBase, ICheckableMutation { - public PlaceConnectedOverlayMutation(IMutationTarget mutationTarget, ConnectedOverlayType connectedOverlayType, Point2D cellCoords) : base(mutationTarget, connectedOverlayType) + public PlaceConnectedOverlayMutation(IMutationTarget mutationTarget, ConnectedOverlayType connectedOverlayType, Point2D cellCoords) + : this(mutationTarget, connectedOverlayType, cellCoords, mutationTarget.BrushSize) + { + } + + public PlaceConnectedOverlayMutation(IMutationTarget mutationTarget, ConnectedOverlayType connectedOverlayType, Point2D cellCoords, BrushSize brush) + : base(mutationTarget, connectedOverlayType) { this.cellCoords = cellCoords; - brush = mutationTarget.BrushSize; + this.brush = brush ?? throw new ArgumentNullException(nameof(brush)); } private readonly BrushSize brush; From 75214d9aadcc8cb5f82b06812c95d662e07775f1 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sun, 2 Aug 2026 02:47:46 +0300 Subject: [PATCH 11/27] Add waypoint placement and waypoint information fetching capabilities to MCP server --- src/TSMapEditor/AI/MapFacade.cs | 78 ++++++++++++++++++++++++++- src/TSMapEditor/AI/MapTools.cs | 43 ++++++++++++++- src/TSMapEditor/AI/MapWaypointInfo.cs | 19 +++++++ 3 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 src/TSMapEditor/AI/MapWaypointInfo.cs diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index 2ffdf6008..ccfac6901 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -113,7 +113,8 @@ public MapFootInfo(string rtti, int objectId, int index, int x, int y, string in public class CellInfo { public CellInfo(int x, int y, string tileSetName, int tileIndex, int tileIndexInTileSet, int subTileIndex, int height, - MapObjectInfo terrainObjectInfo, MapOverlayInfo overlayInfo, List buildingInfos, List footInfos) + MapObjectInfo terrainObjectInfo, MapOverlayInfo overlayInfo, List buildingInfos, List footInfos, + List waypointInfos) { X = x; Y = y; @@ -126,6 +127,7 @@ public CellInfo(int x, int y, string tileSetName, int tileIndex, int tileIndexIn OverlayInfo = overlayInfo; BuildingInfos = buildingInfos; FootInfos = footInfos; + WaypointInfos = waypointInfos; } public int X { get; } @@ -139,6 +141,7 @@ public CellInfo(int x, int y, string tileSetName, int tileIndex, int tileIndexIn public MapOverlayInfo OverlayInfo { get; } public List BuildingInfos { get; } public List FootInfos { get; } + public List WaypointInfos { get; } public static CellInfo FromMapCell(Map map, MapTile mapTile) { @@ -152,10 +155,21 @@ public static CellInfo FromMapCell(Map map, MapTile mapTile) var vehicleInfos = mapTile.Vehicles.Select(v => (MapFootInfo)FromTechno(map, v)); var infantryInfos = mapTile.Infantry.Where(i => i != null).Select(i => (MapFootInfo)FromTechno(map, i)); var aircraftInfos = mapTile.Aircraft.Select(a => (MapFootInfo)FromTechno(map, a)); + var waypointInfos = mapTile.Waypoints.OrderBy(waypoint => waypoint.Identifier).Select(FromWaypoint).ToList(); return new CellInfo(mapTile.X, mapTile.Y, tileSet.SetName, mapTile.TileIndex, mapTile.TileIndex - tileSet.StartTileIndex, mapTile.SubTileIndex, mapTile.Level, terrainObjectInfo, overlayInfo, buildingInfos, - vehicleInfos.Concat(infantryInfos).Concat(aircraftInfos).ToList()); + vehicleInfos.Concat(infantryInfos).Concat(aircraftInfos).ToList(), waypointInfos); + } + + public static MapWaypointInfo FromWaypoint(Waypoint waypoint) + { + return new MapWaypointInfo( + waypoint.Identifier, + waypoint.Position.X, + waypoint.Position.Y, + waypoint.EditorColor, + waypoint.Identifier >= 0 && waypoint.Identifier < Constants.MultiplayerMaxPlayers); } public static MapTechnoInfo FromTechno(Map map, TechnoBase techno) @@ -511,6 +525,21 @@ public List GetHouses(string nameFilter = null) .ToList(); } + public List GetWaypoints(int? identifier = null) + { + if (identifier.HasValue && (identifier.Value < 0 || identifier.Value >= Constants.MaxWaypoint)) + { + throw new MapFacadeValidationException( + $"Waypoint identifier must be from 0 through {Constants.MaxWaypoint - 1}."); + } + + return map.Waypoints + .Where(waypoint => !identifier.HasValue || waypoint.Identifier == identifier.Value) + .OrderBy(waypoint => waypoint.Identifier) + .Select(CellInfo.FromWaypoint) + .ToList(); + } + public List GetTileSets(string nameFilter = null) { string normalizedFilter = nameFilter?.Trim(); @@ -748,6 +777,30 @@ public MapEditResult PlaceConnectedOverlay(string connectedOverlayName, int x, i affectedMapTiles.Select(mapTile => CellInfo.FromMapCell(map, mapTile)).ToList()); } + public MapEditResult PlaceWaypoint(int identifier, int x, int y, string editorColor) + { + if (identifier < 0 || identifier >= Constants.MaxWaypoint) + { + throw new MapFacadeValidationException( + $"Waypoint identifier must be from 0 through {Constants.MaxWaypoint - 1}."); + } + + if (map.Waypoints.Exists(waypoint => waypoint.Identifier == identifier)) + throw new MapFacadeValidationException($"Waypoint {identifier} already exists on the map."); + + var cellCoords = new Point2D(x, y); + var mapTile = map.GetTile(cellCoords); + if (mapTile == null) + throw new MapFacadeValidationException($"Cell ({x}, {y}) is outside the map."); + + string resolvedEditorColor = ResolveWaypointColor(editorColor); + mutationManager.PerformMutation(new PlaceWaypointMutation(mutationTarget, cellCoords, identifier, resolvedEditorColor)); + + return new MapEditResult( + mutationManager.Revision, + new List { CellInfo.FromMapCell(map, mapTile) }); + } + public MapEditResult PlaceTerrainObject(string terrainTypeName, int x, int y) { if (string.IsNullOrWhiteSpace(terrainTypeName)) @@ -1341,6 +1394,27 @@ private House ResolveHouse(string ownerName) throw new MapFacadeValidationException($"House '{ownerName}' does not exist on the map."); } + private static string ResolveWaypointColor(string editorColor) + { + if (editorColor == null) + return null; + + if (string.IsNullOrWhiteSpace(editorColor)) + throw new MapFacadeValidationException("A waypoint editor color cannot be empty."); + + int colorIndex = Array.FindIndex( + Waypoint.SupportedColors, + supportedColor => string.Equals(supportedColor.Name, editorColor.Trim(), StringComparison.OrdinalIgnoreCase)); + + if (colorIndex < 0) + { + throw new MapFacadeValidationException( + $"Waypoint editor color '{editorColor}' is not supported. Valid colors are: {string.Join(", ", Waypoint.SupportedColors.Select(color => color.Name))}."); + } + + return Waypoint.SupportedColors[colorIndex].Name; + } + private static string ResolveMission(string missionName) { string mission = Array.Find(ValidMissions, validMission => string.Equals(validMission, missionName, StringComparison.OrdinalIgnoreCase)); diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index 557307c43..08240909d 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -120,6 +120,24 @@ public Task> GetHouses( return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetHouses(nameFilter), cancellationToken); } + [McpServerTool(Name = "get_waypoints", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns waypoints placed on the current map, ordered by identifier. Waypoints 0 through 7 are multiplayer starting locations. Waypoints are also included in inspect_map_region cell results.")] + public async Task> GetWaypoints( + [Description("Optional exact waypoint identifier. Omit it to return every waypoint on the map.")] int? identifier = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetWaypoints)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync(() => mapFacade.GetWaypoints(identifier), cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + [McpServerTool(Name = "get_tile_sets", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] [Description("Returns tile sets that are available for placement in the current map's theater.")] public Task> GetTileSets( @@ -171,7 +189,7 @@ public async Task GetTechnos( } [McpServerTool(Name = "inspect_map_region", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] - [Description("Returns terrain, overlays, and placed map objects from a rectangular region of the open map.")] + [Description("Returns terrain, overlays, waypoints, and placed map objects from a rectangular region of the open map.")] public Task> InspectMapRegion( [Description("X coordinate of the region's top-left cell.")] int x, [Description("Y coordinate of the region's top-left cell.")] int y, @@ -307,6 +325,29 @@ public async Task PlaceConnectedOverlay( } } + [McpServerTool(Name = "place_waypoint", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Places a uniquely numbered waypoint on a map cell. Waypoints 0 through 7 are multiplayer starting locations. Multiple differently numbered waypoints may share a cell. The operation is one undo entry and one revision bump.")] + public async Task PlaceWaypoint( + [Description("Unique waypoint identifier. Valid values are determined by the editor's configured waypoint limit; 0 through 7 denote multiplayer starting locations.")] int identifier, + [Description("X coordinate of the destination cell.")] int x, + [Description("Y coordinate of the destination cell.")] int y, + [Description("Optional editor-only display color. Supported values include Teal, Green, Dark Green, Lime Green, Yellow, Orange, Red, Blood Red, Pink, Cherry, Purple, Sky Blue, Blue, Brown, and Metalic.")] string editorColor = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(PlaceWaypoint)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.PlaceWaypoint(identifier, x, y, editorColor), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + [McpServerTool(Name = "place_terrain_object", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] [Description("Places one terrain object, such as a tree, on an empty map cell. The placement is added to the editor's undo history.")] public async Task PlaceTerrainObject( diff --git a/src/TSMapEditor/AI/MapWaypointInfo.cs b/src/TSMapEditor/AI/MapWaypointInfo.cs new file mode 100644 index 000000000..fdcf09329 --- /dev/null +++ b/src/TSMapEditor/AI/MapWaypointInfo.cs @@ -0,0 +1,19 @@ +namespace TSMapEditor.AI; + +public class MapWaypointInfo +{ + public MapWaypointInfo(int identifier, int x, int y, string editorColor, bool isMultiplayerStartingLocation) + { + Identifier = identifier; + X = x; + Y = y; + EditorColor = editorColor; + IsMultiplayerStartingLocation = isMultiplayerStartingLocation; + } + + public int Identifier { get; } + public int X { get; } + public int Y { get; } + public string EditorColor { get; } + public bool IsMultiplayerStartingLocation { get; } +} From 7e7ecc25aaebb1a9031ad25e0661360fd5790f35 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sun, 2 Aug 2026 14:40:24 +0300 Subject: [PATCH 12/27] Add map screenshotting capability to MCP server --- src/TSMapEditor/AI/MCPServer.cs | 7 +- src/TSMapEditor/AI/MapTools.cs | 52 +++- src/TSMapEditor/Rendering/MapView.cs | 380 +++++++++++++++++++++++---- src/TSMapEditor/UI/MapUI.cs | 9 +- src/TSMapEditor/UI/UIManager.cs | 2 +- 5 files changed, 388 insertions(+), 62 deletions(-) diff --git a/src/TSMapEditor/AI/MCPServer.cs b/src/TSMapEditor/AI/MCPServer.cs index 91fd3634c..3a1de37da 100644 --- a/src/TSMapEditor/AI/MCPServer.cs +++ b/src/TSMapEditor/AI/MCPServer.cs @@ -8,6 +8,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using TSMapEditor.Rendering; namespace TSMapEditor.AI; @@ -18,14 +19,16 @@ public sealed class MCPServer : IDisposable private static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(5.0); - public MCPServer(WindowManager windowManager, MapFacade mapFacade) + public MCPServer(WindowManager windowManager, MapFacade mapFacade, IMapScreenCropper mapScreenCropper) { this.windowManager = windowManager; this.mapFacade = mapFacade; + this.mapScreenCropper = mapScreenCropper; } private readonly WindowManager windowManager; private readonly MapFacade mapFacade; + private readonly IMapScreenCropper mapScreenCropper; private readonly CancellationTokenSource shutdownCancellationTokenSource = new CancellationTokenSource(); private WebApplication application; @@ -48,6 +51,7 @@ public async Task StartAsync(CancellationToken cancellationToken = default) builder.Configuration["AllowedHosts"] = "localhost;127.0.0.1;[::1]"; builder.Services.AddSingleton(mapFacade); + builder.Services.AddSingleton(mapScreenCropper); builder.Services.AddSingleton(new GameThreadDispatcher(windowManager, shutdownCancellationTokenSource.Token)); builder.Services .AddMcpServer() @@ -67,6 +71,7 @@ public void Dispose() disposed = true; shutdownCancellationTokenSource.Cancel(); + mapScreenCropper.StopScreenCropRequests(); WebApplication applicationToDispose = application; application = null; diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index 08240909d..faa0727d6 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -1,11 +1,13 @@ using Microsoft.Xna.Framework; using ModelContextProtocol; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Rampastring.Tools; using System.Collections.Generic; using System.ComponentModel; using System.Threading; using System.Threading.Tasks; +using TSMapEditor.Rendering; namespace TSMapEditor.AI; @@ -14,15 +16,18 @@ public sealed class MapTools { private const int MaxRegionDimension = 256; private const int MaxRegionCellCount = 10_000; + private const int MaxScreenshotPixelCount = 8_000_000; - public MapTools(MapFacade mapFacade, GameThreadDispatcher gameThreadDispatcher) + public MapTools(MapFacade mapFacade, GameThreadDispatcher gameThreadDispatcher, IMapScreenCropper mapScreenCropper) { this.mapFacade = mapFacade; this.gameThreadDispatcher = gameThreadDispatcher; + this.mapScreenCropper = mapScreenCropper; } private readonly MapFacade mapFacade; private readonly GameThreadDispatcher gameThreadDispatcher; + private readonly IMapScreenCropper mapScreenCropper; [McpServerTool(Name = "get_map_info", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] [Description("Returns basic information about the map currently open in the World-Altering Editor.")] @@ -210,6 +215,51 @@ public Task> InspectMapRegion( cancellationToken); } + [McpServerTool(Name = "screenshot_map_region", ReadOnly = true, OpenWorld = false)] + [Description("Renders the entire open map and returns a PNG screenshot of the axis-aligned pixel bounds for a rectangular region of cells. In normal 3D mode, the image includes fixed vertical padding above those bounds for terrain at the maximum supported height. Because the map is isometric, the requested cells form a diamond within the returned rectangular image, whose corners can contain map content outside the requested cells.")] + public async Task ScreenshotMapRegion( + [Description("X coordinate of the region's top-left cell.")] int x, + [Description("Y coordinate of the region's top-left cell.")] int y, + [Description("Width of the region in cells.")] int width, + [Description("Height of the region in cells.")] int height, + CancellationToken cancellationToken) + { + Logger.Log($"{nameof(MapTools)}.{nameof(ScreenshotMapRegion)}"); + + if (width <= 0 || height <= 0) + throw new McpException("The region width and height must both be greater than zero."); + + if (width > MaxRegionDimension || height > MaxRegionDimension || (long)width * height > MaxRegionCellCount) + throw new McpException($"The requested region is too large. Each dimension may be at most {MaxRegionDimension} cells and the total area may be at most {MaxRegionCellCount} cells."); + + long projectedPixelWidth = ((long)width + height - 2L) * (Constants.CellSizeX / 2L) + Constants.CellSizeX; + long projectedPixelHeight = ((long)width + height - 2L) * (Constants.CellSizeY / 2L) + Constants.CellSizeY + Constants.MapYBaseline; + if (projectedPixelWidth * projectedPixelHeight > MaxScreenshotPixelCount) + { + throw new McpException( + $"The requested screenshot would be too large ({projectedPixelWidth}x{projectedPixelHeight} pixels). " + + $"The projected image may contain at most {MaxScreenshotPixelCount} pixels."); + } + + if (!mapScreenCropper.TryRequestScreenCrop( + new Rectangle(x, y, width, height), + cancellationToken, + out Task screenCropTask)) + { + throw new McpException("The renderer is already busy with a previous screen-crop request."); + } + + try + { + byte[] pngData = await screenCropTask.ConfigureAwait(false); + return ImageContentBlock.FromBytes(pngData, "image/png"); + } + catch (MapScreenCropException ex) + { + throw new McpException(ex.Message); + } + } + [McpServerTool(Name = "modify_technos", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] [Description("Atomically modifies properties of explicitly referenced technos. The entire batch is one undo entry and one revision bump.")] public async Task ModifyTechnos( diff --git a/src/TSMapEditor/Rendering/MapView.cs b/src/TSMapEditor/Rendering/MapView.cs index ffd182284..4128990f8 100644 --- a/src/TSMapEditor/Rendering/MapView.cs +++ b/src/TSMapEditor/Rendering/MapView.cs @@ -8,6 +8,8 @@ using System.Globalization; using System.IO; using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; using TSMapEditor.GameMath; using TSMapEditor.Models; using TSMapEditor.Models.Enums; @@ -19,6 +21,19 @@ namespace TSMapEditor.Rendering { + public interface IMapScreenCropper + { + bool TryRequestScreenCrop(Rectangle cellRectangle, CancellationToken cancellationToken, out Task screenCropTask); + void StopScreenCropRequests(); + } + + public sealed class MapScreenCropException : Exception + { + public MapScreenCropException(string message) : base(message) + { + } + } + public interface IMapView { Map Map { get; } @@ -34,8 +49,41 @@ public interface IMapView /// /// The renderer. Draws the map. /// - public class MapView : IMapView + public class MapView : IMapView, IMapScreenCropper { + private sealed class ScreenCropRequest : IDisposable + { + public ScreenCropRequest( + Rectangle cellRectangle, + CancellationToken cancellationToken, + Action cancellationCallback) + { + CellRectangle = cellRectangle; + CancellationToken = cancellationToken; + completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + cancellationTokenRegistration = cancellationToken.Register( + () => + { + completionSource.TrySetCanceled(cancellationToken); + cancellationCallback(this); + }); + } + + private readonly TaskCompletionSource completionSource; + private readonly CancellationTokenRegistration cancellationTokenRegistration; + + public Rectangle CellRectangle { get; } + public Rectangle CalculatedPixelRectangle { get; set; } + public CancellationToken CancellationToken { get; } + public bool IsProcessing { get; set; } + public Task Task => completionSource.Task; + + public void TrySetResult(byte[] pngData) => completionSource.TrySetResult(pngData); + public void TrySetException(Exception exception) => completionSource.TrySetException(exception); + + public void Dispose() => cancellationTokenRegistration.Dispose(); + } + struct WaypointDrawStruct { public Waypoint Waypoint; @@ -132,6 +180,11 @@ public MapView(WindowManager windowManager, Map map, TheaterGraphics theaterGrap private bool mapInvalidated; private bool cameraMoved; private bool minimapNeedsRefresh; + private bool renderingWholeMapForScreenCrop; + + private readonly object screenCropRequestLock = new object(); + private ScreenCropRequest screenCropRequest; + private bool acceptingScreenCropRequests = true; private List structuresToRender = new List(); private List flatOverlaysToRender = new List(); @@ -168,6 +221,216 @@ public void AddRefreshPoint(Point2D point, int size = 1) InvalidateMap(); } + #region Screen-crop support for MCP + + public bool TryRequestScreenCrop(Rectangle cellRectangle, CancellationToken cancellationToken, out Task screenCropTask) + { + cancellationToken.ThrowIfCancellationRequested(); + + ScreenCropRequest canceledRequest = null; + ScreenCropRequest staleCanceledRequest = null; + + lock (screenCropRequestLock) + { + if (!acceptingScreenCropRequests) + { + screenCropTask = Task.FromException( + new MapScreenCropException("The map renderer is not available.")); + return true; + } + + if (screenCropRequest != null && + screenCropRequest.CancellationToken.IsCancellationRequested && + !screenCropRequest.IsProcessing) + { + staleCanceledRequest = screenCropRequest; + screenCropRequest = null; + } + + if (screenCropRequest != null) + { + screenCropTask = null; + return false; + } + + var request = new ScreenCropRequest(cellRectangle, cancellationToken, ScreenCropRequest_Canceled); + request.CalculatedPixelRectangle = GetScreenCropSourceRectangle(cellRectangle); + screenCropRequest = request; + screenCropTask = request.Task; + + if (cancellationToken.IsCancellationRequested) + { + screenCropRequest = null; + canceledRequest = request; + } + } + + staleCanceledRequest?.Dispose(); + canceledRequest?.Dispose(); + return true; + } + + private void ScreenCropRequest_Canceled(ScreenCropRequest request) + { + lock (screenCropRequestLock) + { + if (ReferenceEquals(screenCropRequest, request) && !request.IsProcessing) + screenCropRequest = null; + } + } + + public void StopScreenCropRequests() + { + ScreenCropRequest request; + bool releaseRequest; + + lock (screenCropRequestLock) + { + acceptingScreenCropRequests = false; + request = screenCropRequest; + releaseRequest = request != null && !request.IsProcessing; + + if (releaseRequest) + screenCropRequest = null; + } + + if (request == null) + return; + + request.TrySetException(new MapScreenCropException("The MCP server stopped before the screenshot could be captured.")); + + if (releaseRequest) + request.Dispose(); + } + + private ScreenCropRequest TryBeginScreenCropRequest() + { + ScreenCropRequest canceledRequest = null; + + lock (screenCropRequestLock) + { + if (screenCropRequest == null || screenCropRequest.IsProcessing) + return null; + + if (screenCropRequest.CancellationToken.IsCancellationRequested) + { + canceledRequest = screenCropRequest; + screenCropRequest = null; + } + else + { + screenCropRequest.IsProcessing = true; + return screenCropRequest; + } + } + + canceledRequest.Dispose(); + return null; + } + + private Rectangle GetScreenCropSourceRectangle(Rectangle cellRectangle) + { + if (compositeRenderTarget == null) + throw new MapScreenCropException("The map renderer is not available."); + + long rightCellX = (long)cellRectangle.X + cellRectangle.Width - 1L; + long bottomCellY = (long)cellRectangle.Y + cellRectangle.Height - 1L; + long halfCellWidth = Constants.CellSizeX / 2L; + long halfCellHeight = Constants.CellSizeY / 2L; + + long left = ((long)cellRectangle.X - 1L) * halfCellWidth + ((long)Map.Size.X - bottomCellY) * halfCellWidth; + long top = ((long)cellRectangle.X - 1L) * halfCellHeight - ((long)Map.Size.X - cellRectangle.Y) * halfCellHeight + Constants.MapYBaseline; + long right = (rightCellX - 1L) * halfCellWidth + ((long)Map.Size.X - cellRectangle.Y) * halfCellWidth + Constants.CellSizeX; + long bottom = (rightCellX - 1L) * halfCellHeight - ((long)Map.Size.X - bottomCellY) * halfCellHeight + Constants.MapYBaseline + Constants.CellSizeY; + + // Terrain is drawn upwards from its flat cell position. Keep a stable logical-cell crop while + // reserving enough space above it for terrain at the maximum supported height level. + if (!EditorState.Is2DMode) + top -= Constants.MapYBaseline; + + if (left < 0L || top < 0L || right > compositeRenderTarget.Width || bottom > compositeRenderTarget.Height || + right <= left || bottom <= top) + { + throw new MapScreenCropException("The requested screenshot region projects outside the map texture."); + } + + return new Rectangle((int)left, (int)top, (int)(right - left), (int)(bottom - top)); + } + + private byte[] CaptureScreenCrop(Rectangle sourceRectangle) + { + using var cropRenderTarget = new RenderTarget2D( + GraphicsDevice, + sourceRectangle.Width, + sourceRectangle.Height, + false, + SurfaceFormat.Color, + DepthFormat.None); + + Renderer.PushRenderTarget(cropRenderTarget); + + GraphicsDevice.Clear(Color.Black); + Renderer.DrawTexture( + compositeRenderTarget, + sourceRectangle, + new Rectangle(0, 0, cropRenderTarget.Width, cropRenderTarget.Height), + Color.White); + + Renderer.PopRenderTarget(); + + using var stream = new MemoryStream(); + cropRenderTarget.SaveAsPng(stream, cropRenderTarget.Width, cropRenderTarget.Height); + return stream.ToArray(); + } + + private void CompleteScreenCropRequest(ScreenCropRequest request, Rectangle sourceRectangle) + { + // No need for exception handling here because the caller already has a try-catch + if (!request.CancellationToken.IsCancellationRequested && !request.Task.IsCompleted) + request.TrySetResult(CaptureScreenCrop(sourceRectangle)); + + renderingWholeMapForScreenCrop = false; + ReleaseScreenCropRequest(request); + } + + private void FailScreenCropRequest(ScreenCropRequest request, Exception exception) + { + request.TrySetException(exception); + renderingWholeMapForScreenCrop = false; + ReleaseScreenCropRequest(request); + } + + private void ReleaseScreenCropRequest(ScreenCropRequest request) + { + lock (screenCropRequestLock) + { + if (ReferenceEquals(screenCropRequest, request)) + screenCropRequest = null; + } + + request.Dispose(); + } + + private void FailPendingScreenCropRequest() + { + ScreenCropRequest request; + + lock (screenCropRequestLock) + { + acceptingScreenCropRequests = false; + request = screenCropRequest; + screenCropRequest = null; + } + + if (request == null) + return; + + request.TrySetException(new MapScreenCropException("The map renderer stopped before the screenshot could be captured.")); + request.Dispose(); + } + + #endregion + /// /// Schedules the visible portion of the map to be re-rendered /// on the next frame. @@ -226,6 +489,8 @@ public void Initialize() public void Clear() { + FailPendingScreenCropRequest(); + EditorState = null; TheaterGraphics = null; MapWideOverlay.Clear(); @@ -504,7 +769,7 @@ private void DoForVisibleCells(Action action) int camRight; int camBottom; - if (minimapNeedsRefresh && MinimapUsers.Count > 0) + if (renderingWholeMapForScreenCrop || (minimapNeedsRefresh && MinimapUsers.Count > 0)) { // If the minimap needs a full refresh, then we need to re-render the whole map tlX = 0; @@ -822,7 +1087,7 @@ private void DrawWaypoints() if (cell != null && !EditorState.Is2DMode) drawPoint -= new Point2D(0, cell.Level * Constants.CellHeight); - if (MinimapUsers.Count == 0 && + if (!renderingWholeMapForScreenCrop && MinimapUsers.Count == 0 && (Camera.TopLeftPoint.X > drawPoint.X + EditorGraphics.TileBorderTexture.Width || Camera.TopLeftPoint.Y > drawPoint.Y + EditorGraphics.TileBorderTexture.Height || GetCameraRightXCoord() < drawPoint.X || @@ -867,39 +1132,6 @@ private void DrawWaypoints() } } - private void DrawWaypoint(Waypoint waypoint) - { - Point2D drawPoint = CellMath.CellTopLeftPointFromCellCoords(waypoint.Position, Map); - - var cell = Map.GetTile(waypoint.Position); - if (cell != null && !EditorState.Is2DMode) - drawPoint -= new Point2D(0, cell.Level * Constants.CellHeight); - - if (MinimapUsers.Count == 0 && - (Camera.TopLeftPoint.X > drawPoint.X + EditorGraphics.TileBorderTexture.Width || - Camera.TopLeftPoint.Y > drawPoint.Y + EditorGraphics.TileBorderTexture.Height || - GetCameraRightXCoord() < drawPoint.X || - GetCameraBottomYCoord() < drawPoint.Y)) - { - // This waypoint is outside the camera - return; - } - - Color waypointColor = string.IsNullOrEmpty(waypoint.EditorColor) ? Color.Fuchsia : waypoint.XNAColor; - var drawRectangle = new Rectangle(drawPoint.X, drawPoint.Y, EditorGraphics.GenericTileTexture.Width, EditorGraphics.GenericTileTexture.Height); - - Renderer.DrawTexture(EditorGraphics.GenericTileTexture, drawRectangle, new Color(0, 0, 0, 128)); - Renderer.DrawTexture(EditorGraphics.TileBorderTexture, drawRectangle, waypointColor); - - int fontIndex = Constants.UIBoldFont; - string waypointIdentifier = waypoint.Identifier.ToString(); - var textDimensions = Renderer.GetTextDimensions(waypointIdentifier, fontIndex); - Renderer.DrawStringWithShadow(waypointIdentifier, - fontIndex, - new Vector2(drawPoint.X + ((Constants.CellSizeX - textDimensions.X) / 2), drawPoint.Y + ((Constants.CellSizeY - textDimensions.Y) / 2)), - waypointColor); - } - private void DrawCellTags() { DoForVisibleCells(t => @@ -1152,7 +1384,7 @@ private void RecordBaseNode(GraphicalBaseNode graphicalBaseNode) // Base nodes can be large, let's increase the level of padding for them. int padding = Constants.RenderPixelPadding * 2; - if (MinimapUsers.Count == 0 && + if (!renderingWholeMapForScreenCrop && MinimapUsers.Count == 0 && (Camera.TopLeftPoint.X > drawPoint.X + padding || Camera.TopLeftPoint.Y > drawPoint.Y + padding || GetCameraRightXCoord() < drawPoint.X - padding || GetCameraBottomYCoord() < drawPoint.Y - padding)) { @@ -1614,37 +1846,69 @@ private static void DrawArrow(Vector2 start, Vector2 end, public void Draw(bool isActive, TechnoBase technoUnderCursor, MapTile tileUnderCursor, CursorAction cursorAction) { - if (isActive && tileUnderCursor != null && cursorAction != null) + ScreenCropRequest currentScreenCropRequest = TryBeginScreenCropRequest(); + Rectangle screenCropSourceRectangle = Rectangle.Empty; + + if (currentScreenCropRequest != null) { - cursorAction.PreMapDraw(tileUnderCursor.CoordsToPoint()); + screenCropSourceRectangle = currentScreenCropRequest.CalculatedPixelRectangle; + + renderingWholeMapForScreenCrop = true; + InvalidateMap(); } - if (mapInvalidated || cameraMoved) + try { - DrawVisibleMapPortion(); - mapInvalidated = false; - cameraMoved = false; - } + if (isActive && tileUnderCursor != null && cursorAction != null) + { + cursorAction.PreMapDraw(tileUnderCursor.CoordsToPoint()); + } - CalculateMapRenderRectangles(); + if (mapInvalidated || cameraMoved) + { + DrawVisibleMapPortion(); + mapInvalidated = false; + cameraMoved = false; + } - DrawPerFrameTransparentElements(technoUnderCursor); + CalculateMapRenderRectangles(); - DrawWorld(); + DrawPerFrameTransparentElements(technoUnderCursor); - if (EditorState.DrawMapWideOverlay) - { - MapWideOverlay.Draw(new Rectangle( - (int)(-Camera.TopLeftPoint.X * Camera.ZoomLevel), - (int)((-Camera.TopLeftPoint.Y + Constants.MapYBaseline) * Camera.ZoomLevel), - (int)(mapRenderTarget.Width * Camera.ZoomLevel), - (int)((mapRenderTarget.Height - Constants.MapYBaseline) * Camera.ZoomLevel))); - } + DrawWorld(); + + if (currentScreenCropRequest != null) + { + ScreenCropRequest requestToComplete = currentScreenCropRequest; + currentScreenCropRequest = null; + CompleteScreenCropRequest(requestToComplete, screenCropSourceRectangle); + } - if (isActive && tileUnderCursor != null && cursorAction != null) + if (EditorState.DrawMapWideOverlay) + { + MapWideOverlay.Draw(new Rectangle( + (int)(-Camera.TopLeftPoint.X * Camera.ZoomLevel), + (int)((-Camera.TopLeftPoint.Y + Constants.MapYBaseline) * Camera.ZoomLevel), + (int)(mapRenderTarget.Width * Camera.ZoomLevel), + (int)((mapRenderTarget.Height - Constants.MapYBaseline) * Camera.ZoomLevel))); + } + + if (isActive && tileUnderCursor != null && cursorAction != null) + { + cursorAction.DrawPreview(tileUnderCursor.CoordsToPoint(), Camera.TopLeftPoint); + cursorAction.PostMapDraw(tileUnderCursor.CoordsToPoint()); + } + } + catch (Exception ex) { - cursorAction.DrawPreview(tileUnderCursor.CoordsToPoint(), Camera.TopLeftPoint); - cursorAction.PostMapDraw(tileUnderCursor.CoordsToPoint()); + if (currentScreenCropRequest != null) + { + FailScreenCropRequest( + currentScreenCropRequest, + new MapScreenCropException("Internal renderer error encountered for the requested screenshot. Returned error: " + ex.Message)); + } + + throw; } } diff --git a/src/TSMapEditor/UI/MapUI.cs b/src/TSMapEditor/UI/MapUI.cs index 0eee76419..fb0a66661 100644 --- a/src/TSMapEditor/UI/MapUI.cs +++ b/src/TSMapEditor/UI/MapUI.cs @@ -6,6 +6,8 @@ using System.Collections.Generic; using System.IO; using System.Text; +using System.Threading; +using System.Threading.Tasks; using TSMapEditor.GameMath; using TSMapEditor.Misc; using TSMapEditor.Models; @@ -58,7 +60,7 @@ public interface ICursorActionTarget : IMapView /// /// Handles user input on the map and utilizes to draw the map. /// - public class MapUI : XNAControl, ICursorActionTarget, IMutationTarget + public class MapUI : XNAControl, ICursorActionTarget, IMutationTarget, IMapScreenCropper { private const float RightClickScrollRateDivisor = 48f; private const double ZoomStep = 0.1; @@ -118,6 +120,11 @@ public CopiedMapData CopiedMapData public Texture2D MinimapTexture => mapView.MinimapTexture; public HashSet MinimapUsers => mapView.MinimapUsers; + public bool TryRequestScreenCrop(Rectangle cellRectangle, CancellationToken cancellationToken, out Task screenCropTask) + => mapView.TryRequestScreenCrop(cellRectangle, cancellationToken, out screenCropTask); + + public void StopScreenCropRequests() => mapView.StopScreenCropRequests(); + public Camera Camera => mapView.Camera; public TechnoBase TechnoUnderCursor { get; set; } diff --git a/src/TSMapEditor/UI/UIManager.cs b/src/TSMapEditor/UI/UIManager.cs index 54c2d3e8d..798a25722 100644 --- a/src/TSMapEditor/UI/UIManager.cs +++ b/src/TSMapEditor/UI/UIManager.cs @@ -212,7 +212,7 @@ private void StartMCPServer() { try { - mcpServer = new MCPServer(WindowManager, new MapFacade(map, mutationManager, mapUI.MutationTarget)); + mcpServer = new MCPServer(WindowManager, new MapFacade(map, mutationManager, mapUI.MutationTarget), mapUI); mcpServer.StartAsync().GetAwaiter().GetResult(); Logger.Log($"MCP server listening at {MCPServer.ServerUrl}{MCPServer.MCPPath}"); } From a2578825e03907bf1bcf148021a13f051aec9afa Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sun, 2 Aug 2026 16:29:58 +0300 Subject: [PATCH 13/27] Add connected tile tool usage to MCP server, optimize some existing MCP tools --- src/TSMapEditor/AI/MapCellCoordinate.cs | 12 ++ .../AI/MapConnectedTileTypeInfo.cs | 28 +++ src/TSMapEditor/AI/MapFacade.cs | 194 ++++++++++++++++-- src/TSMapEditor/AI/MapTools.cs | 50 ++++- src/TSMapEditor/Models/ConnectedTileType.cs | 1 - .../AIMutations/SetCellTerrainMutation.cs | 63 ------ .../AIMutations/SetCellsTerrainMutation.cs | 83 ++++++++ .../Classes/DrawConnectedTilesMutation.cs | 5 + .../UI/Windows/MainMenuWindows/MapSetup.cs | 26 +-- .../UI/Windows/SelectConnectedTileWindow.cs | 3 +- 10 files changed, 366 insertions(+), 99 deletions(-) create mode 100644 src/TSMapEditor/AI/MapCellCoordinate.cs create mode 100644 src/TSMapEditor/AI/MapConnectedTileTypeInfo.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/AIMutations/SetCellTerrainMutation.cs create mode 100644 src/TSMapEditor/Mutations/Classes/AIMutations/SetCellsTerrainMutation.cs diff --git a/src/TSMapEditor/AI/MapCellCoordinate.cs b/src/TSMapEditor/AI/MapCellCoordinate.cs new file mode 100644 index 000000000..aea496f65 --- /dev/null +++ b/src/TSMapEditor/AI/MapCellCoordinate.cs @@ -0,0 +1,12 @@ +using System.ComponentModel; + +namespace TSMapEditor.AI; + +public sealed class MapCellCoordinate +{ + [Description("X coordinate of the map cell.")] + public int X { get; set; } + + [Description("Y coordinate of the map cell.")] + public int Y { get; set; } +} diff --git a/src/TSMapEditor/AI/MapConnectedTileTypeInfo.cs b/src/TSMapEditor/AI/MapConnectedTileTypeInfo.cs new file mode 100644 index 000000000..20ba51e7f --- /dev/null +++ b/src/TSMapEditor/AI/MapConnectedTileTypeInfo.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; + +namespace TSMapEditor.AI; + +public class MapConnectedTileTypeInfo +{ + public MapConnectedTileTypeInfo(string iniName, string name, bool frontOnly, int tileCount) + { + ININame = iniName; + Name = name; + FrontOnly = frontOnly; + TileCount = tileCount; + } + + public string ININame { get; } + public string Name { get; } + public bool FrontOnly { get; } + public int TileCount { get; } +} + +public class MapConnectedTilePathVertex +{ + [Description("X coordinate of the path vertex.")] + public int X { get; set; } + + [Description("Y coordinate of the path vertex.")] + public int Y { get; set; } +} diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index ccfac6901..ff2df2099 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -312,6 +312,7 @@ public class MapFacade { private const int MaxTechnoQueryResults = 1_000; private const int MaxObjectDeletionCount = 1_000; + private const int MaxConnectedTilePathVertexCount = 256; private const int MaxMapOperationDimension = 256; private const int MaxMapOperationCellCount = 10_000; @@ -420,6 +421,25 @@ public List GetConnectedOverlayTypes(string nameFil .ToList(); } + public List GetConnectedTileTypes(string nameFilter = null) + { + string normalizedFilter = nameFilter?.Trim(); + + return map.EditorConfig.Cliffs + .Where(IsConnectedTileTypeAvailable) + .Select(connectedTileType => new MapConnectedTileTypeInfo( + connectedTileType.IniName, + connectedTileType.Name, + connectedTileType.FrontOnly, + connectedTileType.Tiles.Count)) + .Where(typeInfo => string.IsNullOrWhiteSpace(normalizedFilter) || + ContainsIgnoringCase(typeInfo.ININame, normalizedFilter) || + ContainsIgnoringCase(typeInfo.Name, normalizedFilter)) + .OrderBy(typeInfo => typeInfo.Name) + .ThenBy(typeInfo => typeInfo.ININame) + .ToList(); + } + public List GetBuildingTypes(string nameFilter = null) { string normalizedFilter = nameFilter?.Trim(); @@ -777,6 +797,81 @@ public MapEditResult PlaceConnectedOverlay(string connectedOverlayName, int x, i affectedMapTiles.Select(mapTile => CellInfo.FromMapCell(map, mapTile)).ToList()); } + public MapEditResult DrawConnectedTiles(string connectedTileTypeName, List path, + string side, int randomSeed, int extraHeight) + { + if (string.IsNullOrWhiteSpace(connectedTileTypeName)) + throw new MapFacadeValidationException("A connected tile type INI name must be provided."); + + var connectedTileType = map.EditorConfig.Cliffs.Find( + candidate => string.Equals(candidate.IniName, connectedTileTypeName, StringComparison.OrdinalIgnoreCase)); + if (connectedTileType == null) + throw new MapFacadeValidationException($"Connected tile type '{connectedTileTypeName}' does not exist in the editor configuration."); + if (!IsConnectedTileTypeAvailableInTheater(connectedTileType)) + throw new MapFacadeValidationException($"Connected tile type '{connectedTileType.IniName}' is not valid for theater '{map.LoadedTheaterName}'."); + + if (path == null) + throw new MapFacadeValidationException("A connected tile path must be provided."); + if (path.Count < 2) + throw new MapFacadeValidationException("A connected tile path must contain at least two vertices."); + if (path.Count > MaxConnectedTilePathVertexCount) + throw new MapFacadeValidationException($"A connected tile path may contain at most {MaxConnectedTilePathVertexCount} vertices."); + + var pathCoords = new List(path.Count); + for (int i = 0; i < path.Count; i++) + { + MapConnectedTilePathVertex vertex = path[i]; + if (vertex == null) + throw new MapFacadeValidationException($"Connected tile path vertex {i} cannot be null."); + + var coords = new Point2D(vertex.X, vertex.Y); + if (map.GetTile(coords) == null) + throw new MapFacadeValidationException($"Connected tile path vertex {i} at ({vertex.X}, {vertex.Y}) is outside the map."); + if (i > 0 && coords == pathCoords[i - 1]) + throw new MapFacadeValidationException($"Connected tile path vertices {i - 1} and {i} cannot have the same coordinates."); + + pathCoords.Add(coords); + } + + ConnectedTileSide startingSide; + if (string.Equals(side, nameof(ConnectedTileSide.Front), StringComparison.OrdinalIgnoreCase)) + startingSide = ConnectedTileSide.Front; + else if (string.Equals(side, nameof(ConnectedTileSide.Back), StringComparison.OrdinalIgnoreCase)) + startingSide = ConnectedTileSide.Back; + else + throw new MapFacadeValidationException("Connected tile side must be Front or Back."); + + if (connectedTileType.FrontOnly && startingSide != ConnectedTileSide.Front) + throw new MapFacadeValidationException($"Connected tile type '{connectedTileType.IniName}' only supports the Front side."); + + int originLevel = map.GetTile(pathCoords[0]).Level; + if (extraHeight < 0 || extraHeight > Constants.MaxMapHeightLevel - originLevel) + { + throw new MapFacadeValidationException( + $"extraHeight must be from 0 through {Constants.MaxMapHeightLevel - originLevel} for the first path vertex's current height."); + } + + ValidateConnectedTileType(connectedTileType); + + var mutation = new DrawConnectedTilesMutation( + mutationTarget, + pathCoords, + connectedTileType, + startingSide, + randomSeed, + (byte)extraHeight); + + mutationManager.PerformMutation(mutation); + + return new MapEditResult( + mutationManager.Revision, + mutation.AffectedCellCoords + .Select(coords => CellInfo.FromMapCell(map, map.GetTile(coords))) + .OrderBy(cellInfo => cellInfo.Y) + .ThenBy(cellInfo => cellInfo.X) + .ToList()); + } + public MapEditResult PlaceWaypoint(int identifier, int x, int y, string editorColor) { if (identifier < 0 || identifier >= Constants.MaxWaypoint) @@ -1067,9 +1162,10 @@ public MapEditResult PlaceTerrainTile(string tileSetName, int tileIndexInTileSet $"Tile index {tileIndexInTileSet} is outside tile set '{tileSet.SetName}', which contains {tileSet.LoadedTileCount} tiles."); } + // Use an existing brush size instance if preconfigured. If not, create a new one. var brushSize = map.EditorConfig.BrushSizes.Find(bs => bs.Width == brushWidth && bs.Height == brushHeight); if (brushSize == null) - throw new MapFacadeValidationException($"Brush size {brushWidth}x{brushHeight} is not configured in the editor."); + brushSize = new BrushSize(brushWidth, brushHeight); if (tileSet.Only1x1 && (brushSize.Width != 1 || brushSize.Height != 1)) throw new MapFacadeValidationException($"Tile set '{tileSet.SetName}' only supports a 1x1 brush."); @@ -1112,12 +1208,18 @@ public MapEditResult PlaceTerrainTile(string tileSetName, int tileIndexInTileSet return new MapEditResult(mutationManager.Revision, InspectRegion(affectedArea)); } - public MapEditResult SetCellTerrain(int x, int y, int tileIndex, int subTileIndex) + public MapEditResult SetCellsTerrain(List cells, int tileIndex, int subTileIndex, int? expectedRevision) { - var cellCoords = new Point2D(x, y); - var mapTile = map.IsCoordWithinMap(cellCoords) ? map.GetTile(cellCoords) : null; - if (mapTile == null) - throw new MapFacadeValidationException($"Cell ({x}, {y}) is outside the map."); + if (expectedRevision.HasValue && expectedRevision.Value != mutationManager.Revision) + { + throw new MapFacadeValidationException( + $"The map revision changed from {expectedRevision.Value} to {mutationManager.Revision}. Query the map again before setting terrain, or omit expectedRevision to allow concurrent edits."); + } + + if (cells == null || cells.Count == 0) + throw new MapFacadeValidationException("At least one cell coordinate must be provided."); + if (cells.Count > MaxMapOperationCellCount) + throw new MapFacadeValidationException($"At most {MaxMapOperationCellCount} cell coordinates can be set in one call."); if (tileIndex < 0 || tileIndex >= mutationTarget.TheaterGraphics.TileCount) throw new MapFacadeValidationException($"Absolute tile index {tileIndex} is not loaded."); @@ -1132,18 +1234,42 @@ public MapEditResult SetCellTerrain(int x, int y, int tileIndex, int subTileInde $"Sub-tile index {subTileIndex} is not valid for absolute tile index {tileIndex}."); } - var mutation = new SetCellTerrainMutation(mutationTarget, cellCoords, tileIndex, (byte)subTileIndex); - if (!mutation.ShouldPerform()) + var distinctCoords = new HashSet(); + for (int i = 0; i < cells.Count; i++) { - throw new MapFacadeValidationException( - $"Cell ({x}, {y}) already uses absolute tile index {tileIndex} and sub-tile index {subTileIndex}."); + MapCellCoordinate cell = cells[i]; + if (cell == null) + throw new MapFacadeValidationException($"Cell coordinate {i} cannot be null."); + + var cellCoords = new Point2D(cell.X, cell.Y); + if (map.GetTile(cellCoords) == null) + throw new MapFacadeValidationException($"Cell coordinate {i} at ({cell.X}, {cell.Y}) is outside the map."); + + distinctCoords.Add(cellCoords); } - mutationManager.PerformMutation(mutation); + var changedCoords = distinctCoords + .Where(coords => + { + MapTile mapTile = map.GetTile(coords); + return mapTile.TileIndex != tileIndex || mapTile.SubTileIndex != subTileIndex; + }) + .OrderBy(coords => coords.Y) + .ThenBy(coords => coords.X) + .ToList(); + + if (changedCoords.Count == 0) + return new MapEditResult(mutationManager.Revision, new List()); + + mutationManager.PerformMutation(new SetCellsTerrainMutation( + mutationTarget, + changedCoords, + tileIndex, + (byte)subTileIndex)); return new MapEditResult( mutationManager.Revision, - new List { CellInfo.FromMapCell(map, mapTile) }); + changedCoords.Select(coords => CellInfo.FromMapCell(map, map.GetTile(coords))).ToList()); } private void ApplyModificationProperties(TechnoBase techno, TechnoPropertiesSnapshot snapshot, MapTechnoModificationProperties properties) @@ -1368,6 +1494,50 @@ private int GetPlaceableOverlayFrameCount(OverlayType overlayType) return mutationTarget.TheaterGraphics.GetOverlayFrameCount(overlayType); } + private bool IsConnectedTileTypeAvailable(ConnectedTileType connectedTileType) + { + return IsConnectedTileTypeAvailableInTheater(connectedTileType); + } + + private bool IsConnectedTileTypeAvailableInTheater(ConnectedTileType connectedTileType) + { + return connectedTileType.AllowedTheaters.Exists( + theaterName => string.Equals(theaterName, map.LoadedTheaterName, StringComparison.OrdinalIgnoreCase)); + } + + private void ValidateConnectedTileType(ConnectedTileType connectedTileType) + { + if (connectedTileType.Tiles.Count == 0) + throw new MapFacadeValidationException($"Connected tile type '{connectedTileType.IniName}' does not contain any tiles."); + + foreach (ConnectedTile connectedTile in connectedTileType.Tiles) + { + var tileSet = map.TheaterInstance.Theater.TileSets.Find( + candidate => candidate.AllowToPlace && string.Equals(candidate.SetName, connectedTile.TileSetName, StringComparison.OrdinalIgnoreCase)); + if (tileSet == null) + { + throw new MapFacadeValidationException( + $"Connected tile type '{connectedTileType.IniName}' references unavailable tile set '{connectedTile.TileSetName}'."); + } + + if (connectedTile.Foundation == null || connectedTile.Foundation.Count == 0) + { + throw new MapFacadeValidationException( + $"Connected tile type '{connectedTileType.IniName}' tile {connectedTile.Index} has no usable foundation."); + } + + foreach (int tileIndexInSet in connectedTile.IndicesInTileSet) + { + if (tileIndexInSet < 0 || tileIndexInSet >= tileSet.LoadedTileCount || + mutationTarget.TheaterGraphics.GetTileGraphics(tileSet.StartTileIndex + tileIndexInSet) == null) + { + throw new MapFacadeValidationException( + $"Connected tile type '{connectedTileType.IniName}' tile {connectedTile.Index} references unavailable tile index {tileIndexInSet} in tile set '{tileSet.SetName}'."); + } + } + } + } + private void ValidateOverlayFrame(OverlayType overlayType, int frameIndex) { int placeableFrameCount = GetPlaceableOverlayFrameCount(overlayType); diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index faa0727d6..ced08ecbf 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -75,6 +75,16 @@ public Task> GetConnectedOverlayTypes( return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetConnectedOverlayTypes(nameFilter), cancellationToken); } + [McpServerTool(Name = "get_connected_tile_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns WAE Connected Tiles configurations that are enabled and valid for the current map's theater. Use draw_connected_tiles with an INI name from this result to lay out cliffs, shores, roads, rivers, and other configured connected terrain.")] + public Task> GetConnectedTileTypes( + [Description("Optional case-insensitive filter matched against INI name and display name.")] string nameFilter = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetConnectedTileTypes)}"); + return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetConnectedTileTypes(nameFilter), cancellationToken); + } + [McpServerTool(Name = "get_building_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] [Description("Returns building types that are visible in the editor and valid for the current map's theater, including foundation dimensions.")] public Task> GetBuildingTypes( @@ -375,6 +385,30 @@ public async Task PlaceConnectedOverlay( } } + [McpServerTool(Name = "draw_connected_tiles", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Uses WAE's Connected Tiles pathfinder to draw a connected terrain line through two or more map-cell vertices. The line can overwrite terrain and update cell heights. The operation is one undo entry and one revision bump.")] + public async Task DrawConnectedTiles( + [Description("INI name of a Connected Tiles configuration returned by get_connected_tile_types.")] string connectedTileTypeName, + [Description("Ordered polyline vertices for the connected terrain path. Each consecutive pair defines one segment; at least two and at most 256 vertices are supported.")] List path, + [Description("Starting side of the connected terrain: Front or Back. Front-only types require Front.")] string side = "Front", + [Description("Seed used to select and score tile variants. Change it to request a different pattern while keeping the same path. Defaults to 0.")] int randomSeed = 0, + [Description("Non-negative height offset added to the first vertex's current level before the connected tiles' own height offsets are applied. Defaults to 0.")] int extraHeight = 0, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(DrawConnectedTiles)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.DrawConnectedTiles(connectedTileTypeName, path, side, randomSeed, extraHeight), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + [McpServerTool(Name = "place_waypoint", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] [Description("Places a uniquely numbered waypoint on a map cell. Waypoints 0 through 7 are multiplayer starting locations. Multiple differently numbered waypoints may share a cell. The operation is one undo entry and one revision bump.")] public async Task PlaceWaypoint( @@ -545,21 +579,21 @@ public async Task PlaceTerrainTile( } } - [McpServerTool(Name = "set_cell_terrain", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] - [Description("Directly sets the absolute tile index and sub-tile index of one cell. This is a low-level operation that does not apply a brush or AutoLAT, and it is added to undo history.")] - public async Task SetCellTerrain( - [Description("X coordinate of the cell.")] int x, - [Description("Y coordinate of the cell.")] int y, + [McpServerTool(Name = "set_cells_terrain", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Directly sets the same absolute tile index and sub-tile index on one or more map cells. Duplicate coordinates and cells that already have the requested terrain are ignored. The batch is one atomic undo entry and one revision bump when any cells change. This low-level operation does not apply a brush or AutoLAT.")] + public async Task SetCellsTerrain( + [Description("One or more map-cell coordinates. At most 10,000 entries are supported.")] List cells, [Description("Absolute tile index in the loaded theater.")] int tileIndex, [Description("Sub-tile index within the selected full tile.")] int subTileIndex, - CancellationToken cancellationToken) + [Description("Optional map revision returned by get_map_revision or another map tool. When supplied, the operation fails if the map has changed; omit it to allow concurrent human edits.")] int? expectedRevision = null, + CancellationToken cancellationToken = default) { - Logger.Log($"{nameof(MapTools)}.{nameof(SetCellTerrain)}"); + Logger.Log($"{nameof(MapTools)}.{nameof(SetCellsTerrain)}"); try { return await gameThreadDispatcher.InvokeAsync( - () => mapFacade.SetCellTerrain(x, y, tileIndex, subTileIndex), + () => mapFacade.SetCellsTerrain(cells, tileIndex, subTileIndex, expectedRevision), cancellationToken); } catch (MapFacadeValidationException ex) diff --git a/src/TSMapEditor/Models/ConnectedTileType.cs b/src/TSMapEditor/Models/ConnectedTileType.cs index 9bafbf0b8..027b3feba 100644 --- a/src/TSMapEditor/Models/ConnectedTileType.cs +++ b/src/TSMapEditor/Models/ConnectedTileType.cs @@ -466,7 +466,6 @@ private ConnectedTileType(IniFile iniFile, string iniName, string name, bool fro public string IniName { get; } public string Name { get; } public bool FrontOnly { get; } - public bool IsLegal { get; set; } = true; public Color? Color { get; set; } public List AllowedTheaters { get; set; } public List Tiles { get; } diff --git a/src/TSMapEditor/Mutations/Classes/AIMutations/SetCellTerrainMutation.cs b/src/TSMapEditor/Mutations/Classes/AIMutations/SetCellTerrainMutation.cs deleted file mode 100644 index 5a8b3f60b..000000000 --- a/src/TSMapEditor/Mutations/Classes/AIMutations/SetCellTerrainMutation.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using TSMapEditor.GameMath; -using TSMapEditor.UI; - -namespace TSMapEditor.Mutations.Classes.AIMutations -{ - /// - /// Directly changes the absolute tile and sub-tile index of one map cell. - /// - public sealed class SetCellTerrainMutation : Mutation, ICheckableMutation - { - public SetCellTerrainMutation(IMutationTarget mutationTarget, Point2D cellCoords, int tileIndex, byte subTileIndex) - : base(mutationTarget) - { - this.cellCoords = cellCoords; - this.tileIndex = tileIndex; - this.subTileIndex = subTileIndex; - } - - private readonly Point2D cellCoords; - private readonly int tileIndex; - private readonly byte subTileIndex; - - private int originalTileIndex; - private byte originalSubTileIndex; - - public bool ShouldPerform() - { - var mapTile = Map.GetTile(cellCoords); - return mapTile != null && (mapTile.TileIndex != tileIndex || mapTile.SubTileIndex != subTileIndex); - } - - public override string GetDisplayString() - { - return $"Set terrain at {cellCoords} to tile {tileIndex}, sub-tile {subTileIndex}"; - } - - public override void Perform() - { - var mapTile = Map.GetTile(cellCoords); - if (mapTile == null) - throw new InvalidOperationException($"Cell {cellCoords} does not exist."); - - originalTileIndex = mapTile.TileIndex; - originalSubTileIndex = mapTile.SubTileIndex; - - mapTile.ChangeTileIndex(tileIndex, subTileIndex); - RefreshCellLighting(mapTile); - MutationTarget.AddRefreshPoint(cellCoords); - } - - public override void Undo() - { - var mapTile = Map.GetTile(cellCoords); - if (mapTile == null) - throw new InvalidOperationException($"Cell {cellCoords} does not exist."); - - mapTile.ChangeTileIndex(originalTileIndex, originalSubTileIndex); - RefreshCellLighting(mapTile); - MutationTarget.AddRefreshPoint(cellCoords); - } - } -} diff --git a/src/TSMapEditor/Mutations/Classes/AIMutations/SetCellsTerrainMutation.cs b/src/TSMapEditor/Mutations/Classes/AIMutations/SetCellsTerrainMutation.cs new file mode 100644 index 000000000..4e822f101 --- /dev/null +++ b/src/TSMapEditor/Mutations/Classes/AIMutations/SetCellsTerrainMutation.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using TSMapEditor.GameMath; +using TSMapEditor.Models; +using TSMapEditor.UI; + +namespace TSMapEditor.Mutations.Classes.AIMutations; + +/// +/// Directly changes the absolute tile and sub-tile index of one or more map cells. +/// +public sealed class SetCellsTerrainMutation : Mutation +{ + public SetCellsTerrainMutation(IMutationTarget mutationTarget, List cellCoords, int tileIndex, byte subTileIndex) + : base(mutationTarget) + { + this.cellCoords = cellCoords ?? throw new ArgumentNullException(nameof(cellCoords)); + if (cellCoords.Count == 0) + throw new ArgumentException("At least one cell coordinate must be provided.", nameof(cellCoords)); + + this.tileIndex = tileIndex; + this.subTileIndex = subTileIndex; + } + + private readonly struct OriginalTerrain + { + public OriginalTerrain(Point2D cellCoords, int tileIndex, byte subTileIndex) + { + CellCoords = cellCoords; + TileIndex = tileIndex; + SubTileIndex = subTileIndex; + } + + public Point2D CellCoords { get; } + public int TileIndex { get; } + public byte SubTileIndex { get; } + } + + private readonly List cellCoords; + private readonly int tileIndex; + private readonly byte subTileIndex; + private readonly List originalTerrain = new(); + + public override string GetDisplayString() + { + return $"Set terrain on {cellCoords.Count} map cell(s) to tile {tileIndex}, sub-tile {subTileIndex}"; + } + + public override void Perform() + { + originalTerrain.Clear(); + + foreach (Point2D coords in cellCoords) + { + var mapTile = Map.GetTile(coords); + if (mapTile == null) + throw new InvalidOperationException($"Cell {coords} does not exist."); + + originalTerrain.Add(new OriginalTerrain(coords, mapTile.TileIndex, mapTile.SubTileIndex)); + SetTerrain(mapTile, coords, tileIndex, subTileIndex); + } + } + + public override void Undo() + { + for (int i = originalTerrain.Count - 1; i >= 0; i--) + { + OriginalTerrain original = originalTerrain[i]; + var mapTile = Map.GetTile(original.CellCoords); + if (mapTile == null) + throw new InvalidOperationException($"Cell {original.CellCoords} does not exist."); + + SetTerrain(mapTile, original.CellCoords, original.TileIndex, original.SubTileIndex); + } + } + + private void SetTerrain(MapTile mapTile, Point2D coords, int newTileIndex, byte newSubTileIndex) + { + mapTile.ChangeTileIndex(newTileIndex, newSubTileIndex); + RefreshCellLighting(mapTile); + MutationTarget.AddRefreshPoint(coords); + } +} diff --git a/src/TSMapEditor/Mutations/Classes/DrawConnectedTilesMutation.cs b/src/TSMapEditor/Mutations/Classes/DrawConnectedTilesMutation.cs index 610697d92..c437faa74 100644 --- a/src/TSMapEditor/Mutations/Classes/DrawConnectedTilesMutation.cs +++ b/src/TSMapEditor/Mutations/Classes/DrawConnectedTilesMutation.cs @@ -48,6 +48,9 @@ private struct ConnectedTileUndoData private const float NodeScoreJitterAmplitude = 0.02f; private readonly List undoData = new List(); + private readonly HashSet affectedCellCoords = new HashSet(); + + public IReadOnlyCollection AffectedCellCoords => affectedCellCoords; private readonly List path; private readonly ConnectedTileType connectedTileType; @@ -255,6 +258,7 @@ private void PlaceTile(TileImage tile, Point2D targetCellCoords) SubTileIndex = mapTile.SubTileIndex, Level = mapTile.Level }); + affectedCellCoords.Add(new Point2D(cx, cy)); mapTile.ChangeTileIndex(tile.TileID, (byte)i); mapTile.Level = (byte)Math.Min(originLevel + image.TmpImage.Height, Constants.MaxMapHeightLevel); @@ -279,6 +283,7 @@ public override void Undo() } undoData.Clear(); + affectedCellCoords.Clear(); MutationTarget.InvalidateMap(); } } diff --git a/src/TSMapEditor/UI/Windows/MainMenuWindows/MapSetup.cs b/src/TSMapEditor/UI/Windows/MainMenuWindows/MapSetup.cs index 9a7074bca..794dbdbc3 100644 --- a/src/TSMapEditor/UI/Windows/MainMenuWindows/MapSetup.cs +++ b/src/TSMapEditor/UI/Windows/MainMenuWindows/MapSetup.cs @@ -203,23 +203,23 @@ public void LoadNonGraphicalTheater() /// private void FillConnectedTileFoundations(ITheater theaterTileInfo) { - foreach (var cliffType in LoadedMap.EditorConfig.Cliffs) + foreach (var connectedTileType in LoadedMap.EditorConfig.Cliffs) { - if (!cliffType.AllowedTheaters.Select(at => at.ToUpperInvariant()).Contains(LoadedMap.LoadedTheaterName.ToUpperInvariant())) + if (!connectedTileType.AllowedTheaters.Select(at => at.ToUpperInvariant()).Contains(LoadedMap.LoadedTheaterName.ToUpperInvariant())) continue; - var tiles = cliffType.Tiles; + var tiles = connectedTileType.Tiles; if (tiles.Count == 0) - throw new INIConfigException($"Connected terrain type {cliffType.IniName} has 0 tiles!"); + throw new INIConfigException($"Connected terrain type {connectedTileType.IniName} has 0 tiles!"); - foreach (var cliffTypeTile in cliffType.Tiles) + foreach (var connectedTile in connectedTileType.Tiles) { - var tileSet = theaterTileInfo.Theater.TileSets.Find(ts => ts.SetName == cliffTypeTile.TileSetName && ts.AllowToPlace); + var tileSet = theaterTileInfo.Theater.TileSets.Find(ts => ts.SetName == connectedTile.TileSetName && ts.AllowToPlace); if (tileSet == null) { - string errorMessage = $"Unable to find TileSet \"{cliffTypeTile.TileSetName}\" " + - $"for connected terrain type \"{cliffType.IniName}\", tile index {cliffTypeTile.Index}"; + string errorMessage = $"Unable to find TileSet \"{connectedTile.TileSetName}\" " + + $"for connected terrain type \"{connectedTileType.IniName}\", tile index {connectedTile.Index}"; #if DEBUG throw new INIConfigException(errorMessage); #else @@ -229,15 +229,15 @@ private void FillConnectedTileFoundations(ITheater theaterTileInfo) #endif } - if (cliffTypeTile.IndicesInTileSet.Count == 0) + if (connectedTile.IndicesInTileSet.Count == 0) continue; - if (cliffTypeTile.Foundation != null) + if (connectedTile.Foundation != null) continue; - cliffTypeTile.Foundation = new HashSet(); + connectedTile.Foundation = new HashSet(); - int firstTileIndexWithinSet = cliffTypeTile.IndicesInTileSet[0]; + int firstTileIndexWithinSet = connectedTile.IndicesInTileSet[0]; int totalFirstTileIndex = tileSet.StartTileIndex + firstTileIndexWithinSet; @@ -250,7 +250,7 @@ private void FillConnectedTileFoundations(ITheater theaterTileInfo) continue; var offset = tile.GetSubTileCoordOffset(i).Value; - cliffTypeTile.Foundation.Add(offset); + connectedTile.Foundation.Add(offset); } } } diff --git a/src/TSMapEditor/UI/Windows/SelectConnectedTileWindow.cs b/src/TSMapEditor/UI/Windows/SelectConnectedTileWindow.cs index de0f82334..95b002e1d 100644 --- a/src/TSMapEditor/UI/Windows/SelectConnectedTileWindow.cs +++ b/src/TSMapEditor/UI/Windows/SelectConnectedTileWindow.cs @@ -44,8 +44,7 @@ protected override void ListObjects() foreach (ConnectedTileType cliff in map.EditorConfig.Cliffs.Where(cliff => cliff.AllowedTheaters.Exists(theaterName => theaterName.Equals(map.TheaterName, StringComparison.OrdinalIgnoreCase)))) { - if (cliff.IsLegal) - lbObjectList.AddItem(new XNAListBoxItem() { Text = cliff.Name, Tag = cliff, TextColor = cliff.Color.GetValueOrDefault(lbObjectList.DefaultItemColor) }); + lbObjectList.AddItem(new XNAListBoxItem() { Text = cliff.Name, Tag = cliff, TextColor = cliff.Color.GetValueOrDefault(lbObjectList.DefaultItemColor) }); } } } From 0ea1d6189078710c1e38affd6d255b8e4dc0d902 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sun, 2 Aug 2026 19:53:08 +0300 Subject: [PATCH 14/27] Make connected tiles support closed formations and ending pieces --- .../AI/MapConnectedTileTypeInfo.cs | 7 + src/TSMapEditor/AI/MapFacade.cs | 46 +- src/TSMapEditor/AI/MapTools.cs | 22 +- .../Config/Default/ConnectedTileDrawer.ini | 331 ++++++-- .../Config/Translations/en/Translation_en.ini | 11 +- .../Models/ConnectedTilePlanner.cs | 761 ++++++++++++++++++ src/TSMapEditor/Models/ConnectedTileType.cs | 223 ++--- .../Classes/DrawConnectedTilesMutation.cs | 225 +----- .../DrawConnectedTilesCursorAction.cs | 160 +++- 9 files changed, 1328 insertions(+), 458 deletions(-) create mode 100644 src/TSMapEditor/Models/ConnectedTilePlanner.cs diff --git a/src/TSMapEditor/AI/MapConnectedTileTypeInfo.cs b/src/TSMapEditor/AI/MapConnectedTileTypeInfo.cs index 20ba51e7f..743c91374 100644 --- a/src/TSMapEditor/AI/MapConnectedTileTypeInfo.cs +++ b/src/TSMapEditor/AI/MapConnectedTileTypeInfo.cs @@ -5,17 +5,24 @@ namespace TSMapEditor.AI; public class MapConnectedTileTypeInfo { public MapConnectedTileTypeInfo(string iniName, string name, bool frontOnly, int tileCount) + : this(iniName, name, frontOnly, tileCount, false) + { + } + + public MapConnectedTileTypeInfo(string iniName, string name, bool frontOnly, int tileCount, bool supportsEndPieces) { ININame = iniName; Name = name; FrontOnly = frontOnly; TileCount = tileCount; + SupportsEndPieces = supportsEndPieces; } public string ININame { get; } public string Name { get; } public bool FrontOnly { get; } public int TileCount { get; } + public bool SupportsEndPieces { get; } } public class MapConnectedTilePathVertex diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index ff2df2099..b1db3f1a7 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -431,7 +431,8 @@ public List GetConnectedTileTypes(string nameFilter = connectedTileType.IniName, connectedTileType.Name, connectedTileType.FrontOnly, - connectedTileType.Tiles.Count)) + connectedTileType.Tiles.Count, + connectedTileType.SupportsEndPieces)) .Where(typeInfo => string.IsNullOrWhiteSpace(normalizedFilter) || ContainsIgnoringCase(typeInfo.ININame, normalizedFilter) || ContainsIgnoringCase(typeInfo.Name, normalizedFilter)) @@ -799,6 +800,13 @@ public MapEditResult PlaceConnectedOverlay(string connectedOverlayName, int x, i public MapEditResult DrawConnectedTiles(string connectedTileTypeName, List path, string side, int randomSeed, int extraHeight) + { + return DrawConnectedTiles(connectedTileTypeName, path, side, randomSeed, extraHeight, + useEndPieces: false, closed: false); + } + + public MapEditResult DrawConnectedTiles(string connectedTileTypeName, List path, + string side, int randomSeed, int extraHeight, bool useEndPieces = false, bool closed = false) { if (string.IsNullOrWhiteSpace(connectedTileTypeName)) throw new MapFacadeValidationException("A connected tile type INI name must be provided."); @@ -833,6 +841,9 @@ public MapEditResult DrawConnectedTiles(string connectedTileTypeName, List Constants.MaxMapHeightLevel - originLevel) { @@ -853,12 +867,36 @@ public MapEditResult DrawConnectedTiles(string connectedTileTypeName, List map.GetTile(coords) != null); + + if (!planResult.IsSuccess) + { + string failureReason = string.IsNullOrWhiteSpace(planResult.Message) + ? planResult.Status switch + { + ConnectedTilePlanStatus.InvalidInput => "the path is invalid", + ConnectedTilePlanStatus.NoSolution => "no exact connection pattern fits the requested path", + ConnectedTilePlanStatus.SearchLimit => "the search limit was reached before an exact pattern was found", + _ => "the connected tile planner failed" + } + : planResult.Message; + + throw new MapFacadeValidationException( + $"Unable to plan the connected tile formation: {failureReason}"); + } + + var mutation = new DrawConnectedTilesMutation( + mutationTarget, + planResult.Plan, + randomSeed, (byte)extraHeight); mutationManager.PerformMutation(mutation); diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index ced08ecbf..4bcbe8e87 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -76,7 +76,7 @@ public Task> GetConnectedOverlayTypes( } [McpServerTool(Name = "get_connected_tile_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] - [Description("Returns WAE Connected Tiles configurations that are enabled and valid for the current map's theater. Use draw_connected_tiles with an INI name from this result to lay out cliffs, shores, roads, rivers, and other configured connected terrain.")] + [Description("Returns WAE Connected Tiles configurations that are enabled and valid for the current map's theater, including whether each type supports ending pieces. Use draw_connected_tiles with an INI name from this result to lay out cliffs, shores, roads, rivers, and other configured connected terrain.")] public Task> GetConnectedTileTypes( [Description("Optional case-insensitive filter matched against INI name and display name.")] string nameFilter = null, CancellationToken cancellationToken = default) @@ -385,14 +385,28 @@ public async Task PlaceConnectedOverlay( } } + public Task DrawConnectedTiles( + string connectedTileTypeName, + List path, + string side, + int randomSeed, + int extraHeight, + CancellationToken cancellationToken) + { + return DrawConnectedTiles(connectedTileTypeName, path, side, randomSeed, extraHeight, + useEndPieces: false, closed: false, cancellationToken: cancellationToken); + } + [McpServerTool(Name = "draw_connected_tiles", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] - [Description("Uses WAE's Connected Tiles pathfinder to draw a connected terrain line through two or more map-cell vertices. The line can overwrite terrain and update cell heights. The operation is one undo entry and one revision bump.")] + [Description("Uses WAE's Connected Tiles pathfinder to draw an open or closed connected terrain formation through map-cell vertices. The operation can optionally cap open paths with configured ending pieces. It can overwrite terrain and update cell heights, and is one undo entry and one revision bump.")] public async Task DrawConnectedTiles( [Description("INI name of a Connected Tiles configuration returned by get_connected_tile_types.")] string connectedTileTypeName, - [Description("Ordered polyline vertices for the connected terrain path. Each consecutive pair defines one segment; at least two and at most 256 vertices are supported.")] List path, + [Description("Ordered polyline vertices for the connected terrain path. Each consecutive pair defines one segment. Open paths require at least two vertices; closed paths require at least three distinct vertices. At most 256 vertices are supported.")] List path, [Description("Starting side of the connected terrain: Front or Back. Front-only types require Front.")] string side = "Front", [Description("Seed used to select and score tile variants. Change it to request a different pattern while keeping the same path. Defaults to 0.")] int randomSeed = 0, [Description("Non-negative height offset added to the first vertex's current level before the connected tiles' own height offsets are applied. Defaults to 0.")] int extraHeight = 0, + [Description("Whether to cap both ends of an open path with configured one-connection-point ending pieces. The selected type must support ending pieces. Ignored for closed paths. Defaults to false.")] bool useEndPieces = false, + [Description("Whether to connect the last vertex back to the first. Closed paths require at least three distinct vertices and never use ending pieces. Defaults to false.")] bool closed = false, CancellationToken cancellationToken = default) { Logger.Log($"{nameof(MapTools)}.{nameof(DrawConnectedTiles)}"); @@ -400,7 +414,7 @@ public async Task DrawConnectedTiles( try { return await gameThreadDispatcher.InvokeAsync( - () => mapFacade.DrawConnectedTiles(connectedTileTypeName, path, side, randomSeed, extraHeight), + () => mapFacade.DrawConnectedTiles(connectedTileTypeName, path, side, randomSeed, extraHeight, useEndPieces, closed), cancellationToken); } catch (MapFacadeValidationException ex) diff --git a/src/TSMapEditor/Config/Default/ConnectedTileDrawer.ini b/src/TSMapEditor/Config/Default/ConnectedTileDrawer.ini index d5ae354ca..640a6b7bb 100644 --- a/src/TSMapEditor/Config/Default/ConnectedTileDrawer.ini +++ b/src/TSMapEditor/Config/Default/ConnectedTileDrawer.ini @@ -12,11 +12,14 @@ ; [ConnectedTileTypeName.i], where i is a unique integer ID ; TileSet=tile set the tiles belong to ; TileIndices=indices of tiles in tile set, comma-separated -; ConnectionPoint0/1=x,y - coordinates of the first connection point -; ConnectionPoint0/1.Directions=8 bits denoting which way the point connects, starting from North (top-right) clockwise -; ConnectionPoint0/1.Side=Front/Back -; ConnectionPoint0/1.RequiredTiles=IDs of tiles this point must connect to, optional -; ConnectionPoint0/1.ForbiddenTiles=IDs of tiles this point cannot connect to, optional, RequiredTiles take priority +; Connection-point count is discovered from exact ConnectionPointX base keys (case-insensitive). ConnectionPoint0 is required, +; and indices must be contiguous. One point defines an ending piece and two define a regular path piece. +; Three or more points are reserved for future junction support and are not currently used by the drawing planner. +; ConnectionPointX=x,y - coordinates of a connection point +; ConnectionPointX.Directions=8 bits denoting which way the point connects, starting from North (top-right) clockwise +; ConnectionPointX.Side=Front/Back +; ConnectionPointX.RequiredTiles=IDs of tiles this point must connect to, optional +; ConnectionPointX.ForbiddenTiles=IDs of tiles this point cannot connect to, optional, RequiredTiles take priority ; Foundation=0,0|0,1|1,1|1,2 - optional, list of coordinates this tile occupies relative to its 0,0 point. If not specified, automatically filled by editor based on the shape of the tile ; ExtraPriority=integer - secondary sorting key when all else is equal, higher values mean higher priority ; DistanceModifier=integer - value to add to the primary key when sorting tiles, negative means the tile is "better" or "closer to the target at a lower cost". @@ -68,6 +71,13 @@ Color=0,200,0 [TemperateCliffSet.0] TileSet=Cliffs +TileIndices=0 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00100000 ;Bottom-Right +ConnectionPoint0.Side=Front + +[TemperateCliffSet.1] +TileSet=Cliffs TileIndices=1,2,3 ConnectionPoint0=1,1 ConnectionPoint0.Directions=00000010 ;Top-Left @@ -77,7 +87,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=0,0|1,0|1,1|2,1|2,2|3,2 -[TemperateCliffSet.1] +[TemperateCliffSet.2] TileSet=Cliffs TileIndices=4,5,6 ConnectionPoint0=1,2 @@ -88,7 +98,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Front Foundation=0,0|0,1|1,1|1,2|2,2 -[TemperateCliffSet.2] +[TemperateCliffSet.3] TileSet=Cliffs TileIndices=7,8,9 ConnectionPoint0=1,1 @@ -99,9 +109,23 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=0,0|1,0|1,1|2,1|2,2 +[TemperateCliffSet.4] +TileSet=Cliffs +TileIndices=10 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Side=Front + ; N-S front cliffs -[TemperateCliffSet.3] +[TemperateCliffSet.5] +TileSet=Cliffs +TileIndices=11 +ConnectionPoint0=2,2 +ConnectionPoint0.Directions=00010000 ;Bottom +ConnectionPoint0.Side=Front + +[TemperateCliffSet.6] TileSet=Cliffs TileIndices=13,15,16 ConnectionPoint0=1,1 @@ -112,7 +136,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[TemperateCliffSet.4] +[TemperateCliffSet.7] TileSet=Cliffs TileIndices=12 ConnectionPoint0=1,1 @@ -123,7 +147,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=1,0|2,0|0,1|1,1|2,1|3,1|4,1|2,2|3,2|4,2|3,3 -[TemperateCliffSet.5] +[TemperateCliffSet.8] TileSet=Cliffs TileIndices=17 ConnectionPoint0=1,1 @@ -134,9 +158,23 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,1|1,0|1,1|2,1|3,1|2,1|2,2|2,3 +[TemperateCliffSet.9] +TileSet=Cliffs +TileIndices=18 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00000001 ;Top +ConnectionPoint0.Side=Front + ; E-W back cliffs -[TemperateCliffSet.6] +[TemperateCliffSet.10] +TileSet=Cliffs +TileIndices=19 +ConnectionPoint0=2,1 +ConnectionPoint0.Directions=00100000 ;Bottom-Right +ConnectionPoint0.Side=Back + +[TemperateCliffSet.11] TileSet=Cliffs TileIndices=20 ConnectionPoint0=1,1 @@ -147,7 +185,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|1,0|1,1|2,1|3,1|2,2|3,2|4,2 -[TemperateCliffSet.7] +[TemperateCliffSet.12] TileSet=Cliffs TileIndices=21,22 ConnectionPoint0=1,1 @@ -158,7 +196,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|1,1|2,1 -[TemperateCliffSet.8] +[TemperateCliffSet.13] TileSet=Cliffs TileIndices=23,24,25 ConnectionPoint0=1,2 @@ -169,7 +207,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|0,1|1,1|1,2|2,2 -[TemperateCliffSet.9] +[TemperateCliffSet.14] TileSet=Cliffs TileIndices=26,27,28 ConnectionPoint0=1,2 @@ -180,9 +218,23 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|0,1|1,1|1,2 +[TemperateCliffSet.15] +TileSet=Cliffs +TileIndices=29 +ConnectionPoint0=1,2 +ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Side=Back + ; N-S back cliffs -[TemperateCliffSet.10] +[TemperateCliffSet.16] +TileSet=Cliffs +TileIndices=30 +ConnectionPoint0=2,1 +ConnectionPoint0.Directions=00010010 ;Bottom +ConnectionPoint0.Side=Back + +[TemperateCliffSet.17] TileSet=Cliffs TileIndices=31 ConnectionPoint0=1,1 @@ -193,7 +245,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,1|1,0|1,1|2,1|3,1|2,1|2,2|2,3 -[TemperateCliffSet.11] +[TemperateCliffSet.18] TileSet=Cliffs TileIndices=32,34,35 ConnectionPoint0=1,1 @@ -204,7 +256,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[TemperateCliffSet.12] +[TemperateCliffSet.19] TileSet=Cliffs TileIndices=36 ConnectionPoint0=1,1 @@ -215,9 +267,16 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|3,1|4,1|1,2|2,2|3,2|4,2 +[TemperateCliffSet.20] +TileSet=Cliffs +TileIndices=37 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00000001 ;Top +ConnectionPoint0.Side=Back + ; Turns -[TemperateCliffSet.13] +[TemperateCliffSet.21] TileSet=Cliffs TileIndices=38 ConnectionPoint0=1,1 @@ -228,7 +287,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[TemperateCliffSet.14] +[TemperateCliffSet.22] TileSet=Cliffs TileIndices=39 ConnectionPoint0=1,0 @@ -239,7 +298,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|1,0|0,1|1,1|0,2|1,2 -[TemperateCliffSet.15] +[TemperateCliffSet.23] TileSet=Cliffs TileIndices=40 ConnectionPoint0=1,2 @@ -250,7 +309,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,0|1,0|2,0|0,1|1,1|2,1|3,1|1,2|2,2|3,2 -[TemperateCliffSet.16] +[TemperateCliffSet.24] TileSet=Cliffs TileIndices=41 ConnectionPoint0=1,1 @@ -261,7 +320,7 @@ ConnectionPoint1.Directions=00000010 ;Top-Left ConnectionPoint1.Side=Front Foundation=1,0|2,0|0,1|1,1|2,1|1,2|2,2|2,3 -[TemperateCliffSet.17] +[TemperateCliffSet.25] TileSet=Cliffs TileIndices=42 ConnectionPoint0=2,1 @@ -272,7 +331,7 @@ ConnectionPoint1.Directions=00000001 ;Top ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2|2,3 -[TemperateCliffSet.18] +[TemperateCliffSet.26] TileSet=Cliffs TileIndices=43 ConnectionPoint0=2,1 @@ -283,7 +342,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2|2,3|3,3 -[TemperateCliffSet.19] +[TemperateCliffSet.27] TileSet=Cliffs TileIndices=44 ConnectionPoint0=1,2 @@ -294,7 +353,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|1,0|0,1|1,1|2,1|1,2|2,2 -[TemperateCliffSet.20] +[TemperateCliffSet.28] TileSet=Cliffs TileIndices=45 ConnectionPoint0=1,1 @@ -305,7 +364,7 @@ ConnectionPoint1.Directions=00000010 ;Top-Left ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2|2,3 -[TemperateCliffSet.21] +[TemperateCliffSet.29] TileSet=Cliffs TileIndices=48 ConnectionPoint0=1,1 @@ -316,7 +375,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2 -[TemperateCliffSet.22] +[TemperateCliffSet.30] TileSet=Cliffs TileIndices=49 ConnectionPoint0=0,0 @@ -327,7 +386,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|0,1|0,2|1,1|1,2 -[TemperateCliffSet.23] +[TemperateCliffSet.31] TileSet=Cliffs TileIndices=50 ConnectionPoint0=1,1 @@ -338,7 +397,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,0|1,1|2,1|3,1|2,2|3,2 -[TemperateCliffSet.24] +[TemperateCliffSet.32] TileSet=Cliffs TileIndices=51 ConnectionPoint0=1,1 @@ -359,6 +418,13 @@ Color=200,200,255 [SnowCliffSet.0] TileSet=~~~Cliffs +TileIndices=0 +ConnectionPoint0=2,1 +ConnectionPoint0.Directions=00100000 ;Bottom-Right +ConnectionPoint0.Side=Front + +[SnowCliffSet.1] +TileSet=~~~Cliffs TileIndices=2,3 ConnectionPoint0=1,1 ConnectionPoint0.Directions=00000010 ;Top-Left @@ -368,7 +434,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=0,0|1,0|1,1|2,1|2,2 -[SnowCliffSet.1] +[SnowCliffSet.2] TileSet=~~~Cliffs TileIndices=1 ConnectionPoint0=1,2 @@ -377,11 +443,11 @@ ConnectionPoint0.Side=Front ConnectionPoint1=2,2 ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front -ConnectionPoint0.ForbiddenTiles=1 ; Force some variety -ConnectionPoint1.ForbiddenTiles=1 +ConnectionPoint0.ForbiddenTiles=2 ; Force some variety +ConnectionPoint1.ForbiddenTiles=2 Foundation=0,0|1,0|0,1|1,1|1,2|2,2|2,3 -[SnowCliffSet.2] +[SnowCliffSet.3] TileSet=~~~Cliffs TileIndices=4,5,6 ConnectionPoint0=1,2 @@ -392,7 +458,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Front Foundation=0,0|0,1|1,1|1,2|2,2 -[SnowCliffSet.3] +[SnowCliffSet.4] TileSet=~~~Cliffs TileIndices=7,8,9 ConnectionPoint0=1,2 @@ -403,9 +469,23 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=0,0|0,1|1,1|1,2|2,2|2,3 +[SnowCliffSet.5] +TileSet=~~~Cliffs +TileIndices=10 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Side=Front + ; N-S front cliffs -[SnowCliffSet.4] +[SnowCliffSet.6] +TileSet=~~~Cliffs +TileIndices=11 +ConnectionPoint0=2,2 +ConnectionPoint0.Directions=00010000 ;Bottom +ConnectionPoint0.Side=Front + +[SnowCliffSet.7] TileSet=~~~Cliffs TileIndices=13,15,16 ConnectionPoint0=1,1 @@ -416,7 +496,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[SnowCliffSet.5] +[SnowCliffSet.8] TileSet=~~~Cliffs TileIndices=12 ConnectionPoint0=1,1 @@ -427,7 +507,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=1,0|2,0|0,1|1,1|2,1|3,1|4,1|2,2|3,2|4,2|3,3 -[SnowCliffSet.5] +[SnowCliffSet.9] TileSet=~~~Cliffs TileIndices=17 ConnectionPoint0=1,1 @@ -438,9 +518,23 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,1|1,0|1,1|2,1|3,1|2,1|2,2|2,3 +[SnowCliffSet.10] +TileSet=~~~Cliffs +TileIndices=18 +ConnectionPoint0=2,2 +ConnectionPoint0.Directions=00000001 ;Top +ConnectionPoint0.Side=Front + ; E-W back cliffs -[SnowCliffSet.6] +[SnowCliffSet.11] +TileSet=~~~Cliffs +TileIndices=19 +ConnectionPoint0=2,1 +ConnectionPoint0.Directions=00100000 ;Bottom-Right +ConnectionPoint0.Side=Back + +[SnowCliffSet.12] TileSet=~~~Cliffs TileIndices=21,22 ConnectionPoint0=1,1 @@ -451,7 +545,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|1,1|2,1 -[SnowCliffSet.7] +[SnowCliffSet.13] TileSet=~~~Cliffs TileIndices=20 ConnectionPoint0=1,1 @@ -462,7 +556,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|1,1|2,1|3,1|2,2|3,2|4,2 -[SnowCliffSet.8] +[SnowCliffSet.14] TileSet=~~~Cliffs TileIndices=23,24,25 ConnectionPoint0=1,2 @@ -473,7 +567,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|0,1|1,1|1,2|2,2 -[SnowCliffSet.9] +[SnowCliffSet.15] TileSet=~~~Cliffs TileIndices=26,27,28 ConnectionPoint0=1,2 @@ -484,9 +578,23 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|0,1|1,1|1,2 +[SnowCliffSet.16] +TileSet=~~~Cliffs +TileIndices=29 +ConnectionPoint0=1,2 +ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Side=Back + ; N-S back cliffs -[SnowCliffSet.10] +[SnowCliffSet.17] +TileSet=~~~Cliffs +TileIndices=30 +ConnectionPoint0=2,1 +ConnectionPoint0.Directions=00010000 ;Bottom +ConnectionPoint0.Side=Back + +[SnowCliffSet.18] TileSet=~~~Cliffs TileIndices=31 ConnectionPoint0=1,1 @@ -497,7 +605,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,1|1,0|1,1|2,1|3,1|2,1|2,2|2,3 -[SnowCliffSet.11] +[SnowCliffSet.19] TileSet=~~~Cliffs TileIndices=32,34,35 ConnectionPoint0=1,1 @@ -508,7 +616,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[SnowCliffSet.12] +[SnowCliffSet.20] TileSet=~~~Cliffs TileIndices=36 ConnectionPoint0=1,1 @@ -519,9 +627,16 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|3,1|4,1|1,2|2,2|3,2|4,2 +[SnowCliffSet.21] +TileSet=~~~Cliffs +TileIndices=37 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00000001 ;Top +ConnectionPoint0.Side=Back + ; Turns -[SnowCliffSet.13] +[SnowCliffSet.22] TileSet=~~~Cliffs TileIndices=38 ConnectionPoint0=1,1 @@ -532,7 +647,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[SnowCliffSet.14] +[SnowCliffSet.23] TileSet=~~~Cliffs TileIndices=39 ConnectionPoint0=1,0 @@ -543,7 +658,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|1,0|0,1|1,1|0,2|1,2 -[SnowCliffSet.15] +[SnowCliffSet.24] TileSet=~~~Cliffs TileIndices=40 ConnectionPoint0=1,2 @@ -554,7 +669,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,0|1,0|2,0|0,1|1,1|2,1|3,1|1,2|2,2|3,2 -[SnowCliffSet.16] +[SnowCliffSet.25] TileSet=~~~Cliffs TileIndices=41 ConnectionPoint0=1,1 @@ -565,7 +680,7 @@ ConnectionPoint1.Directions=00000010 ;Top-Left ConnectionPoint1.Side=Front Foundation=1,0|2,0|0,1|1,1|2,1|1,2|2,2|2,3 -[SnowCliffSet.17] +[SnowCliffSet.26] TileSet=~~~Cliffs TileIndices=42 ConnectionPoint0=2,1 @@ -576,7 +691,7 @@ ConnectionPoint1.Directions=00000001 ;Top ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2|2,3 -[SnowCliffSet.18] +[SnowCliffSet.27] TileSet=~~~Cliffs TileIndices=43 ConnectionPoint0=2,1 @@ -587,7 +702,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2|2,3|3,3 -[SnowCliffSet.19] +[SnowCliffSet.28] TileSet=~~~Cliffs TileIndices=44 ConnectionPoint0=1,2 @@ -598,7 +713,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|1,0|0,1|1,1|2,1|1,2|2,2 -[SnowCliffSet.20] +[SnowCliffSet.29] TileSet=~~~Cliffs TileIndices=45 ConnectionPoint0=1,1 @@ -609,7 +724,7 @@ ConnectionPoint1.Directions=00000010 ;Top-Left ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2|2,3 -[SnowCliffSet.21] +[SnowCliffSet.30] TileSet=~~~Cliffs TileIndices=48 ConnectionPoint0=1,1 @@ -620,7 +735,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2 -[SnowCliffSet.22] +[SnowCliffSet.31] TileSet=~~~Cliffs TileIndices=49 ConnectionPoint0=0,0 @@ -631,7 +746,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|0,1|0,2|1,1|1,2 -[SnowCliffSet.23] +[SnowCliffSet.32] TileSet=~~~Cliffs TileIndices=50 ConnectionPoint0=1,1 @@ -642,7 +757,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,0|1,1|2,1|3,1|2,2|3,2 -[SnowCliffSet.24] +[SnowCliffSet.33] TileSet=~~~Cliffs TileIndices=51 ConnectionPoint0=1,1 @@ -663,6 +778,13 @@ Color=100,200,168 [TDWinterCliffSet.0] TileSet=---Cliffs +TileIndices=0 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00100000 ;Bottom-Right +ConnectionPoint0.Side=Front + +[TDWinterCliffSet.1] +TileSet=---Cliffs TileIndices=1,2,3 ConnectionPoint0=1,1 ConnectionPoint0.Directions=00000010 ;Top-Left @@ -672,7 +794,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=0,0|1,0|1,1|2,1|2,2|3,2 -[TDWinterCliffSet.1] +[TDWinterCliffSet.2] TileSet=---Cliffs TileIndices=4,5,6 ConnectionPoint0=1,2 @@ -683,7 +805,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Front Foundation=0,0|0,1|1,1|1,2|2,2 -[TDWinterCliffSet.2] +[TDWinterCliffSet.3] TileSet=---Cliffs TileIndices=7,8,9 ConnectionPoint0=1,1 @@ -694,9 +816,23 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=0,0|1,0|1,1|2,1|2,2 +[TDWinterCliffSet.4] +TileSet=---Cliffs +TileIndices=10 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Side=Front + ; N-S front cliffs -[TDWinterCliffSet.3] +[TDWinterCliffSet.5] +TileSet=---Cliffs +TileIndices=11 +ConnectionPoint0=2,2 +ConnectionPoint0.Directions=00010000 ;Bottom +ConnectionPoint0.Side=Front + +[TDWinterCliffSet.6] TileSet=---Cliffs TileIndices=13,15,16 ConnectionPoint0=1,1 @@ -707,7 +843,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[TDWinterCliffSet.4] +[TDWinterCliffSet.7] TileSet=---Cliffs TileIndices=12 ConnectionPoint0=1,1 @@ -718,7 +854,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=1,0|2,0|0,1|1,1|2,1|3,1|4,1|2,2|3,2|4,2|3,3 -[TDWinterCliffSet.5] +[TDWinterCliffSet.8] TileSet=---Cliffs TileIndices=17 ConnectionPoint0=1,1 @@ -729,9 +865,23 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,1|1,0|1,1|2,1|3,1|2,1|2,2|2,3 +[TDWinterCliffSet.9] +TileSet=---Cliffs +TileIndices=18 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00000001 ;Top +ConnectionPoint0.Side=Front + ; E-W back cliffs -[TDWinterCliffSet.6] +[TDWinterCliffSet.10] +TileSet=---Cliffs +TileIndices=19 +ConnectionPoint0=2,1 +ConnectionPoint0.Directions=00100000 ;Bottom-Right +ConnectionPoint0.Side=Back + +[TDWinterCliffSet.11] TileSet=---Cliffs TileIndices=20 ConnectionPoint0=1,1 @@ -742,7 +892,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|1,0|1,1|2,1|3,1|2,2|3,2|4,2 -[TDWinterCliffSet.7] +[TDWinterCliffSet.12] TileSet=---Cliffs TileIndices=21,22 ConnectionPoint0=1,1 @@ -753,7 +903,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|1,1|2,1 -[TDWinterCliffSet.8] +[TDWinterCliffSet.13] TileSet=---Cliffs TileIndices=23,24,25 ConnectionPoint0=1,2 @@ -764,7 +914,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|0,1|1,1|1,2|2,2 -[TDWinterCliffSet.9] +[TDWinterCliffSet.14] TileSet=---Cliffs TileIndices=26,27,28 ConnectionPoint0=1,2 @@ -775,9 +925,23 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|0,1|1,1|1,2 +[TDWinterCliffSet.15] +TileSet=---Cliffs +TileIndices=29 +ConnectionPoint0=1,2 +ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Side=Back + ; N-S back cliffs -[TDWinterCliffSet.10] +[TDWinterCliffSet.16] +TileSet=---Cliffs +TileIndices=30 +ConnectionPoint0=2,1 +ConnectionPoint0.Directions=00010010 ;Bottom +ConnectionPoint0.Side=Back + +[TDWinterCliffSet.17] TileSet=---Cliffs TileIndices=31 ConnectionPoint0=1,1 @@ -788,7 +952,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,1|1,0|1,1|2,1|3,1|2,1|2,2|2,3 -[TDWinterCliffSet.11] +[TDWinterCliffSet.18] TileSet=---Cliffs TileIndices=32,34,35 ConnectionPoint0=1,1 @@ -799,7 +963,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[TDWinterCliffSet.12] +[TDWinterCliffSet.19] TileSet=---Cliffs TileIndices=36 ConnectionPoint0=1,1 @@ -810,9 +974,16 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|3,1|4,1|1,2|2,2|3,2|4,2 +[TDWinterCliffSet.20] +TileSet=---Cliffs +TileIndices=37 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00000001 ;Top +ConnectionPoint0.Side=Back + ; Turns -[TDWinterCliffSet.13] +[TDWinterCliffSet.21] TileSet=---Cliffs TileIndices=38 ConnectionPoint0=1,1 @@ -823,7 +994,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[TDWinterCliffSet.14] +[TDWinterCliffSet.22] TileSet=---Cliffs TileIndices=39 ConnectionPoint0=1,0 @@ -834,7 +1005,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|1,0|0,1|1,1|0,2|1,2 -[TDWinterCliffSet.15] +[TDWinterCliffSet.23] TileSet=---Cliffs TileIndices=40 ConnectionPoint0=1,2 @@ -845,7 +1016,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,0|1,0|2,0|0,1|1,1|2,1|3,1|1,2|2,2|3,2 -[TDWinterCliffSet.16] +[TDWinterCliffSet.24] TileSet=---Cliffs TileIndices=41 ConnectionPoint0=1,1 @@ -856,7 +1027,7 @@ ConnectionPoint1.Directions=00000010 ;Top-Left ConnectionPoint1.Side=Front Foundation=1,0|2,0|0,1|1,1|2,1|1,2|2,2|2,3 -[TDWinterCliffSet.17] +[TDWinterCliffSet.25] TileSet=---Cliffs TileIndices=42 ConnectionPoint0=2,1 @@ -867,7 +1038,7 @@ ConnectionPoint1.Directions=00000001 ;Top ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2|2,3 -[TDWinterCliffSet.18] +[TDWinterCliffSet.26] TileSet=---Cliffs TileIndices=43 ConnectionPoint0=2,1 @@ -878,7 +1049,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2|2,3|3,3 -[TDWinterCliffSet.19] +[TDWinterCliffSet.27] TileSet=---Cliffs TileIndices=44 ConnectionPoint0=1,2 @@ -889,7 +1060,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|1,0|0,1|1,1|2,1|1,2|2,2 -[TDWinterCliffSet.20] +[TDWinterCliffSet.28] TileSet=---Cliffs TileIndices=45 ConnectionPoint0=1,1 @@ -900,7 +1071,7 @@ ConnectionPoint1.Directions=00000010 ;Top-Left ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2|2,3 -[TDWinterCliffSet.21] +[TDWinterCliffSet.29] TileSet=---Cliffs TileIndices=48 ConnectionPoint0=1,1 @@ -911,7 +1082,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2 -[TDWinterCliffSet.22] +[TDWinterCliffSet.30] TileSet=---Cliffs TileIndices=49 ConnectionPoint0=0,0 @@ -922,7 +1093,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|0,1|0,2|1,1|1,2 -[TDWinterCliffSet.23] +[TDWinterCliffSet.31] TileSet=---Cliffs TileIndices=50 ConnectionPoint0=1,1 @@ -933,7 +1104,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,0|1,1|2,1|3,1|2,2|3,2 -[TDWinterCliffSet.24] +[TDWinterCliffSet.32] TileSet=---Cliffs TileIndices=51 ConnectionPoint0=1,1 diff --git a/src/TSMapEditor/Config/Translations/en/Translation_en.ini b/src/TSMapEditor/Config/Translations/en/Translation_en.ini index 1ccd4d36d..ced57ab4f 100644 --- a/src/TSMapEditor/Config/Translations/en/Translation_en.ini +++ b/src/TSMapEditor/Config/Translations/en/Translation_en.ini @@ -1398,9 +1398,18 @@ DeletionModeCursorAction.Name=Delete Object DeletionModeCursorAction.Text=Delete DrawConnectedTilesCursorAction.Name=Draw Connected Tiles -DrawConnectedTilesCursorAction.MainText.V2=Click on a cell to place a new vertex.\r\n\r\nENTER to confirm\r\nBackspace to go back one step\r\n\r\nR to re-generate the pattern +DrawConnectedTilesCursorAction.MainText.V3=Click on a cell to place a new vertex.\r\nAfter placing at least three distinct vertices, you can optionally\r\nclick the first one to close the formation.\r\n\r\nENTER to confirm\r\nBackspace to go back one step\r\nR to re-generate the pattern\r\n DrawConnectedTilesCursorAction.TabText=TAB to toggle between front and back sides\r\n DrawConnectedTilesCursorAction.PageUpDownText=PageUp to raise the tiles, PageDown to lower them\r\n +DrawConnectedTilesCursorAction.EndPiecesText=E to toggle ending pieces ({0})\r\n +DrawConnectedTilesCursorAction.EndPieces.Enabled=enabled +DrawConnectedTilesCursorAction.EndPieces.Disabled=disabled +DrawConnectedTilesCursorAction.ClosedText=Closed formation (ending pieces disabled); Backspace to reopen\r\n +DrawConnectedTilesCursorAction.PlanFailureText=Cannot create an exact connected pattern: {0}\r\nAdjust the vertices or press R to try another pattern. +DrawConnectedTilesCursorAction.PlanStatus.InvalidInput=the selected path is invalid +DrawConnectedTilesCursorAction.PlanStatus.NoSolution=no exact connection pattern fits the selected vertices +DrawConnectedTilesCursorAction.PlanStatus.SearchLimit=the search limit was reached before an exact pattern was found +DrawConnectedTilesCursorAction.PlanStatus.Unknown=the connected tile planner failed DrawConnectedTilesCursorAction.ExitText=Right-click or ESC to exit GenerateTerrainCursorAction.Name=Generate Terrain diff --git a/src/TSMapEditor/Models/ConnectedTilePlanner.cs b/src/TSMapEditor/Models/ConnectedTilePlanner.cs new file mode 100644 index 000000000..825edf857 --- /dev/null +++ b/src/TSMapEditor/Models/ConnectedTilePlanner.cs @@ -0,0 +1,761 @@ +using Microsoft.Xna.Framework; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using TSMapEditor.GameMath; + +namespace TSMapEditor.Models +{ + public enum ConnectedTilePlanStatus + { + Success, + InvalidInput, + NoSolution, + SearchLimit + } + + public sealed class ConnectedTilePlacement + { + internal ConnectedTilePlacement(ConnectedTile tile, Point2D location, + TileConnectionPoint? entryPoint, TileConnectionPoint? exitPoint) + { + Tile = tile; + Location = location; + EntryPoint = entryPoint; + ExitPoint = exitPoint; + } + + public ConnectedTile Tile { get; } + public Point2D Location { get; } + public TileConnectionPoint? EntryPoint { get; } + public TileConnectionPoint? ExitPoint { get; } + + public Point2D? EntryCoordinates => EntryPoint.HasValue + ? Location + EntryPoint.Value.CoordinateOffset + : null; + + public Point2D? ExitCoordinates => ExitPoint.HasValue + ? Location + ExitPoint.Value.CoordinateOffset + : null; + } + + public sealed class ConnectedTilePlan + { + internal ConnectedTilePlan(ConnectedTileType type, Point2D origin, + IEnumerable placements, bool isClosed, bool usesEndPieces) + { + Type = type; + Origin = origin; + Placements = new ReadOnlyCollection(placements.ToList()); + IsClosed = isClosed; + UsesEndPieces = usesEndPieces; + } + + public ConnectedTileType Type { get; } + public ConnectedTileType ConnectedTileType => Type; + public Point2D Origin { get; } + public Point2D Start => Origin; + public IReadOnlyList Placements { get; } + public bool IsClosed { get; } + public bool UsesEndPieces { get; } + } + + public sealed class ConnectedTilePlanResult + { + private ConnectedTilePlanResult(ConnectedTilePlanStatus status, ConnectedTilePlan plan, string message) + { + Status = status; + Plan = plan; + Message = message; + } + + public ConnectedTilePlanStatus Status { get; } + public bool Success => Status == ConnectedTilePlanStatus.Success; + public bool IsSuccess => Success; + public ConnectedTilePlan Plan { get; } + public string Message { get; } + + internal static ConnectedTilePlanResult Succeeded(ConnectedTilePlan plan) + => new ConnectedTilePlanResult(ConnectedTilePlanStatus.Success, plan, null); + + internal static ConnectedTilePlanResult Failed(ConnectedTilePlanStatus status, string message) + => new ConnectedTilePlanResult(status, null, message); + } + + /// + /// Creates an immutable placement plan for connected terrain. Planning is deliberately separate from + /// map mutation so a validated preview can be committed without running the bounded search again. + /// + public static class ConnectedTilePlanner + { + private const int MaxExpandedNodesPerLegacySegment = 1_000; + private const int MaxQueuedNodesPerLegacySegment = 8_000; + private const int MaxExpandedNodesPerStrictSegment = 25_000; + private const int MaxQueuedNodesPerStrictSegment = 100_000; + private const int MaxPlacementCount = 2_048; + private const int MaxBoundaryStates = 24; + + private const float TileSizePenaltyPerFoundationCell = 0.07f; + private const int RepeatPenaltyAncestorWindow = 5; + private const float RepeatPenaltyPerWeightedUse = 0.75f; + private const float RepeatPenaltyPerFoundationCell = 0.12f; + private const float TurnTilePenalty = 1.0f; + private const float NodeScoreJitterAmplitude = 0.02f; + + private enum SegmentGoal + { + Point, + EndingPiece, + ClosedSeam + } + + private sealed class SearchNode + { + private SearchNode() { } + + public SearchNode(SearchNode parent, ConnectedTile tile, Point2D location, + TileConnectionPoint? entryPoint, TileConnectionPoint? exitPoint, HashSet occupiedCells, + float gScore) + { + Parent = parent; + Tile = tile; + Location = location; + EntryPoint = entryPoint; + ExitPoint = exitPoint; + OccupiedCells = occupiedCells; + GScore = gScore; + Depth = (parent?.Depth ?? 0) + 1; + + if (parent?.FirstPlacement != null) + FirstPlacement = parent.FirstPlacement; + else if (tile != null) + FirstPlacement = this; + } + + public SearchNode Parent { get; private init; } + public SearchNode FirstPlacement { get; private set; } + public ConnectedTile Tile { get; private init; } + public Point2D Location { get; private init; } + public TileConnectionPoint? EntryPoint { get; private init; } + public TileConnectionPoint? ExitPoint { get; private init; } + public HashSet OccupiedCells { get; private init; } + public float GScore { get; private init; } + public int Depth { get; private init; } + + public Point2D ExitCoordinates => Location + ExitPoint.Value.CoordinateOffset; + + public static SearchNode MakeSyntheticStart(Point2D location, ConnectedTileSide startingSide) + { + var connectionPoint = new TileConnectionPoint + { + Index = -1, + ConnectionMask = byte.MaxValue, + CoordinateOffset = Point2D.Zero, + Side = startingSide, + RequiredTiles = Array.Empty(), + ForbiddenTiles = Array.Empty() + }; + + return new SearchNode + { + Location = location, + ExitPoint = connectionPoint, + OccupiedCells = new HashSet(), + GScore = 0.0f, + Depth = 0 + }; + } + } + + private sealed class SegmentSearchResult + { + public List ExactNodes { get; } = new List(); + public SearchNode BestNode { get; set; } + public bool SearchWasLimited { get; set; } + } + + public static ConnectedTilePlanResult Plan(ConnectedTileType type, IReadOnlyList path, + ConnectedTileSide startingSide, int randomSeed, bool useEndPieces, bool closed, + Func isCellValid) + { + if (type == null) + return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, + "A connected tile type is required."); + + if (path == null) + return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, + "A connected tile path is required."); + + if (isCellValid == null) + return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, + "A map-cell validator is required."); + + var vertices = path.ToList(); + if (closed && vertices.Count > 1 && vertices[0] == vertices[^1]) + vertices.RemoveAt(vertices.Count - 1); + + int minimumVertexCount = closed ? 3 : 2; + if (vertices.Count < minimumVertexCount) + { + return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, + closed + ? "A closed connected tile path requires at least three vertices." + : "A connected tile path requires at least two vertices."); + } + + if (closed && vertices.Distinct().Count() < 3) + { + return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, + "A closed connected tile path requires at least three distinct vertices."); + } + + for (int i = 0; i < vertices.Count; i++) + { + if (!isCellValid(vertices[i])) + { + return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, + $"Connected tile path vertex {i} is outside the valid map area."); + } + + if (i > 0 && vertices[i] == vertices[i - 1]) + { + return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, + $"Connected tile path vertices {i - 1} and {i} cannot be identical."); + } + } + + List linearTiles = type.Tiles + .Where(tile => tile.ConnectionPoints.Length == 2 && HasUsableFoundation(tile)) + .ToList(); + List endingTiles = type.Tiles + .Where(tile => tile.ConnectionPoints.Length == 1 && HasUsableFoundation(tile)) + .ToList(); + + bool usesEndPieces = useEndPieces && !closed; + bool strict = usesEndPieces || closed; + + List frontier; + if (usesEndPieces) + { + frontier = MakeStartingEndingNodes(endingTiles, vertices[0], startingSide, isCellValid); + if (frontier.Count == 0) + { + return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.NoSolution, + "No compatible starting ending piece can be placed at the first vertex."); + } + } + else + { + frontier = new List + { + SearchNode.MakeSyntheticStart(vertices[0], startingSide) + }; + } + + var destinations = vertices.Skip(1).ToList(); + if (closed) + destinations.Add(vertices[0]); + + bool earlierSearchWasLimited = false; + + for (int segmentIndex = 0; segmentIndex < destinations.Count; segmentIndex++) + { + bool isFirstSegment = segmentIndex == 0; + bool isLastSegment = segmentIndex == destinations.Count - 1; + + if (!isFirstSegment) + frontier = BackUpOnePlacement(frontier); + + SegmentGoal goal = isLastSegment && closed + ? SegmentGoal.ClosedSeam + : isLastSegment && usesEndPieces + ? SegmentGoal.EndingPiece + : SegmentGoal.Point; + + int exactResultLimit = strict && !isLastSegment ? MaxBoundaryStates : 1; + SegmentSearchResult searchResult = SearchSegment( + frontier, + destinations[segmentIndex], + vertices[0], + linearTiles, + endingTiles, + allowSideChangingTiles: !isFirstSegment || strict, + goal, + randomSeed, + exactResultLimit, + strict, + isCellValid); + + earlierSearchWasLimited |= searchResult.SearchWasLimited; + + if (searchResult.ExactNodes.Count > 0) + { + frontier = searchResult.ExactNodes; + continue; + } + + if (!strict && searchResult.BestNode != null) + { + frontier = new List { searchResult.BestNode }; + continue; + } + + ConnectedTilePlanStatus failureStatus = earlierSearchWasLimited + ? ConnectedTilePlanStatus.SearchLimit + : ConnectedTilePlanStatus.NoSolution; + + return ConnectedTilePlanResult.Failed(failureStatus, + failureStatus == ConnectedTilePlanStatus.SearchLimit + ? "The connected tile search limit was reached before an exact formation was found." + : "No exact connected tile formation matches the requested path."); + } + + SearchNode finalNode = frontier.Count > 0 ? frontier[0] : null; + if (finalNode == null) + { + return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.NoSolution, + "No connected tile placements were found."); + } + + var placements = BuildPlacements(finalNode); + var plan = new ConnectedTilePlan(type, vertices[0], placements, closed, usesEndPieces); + return ConnectedTilePlanResult.Succeeded(plan); + } + + private static SegmentSearchResult SearchSegment(IReadOnlyList seeds, Point2D destination, + Point2D origin, IReadOnlyList linearTiles, IReadOnlyList endingTiles, + bool allowSideChangingTiles, SegmentGoal goal, int randomSeed, int exactResultLimit, bool strict, + Func isCellValid) + { + var result = new SegmentSearchResult(); + var openSet = new PriorityQueue(); + long sequence = 0; + int maxExpandedNodes = strict + ? MaxExpandedNodesPerStrictSegment + : MaxExpandedNodesPerLegacySegment; + int maxQueuedNodes = strict + ? MaxQueuedNodesPerStrictSegment + : MaxQueuedNodesPerLegacySegment; + + foreach (SearchNode seed in seeds) + { + if (seed?.ExitPoint == null) + continue; + + if (openSet.Count >= maxQueuedNodes) + { + result.SearchWasLimited = true; + break; + } + + openSet.Enqueue(seed, (GetNodePriorityScore(seed, destination, randomSeed), + seed.Tile?.ExtraPriority ?? 0, sequence++)); + } + + float bestDistance = float.PositiveInfinity; + int expandedNodeCount = 0; + bool hitDepthLimit = false; + + while (openSet.Count > 0) + { + if (expandedNodeCount >= maxExpandedNodes || openSet.Count > maxQueuedNodes) + { + result.SearchWasLimited = true; + break; + } + + SearchNode currentNode = openSet.Dequeue(); + expandedNodeCount++; + + float currentDistance = Distance(currentNode.ExitCoordinates, destination); + if (currentDistance < bestDistance) + { + bestDistance = currentDistance; + result.BestNode = currentNode; + } + + if (goal == SegmentGoal.Point && currentNode.ExitCoordinates == destination) + { + result.ExactNodes.Add(currentNode); + if (result.ExactNodes.Count >= exactResultLimit) + { + if (strict && exactResultLimit > 1 && openSet.Count > 0) + result.SearchWasLimited = true; + + break; + } + + continue; + } + + if (goal == SegmentGoal.ClosedSeam && CanCloseAtOrigin(currentNode, origin)) + { + result.ExactNodes.Add(currentNode); + break; + } + + if (goal == SegmentGoal.EndingPiece) + { + List terminalNodes = GetTerminalEndingNodes( + currentNode, endingTiles, destination, randomSeed, isCellValid); + + if (terminalNodes.Count > 0) + { + result.ExactNodes.Add(terminalNodes[0]); + break; + } + } + + if (currentNode.Depth >= MaxPlacementCount) + { + hitDepthLimit = true; + continue; + } + + bool queueLimitReached = false; + foreach (ConnectedTile tile in linearTiles) + { + if (!allowSideChangingTiles && tile.ConnectionPoints[0].Side != tile.ConnectionPoints[1].Side) + continue; + + int remainingQueueCapacity = maxQueuedNodes - openSet.Count; + if (remainingQueueCapacity <= 0) + { + queueLimitReached = true; + break; + } + + foreach (SearchNode nextNode in GetNextLinearNodes( + currentNode, tile, remainingQueueCapacity, isCellValid)) + { + openSet.Enqueue(nextNode, (GetNodePriorityScore(nextNode, destination, randomSeed), + nextNode.Tile.ExtraPriority, sequence++)); + } + + if (openSet.Count >= maxQueuedNodes) + { + queueLimitReached = true; + break; + } + } + + if (queueLimitReached) + { + result.SearchWasLimited = true; + continue; + } + } + + if (hitDepthLimit) + result.SearchWasLimited = true; + + if (result.BestNode == null && seeds.Count > 0) + result.BestNode = seeds[0]; + + return result; + } + + private static IEnumerable GetNextLinearNodes(SearchNode currentNode, ConnectedTile tile, + int maxNodeCount, Func isCellValid) + { + int generatedNodeCount = 0; + + for (int entryIndex = 0; entryIndex < tile.ConnectionPoints.Length; entryIndex++) + { + TileConnectionPoint entryPoint = tile.ConnectionPoints[entryIndex]; + if (!CanEnterTile(currentNode, tile, entryPoint)) + continue; + + byte directionMask = (byte)(entryPoint.ReversedConnectionMask & currentNode.ExitPoint.Value.ConnectionMask); + foreach (Direction direction in Helpers.GetDirectionsInMask(directionMask)) + { + Point2D placementOffset = Helpers.VisualDirectionToPoint(direction) - entryPoint.CoordinateOffset; + Point2D placementLocation = currentNode.ExitCoordinates + placementOffset; + + if (!TryAddFoundation(currentNode.OccupiedCells, tile, placementLocation, + isCellValid, out HashSet occupiedCells)) + { + continue; + } + + TileConnectionPoint exitPoint = tile.ConnectionPoints[entryIndex == 0 ? 1 : 0]; + Point2D exitCoordinates = placementLocation + exitPoint.CoordinateOffset; + float gScore = currentNode.GScore + Distance(currentNode.ExitCoordinates, exitCoordinates); + + generatedNodeCount++; + yield return new SearchNode(currentNode, tile, placementLocation, + entryPoint, exitPoint, occupiedCells, gScore); + + if (generatedNodeCount >= maxNodeCount) + yield break; + } + } + } + + private static List MakeStartingEndingNodes(IReadOnlyList endingTiles, + Point2D origin, ConnectedTileSide startingSide, Func isCellValid) + { + var nodes = new List(); + + foreach (ConnectedTile endingTile in endingTiles) + { + TileConnectionPoint point = endingTile.ConnectionPoints[0]; + if (point.Side != startingSide) + continue; + + Point2D location = origin - point.CoordinateOffset; + if (!TryAddFoundation(new HashSet(), endingTile, location, + isCellValid, out HashSet occupiedCells)) + { + continue; + } + + nodes.Add(new SearchNode(null, endingTile, location, null, point, occupiedCells, 0.0f)); + } + + return nodes; + } + + private static List GetTerminalEndingNodes(SearchNode currentNode, + IReadOnlyList endingTiles, Point2D destination, int randomSeed, + Func isCellValid) + { + var terminalNodes = new List(); + + foreach (ConnectedTile endingTile in endingTiles) + { + TileConnectionPoint entryPoint = endingTile.ConnectionPoints[0]; + if (!CanEnterTile(currentNode, endingTile, entryPoint)) + continue; + + byte directionMask = (byte)(entryPoint.ReversedConnectionMask & currentNode.ExitPoint.Value.ConnectionMask); + foreach (Direction direction in Helpers.GetDirectionsInMask(directionMask)) + { + Point2D placementOffset = Helpers.VisualDirectionToPoint(direction) - entryPoint.CoordinateOffset; + Point2D location = currentNode.ExitCoordinates + placementOffset; + Point2D entryCoordinates = location + entryPoint.CoordinateOffset; + + if (entryCoordinates != destination) + continue; + + if (!TryAddFoundation(currentNode.OccupiedCells, endingTile, location, + isCellValid, out HashSet occupiedCells)) + { + continue; + } + + float gScore = currentNode.GScore + Distance(currentNode.ExitCoordinates, entryCoordinates); + terminalNodes.Add(new SearchNode(currentNode, endingTile, location, + entryPoint, null, occupiedCells, gScore)); + } + } + + terminalNodes.Sort((left, right) => + { + int priorityComparison = left.Tile.ExtraPriority.CompareTo(right.Tile.ExtraPriority); + if (priorityComparison != 0) + return priorityComparison; + + return GetStableNodeHash(left, randomSeed).CompareTo(GetStableNodeHash(right, randomSeed)); + }); + + return terminalNodes; + } + + private static bool CanEnterTile(SearchNode currentNode, ConnectedTile candidateTile, + TileConnectionPoint candidateEntry) + { + if (currentNode.ExitPoint == null || candidateEntry.Side != currentNode.ExitPoint.Value.Side) + return false; + + if (currentNode.Tile != null && !ConnectionPointAllowsTile(candidateEntry, currentNode.Tile.Index)) + return false; + + // Existing two-point configurations apply RequiredTiles / ForbiddenTiles from the + // incoming candidate only. Preserve that behavior, while allowing a one-point start + // cap to constrain the first linear tile through its sole outgoing connection. + if (currentNode.Tile?.IsEndingPiece == true && + !ConnectionPointAllowsTile(currentNode.ExitPoint.Value, candidateTile.Index)) + { + return false; + } + + return (candidateEntry.ReversedConnectionMask & currentNode.ExitPoint.Value.ConnectionMask) != 0; + } + + private static bool CanCloseAtOrigin(SearchNode finalNode, Point2D origin) + { + SearchNode firstNode = finalNode.FirstPlacement; + if (finalNode.Tile == null || finalNode.ExitPoint == null || firstNode?.EntryPoint == null) + return false; + + if (finalNode.Depth < 2 || finalNode.ExitCoordinates != origin) + return false; + + TileConnectionPoint finalExit = finalNode.ExitPoint.Value; + TileConnectionPoint firstEntry = firstNode.EntryPoint.Value; + + if (finalExit.Side != firstEntry.Side || !ConnectionPointAllowsTile(firstEntry, finalNode.Tile.Index)) + return false; + + Point2D firstEntryCoordinates = firstNode.Location + firstEntry.CoordinateOffset; + Point2D delta = firstEntryCoordinates - finalNode.ExitCoordinates; + byte compatibleDirections = (byte)(finalExit.ConnectionMask & firstEntry.ReversedConnectionMask); + + foreach (Direction direction in Helpers.GetDirectionsInMask(compatibleDirections)) + { + if (Helpers.VisualDirectionToPoint(direction) == delta) + return true; + } + + return false; + } + + private static bool ConnectionPointAllowsTile(TileConnectionPoint point, int tileIndex) + { + if (point.RequiredTiles?.Length > 0) + return point.RequiredTiles.Contains(tileIndex); + + return point.ForbiddenTiles == null || !point.ForbiddenTiles.Contains(tileIndex); + } + + private static bool TryAddFoundation(HashSet existingOccupiedCells, ConnectedTile tile, + Point2D location, Func isCellValid, out HashSet occupiedCells) + { + occupiedCells = null; + if (!HasUsableFoundation(tile)) + return false; + + foreach (Point2D foundationCell in tile.Foundation) + { + Point2D absoluteCell = foundationCell + location; + if (!isCellValid(absoluteCell) || existingOccupiedCells.Contains(absoluteCell)) + return false; + } + + occupiedCells = new HashSet(existingOccupiedCells); + foreach (Point2D foundationCell in tile.Foundation) + occupiedCells.Add(foundationCell + location); + + return true; + } + + private static bool HasUsableFoundation(ConnectedTile tile) + => tile.Foundation != null && tile.Foundation.Count > 0; + + private static List BackUpOnePlacement(IEnumerable nodes) + { + var backedUpNodes = new List(); + foreach (SearchNode node in nodes) + { + SearchNode backedUpNode = node.Parent ?? node; + if (!backedUpNodes.Contains(backedUpNode)) + backedUpNodes.Add(backedUpNode); + } + + return backedUpNodes; + } + + private static List BuildPlacements(SearchNode finalNode) + { + var placements = new List(); + SearchNode node = finalNode; + + while (node != null) + { + if (node.Tile != null) + { + placements.Add(new ConnectedTilePlacement(node.Tile, node.Location, + node.EntryPoint, node.ExitPoint)); + } + + node = node.Parent; + } + + placements.Reverse(); + return placements; + } + + private static float GetNodePriorityScore(SearchNode node, Point2D destination, int randomSeed) + { + float hScore = Distance(node.ExitCoordinates, destination); + float fScore = node.GScore * 0.7f + hScore + (node.Tile?.DistanceModifier ?? 0); + if (node.Tile == null) + return fScore; + + int foundationCellCount = Math.Max(node.Tile.Foundation?.Count ?? 1, 1); + float sizePenalty = (foundationCellCount - 1) * TileSizePenaltyPerFoundationCell; + float repeatedWeightedUseCount = CountPreviousTileUsesInRecentAncestors( + node, RepeatPenaltyAncestorWindow); + float repeatPenalty = repeatedWeightedUseCount * RepeatPenaltyPerWeightedUse * + (1.0f + (foundationCellCount - 1) * RepeatPenaltyPerFoundationCell); + float turnPenalty = IsTurningTile(node.Tile) ? TurnTilePenalty : 0.0f; + + uint hash = GetStableNodeHash(node, randomSeed); + float jitter = ((hash & 1023) / 1023.0f - 0.5f) * 2.0f * NodeScoreJitterAmplitude; + + return fScore + sizePenalty + repeatPenalty + turnPenalty + jitter; + } + + private static uint GetStableNodeHash(SearchNode node, int randomSeed) + { + uint hash = 2166136261; + hash = MixHash(hash, unchecked((uint)randomSeed)); + hash = MixHash(hash, unchecked((uint)node.Location.X)); + hash = MixHash(hash, unchecked((uint)node.Location.Y)); + hash = MixHash(hash, unchecked((uint)(node.ExitPoint?.Index ?? -1))); + hash = MixHash(hash, unchecked((uint)(node.Tile?.Index ?? -1))); + return hash; + } + + private static uint MixHash(uint hash, uint value) + { + hash ^= value; + hash *= 16777619; + return hash; + } + + private static bool IsTurningTile(ConnectedTile tile) + { + if (tile.ConnectionPoints.Length != 2) + return false; + + int oppositeMask = tile.ConnectionPoints[0].ConnectionMask & + tile.ConnectionPoints[1].ReversedConnectionMask; + if (oppositeMask == 0) + return true; + + return !tile.ConnectionPoints[0].CoordinateOffset + .IsInStraightLineWith(tile.ConnectionPoints[1].CoordinateOffset); + } + + private static float CountPreviousTileUsesInRecentAncestors(SearchNode node, int ancestorWindow) + { + if (node.Tile == null) + return 0.0f; + + float weightedUses = 0.0f; + int tileIndex = node.Tile.Index; + int depth = 1; + SearchNode current = node.Parent; + + while (current != null && depth <= ancestorWindow) + { + if (current.Tile?.Index == tileIndex) + { + float recencyWeight = (ancestorWindow - depth + 1) / (float)ancestorWindow; + weightedUses += recencyWeight; + } + + current = current.Parent; + depth++; + } + + return weightedUses; + } + + private static float Distance(Point2D first, Point2D second) + => Vector2.Distance(first.ToXNAVector(), second.ToXNAVector()); + } +} diff --git a/src/TSMapEditor/Models/ConnectedTileType.cs b/src/TSMapEditor/Models/ConnectedTileType.cs index 027b3feba..fbf35c8ac 100644 --- a/src/TSMapEditor/Models/ConnectedTileType.cs +++ b/src/TSMapEditor/Models/ConnectedTileType.cs @@ -18,7 +18,7 @@ public enum ConnectedTileSide public readonly struct TileConnectionPoint { /// - /// Index of the connection point, 0 or 1 + /// Zero-based index of the connection point within its tile. /// public int Index { get; init; } @@ -54,193 +54,75 @@ public readonly struct TileConnectionPoint public ConnectedTileSide Side { get; init; } } - public class ConnectedTileAStarNode + public class ConnectedTile { - private ConnectedTileAStarNode() {} - - public ConnectedTileAStarNode(ConnectedTileAStarNode parent, TileConnectionPoint exit, Point2D location, ConnectedTile tile) + public ConnectedTile(IniSection iniSection, int index) { - Location = location; - Tile = tile; - - Parent = parent; - Exit = exit; - Destination = Parent.Destination; - GScore = Parent.GScore + Vector2.Distance(Parent.ExitCoords.ToXNAVector(), ExitCoords.ToXNAVector()); - - OccupiedCells = new HashSet(parent.OccupiedCells); - foreach (var foundationCell in tile.Foundation) - { - OccupiedCells.Add(foundationCell + Location); - } - } - - /// - /// Absolute world coordinates of the node's tile - /// - public Point2D Location; - - /// - /// Absolute world coordinates of the node's tile's exit - /// - public Point2D ExitCoords => Location + Exit.CoordinateOffset; - - /// - /// Tile data - /// - public ConnectedTile Tile; - - ///// A* Stuff + Index = index; - /// - /// A* end point - /// - public Point2D Destination; + string indicesString = iniSection.GetStringValue("TileIndices", null); + if (indicesString == null || !Regex.IsMatch(indicesString, "^((?:\\d+?,)*(?:\\d+?))$")) + throw new INIConfigException($"Connected Tile {iniSection.SectionName} has invalid TileIndices list: {indicesString}!"); - /// - /// Where this node connects to the next node - /// - public TileConnectionPoint Exit; - /// - /// Distance from starting node - /// - public float GScore { get; private set; } + string tileSet = iniSection.GetStringValue("TileSet", null); + if (string.IsNullOrWhiteSpace(tileSet)) + throw new INIConfigException($"Connected Tile {iniSection.SectionName} has no TileSet!"); - /// - /// Distance to end node - /// - public float HScore => Vector2.Distance(Destination.ToXNAVector(), ExitCoords.ToXNAVector()); - public float FScore => GScore * 0.7f + HScore + (Tile?.DistanceModifier ?? 0); + TileSetName = tileSet; - /// - /// Previous node - /// - public ConnectedTileAStarNode Parent; + IndicesInTileSet = indicesString.Split(',').Select(s => int.Parse(s, CultureInfo.InvariantCulture)).ToList(); - /// - /// Accumulated set of all cell coordinates occupied up to this node - /// - public HashSet OccupiedCells = new HashSet(); + var connectionPointValues = new SortedDictionary(); - public static ConnectedTileAStarNode MakeStartNode(Point2D location, Point2D destination, ConnectedTileSide startingSide) - { - TileConnectionPoint connectionPoint = new TileConnectionPoint - { - Index = 0, - ConnectionMask = 0b11111111, - CoordinateOffset = Point2D.Zero, - Side = startingSide, - RequiredTiles = Array.Empty(), - ForbiddenTiles = Array.Empty() - }; - - var startNode = new ConnectedTileAStarNode() + foreach (var keyValuePair in iniSection.Keys) { - Location = location, - Tile = null, - - Parent = null, - Exit = connectionPoint, - Destination = destination, - GScore = 0 - }; - - return startNode; - } - - public List GetNextNodes(ConnectedTile tile) - { - var neighbors = new List(); + Match match = Regex.Match(keyValuePair.Key, "^ConnectionPoint(\\d+)$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!match.Success) + continue; - foreach (TileConnectionPoint cp in tile.ConnectionPoints) - { - if (Tile != null) + if (!int.TryParse(match.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, + out int connectionPointIndex)) { - if ((cp.RequiredTiles?.Length > 0 && !cp.RequiredTiles.Contains(Tile.Index)) || - (cp.ForbiddenTiles?.Length > 0 && cp.ForbiddenTiles.Contains(Tile.Index))) - continue; + throw new INIConfigException( + $"Connected Tile {iniSection.SectionName} has an invalid connection point index in key {keyValuePair.Key}!"); } - - var possibleDirections = Helpers.GetDirectionsInMask((byte)(cp.ReversedConnectionMask & Exit.ConnectionMask)); - if (possibleDirections.Count == 0) - continue; - if (cp.Side != Exit.Side) - continue; - - foreach (Direction dir in possibleDirections) + if (!connectionPointValues.TryAdd(connectionPointIndex, keyValuePair.Value)) { - Point2D placementOffset = Helpers.VisualDirectionToPoint(dir) - cp.CoordinateOffset; - Point2D placementCoords = ExitCoords + placementOffset; - - bool overlaps = false; - foreach (var foundationCell in tile.Foundation) - { - if (OccupiedCells.Contains(foundationCell + placementCoords)) - { - overlaps = true; - break; - } - } - - if (overlaps) - continue; - - var exit = tile.GetExit(cp.Index); - neighbors.Add(new ConnectedTileAStarNode(this, exit, placementCoords, tile)); + throw new INIConfigException( + $"Connected Tile {iniSection.SectionName} defines ConnectionPoint{connectionPointIndex} multiple times!"); } } - - return neighbors; - } - public List GetNextNodes(List tiles, bool allowTurn) - { - List nextNodes = new List(); - foreach (var tile in tiles) + if (!connectionPointValues.ContainsKey(0)) + throw new INIConfigException($"Connected Tile {iniSection.SectionName} has no ConnectionPoint0!"); + + int expectedConnectionPointIndex = 0; + foreach (int connectionPointIndex in connectionPointValues.Keys) { - if (!allowTurn && tile.ConnectionPoints[0].Side != tile.ConnectionPoints[1].Side) - continue; + if (connectionPointIndex != expectedConnectionPointIndex) + { + throw new INIConfigException( + $"Connected Tile {iniSection.SectionName} has no ConnectionPoint{expectedConnectionPointIndex}; connection point indices must be contiguous!"); + } - nextNodes.AddRange(GetNextNodes(tile)); + expectedConnectionPointIndex++; } - return nextNodes; - } - } - - public class ConnectedTile - { - public ConnectedTile(IniSection iniSection, int index) - { - Index = index; - - string indicesString = iniSection.GetStringValue("TileIndices", null); - if (indicesString == null || !Regex.IsMatch(indicesString, "^((?:\\d+?,)*(?:\\d+?))$")) - throw new INIConfigException($"Connected Tile {iniSection.SectionName} has invalid TileIndices list: {indicesString}!"); - - - string tileSet = iniSection.GetStringValue("TileSet", null); - if (string.IsNullOrWhiteSpace(tileSet)) - throw new INIConfigException($"Connected Tile {iniSection.SectionName} has no TileSet!"); - - TileSetName = tileSet; - - IndicesInTileSet = indicesString.Split(',').Select(s => int.Parse(s, CultureInfo.InvariantCulture)).ToList(); - - ConnectionPoints = new TileConnectionPoint[2]; + ConnectionPoints = new TileConnectionPoint[connectionPointValues.Count]; for (int i = 0; i < ConnectionPoints.Length; i++) { - string coordsString = iniSection.GetStringValue($"ConnectionPoint{i}", null); + string coordsString = connectionPointValues[i]; if (coordsString == null || !Regex.IsMatch(coordsString, "^\\d+?,\\d+?$")) throw new INIConfigException($"Connected Tile {iniSection.SectionName} has invalid ConnectionPoint{i} value: {coordsString}!"); Point2D coords = Point2D.FromString(coordsString); string directionsString = iniSection.GetStringValue($"ConnectionPoint{i}.Directions", null); - string[] directionParts = directionsString.Split(','); + string[] directionParts = directionsString?.Split(',') ?? Array.Empty(); byte directions = 0; // Try parsing the string as a comma-separated list of named directions @@ -335,6 +217,21 @@ public ConnectedTile(IniSection iniSection, int index) /// public TileConnectionPoint[] ConnectionPoints { get; set; } + /// + /// Whether this tile has one connection point and can cap an open connected-tile path. + /// + public bool IsEndingPiece => ConnectionPoints.Length == 1; + + /// + /// Whether this tile has two connection points and can be used as a linear path segment. + /// + public bool IsLinear => ConnectionPoints.Length == 2; + + /// + /// Whether this tile has three or more connection points and can form a junction. + /// + public bool IsJunction => ConnectionPoints.Length >= 3; + /// /// Set of all relative cell coordinates this tile occupies /// @@ -350,19 +247,20 @@ public ConnectedTile(IniSection iniSection, int index) /// public int DistanceModifier { get; set; } - public TileConnectionPoint GetExit(int entryIndex) - { - return ConnectionPoints[0].Index == entryIndex ? ConnectionPoints[1] : ConnectionPoints[0]; - } - private bool IsStraight(TileConnectionPoint[] connectionPoints) { + if (connectionPoints.Length != 2) + return false; + int mask = connectionPoints[0].ConnectionMask & connectionPoints[1].ReversedConnectionMask; return mask > 0; } private bool IsDiagonal(TileConnectionPoint[] connectionPoints) { + if (connectionPoints.Length != 2) + return false; + var directions = Helpers.GetDirectionsInMask((byte)(connectionPoints[0].ConnectionMask & connectionPoints[1].ReversedConnectionMask)); @@ -469,5 +367,6 @@ private ConnectedTileType(IniFile iniFile, string iniName, string name, bool fro public Color? Color { get; set; } public List AllowedTheaters { get; set; } public List Tiles { get; } + public bool SupportsEndPieces => Tiles.Exists(tile => tile.IsEndingPiece); } } diff --git a/src/TSMapEditor/Mutations/Classes/DrawConnectedTilesMutation.cs b/src/TSMapEditor/Mutations/Classes/DrawConnectedTilesMutation.cs index c437faa74..e6dd84ce5 100644 --- a/src/TSMapEditor/Mutations/Classes/DrawConnectedTilesMutation.cs +++ b/src/TSMapEditor/Mutations/Classes/DrawConnectedTilesMutation.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using TSMapEditor.CCEngine.TileData; using TSMapEditor.GameMath; using TSMapEditor.Misc; @@ -10,25 +9,21 @@ namespace TSMapEditor.Mutations.Classes { /// - /// A mutation for drawing cliffs. + /// Applies an already validated connected-tile placement plan. /// public class DrawConnectedTilesMutation : Mutation { - public DrawConnectedTilesMutation(IMutationTarget mutationTarget, List path, ConnectedTileType connectedTileType, ConnectedTileSide startingSide, int randomSeed, byte extraHeight) : base(mutationTarget) + public DrawConnectedTilesMutation(IMutationTarget mutationTarget, ConnectedTilePlan plan, + int randomSeed, byte extraHeight) : base(mutationTarget) { - if (path.Count < 2) - { - throw new ArgumentException(nameof(DrawConnectedTilesMutation) + - ": to draw a connected tile at least 2 path vertices are required."); - } + this.plan = plan ?? throw new ArgumentNullException(nameof(plan)); + this.randomSeed = randomSeed; - this.path = path; - this.connectedTileType = connectedTileType; - this.startingSide = startingSide; + var originTile = mutationTarget.Map.GetTile(plan.Origin); + if (originTile == null) + throw new ArgumentException("The connected tile plan origin is outside the map.", nameof(plan)); - this.originLevel = mutationTarget.Map.GetTile(path[0]).Level + extraHeight; - this.randomSeed = randomSeed; - this.random = new Random(randomSeed); + originLevel = originTile.Level + extraHeight; } private struct ConnectedTileUndoData @@ -39,199 +34,60 @@ private struct ConnectedTileUndoData public byte Level; } - private const int MaxTimeInMilliseconds = 10; - private const float TileSizePenaltyPerFoundationCell = 0.07f; - private const int RepeatPenaltyAncestorWindow = 5; - private const float RepeatPenaltyPerWeightedUse = 0.75f; - private const float RepeatPenaltyPerFoundationCell = 0.12f; - private const float TurnTilePenalty = 1.0f; - private const float NodeScoreJitterAmplitude = 0.02f; - private readonly List undoData = new List(); private readonly HashSet affectedCellCoords = new HashSet(); - - public IReadOnlyCollection AffectedCellCoords => affectedCellCoords; - - private readonly List path; - private readonly ConnectedTileType connectedTileType; - private readonly ConnectedTileSide startingSide; - + private readonly ConnectedTilePlan plan; private readonly int originLevel; private readonly int randomSeed; - private readonly Random random; - private ConnectedTileAStarNode lastNode; + public IReadOnlyCollection AffectedCellCoords => affectedCellCoords; + public ConnectedTilePlan Plan => plan; public override string GetDisplayString() { return string.Format(Translate(this, "DisplayString", - "Draw Connected Tiles of type {0}"), connectedTileType.Name); + "Draw Connected Tiles of type {0}"), plan.Type.Name); } public override void Perform() { - lastNode = null; - - for (int i = 0; i < path.Count - 1; i++) - { - FindConnectedTilePath(path[i], path[i + 1], i != 0); - } - - PlaceConnectedTiles(lastNode); - - MutationTarget.InvalidateMap(); - } - - private void FindConnectedTilePath(Point2D start, Point2D end, bool allowInstantTurn) - { - PriorityQueue openSet = new(); - List candidateTiles = allowInstantTurn - ? connectedTileType.Tiles - : connectedTileType.Tiles.FindAll(static tile => tile.ConnectionPoints[0].Side == tile.ConnectionPoints[1].Side); - - ConnectedTileAStarNode bestNode = null; - float bestDistance = float.PositiveInfinity; + var random = new Random(randomSeed); + ConnectedTile lastPlacedTile = null; + int lastPlacedTileIndex = -1; - if (lastNode == null) + // Preserve legacy placement order. Besides retaining seeded visual variation, placing from end + // to start keeps overlapping TMP subtile behavior identical to the former parent-chain walk. + for (int placementIndex = plan.Placements.Count - 1; placementIndex >= 0; placementIndex--) { - lastNode = ConnectedTileAStarNode.MakeStartNode(start, end, startingSide); - } - else - { - // Go back one step if we can, since we didn't know we needed to turn yet - // and it's likely not gonna be very nice - lastNode = lastNode.Parent ?? lastNode; - lastNode.Destination = end; - } + ConnectedTilePlacement placement = plan.Placements[placementIndex]; + ConnectedTile connectedTile = placement.Tile; + var tileSet = MutationTarget.Map.TheaterInstance.Theater.TileSets.Find( + tileSet => tileSet.SetName == connectedTile.TileSetName && tileSet.AllowToPlace); - long timeoutTimestamp = Stopwatch.GetTimestamp() + TimeSpan.FromMilliseconds(MaxTimeInMilliseconds).Ticks; - openSet.Enqueue(lastNode, (GetNodePriorityScore(lastNode), lastNode.Tile?.ExtraPriority ?? 0)); + if (tileSet == null) + throw new INIConfigException($"Tile Set {connectedTile.TileSetName} not found when placing connected tiles!"); - while (openSet.Count > 0) - { - ConnectedTileAStarNode currentNode = openSet.Dequeue(); - var nextNodes = currentNode.GetNextNodes(candidateTiles, true); - for (int i = 0; i < nextNodes.Count; i++) + int tileIndex; + if (connectedTile.IndicesInTileSet.Count > 1 && lastPlacedTile == connectedTile) { - ConnectedTileAStarNode node = nextNodes[i]; - openSet.Enqueue(node, (GetNodePriorityScore(node), node.Tile?.ExtraPriority ?? 0)); + tileIndex = connectedTile.IndicesInTileSet.GetRandomElementIndex(random, lastPlacedTileIndex); } - - float currentDistance = currentNode.HScore; - - if (currentDistance < bestDistance) + else { - bestNode = currentNode; - bestDistance = currentDistance; - timeoutTimestamp = Stopwatch.GetTimestamp() + TimeSpan.FromMilliseconds(MaxTimeInMilliseconds).Ticks; + tileIndex = connectedTile.IndicesInTileSet.GetRandomElementIndex(random, -1); } - if (bestDistance == 0 || Stopwatch.GetTimestamp() > timeoutTimestamp) - break; - } - - lastNode = bestNode; - } - - private float GetNodePriorityScore(ConnectedTileAStarNode node) - { - if (node.Tile == null) - return node.FScore; - - int foundationCellCount = Math.Max(node.Tile.Foundation?.Count ?? 1, 1); - float sizePenalty = (foundationCellCount - 1) * TileSizePenaltyPerFoundationCell; - float repeatedWeightedUseCount = CountPreviousTileUsesInRecentAncestors(node, RepeatPenaltyAncestorWindow); - float repeatPenalty = repeatedWeightedUseCount * RepeatPenaltyPerWeightedUse * - (1.0f + (foundationCellCount - 1) * RepeatPenaltyPerFoundationCell); - float turnPenalty = IsTurningTile(node.Tile) ? TurnTilePenalty : 0.0f; + int tileIndexInSet = connectedTile.IndicesInTileSet[tileIndex]; + TileImage tileImage = MutationTarget.TheaterGraphics.GetTileGraphics( + tileSet.StartTileIndex + tileIndexInSet); - int hash = HashCode.Combine(randomSeed, node.Location.X, node.Location.Y, node.Exit.Index, node.Tile.Index); - float jitter = ((hash & 1023) / 1023.0f - 0.5f) * 2f * NodeScoreJitterAmplitude; + PlaceTile(tileImage, placement.Location); - return node.FScore + sizePenalty + repeatPenalty + turnPenalty + jitter; - } - - private static bool IsTurningTile(ConnectedTile tile) - { - int oppositeMask = tile.ConnectionPoints[0].ConnectionMask & tile.ConnectionPoints[1].ReversedConnectionMask; - if (oppositeMask == 0) - return true; - - // If the tile has connection points that are not straight in line, it is considered a turning tile - if (!tile.ConnectionPoints[0].CoordinateOffset.IsInStraightLineWith(tile.ConnectionPoints[1].CoordinateOffset)) - { - return true; + lastPlacedTileIndex = tileIndex; + lastPlacedTile = connectedTile; } - return false; - } - - private static float CountPreviousTileUsesInRecentAncestors(ConnectedTileAStarNode node, int ancestorWindow) - { - if (node.Tile == null) - return 0; - - float weightedUses = 0; - int tileIndex = node.Tile.Index; - int depth = 1; - - var current = node.Parent; - while (current != null && depth <= ancestorWindow) - { - if (current.Tile?.Index == tileIndex) - { - float recencyWeight = (ancestorWindow - depth + 1) / (float)ancestorWindow; - weightedUses += recencyWeight; - } - - current = current.Parent; - depth++; - } - - return weightedUses; - } - - private void PlaceConnectedTiles(ConnectedTileAStarNode endNode) - { - ConnectedTile lastPlacedTile = null; - int lastPlacedTileIndex = -1; - - var node = endNode; - while (node != null) - { - if (node.Tile != null) - { - var tileSet = MutationTarget.Map.TheaterInstance.Theater.TileSets.Find(ts => ts.SetName == node.Tile.TileSetName && ts.AllowToPlace); - if (tileSet != null) - { - int tileIndex; - - // To avoid visual repetition, do not place the same tile twice consecutively if it can be avoided - if (node.Tile.IndicesInTileSet.Count > 1 && lastPlacedTile == node.Tile) - { - tileIndex = node.Tile.IndicesInTileSet.GetRandomElementIndex(random, lastPlacedTileIndex); - } - else - { - tileIndex = node.Tile.IndicesInTileSet.GetRandomElementIndex(random, -1); - } - - var tileIndexInSet = node.Tile.IndicesInTileSet[tileIndex]; - var tileImage = MutationTarget.TheaterGraphics.GetTileGraphics(tileSet.StartTileIndex + tileIndexInSet); - - PlaceTile(tileImage, new Point2D((int)node.Location.X, (int)node.Location.Y)); - - lastPlacedTileIndex = tileIndex; - lastPlacedTile = node.Tile; - } - else - { - throw new INIConfigException($"Tile Set {node.Tile.TileSetName} not found when placing cliffs!"); - } - } - - node = node.Parent; - } + MutationTarget.InvalidateMap(); } private void PlaceTile(TileImage tile, Point2D targetCellCoords) @@ -251,7 +107,7 @@ private void PlaceTile(TileImage tile, Point2D targetCellCoords) var mapTile = MutationTarget.Map.GetTile(cx, cy); if (mapTile != null) { - undoData.Add(new ConnectedTileUndoData() + undoData.Add(new ConnectedTileUndoData { CellCoords = new Point2D(cx, cy), TileIndex = mapTile.TileIndex, @@ -261,7 +117,8 @@ private void PlaceTile(TileImage tile, Point2D targetCellCoords) affectedCellCoords.Add(new Point2D(cx, cy)); mapTile.ChangeTileIndex(tile.TileID, (byte)i); - mapTile.Level = (byte)Math.Min(originLevel + image.TmpImage.Height, Constants.MaxMapHeightLevel); + mapTile.Level = (byte)Math.Min(originLevel + image.TmpImage.Height, + Constants.MaxMapHeightLevel); RefreshCellLighting(mapTile); } } @@ -271,7 +128,7 @@ public override void Undo() { for (int i = undoData.Count - 1; i >= 0; i--) { - var data = undoData[i]; + ConnectedTileUndoData data = undoData[i]; var mapTile = MutationTarget.Map.GetTile(data.CellCoords); if (mapTile != null) diff --git a/src/TSMapEditor/UI/CursorActions/DrawConnectedTilesCursorAction.cs b/src/TSMapEditor/UI/CursorActions/DrawConnectedTilesCursorAction.cs index 2ac37785a..aab917da6 100644 --- a/src/TSMapEditor/UI/CursorActions/DrawConnectedTilesCursorAction.cs +++ b/src/TSMapEditor/UI/CursorActions/DrawConnectedTilesCursorAction.cs @@ -10,7 +10,7 @@ namespace TSMapEditor.UI.CursorActions { /// - /// Cursor action for placing bridges. + /// Cursor action for drawing connected tiles. /// public class DrawConnectedTilesCursorAction : CursorAction { @@ -31,6 +31,10 @@ public DrawConnectedTilesCursorAction(ICursorActionTarget cursorActionTarget, Co private List connectedTilePath; private ConnectedTileSide connectedTileSide = ConnectedTileSide.Front; private DrawConnectedTilesMutation previewMutation; + private ConnectedTilePlan previewPlan; + private string previewPlanFailureText; + private bool useEndPieces; + private bool closed; private byte extraHeight = 0; private int randomSeed = new Random().Next(); @@ -38,40 +42,71 @@ public DrawConnectedTilesCursorAction(ICursorActionTarget cursorActionTarget, Co public override void OnActionEnter() { connectedTilePath = new List(); + previewMutation = null; + previewPlan = null; + previewPlanFailureText = null; + useEndPieces = false; + closed = false; base.OnActionEnter(); } public override void DrawPreview(Point2D cellCoords, Point2D cameraTopLeftPoint) { - string mainText = Translate("MainText.V2", "Click on a cell to place a new vertex.\r\n\r\n" + + string mainText = Translate("MainText.V3", "Click on a cell to place a new vertex.\r\n\r\n" + + "After placing at least three distinct vertices, you can optionally\r\nclick the first one to close the formation.\r\n\r\n" + "ENTER to confirm\r\n" + "Backspace to go back one step\r\n" + "R to re-generate the pattern\r\n"); string tabText = Translate("TabText", "TAB to toggle between front and back sides\r\n"); string pageUpDownText = Translate("PageUpDownText", "PageUp to raise the tiles, PageDown to lower them\r\n"); + string endPiecesText = string.Empty; + if (connectedTileType.SupportsEndPieces && !closed) + { + endPiecesText = string.Format( + Translate("EndPiecesText", "E to toggle ending pieces ({0})\r\n"), + useEndPieces ? Translate("EndPieces.Enabled", "enabled") : Translate("EndPieces.Disabled", "disabled")); + } + + string closedText = closed + ? Translate("ClosedText", "Closed formation (ending pieces disabled); Backspace to reopen\r\n") + : string.Empty; string exitText = Translate("ExitText", "Right-click or ESC to exit"); - string text = (Constants.IsFlatWorld, connectedTileType.FrontOnly) switch + string text = mainText + endPiecesText + closedText; + if (!connectedTileType.FrontOnly) + text += tabText; + if (!Constants.IsFlatWorld) + text += pageUpDownText; + text += exitText; + + if (!string.IsNullOrEmpty(previewPlanFailureText)) { - (true, true) => mainText + exitText, - (true, false) => mainText + tabText + exitText, - (false, true) => mainText + pageUpDownText + exitText, - (false, false) => mainText + tabText + pageUpDownText + exitText - }; + text += "\r\n\r\n" + string.Format( + Translate("PlanFailureText", "Cannot create an exact connected pattern: {0}\r\nAdjust the vertices or press R to try another pattern."), + previewPlanFailureText); + } - DrawText(cellCoords, cameraTopLeftPoint, 60, -150, text, Color.Yellow); + DrawText(cellCoords, cameraTopLeftPoint, 60, -180, text, + string.IsNullOrEmpty(previewPlanFailureText) ? Color.Yellow : Color.OrangeRed); Func getCellCenterPoint = Is2DMode ? CellMath.CellCenterPointFromCellCoords : CellMath.CellCenterPointFromCellCoords_3D; + bool pointingAtFirstVertex = connectedTilePath.Count > 0 && cellCoords == connectedTilePath[0]; + bool showingClosingGuide = closed || (pointingAtFirstVertex && HasEnoughVerticesToClose()); + if (connectedTilePath.Count > 0) { Point2D start = connectedTilePath[0]; start = getCellCenterPoint(start, CursorActionTarget.Map) - cameraTopLeftPoint; start = start.ScaleBy(CursorActionTarget.Camera.ZoomLevel); - Color color = Color.Red; + Color color = closed && previewPlan == null + ? Color.OrangeRed + : showingClosingGuide + ? Color.LimeGreen + : Color.Red; int precision = 8; int thickness = 3; Renderer.DrawCircle(start.ToXNAVector(), Constants.CellSizeY * 0.25f, color, precision, thickness); @@ -94,6 +129,18 @@ public override void DrawPreview(Point2D cellCoords, Point2D cameraTopLeftPoint) Renderer.DrawLine(start.ToXNAVector(), end.ToXNAVector(), color, thickness); } + + if (showingClosingGuide && connectedTilePath.Count > 1) + { + Point2D start = getCellCenterPoint(connectedTilePath[connectedTilePath.Count - 1], CursorActionTarget.Map) - cameraTopLeftPoint; + start = start.ScaleBy(CursorActionTarget.Camera.ZoomLevel); + + Point2D end = getCellCenterPoint(connectedTilePath[0], CursorActionTarget.Map) - cameraTopLeftPoint; + end = end.ScaleBy(CursorActionTarget.Camera.ZoomLevel); + + Color closingGuideColor = closed && previewPlan == null ? Color.OrangeRed : Color.LimeGreen; + Renderer.DrawLine(start.ToXNAVector(), end.ToXNAVector(), closingGuideColor, 3); + } } public override void OnKeyPressed(KeyPressEventArgs e, Point2D cellCoords) @@ -116,13 +163,29 @@ public override void OnKeyPressed(KeyPressEventArgs e, Point2D cellCoords) } else if (e.PressedKey == Microsoft.Xna.Framework.Input.Keys.Back) { - if (connectedTilePath.Count > 0) + if (closed) + { + closed = false; + } + else if (connectedTilePath.Count > 0) + { connectedTilePath.RemoveAt(connectedTilePath.Count - 1); - + } + RedrawPreview(); e.Handled = true; } + else if (e.PressedKey == Microsoft.Xna.Framework.Input.Keys.E) + { + if (connectedTileType.SupportsEndPieces && !closed) + { + useEndPieces = !useEndPieces; + RedrawPreview(); + } + + e.Handled = true; + } else if (e.PressedKey == Microsoft.Xna.Framework.Input.Keys.R) { if (connectedTilePath.Count > 0) @@ -163,12 +226,20 @@ public override void OnKeyPressed(KeyPressEventArgs e, Point2D cellCoords) e.Handled = true; } - else if (e.PressedKey == Microsoft.Xna.Framework.Input.Keys.Enter && connectedTilePath.Count >= 2) + else if (e.PressedKey == Microsoft.Xna.Framework.Input.Keys.Enter) { - previewMutation?.Undo(); - CursorActionTarget.MutationManager.PerformMutation(new DrawConnectedTilesMutation(MutationTarget, connectedTilePath, connectedTileType, connectedTileSide, randomSeed, extraHeight)); + if (previewPlan != null) + { + ConnectedTilePlan planToCommit = previewPlan; + previewMutation?.Undo(); + previewMutation = null; + previewPlan = null; - ExitAction(); + CursorActionTarget.MutationManager.PerformMutation( + new DrawConnectedTilesMutation(MutationTarget, planToCommit, randomSeed, extraHeight)); + + ExitAction(); + } e.Handled = true; } @@ -176,6 +247,16 @@ public override void OnKeyPressed(KeyPressEventArgs e, Point2D cellCoords) public override void LeftClick(Point2D cellCoords) { + if (closed) + return; + + if (connectedTilePath.Count > 0 && cellCoords == connectedTilePath[0] && HasEnoughVerticesToClose()) + { + closed = true; + RedrawPreview(); + return; + } + connectedTilePath.Add(cellCoords); RedrawPreview(); } @@ -183,21 +264,54 @@ public override void LeftClick(Point2D cellCoords) private void RedrawPreview() { previewMutation?.Undo(); - - if (connectedTilePath.Count >= 2) + previewMutation = null; + previewPlan = null; + previewPlanFailureText = null; + + if (connectedTilePath.Count < 2 || (closed && !HasEnoughVerticesToClose())) + return; + + ConnectedTilePlanResult planResult = ConnectedTilePlanner.Plan( + connectedTileType, + connectedTilePath, + connectedTileSide, + randomSeed, + useEndPieces && !closed, + closed, + coords => MutationTarget.Map.GetTile(coords) != null); + + if (!planResult.IsSuccess) { - previewMutation = new DrawConnectedTilesMutation(MutationTarget, connectedTilePath, connectedTileType, connectedTileSide, randomSeed, extraHeight); - previewMutation.Perform(); + previewPlanFailureText = GetPlanFailureText(planResult); + return; } - else + + previewPlan = planResult.Plan; + previewMutation = new DrawConnectedTilesMutation(MutationTarget, previewPlan, randomSeed, extraHeight); + previewMutation.Perform(); + } + + private bool HasEnoughVerticesToClose() + { + return new HashSet(connectedTilePath).Count >= 3; + } + + private string GetPlanFailureText(ConnectedTilePlanResult planResult) + { + return planResult.Status switch { - previewMutation = null; - } + ConnectedTilePlanStatus.InvalidInput => Translate("PlanStatus.InvalidInput", "the selected path is invalid"), + ConnectedTilePlanStatus.NoSolution => Translate("PlanStatus.NoSolution", "no exact connection pattern fits the selected vertices"), + ConnectedTilePlanStatus.SearchLimit => Translate("PlanStatus.SearchLimit", "the search limit was reached before an exact pattern was found"), + _ when !string.IsNullOrWhiteSpace(planResult.Message) => planResult.Message, + _ => Translate("PlanStatus.Unknown", "the connected tile planner failed") + }; } private void UndoOnExit(object sender, EventArgs e) { previewMutation?.Undo(); + previewMutation = null; } } } From 02591003b26993056fd870707f21fbf549195206 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sun, 2 Aug 2026 21:50:05 +0300 Subject: [PATCH 15/27] Optimize connected tile placement --- src/TSMapEditor/AI/MapFacade.cs | 6 +- .../Models/ConnectedTilePlanner.cs | 468 ++++++++++++------ src/TSMapEditor/Models/ConnectedTileType.cs | 6 + src/TSMapEditor/Models/EditorConfig.cs | 14 +- src/TSMapEditor/UI/TopBar/TopBarMenu.cs | 9 +- .../UI/Windows/MainMenuWindows/MapSetup.cs | 4 +- .../UI/Windows/SelectConnectedTileWindow.cs | 9 +- 7 files changed, 358 insertions(+), 158 deletions(-) diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index b1db3f1a7..0bf7f8b7c 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -425,7 +425,7 @@ public List GetConnectedTileTypes(string nameFilter = { string normalizedFilter = nameFilter?.Trim(); - return map.EditorConfig.Cliffs + return map.EditorConfig.ConnectedTileTypes .Where(IsConnectedTileTypeAvailable) .Select(connectedTileType => new MapConnectedTileTypeInfo( connectedTileType.IniName, @@ -811,10 +811,12 @@ public MapEditResult DrawConnectedTiles(string connectedTileTypeName, List string.Equals(candidate.IniName, connectedTileTypeName, StringComparison.OrdinalIgnoreCase)); if (connectedTileType == null) throw new MapFacadeValidationException($"Connected tile type '{connectedTileTypeName}' does not exist in the editor configuration."); + if (!connectedTileType.IsLegal) + throw new MapFacadeValidationException($"Connected tile type '{connectedTileTypeName}' is not available because it is configured incorrectly."); if (!IsConnectedTileTypeAvailableInTheater(connectedTileType)) throw new MapFacadeValidationException($"Connected tile type '{connectedTileType.IniName}' is not valid for theater '{map.LoadedTheaterName}'."); diff --git a/src/TSMapEditor/Models/ConnectedTilePlanner.cs b/src/TSMapEditor/Models/ConnectedTilePlanner.cs index 825edf857..e6089535c 100644 --- a/src/TSMapEditor/Models/ConnectedTilePlanner.cs +++ b/src/TSMapEditor/Models/ConnectedTilePlanner.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Linq; +using System.Runtime.CompilerServices; using TSMapEditor.GameMath; namespace TSMapEditor.Models @@ -43,11 +43,11 @@ internal ConnectedTilePlacement(ConnectedTile tile, Point2D location, public sealed class ConnectedTilePlan { internal ConnectedTilePlan(ConnectedTileType type, Point2D origin, - IEnumerable placements, bool isClosed, bool usesEndPieces) + List placements, bool isClosed, bool usesEndPieces) { Type = type; Origin = origin; - Placements = new ReadOnlyCollection(placements.ToList()); + Placements = new ReadOnlyCollection(placements); IsClosed = isClosed; UsesEndPieces = usesEndPieces; } @@ -95,6 +95,7 @@ public static class ConnectedTilePlanner private const int MaxQueuedNodesPerStrictSegment = 100_000; private const int MaxPlacementCount = 2_048; private const int MaxBoundaryStates = 24; + private const int MaxStackAllocatedPathVertexCount = 256; private const float TileSizePenaltyPerFoundationCell = 0.07f; private const int RepeatPenaltyAncestorWindow = 5; @@ -103,6 +104,8 @@ public static class ConnectedTilePlanner private const float TurnTilePenalty = 1.0f; private const float NodeScoreJitterAmplitude = 0.02f; + private static readonly ConditionalWeakTable SearchTileCaches = new(); + private enum SegmentGoal { Point, @@ -110,58 +113,152 @@ private enum SegmentGoal ClosedSeam } + private readonly struct SearchTile + { + public SearchTile(ConnectedTile tile) + { + Tile = tile; + FoundationCells = new Point2D[tile.Foundation.Count]; + tile.Foundation.CopyTo(FoundationCells); + } + + public ConnectedTile Tile { get; } + public Point2D[] FoundationCells { get; } + } + + private sealed class SearchTileCache + { + public SearchTileCache(ConnectedTileType type) + { + Tiles = type.Tiles.ToArray(); + Foundations = new HashSet[Tiles.Length]; + FoundationCounts = new int[Tiles.Length]; + + int linearTileCount = 0; + int endingTileCount = 0; + for (int i = 0; i < Tiles.Length; i++) + { + ConnectedTile tile = Tiles[i]; + Foundations[i] = tile.Foundation; + FoundationCounts[i] = tile.Foundation?.Count ?? 0; + if (!HasUsableFoundation(tile)) + continue; + + if (tile.ConnectionPoints.Length == 2) + linearTileCount++; + else if (tile.ConnectionPoints.Length == 1) + endingTileCount++; + } + + LinearTiles = new SearchTile[linearTileCount]; + EndingTiles = new SearchTile[endingTileCount]; + + int linearTileIndex = 0; + int endingTileIndex = 0; + for (int i = 0; i < Tiles.Length; i++) + { + ConnectedTile tile = Tiles[i]; + if (!HasUsableFoundation(tile)) + continue; + + if (tile.ConnectionPoints.Length == 2) + LinearTiles[linearTileIndex++] = new SearchTile(tile); + else if (tile.ConnectionPoints.Length == 1) + EndingTiles[endingTileIndex++] = new SearchTile(tile); + } + } + + public ConnectedTile[] Tiles { get; } + public HashSet[] Foundations { get; } + public int[] FoundationCounts { get; } + public SearchTile[] LinearTiles { get; } + public SearchTile[] EndingTiles { get; } + + public bool Matches(ConnectedTileType type) + { + if (type.Tiles.Count != Tiles.Length) + return false; + + for (int i = 0; i < Tiles.Length; i++) + { + ConnectedTile tile = type.Tiles[i]; + if (!ReferenceEquals(tile, Tiles[i]) || + !ReferenceEquals(tile.Foundation, Foundations[i]) || + (tile.Foundation?.Count ?? 0) != FoundationCounts[i]) + { + return false; + } + } + + return true; + } + } + private sealed class SearchNode { private SearchNode() { } - public SearchNode(SearchNode parent, ConnectedTile tile, Point2D location, - TileConnectionPoint? entryPoint, TileConnectionPoint? exitPoint, HashSet occupiedCells, - float gScore) + public SearchNode(SearchNode parent, SearchTile searchTile, Point2D location, + int entryPointIndex, int exitPointIndex, float gScore) { Parent = parent; - Tile = tile; + SearchTile = searchTile; Location = location; - EntryPoint = entryPoint; - ExitPoint = exitPoint; - OccupiedCells = occupiedCells; + EntryPointIndex = entryPointIndex; + ExitPointIndex = exitPointIndex; GScore = gScore; Depth = (parent?.Depth ?? 0) + 1; if (parent?.FirstPlacement != null) FirstPlacement = parent.FirstPlacement; - else if (tile != null) + else if (searchTile.Tile != null) FirstPlacement = this; } public SearchNode Parent { get; private init; } public SearchNode FirstPlacement { get; private set; } - public ConnectedTile Tile { get; private init; } + public SearchTile SearchTile { get; private init; } + public ConnectedTile Tile => SearchTile.Tile; public Point2D Location { get; private init; } - public TileConnectionPoint? EntryPoint { get; private init; } - public TileConnectionPoint? ExitPoint { get; private init; } - public HashSet OccupiedCells { get; private init; } + public int EntryPointIndex { get; private init; } + public int ExitPointIndex { get; private init; } public float GScore { get; private init; } public int Depth { get; private init; } + public ConnectedTileSide SyntheticStartingSide { get; private init; } - public Point2D ExitCoordinates => Location + ExitPoint.Value.CoordinateOffset; + public bool HasExitPoint => Tile == null || ExitPointIndex >= 0; - public static SearchNode MakeSyntheticStart(Point2D location, ConnectedTileSide startingSide) + public TileConnectionPoint ExitPoint { - var connectionPoint = new TileConnectionPoint + get { - Index = -1, - ConnectionMask = byte.MaxValue, - CoordinateOffset = Point2D.Zero, - Side = startingSide, - RequiredTiles = Array.Empty(), - ForbiddenTiles = Array.Empty() - }; + if (Tile != null) + return Tile.ConnectionPoints[ExitPointIndex]; + + return new TileConnectionPoint + { + Index = -1, + ConnectionMask = byte.MaxValue, + CoordinateOffset = Point2D.Zero, + Side = SyntheticStartingSide, + RequiredTiles = Array.Empty(), + ForbiddenTiles = Array.Empty() + }; + } + } + + public Point2D ExitCoordinates => Tile == null + ? Location + : Location + Tile.ConnectionPoints[ExitPointIndex].CoordinateOffset; + public static SearchNode MakeSyntheticStart(Point2D location, ConnectedTileSide startingSide) + { return new SearchNode { Location = location, - ExitPoint = connectionPoint, - OccupiedCells = new HashSet(), + EntryPointIndex = -1, + ExitPointIndex = -1, + SyntheticStartingSide = startingSide, GScore = 0.0f, Depth = 0 }; @@ -180,23 +277,26 @@ public static ConnectedTilePlanResult Plan(ConnectedTileType type, IReadOnlyList Func isCellValid) { if (type == null) - return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, - "A connected tile type is required."); + return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, "A connected tile type is required."); if (path == null) - return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, - "A connected tile path is required."); + return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, "A connected tile path is required."); if (isCellValid == null) - return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, - "A map-cell validator is required."); + return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, "A map-cell validator is required."); - var vertices = path.ToList(); - if (closed && vertices.Count > 1 && vertices[0] == vertices[^1]) - vertices.RemoveAt(vertices.Count - 1); + Span vertices = path.Count <= MaxStackAllocatedPathVertexCount + ? stackalloc Point2D[path.Count] + : new Point2D[path.Count]; + for (int i = 0; i < path.Count; i++) + vertices[i] = path[i]; + + int vertexCount = vertices.Length; + if (closed && vertexCount > 1 && vertices[0] == vertices[vertexCount - 1]) + vertexCount--; int minimumVertexCount = closed ? 3 : 2; - if (vertices.Count < minimumVertexCount) + if (vertexCount < minimumVertexCount) { return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, closed @@ -204,13 +304,13 @@ public static ConnectedTilePlanResult Plan(ConnectedTileType type, IReadOnlyList : "A connected tile path requires at least two vertices."); } - if (closed && vertices.Distinct().Count() < 3) + if (closed && !HasThreeDistinctVertices(vertices[..vertexCount])) { return ConnectedTilePlanResult.Failed(ConnectedTilePlanStatus.InvalidInput, "A closed connected tile path requires at least three distinct vertices."); } - for (int i = 0; i < vertices.Count; i++) + for (int i = 0; i < vertexCount; i++) { if (!isCellValid(vertices[i])) { @@ -225,12 +325,9 @@ public static ConnectedTilePlanResult Plan(ConnectedTileType type, IReadOnlyList } } - List linearTiles = type.Tiles - .Where(tile => tile.ConnectionPoints.Length == 2 && HasUsableFoundation(tile)) - .ToList(); - List endingTiles = type.Tiles - .Where(tile => tile.ConnectionPoints.Length == 1 && HasUsableFoundation(tile)) - .ToList(); + SearchTileCache searchTileCache = GetSearchTileCache(type); + SearchTile[] linearTiles = searchTileCache.LinearTiles; + SearchTile[] endingTiles = searchTileCache.EndingTiles; bool usesEndPieces = useEndPieces && !closed; bool strict = usesEndPieces || closed; @@ -253,16 +350,17 @@ public static ConnectedTilePlanResult Plan(ConnectedTileType type, IReadOnlyList }; } - var destinations = vertices.Skip(1).ToList(); - if (closed) - destinations.Add(vertices[0]); - + var openSet = new PriorityQueue(); bool earlierSearchWasLimited = false; + int segmentCount = vertexCount - 1 + (closed ? 1 : 0); - for (int segmentIndex = 0; segmentIndex < destinations.Count; segmentIndex++) + for (int segmentIndex = 0; segmentIndex < segmentCount; segmentIndex++) { bool isFirstSegment = segmentIndex == 0; - bool isLastSegment = segmentIndex == destinations.Count - 1; + bool isLastSegment = segmentIndex == segmentCount - 1; + Point2D destination = closed && isLastSegment + ? vertices[0] + : vertices[segmentIndex + 1]; if (!isFirstSegment) frontier = BackUpOnePlacement(frontier); @@ -275,8 +373,9 @@ public static ConnectedTilePlanResult Plan(ConnectedTileType type, IReadOnlyList int exactResultLimit = strict && !isLastSegment ? MaxBoundaryStates : 1; SegmentSearchResult searchResult = SearchSegment( + openSet, frontier, - destinations[segmentIndex], + destination, vertices[0], linearTiles, endingTiles, @@ -323,13 +422,15 @@ public static ConnectedTilePlanResult Plan(ConnectedTileType type, IReadOnlyList return ConnectedTilePlanResult.Succeeded(plan); } - private static SegmentSearchResult SearchSegment(IReadOnlyList seeds, Point2D destination, - Point2D origin, IReadOnlyList linearTiles, IReadOnlyList endingTiles, + private static SegmentSearchResult SearchSegment( + PriorityQueue openSet, + IReadOnlyList seeds, Point2D destination, + Point2D origin, SearchTile[] linearTiles, SearchTile[] endingTiles, bool allowSideChangingTiles, SegmentGoal goal, int randomSeed, int exactResultLimit, bool strict, Func isCellValid) { var result = new SegmentSearchResult(); - var openSet = new PriorityQueue(); + openSet.Clear(); long sequence = 0; int maxExpandedNodes = strict ? MaxExpandedNodesPerStrictSegment @@ -338,9 +439,10 @@ private static SegmentSearchResult SearchSegment(IReadOnlyList seeds ? MaxQueuedNodesPerStrictSegment : MaxQueuedNodesPerLegacySegment; - foreach (SearchNode seed in seeds) + for (int seedIndex = 0; seedIndex < seeds.Count; seedIndex++) { - if (seed?.ExitPoint == null) + SearchNode seed = seeds[seedIndex]; + if (seed == null || !seed.HasExitPoint) continue; if (openSet.Count >= maxQueuedNodes) @@ -397,12 +499,11 @@ private static SegmentSearchResult SearchSegment(IReadOnlyList seeds if (goal == SegmentGoal.EndingPiece) { - List terminalNodes = GetTerminalEndingNodes( + SearchNode terminalNode = GetBestTerminalEndingNode( currentNode, endingTiles, destination, randomSeed, isCellValid); - - if (terminalNodes.Count > 0) + if (terminalNode != null) { - result.ExactNodes.Add(terminalNodes[0]); + result.ExactNodes.Add(terminalNode); break; } } @@ -414,9 +515,12 @@ private static SegmentSearchResult SearchSegment(IReadOnlyList seeds } bool queueLimitReached = false; - foreach (ConnectedTile tile in linearTiles) + for (int tileIndex = 0; tileIndex < linearTiles.Length; tileIndex++) { - if (!allowSideChangingTiles && tile.ConnectionPoints[0].Side != tile.ConnectionPoints[1].Side) + SearchTile searchTile = linearTiles[tileIndex]; + ConnectedTile tile = searchTile.Tile; + if (!allowSideChangingTiles && + tile.ConnectionPoints[0].Side != tile.ConnectionPoints[1].Side) continue; int remainingQueueCapacity = maxQueuedNodes - openSet.Count; @@ -426,14 +530,11 @@ private static SegmentSearchResult SearchSegment(IReadOnlyList seeds break; } - foreach (SearchNode nextNode in GetNextLinearNodes( - currentNode, tile, remainingQueueCapacity, isCellValid)) - { - openSet.Enqueue(nextNode, (GetNodePriorityScore(nextNode, destination, randomSeed), - nextNode.Tile.ExtraPriority, sequence++)); - } + int generatedNodeCount = EnqueueNextLinearNodes( + openSet, currentNode, searchTile, destination, randomSeed, + remainingQueueCapacity, isCellValid, ref sequence); - if (openSet.Count >= maxQueuedNodes) + if (generatedNodeCount >= remainingQueueCapacity) { queueLimitReached = true; break; @@ -456,10 +557,15 @@ private static SegmentSearchResult SearchSegment(IReadOnlyList seeds return result; } - private static IEnumerable GetNextLinearNodes(SearchNode currentNode, ConnectedTile tile, - int maxNodeCount, Func isCellValid) + private static int EnqueueNextLinearNodes( + PriorityQueue openSet, + SearchNode currentNode, SearchTile searchTile, Point2D destination, int randomSeed, + int maxNodeCount, Func isCellValid, ref long sequence) { int generatedNodeCount = 0; + ConnectedTile tile = searchTile.Tile; + TileConnectionPoint currentExitPoint = currentNode.ExitPoint; + Point2D currentExitCoordinates = currentNode.ExitCoordinates; for (int entryIndex = 0; entryIndex < tile.ConnectionPoints.Length; entryIndex++) { @@ -467,106 +573,120 @@ private static IEnumerable GetNextLinearNodes(SearchNode currentNode if (!CanEnterTile(currentNode, tile, entryPoint)) continue; - byte directionMask = (byte)(entryPoint.ReversedConnectionMask & currentNode.ExitPoint.Value.ConnectionMask); - foreach (Direction direction in Helpers.GetDirectionsInMask(directionMask)) + byte directionMask = (byte)(entryPoint.ReversedConnectionMask & currentExitPoint.ConnectionMask); + for (int directionIndex = 0; directionIndex < (int)Direction.Count; directionIndex++) { + if ((directionMask & (byte)(0b10000000 >> directionIndex)) == 0) + continue; + + Direction direction = (Direction)directionIndex; Point2D placementOffset = Helpers.VisualDirectionToPoint(direction) - entryPoint.CoordinateOffset; - Point2D placementLocation = currentNode.ExitCoordinates + placementOffset; + Point2D placementLocation = currentExitCoordinates + placementOffset; - if (!TryAddFoundation(currentNode.OccupiedCells, tile, placementLocation, - isCellValid, out HashSet occupiedCells)) - { + if (!CanPlaceFoundation(currentNode, searchTile, placementLocation, isCellValid)) continue; - } TileConnectionPoint exitPoint = tile.ConnectionPoints[entryIndex == 0 ? 1 : 0]; Point2D exitCoordinates = placementLocation + exitPoint.CoordinateOffset; - float gScore = currentNode.GScore + Distance(currentNode.ExitCoordinates, exitCoordinates); + float gScore = currentNode.GScore + Distance(currentExitCoordinates, exitCoordinates); generatedNodeCount++; - yield return new SearchNode(currentNode, tile, placementLocation, - entryPoint, exitPoint, occupiedCells, gScore); + var nextNode = new SearchNode(currentNode, searchTile, placementLocation, + entryIndex, entryIndex == 0 ? 1 : 0, gScore); + openSet.Enqueue(nextNode, (GetNodePriorityScore(nextNode, destination, randomSeed), + tile.ExtraPriority, sequence++)); if (generatedNodeCount >= maxNodeCount) - yield break; + return generatedNodeCount; } } + + return generatedNodeCount; } - private static List MakeStartingEndingNodes(IReadOnlyList endingTiles, + private static List MakeStartingEndingNodes(SearchTile[] endingTiles, Point2D origin, ConnectedTileSide startingSide, Func isCellValid) { var nodes = new List(); - foreach (ConnectedTile endingTile in endingTiles) + for (int tileIndex = 0; tileIndex < endingTiles.Length; tileIndex++) { + SearchTile searchTile = endingTiles[tileIndex]; + ConnectedTile endingTile = searchTile.Tile; TileConnectionPoint point = endingTile.ConnectionPoints[0]; if (point.Side != startingSide) continue; Point2D location = origin - point.CoordinateOffset; - if (!TryAddFoundation(new HashSet(), endingTile, location, - isCellValid, out HashSet occupiedCells)) - { + if (!CanPlaceFoundation(null, searchTile, location, isCellValid)) continue; - } - nodes.Add(new SearchNode(null, endingTile, location, null, point, occupiedCells, 0.0f)); + nodes.Add(new SearchNode(null, searchTile, location, -1, 0, 0.0f)); } return nodes; } - private static List GetTerminalEndingNodes(SearchNode currentNode, - IReadOnlyList endingTiles, Point2D destination, int randomSeed, + private static SearchNode GetBestTerminalEndingNode(SearchNode currentNode, + SearchTile[] endingTiles, Point2D destination, int randomSeed, Func isCellValid) { - var terminalNodes = new List(); + SearchNode bestNode = null; + TileConnectionPoint currentExitPoint = currentNode.ExitPoint; + Point2D currentExitCoordinates = currentNode.ExitCoordinates; - foreach (ConnectedTile endingTile in endingTiles) + for (int tileIndex = 0; tileIndex < endingTiles.Length; tileIndex++) { + SearchTile searchTile = endingTiles[tileIndex]; + ConnectedTile endingTile = searchTile.Tile; TileConnectionPoint entryPoint = endingTile.ConnectionPoints[0]; if (!CanEnterTile(currentNode, endingTile, entryPoint)) continue; - byte directionMask = (byte)(entryPoint.ReversedConnectionMask & currentNode.ExitPoint.Value.ConnectionMask); - foreach (Direction direction in Helpers.GetDirectionsInMask(directionMask)) + byte directionMask = (byte)(entryPoint.ReversedConnectionMask & currentExitPoint.ConnectionMask); + for (int directionIndex = 0; directionIndex < (int)Direction.Count; directionIndex++) { + if ((directionMask & (byte)(0b10000000 >> directionIndex)) == 0) + continue; + + Direction direction = (Direction)directionIndex; Point2D placementOffset = Helpers.VisualDirectionToPoint(direction) - entryPoint.CoordinateOffset; - Point2D location = currentNode.ExitCoordinates + placementOffset; + Point2D location = currentExitCoordinates + placementOffset; Point2D entryCoordinates = location + entryPoint.CoordinateOffset; if (entryCoordinates != destination) continue; - if (!TryAddFoundation(currentNode.OccupiedCells, endingTile, location, - isCellValid, out HashSet occupiedCells)) - { + if (!CanPlaceFoundation(currentNode, searchTile, location, isCellValid)) continue; - } - float gScore = currentNode.GScore + Distance(currentNode.ExitCoordinates, entryCoordinates); - terminalNodes.Add(new SearchNode(currentNode, endingTile, location, - entryPoint, null, occupiedCells, gScore)); + float gScore = currentNode.GScore + Distance(currentExitCoordinates, entryCoordinates); + var terminalNode = new SearchNode(currentNode, searchTile, location, + 0, -1, gScore); + if (bestNode == null || IsBetterTerminalNode(terminalNode, bestNode, randomSeed)) + bestNode = terminalNode; } } - terminalNodes.Sort((left, right) => - { - int priorityComparison = left.Tile.ExtraPriority.CompareTo(right.Tile.ExtraPriority); - if (priorityComparison != 0) - return priorityComparison; + return bestNode; + } - return GetStableNodeHash(left, randomSeed).CompareTo(GetStableNodeHash(right, randomSeed)); - }); + private static bool IsBetterTerminalNode(SearchNode candidate, SearchNode currentBest, int randomSeed) + { + if (candidate.Tile.ExtraPriority != currentBest.Tile.ExtraPriority) + return candidate.Tile.ExtraPriority < currentBest.Tile.ExtraPriority; - return terminalNodes; + return GetStableNodeHash(candidate, randomSeed) < GetStableNodeHash(currentBest, randomSeed); } private static bool CanEnterTile(SearchNode currentNode, ConnectedTile candidateTile, TileConnectionPoint candidateEntry) { - if (currentNode.ExitPoint == null || candidateEntry.Side != currentNode.ExitPoint.Value.Side) + if (!currentNode.HasExitPoint) + return false; + + TileConnectionPoint currentExitPoint = currentNode.ExitPoint; + if (candidateEntry.Side != currentExitPoint.Side) return false; if (currentNode.Tile != null && !ConnectionPointAllowsTile(candidateEntry, currentNode.Tile.Index)) @@ -576,25 +696,26 @@ private static bool CanEnterTile(SearchNode currentNode, ConnectedTile candidate // incoming candidate only. Preserve that behavior, while allowing a one-point start // cap to constrain the first linear tile through its sole outgoing connection. if (currentNode.Tile?.IsEndingPiece == true && - !ConnectionPointAllowsTile(currentNode.ExitPoint.Value, candidateTile.Index)) + !ConnectionPointAllowsTile(currentExitPoint, candidateTile.Index)) { return false; } - return (candidateEntry.ReversedConnectionMask & currentNode.ExitPoint.Value.ConnectionMask) != 0; + return (candidateEntry.ReversedConnectionMask & currentExitPoint.ConnectionMask) != 0; } private static bool CanCloseAtOrigin(SearchNode finalNode, Point2D origin) { SearchNode firstNode = finalNode.FirstPlacement; - if (finalNode.Tile == null || finalNode.ExitPoint == null || firstNode?.EntryPoint == null) + if (finalNode.Tile == null || !finalNode.HasExitPoint || + firstNode == null || firstNode.EntryPointIndex < 0) return false; if (finalNode.Depth < 2 || finalNode.ExitCoordinates != origin) return false; - TileConnectionPoint finalExit = finalNode.ExitPoint.Value; - TileConnectionPoint firstEntry = firstNode.EntryPoint.Value; + TileConnectionPoint finalExit = finalNode.ExitPoint; + TileConnectionPoint firstEntry = firstNode.Tile.ConnectionPoints[firstNode.EntryPointIndex]; if (finalExit.Side != firstEntry.Side || !ConnectionPointAllowsTile(firstEntry, finalNode.Tile.Index)) return false; @@ -603,8 +724,12 @@ private static bool CanCloseAtOrigin(SearchNode finalNode, Point2D origin) Point2D delta = firstEntryCoordinates - finalNode.ExitCoordinates; byte compatibleDirections = (byte)(finalExit.ConnectionMask & firstEntry.ReversedConnectionMask); - foreach (Direction direction in Helpers.GetDirectionsInMask(compatibleDirections)) + for (int directionIndex = 0; directionIndex < (int)Direction.Count; directionIndex++) { + if ((compatibleDirections & (byte)(0b10000000 >> directionIndex)) == 0) + continue; + + Direction direction = (Direction)directionIndex; if (Helpers.VisualDirectionToPoint(direction) == delta) return true; } @@ -615,40 +740,79 @@ private static bool CanCloseAtOrigin(SearchNode finalNode, Point2D origin) private static bool ConnectionPointAllowsTile(TileConnectionPoint point, int tileIndex) { if (point.RequiredTiles?.Length > 0) - return point.RequiredTiles.Contains(tileIndex); + { + for (int i = 0; i < point.RequiredTiles.Length; i++) + { + if (point.RequiredTiles[i] == tileIndex) + return true; + } - return point.ForbiddenTiles == null || !point.ForbiddenTiles.Contains(tileIndex); + return false; + } + + if (point.ForbiddenTiles == null) + return true; + + for (int i = 0; i < point.ForbiddenTiles.Length; i++) + { + if (point.ForbiddenTiles[i] == tileIndex) + return false; + } + + return true; } - private static bool TryAddFoundation(HashSet existingOccupiedCells, ConnectedTile tile, - Point2D location, Func isCellValid, out HashSet occupiedCells) + private static bool CanPlaceFoundation(SearchNode parent, SearchTile searchTile, + Point2D location, Func isCellValid) { - occupiedCells = null; - if (!HasUsableFoundation(tile)) - return false; - - foreach (Point2D foundationCell in tile.Foundation) + Point2D[] foundationCells = searchTile.FoundationCells; + for (int foundationIndex = 0; foundationIndex < foundationCells.Length; foundationIndex++) { + Point2D foundationCell = foundationCells[foundationIndex]; Point2D absoluteCell = foundationCell + location; - if (!isCellValid(absoluteCell) || existingOccupiedCells.Contains(absoluteCell)) + if (!isCellValid(absoluteCell)) return false; - } - occupiedCells = new HashSet(existingOccupiedCells); - foreach (Point2D foundationCell in tile.Foundation) - occupiedCells.Add(foundationCell + location); + SearchNode ancestor = parent; + while (ancestor != null) + { + if (ancestor.Tile != null) + { + Point2D[] occupiedFoundationCells = ancestor.SearchTile.FoundationCells; + for (int occupiedIndex = 0; occupiedIndex < occupiedFoundationCells.Length; + occupiedIndex++) + { + Point2D occupiedFoundationCell = occupiedFoundationCells[occupiedIndex]; + if (occupiedFoundationCell + ancestor.Location == absoluteCell) + return false; + } + } + + ancestor = ancestor.Parent; + } + } return true; } + private static SearchTileCache GetSearchTileCache(ConnectedTileType type) + { + if (SearchTileCaches.TryGetValue(type, out SearchTileCache cache) && cache.Matches(type)) + return cache; + + SearchTileCaches.Remove(type); + return SearchTileCaches.GetValue(type, static connectedTileType => new SearchTileCache(connectedTileType)); + } + private static bool HasUsableFoundation(ConnectedTile tile) => tile.Foundation != null && tile.Foundation.Count > 0; - private static List BackUpOnePlacement(IEnumerable nodes) + private static List BackUpOnePlacement(IReadOnlyList nodes) { - var backedUpNodes = new List(); - foreach (SearchNode node in nodes) + var backedUpNodes = new List(nodes.Count); + for (int nodeIndex = 0; nodeIndex < nodes.Count; nodeIndex++) { + SearchNode node = nodes[nodeIndex]; SearchNode backedUpNode = node.Parent ?? node; if (!backedUpNodes.Contains(backedUpNode)) backedUpNodes.Add(backedUpNode); @@ -657,6 +821,26 @@ private static List BackUpOnePlacement(IEnumerable nodes return backedUpNodes; } + private static bool HasThreeDistinctVertices(ReadOnlySpan vertices) + { + Point2D first = vertices[0]; + int secondIndex = 1; + while (secondIndex < vertices.Length && vertices[secondIndex] == first) + secondIndex++; + + if (secondIndex >= vertices.Length) + return false; + + Point2D second = vertices[secondIndex]; + for (int i = secondIndex + 1; i < vertices.Length; i++) + { + if (vertices[i] != first && vertices[i] != second) + return true; + } + + return false; + } + private static List BuildPlacements(SearchNode finalNode) { var placements = new List(); @@ -666,8 +850,14 @@ private static List BuildPlacements(SearchNode finalNode { if (node.Tile != null) { + TileConnectionPoint? entryPoint = node.EntryPointIndex >= 0 + ? node.Tile.ConnectionPoints[node.EntryPointIndex] + : null; + TileConnectionPoint? exitPoint = node.ExitPointIndex >= 0 + ? node.Tile.ConnectionPoints[node.ExitPointIndex] + : null; placements.Add(new ConnectedTilePlacement(node.Tile, node.Location, - node.EntryPoint, node.ExitPoint)); + entryPoint, exitPoint)); } node = node.Parent; @@ -684,7 +874,7 @@ private static float GetNodePriorityScore(SearchNode node, Point2D destination, if (node.Tile == null) return fScore; - int foundationCellCount = Math.Max(node.Tile.Foundation?.Count ?? 1, 1); + int foundationCellCount = Math.Max(node.SearchTile.FoundationCells.Length, 1); float sizePenalty = (foundationCellCount - 1) * TileSizePenaltyPerFoundationCell; float repeatedWeightedUseCount = CountPreviousTileUsesInRecentAncestors( node, RepeatPenaltyAncestorWindow); @@ -704,7 +894,7 @@ private static uint GetStableNodeHash(SearchNode node, int randomSeed) hash = MixHash(hash, unchecked((uint)randomSeed)); hash = MixHash(hash, unchecked((uint)node.Location.X)); hash = MixHash(hash, unchecked((uint)node.Location.Y)); - hash = MixHash(hash, unchecked((uint)(node.ExitPoint?.Index ?? -1))); + hash = MixHash(hash, unchecked((uint)node.ExitPointIndex)); hash = MixHash(hash, unchecked((uint)(node.Tile?.Index ?? -1))); return hash; } diff --git a/src/TSMapEditor/Models/ConnectedTileType.cs b/src/TSMapEditor/Models/ConnectedTileType.cs index fbf35c8ac..f675349cd 100644 --- a/src/TSMapEditor/Models/ConnectedTileType.cs +++ b/src/TSMapEditor/Models/ConnectedTileType.cs @@ -368,5 +368,11 @@ private ConnectedTileType(IniFile iniFile, string iniName, string name, bool fro public List AllowedTheaters { get; set; } public List Tiles { get; } public bool SupportsEndPieces => Tiles.Exists(tile => tile.IsEndingPiece); + + /// + /// Determines whether the connected tile type is properly configured and usable in the editor. + /// Intentionally never assigned to anything else in debug mode - but is in release mode. + /// + public bool IsLegal { get; set; } = true; } } diff --git a/src/TSMapEditor/Models/EditorConfig.cs b/src/TSMapEditor/Models/EditorConfig.cs index cae013b8e..21a10dedc 100644 --- a/src/TSMapEditor/Models/EditorConfig.cs +++ b/src/TSMapEditor/Models/EditorConfig.cs @@ -30,7 +30,7 @@ public EditorConfig() public List Theaters { get; } = new List(); public List Bridges { get; } = new List(); public List ConnectedOverlays { get; } = new List(); - public List Cliffs { get; } = new List(); + public List ConnectedTileTypes { get; } = new List(); public List TeamTypeFlags { get; } = new List(); public EvaSpeeches Speeches { get; private set; } @@ -51,7 +51,7 @@ public void EarlyInit() ReadTheaters(); ReadTeamTypeFlags(); ReadSpeeches(); - ReadCliffs(); + ReadConnectedTileTypes(); } public void RulesDependentInit(Rules rules) @@ -436,9 +436,9 @@ private void ReadSpeeches() Speeches = new EvaSpeeches(speeches.ToArray()); } - private void ReadCliffs() + private void ReadConnectedTileTypes() { - Cliffs.Clear(); + ConnectedTileTypes.Clear(); var iniFile = Helpers.ReadConfigINI("ConnectedTileDrawer.ini"); var section = iniFile.GetSection("ConnectedTiles"); @@ -449,9 +449,9 @@ private void ReadCliffs() { string cliffIniName = kvp.Value; - ConnectedTileType cliffType = ConnectedTileType.FromIniSection(iniFile, cliffIniName); - if (cliffType != null) - Cliffs.Add(cliffType); + var connectedTileType = ConnectedTileType.FromIniSection(iniFile, cliffIniName); + if (connectedTileType != null) + ConnectedTileTypes.Add(connectedTileType); } } } diff --git a/src/TSMapEditor/UI/TopBar/TopBarMenu.cs b/src/TSMapEditor/UI/TopBar/TopBarMenu.cs index a4f44da92..c4b57c85c 100644 --- a/src/TSMapEditor/UI/TopBar/TopBarMenu.cs +++ b/src/TSMapEditor/UI/TopBar/TopBarMenu.cs @@ -131,15 +131,14 @@ public override void Initialize() } } - var theaterMatchingCliffs = map.EditorConfig.Cliffs.Where(cliff => cliff.AllowedTheaters.Exists( + var theaterMatchingConnectedTileTypes = map.EditorConfig.ConnectedTileTypes.Where(ctt => ctt.IsLegal && ctt.AllowedTheaters.Exists( theaterName => theaterName.Equals(map.TheaterName, StringComparison.OrdinalIgnoreCase))).ToList(); - int cliffCount = theaterMatchingCliffs.Count; - if (cliffCount > 0) + if (theaterMatchingConnectedTileTypes.Count > 0) { - if (cliffCount == 1) + if (theaterMatchingConnectedTileTypes.Count == 1) { editContextMenu.AddItem(Translate(this, "Edit.DrawConnectedTiles", "Draw Connected Tiles"), () => mapUI.EditorState.CursorAction = - new DrawConnectedTilesCursorAction(mapUI, theaterMatchingCliffs[0]), null, null, null); + new DrawConnectedTilesCursorAction(mapUI, theaterMatchingConnectedTileTypes[0]), null, null, null); } else { diff --git a/src/TSMapEditor/UI/Windows/MainMenuWindows/MapSetup.cs b/src/TSMapEditor/UI/Windows/MainMenuWindows/MapSetup.cs index 794dbdbc3..eb925b48a 100644 --- a/src/TSMapEditor/UI/Windows/MainMenuWindows/MapSetup.cs +++ b/src/TSMapEditor/UI/Windows/MainMenuWindows/MapSetup.cs @@ -203,7 +203,7 @@ public void LoadNonGraphicalTheater() /// private void FillConnectedTileFoundations(ITheater theaterTileInfo) { - foreach (var connectedTileType in LoadedMap.EditorConfig.Cliffs) + foreach (var connectedTileType in LoadedMap.EditorConfig.ConnectedTileTypes) { if (!connectedTileType.AllowedTheaters.Select(at => at.ToUpperInvariant()).Contains(LoadedMap.LoadedTheaterName.ToUpperInvariant())) continue; @@ -224,7 +224,7 @@ private void FillConnectedTileFoundations(ITheater theaterTileInfo) throw new INIConfigException(errorMessage); #else Logger.Log("WARNING: " + errorMessage + ". Disabling the connected terrain type."); - cliffType.IsLegal = false; + connectedTileType.IsLegal = false; break; #endif } diff --git a/src/TSMapEditor/UI/Windows/SelectConnectedTileWindow.cs b/src/TSMapEditor/UI/Windows/SelectConnectedTileWindow.cs index 95b002e1d..c27ec110d 100644 --- a/src/TSMapEditor/UI/Windows/SelectConnectedTileWindow.cs +++ b/src/TSMapEditor/UI/Windows/SelectConnectedTileWindow.cs @@ -41,10 +41,13 @@ protected override void ListObjects() { lbObjectList.Clear(); - foreach (ConnectedTileType cliff in map.EditorConfig.Cliffs.Where(cliff => - cliff.AllowedTheaters.Exists(theaterName => theaterName.Equals(map.TheaterName, StringComparison.OrdinalIgnoreCase)))) + foreach (ConnectedTileType connectedTileType in map.EditorConfig.ConnectedTileTypes.Where(ctt => + ctt.AllowedTheaters.Exists(theaterName => theaterName.Equals(map.TheaterName, StringComparison.OrdinalIgnoreCase)))) { - lbObjectList.AddItem(new XNAListBoxItem() { Text = cliff.Name, Tag = cliff, TextColor = cliff.Color.GetValueOrDefault(lbObjectList.DefaultItemColor) }); + if (!connectedTileType.IsLegal) + continue; + + lbObjectList.AddItem(new XNAListBoxItem() { Text = connectedTileType.Name, Tag = connectedTileType, TextColor = connectedTileType.Color.GetValueOrDefault(lbObjectList.DefaultItemColor) }); } } } From 29a804dfa198f9c0386af076c53f20354d317166 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Sun, 2 Aug 2026 23:02:18 +0300 Subject: [PATCH 16/27] Add ending pieces to DTA connected tile config --- .../Config/Default/ConnectedTileDrawer.ini | 1015 ++++++++++++++--- src/TSMapEditor/Constants.cs | 2 +- 2 files changed, 877 insertions(+), 140 deletions(-) diff --git a/src/TSMapEditor/Config/Default/ConnectedTileDrawer.ini b/src/TSMapEditor/Config/Default/ConnectedTileDrawer.ini index 640a6b7bb..b78e28be9 100644 --- a/src/TSMapEditor/Config/Default/ConnectedTileDrawer.ini +++ b/src/TSMapEditor/Config/Default/ConnectedTileDrawer.ini @@ -231,7 +231,7 @@ ConnectionPoint0.Side=Back TileSet=Cliffs TileIndices=30 ConnectionPoint0=2,1 -ConnectionPoint0.Directions=00010010 ;Bottom +ConnectionPoint0.Directions=00010000 ;Bottom ConnectionPoint0.Side=Back [TemperateCliffSet.17] @@ -938,7 +938,7 @@ ConnectionPoint0.Side=Back TileSet=---Cliffs TileIndices=30 ConnectionPoint0=2,1 -ConnectionPoint0.Directions=00010010 ;Bottom +ConnectionPoint0.Directions=00010000 ;Bottom ConnectionPoint0.Side=Back [TDWinterCliffSet.17] @@ -4261,9 +4261,9 @@ Color=0,200,0 TileSet=Straight Dirt Roads TileIndices=0 ConnectionPoint0=1,0 -ConnectionPoint0.Directions=10000000 ;Top-Right +ConnectionPoint0.Directions=TopRight ConnectionPoint1=1,3 -ConnectionPoint1.Directions=00001000 ;Bottom-Left +ConnectionPoint1.Directions=BottomLeft ConnectionPoint0.ForbiddenTiles=0 ConnectionPoint1.ForbiddenTiles=0,1 @@ -4271,9 +4271,9 @@ ConnectionPoint1.ForbiddenTiles=0,1 TileSet=Straight Dirt Roads TileIndices=1 ConnectionPoint0=0,0 -ConnectionPoint0.Directions=10000000 ;Top-Right +ConnectionPoint0.Directions=TopRight ConnectionPoint1=0,2 -ConnectionPoint1.Directions=00001000 ;Bottom-Left +ConnectionPoint1.Directions=BottomLeft ConnectionPoint0.ForbiddenTiles=0,1 ConnectionPoint1.ForbiddenTiles=1 @@ -4281,18 +4281,18 @@ ConnectionPoint1.ForbiddenTiles=1 TileSet=Straight Dirt Roads TileIndices=2,3 ConnectionPoint0=1,0 -ConnectionPoint0.Directions=10000000 ;Top-Right +ConnectionPoint0.Directions=TopRight ConnectionPoint1=1,1 -ConnectionPoint1.Directions=00001000 ;Bottom-Left +ConnectionPoint1.Directions=BottomLeft ExtraPriority=9999 [TemperateDirtRoadSet.3] TileSet=Straight Dirt Roads TileIndices=4,5 ConnectionPoint0=0,1 -ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Directions=TopLeft ConnectionPoint1=3,1 -ConnectionPoint1.Directions=00100000 ;Bottom-Right +ConnectionPoint1.Directions=BottomRight ConnectionPoint0.ForbiddenTiles=3 ConnectionPoint1.ForbiddenTiles=3 @@ -4300,19 +4300,19 @@ ConnectionPoint1.ForbiddenTiles=3 TileSet=Straight Dirt Roads TileIndices=6,7 ConnectionPoint0=0,1 -ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Directions=TopLeft ConnectionPoint1=1,1 -ConnectionPoint1.Directions=00100000 ;Bottom-Right +ConnectionPoint1.Directions=BottomRight ExtraPriority=1 [TemperateDirtRoadSet.5] TileSet=Straight Dirt Roads TileIndices=8 ConnectionPoint0=0,0 -ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Directions=TopLeft ConnectionPoint0.ForbiddenTiles=5 ConnectionPoint1=3,2 -ConnectionPoint1.Directions=00100000 ;Bottom-Right +ConnectionPoint1.Directions=BottomRight ConnectionPoint1.ForbiddenTiles=5 ExtraPriority=-2 @@ -4320,9 +4320,9 @@ ExtraPriority=-2 TileSet=Straight Dirt Roads TileIndices=9,10 ConnectionPoint0=0,0 -ConnectionPoint0.Directions=00000001 ;Top +ConnectionPoint0.Directions=Top ConnectionPoint1=2,2 -ConnectionPoint1.Directions=00010000 ;Bottom +ConnectionPoint1.Directions=Bottom ExtraPriority=99999 ConnectionPoint0.ForbiddenTiles=6 ConnectionPoint1.ForbiddenTiles=6 @@ -4331,10 +4331,10 @@ ConnectionPoint1.ForbiddenTiles=6 TileSet=Straight Dirt Roads TileIndices=11,12 ConnectionPoint0=0,2 -ConnectionPoint0.Directions=00000100 ;Left +ConnectionPoint0.Directions=Left ConnectionPoint0.ForbiddenTiles=7 ConnectionPoint1=2,0 -ConnectionPoint1.Directions=01000000 ;Right +ConnectionPoint1.Directions=Right ConnectionPoint1.ForbiddenTiles=7 ExtraPriority=99999 DistanceModifier=-3 @@ -4343,25 +4343,25 @@ DistanceModifier=-3 TileSet=Straight Dirt Roads TileIndices=21 ConnectionPoint0=0,0 -ConnectionPoint0.Directions=10000000 ;Top-Right +ConnectionPoint0.Directions=TopRight ConnectionPoint1=0,0 -ConnectionPoint1.Directions=00001000 ;Bottom-Left +ConnectionPoint1.Directions=BottomLeft [TemperateDirtRoadSet.9] TileSet=Straight Dirt Roads TileIndices=22 ConnectionPoint0=0,0 -ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Directions=TopLeft ConnectionPoint1=0,0 -ConnectionPoint1.Directions=00100000 ;Bottom-Right +ConnectionPoint1.Directions=BottomRight [TemperateDirtRoadSet.10] TileSet=Bendy Dirt Roads/Connectors TileIndices=0 ConnectionPoint0=0,0 -ConnectionPoint0.Directions=10000000 ;Top-Right +ConnectionPoint0.Directions=TopRight ConnectionPoint1=2,2 -ConnectionPoint1.Directions=00100000 ;Bottom-Right +ConnectionPoint1.Directions=BottomRight ConnectionPoint0.ForbiddenTiles=12 ConnectionPoint1.ForbiddenTiles=12 @@ -4369,9 +4369,9 @@ ConnectionPoint1.ForbiddenTiles=12 TileSet=Bendy Dirt Roads/Connectors TileIndices=1 ConnectionPoint0=2,0 -ConnectionPoint0.Directions=00100000 ;Bottom-Right +ConnectionPoint0.Directions=BottomRight ConnectionPoint1=1,1 -ConnectionPoint1.Directions=00001000 ;Bottom-Left +ConnectionPoint1.Directions=BottomLeft ConnectionPoint0.ForbiddenTiles=13 ConnectionPoint1.ForbiddenTiles=13 @@ -4379,9 +4379,9 @@ ConnectionPoint1.ForbiddenTiles=13 TileSet=Bendy Dirt Roads/Connectors TileIndices=2 ConnectionPoint0=0,1 -ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Directions=TopLeft ConnectionPoint1=1,2 -ConnectionPoint1.Directions=00001000 ;Bottom-Left +ConnectionPoint1.Directions=BottomLeft ConnectionPoint0.ForbiddenTiles=10 ConnectionPoint1.ForbiddenTiles=10 @@ -4389,9 +4389,9 @@ ConnectionPoint1.ForbiddenTiles=10 TileSet=Bendy Dirt Roads/Connectors TileIndices=3 ConnectionPoint0=1,0 -ConnectionPoint0.Directions=10000000 ;Top-Right +ConnectionPoint0.Directions=TopRight ConnectionPoint1=0,2 -ConnectionPoint1.Directions=00000010 ;Top-Left +ConnectionPoint1.Directions=TopLeft ConnectionPoint0.ForbiddenTiles=11 ConnectionPoint1.ForbiddenTiles=11 @@ -4399,120 +4399,168 @@ ConnectionPoint1.ForbiddenTiles=11 TileSet=Bendy Dirt Roads/Connectors TileIndices=6 ConnectionPoint0=0,0 -ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Directions=TopLeft ConnectionPoint1=0,0 -ConnectionPoint1.Directions=00010000 ;Bottom +ConnectionPoint1.Directions=Bottom [TemperateDirtRoadSet.15] TileSet=Bendy Dirt Roads/Connectors TileIndices=7 ConnectionPoint0=0,0 -ConnectionPoint0.Directions=10000000 ;Top-Right +ConnectionPoint0.Directions=TopRight ConnectionPoint1=0,0 -ConnectionPoint1.Directions=00010000 ;Bottom +ConnectionPoint1.Directions=Bottom [TemperateDirtRoadSet.16] TileSet=Bendy Dirt Roads/Connectors TileIndices=9 ConnectionPoint0=1,1 -ConnectionPoint0.Directions=00100000 ;Bottom-Right +ConnectionPoint0.Directions=BottomRight ConnectionPoint1=1,1 -ConnectionPoint1.Directions=00000001 ;Top +ConnectionPoint1.Directions=Top [TemperateDirtRoadSet.17] TileSet=Bendy Dirt Roads/Connectors TileIndices=10 ConnectionPoint0=0,1 -ConnectionPoint0.Directions=00001000 ;Bottom-Left +ConnectionPoint0.Directions=BottomLeft ConnectionPoint1=1,1 -ConnectionPoint1.Directions=00000001 ;Top +ConnectionPoint1.Directions=Top [TemperateDirtRoadSet.18] TileSet=Bendy Dirt Roads/Connectors TileIndices=14 ConnectionPoint0=1,0 -ConnectionPoint0.Directions=00100000 ;Bottom-Right +ConnectionPoint0.Directions=BottomRight ConnectionPoint1=1,0 -ConnectionPoint1.Directions=00000100 ;Left +ConnectionPoint1.Directions=Left [TemperateDirtRoadSet.19] TileSet=Bendy Dirt Roads/Connectors TileIndices=15 ConnectionPoint0=1,0 -ConnectionPoint0.Directions=10000000 ;Top-Right +ConnectionPoint0.Directions=TopRight ConnectionPoint1=1,0 -ConnectionPoint1.Directions=00000100 ;Left +ConnectionPoint1.Directions=Left [TemperateDirtRoadSet.20] TileSet=Bendy Dirt Roads/Connectors TileIndices=17 ConnectionPoint0=0,1 -ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Directions=TopLeft ConnectionPoint1=0,1 -ConnectionPoint1.Directions=01000000 ;Right +ConnectionPoint1.Directions=Right -[TemperateDirtRoadSet.20] +[TemperateDirtRoadSet.21] TileSet=Bendy Dirt Roads/Connectors TileIndices=18 ConnectionPoint0=0,1 -ConnectionPoint0.Directions=00001000 ;Bottom-Left +ConnectionPoint0.Directions=BottomLeft ConnectionPoint1=0,1 -ConnectionPoint1.Directions=01000000 ;Right +ConnectionPoint1.Directions=Right -[TemperateDirtRoadSet.21] +[TemperateDirtRoadSet.22] TileSet=Bendy Dirt Roads/Connectors TileIndices=20 ConnectionPoint0=1,1 -ConnectionPoint0.Directions=00000001 ;Top +ConnectionPoint0.Directions=Top ConnectionPoint1=3,1 -ConnectionPoint1.Directions=01000000 ;Right +ConnectionPoint1.Directions=Right -[TemperateDirtRoadSet.22] +[TemperateDirtRoadSet.23] TileSet=Bendy Dirt Roads/Connectors TileIndices=21 ConnectionPoint0=0,1 -ConnectionPoint0.Directions=01000000 ;Right +ConnectionPoint0.Directions=Right ConnectionPoint1=0,2 -ConnectionPoint1.Directions=00010000 ;Bottom +ConnectionPoint1.Directions=Bottom -[TemperateDirtRoadSet.23] +[TemperateDirtRoadSet.24] TileSet=Bendy Dirt Roads/Connectors TileIndices=22 ConnectionPoint0=1,0 -ConnectionPoint0.Directions=00000100 ;Left +ConnectionPoint0.Directions=Left ConnectionPoint1=2,1 -ConnectionPoint1.Directions=00010000 ;Bottom +ConnectionPoint1.Directions=Bottom -[TemperateDirtRoadSet.24] +[TemperateDirtRoadSet.25] TileSet=Bendy Dirt Roads/Connectors TileIndices=23 ConnectionPoint0=1,1 -ConnectionPoint0.Directions=00000001 ;Top +ConnectionPoint0.Directions=Top ConnectionPoint1=2,3 -ConnectionPoint1.Directions=00000100 ;Left +ConnectionPoint1.Directions=Left -[TemperateDirtRoadSet.25] +[TemperateDirtRoadSet.26] TileSet=Bendy Dirt Roads/Connectors TileIndices=4,5 ConnectionPoint0=1,1 -ConnectionPoint0.Directions=00000001 ;Top +ConnectionPoint0.Directions=Top ConnectionPoint0.ForbiddenTiles=25 ConnectionPoint1=0,0 -ConnectionPoint1.Directions=00010000 ;Bottom +ConnectionPoint1.Directions=Bottom ConnectionPoint1.ForbiddenTiles=25 ExtraPriority=99999 -[TemperateDirtRoadSet.26] +[TemperateDirtRoadSet.27] TileSet=Bendy Dirt Roads/Connectors TileIndices=12,13 ConnectionPoint0=1,0 -ConnectionPoint0.Directions=00000100 ;Left +ConnectionPoint0.Directions=Left ConnectionPoint0.ForbiddenTiles=26 ConnectionPoint1=0,1 -ConnectionPoint1.Directions=01000000 ;Right +ConnectionPoint1.Directions=Right ConnectionPoint1.ForbiddenTiles=26 ExtraPriority=99999 +[TemperateDirtRoadSet.28] +TileSet=Straight Dirt Roads +TileIndices=13 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=TopRight + +[TemperateDirtRoadSet.29] +TileSet=Straight Dirt Roads +TileIndices=14 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=BottomRight + +[TemperateDirtRoadSet.30] +TileSet=Straight Dirt Roads +TileIndices=15 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=BottomLeft + +[TemperateDirtRoadSet.31] +TileSet=Straight Dirt Roads +TileIndices=16 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=TopLeft + +[TemperateDirtRoadSet.32] +TileSet=Straight Dirt Roads +TileIndices=17 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Top + +[TemperateDirtRoadSet.33] +TileSet=Straight Dirt Roads +TileIndices=18 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=Right + +[TemperateDirtRoadSet.34] +TileSet=Straight Dirt Roads +TileIndices=19 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Bottom + +[TemperateDirtRoadSet.35] +TileSet=Straight Dirt Roads +TileIndices=20 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Left + [SnowDirtRoadSet] Name=Snow Dirt Roads @@ -4777,6 +4825,54 @@ ConnectionPoint1.Directions=01000000 ;Right ConnectionPoint1.ForbiddenTiles=26 ExtraPriority=99999 +[SnowDirtRoadSet.27] +TileSet=~~~Straight Dirt Roads +TileIndices=13 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=TopRight + +[SnowDirtRoadSet.28] +TileSet=~~~Straight Dirt Roads +TileIndices=14 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=BottomRight + +[SnowDirtRoadSet.29] +TileSet=~~~Straight Dirt Roads +TileIndices=15 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=BottomLeft + +[SnowDirtRoadSet.30] +TileSet=~~~Straight Dirt Roads +TileIndices=16 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=TopLeft + +[SnowDirtRoadSet.31] +TileSet=~~~Straight Dirt Roads +TileIndices=17 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Top + +[SnowDirtRoadSet.32] +TileSet=~~~Straight Dirt Roads +TileIndices=18 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=Right + +[SnowDirtRoadSet.33] +TileSet=~~~Straight Dirt Roads +TileIndices=19 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Bottom + +[SnowDirtRoadSet.34] +TileSet=~~~Straight Dirt Roads +TileIndices=20 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Left + [TDWinterDirtRoadSet] Name=TDWinter Dirt Roads @@ -5043,6 +5139,54 @@ ConnectionPoint1=0,1 ConnectionPoint1.Directions=01000000 ;Right ExtraPriority=99999 +[TDWinterDirtRoadSet.27] +TileSet=---Straight Dirt Roads +TileIndices=13 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=TopRight + +[TDWinterDirtRoadSet.28] +TileSet=---Straight Dirt Roads +TileIndices=14 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=BottomRight + +[TDWinterDirtRoadSet.29] +TileSet=---Straight Dirt Roads +TileIndices=15 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=BottomLeft + +[TDWinterDirtRoadSet.30] +TileSet=---Straight Dirt Roads +TileIndices=16 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=TopLeft + +[TDWinterDirtRoadSet.31] +TileSet=---Straight Dirt Roads +TileIndices=17 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Top + +[TDWinterDirtRoadSet.32] +TileSet=---Straight Dirt Roads +TileIndices=18 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=Right + +[TDWinterDirtRoadSet.33] +TileSet=---Straight Dirt Roads +TileIndices=19 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Bottom + +[TDWinterDirtRoadSet.34] +TileSet=---Straight Dirt Roads +TileIndices=20 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Left + [TemperatePavedRoadASet] Name=Temperate Paved Road A @@ -5180,6 +5324,54 @@ ConnectionPoint1=1,0 ConnectionPoint1.Directions=00000100 ;Left ExtraPriority=99999 +[TemperatePavedRoadASet.16] +TileSet=Paved Roads A +TileIndices=15 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopLeft + +[TemperatePavedRoadASet.17] +TileSet=Paved Roads A +TileIndices=16 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=BottomLeft + +[TemperatePavedRoadASet.18] +TileSet=Paved Roads A +TileIndices=17 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=BottomRight + +[TemperatePavedRoadASet.19] +TileSet=Paved Roads A +TileIndices=18 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopRight + +[TemperatePavedRoadASet.20] +TileSet=Paved Roads A +TileIndices=39 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Top + +[TemperatePavedRoadASet.21] +TileSet=Paved Roads A +TileIndices=40 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=Left + +[TemperatePavedRoadASet.22] +TileSet=Paved Roads A +TileIndices=41 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Bottom + +[TemperatePavedRoadASet.23] +TileSet=Paved Roads A +TileIndices=42 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=Right + [TemperatePavedRoadBSet] Name=Temperate Paved Road B @@ -5317,6 +5509,54 @@ ConnectionPoint1=1,0 ConnectionPoint1.Directions=00000100 ;Left ExtraPriority=99999 +[TemperatePavedRoadBSet.16] +TileSet=Paved Roads B +TileIndices=15 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopLeft + +[TemperatePavedRoadBSet.17] +TileSet=Paved Roads B +TileIndices=16 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=BottomLeft + +[TemperatePavedRoadBSet.18] +TileSet=Paved Roads B +TileIndices=17 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=BottomRight + +[TemperatePavedRoadBSet.19] +TileSet=Paved Roads B +TileIndices=18 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopRight + +[TemperatePavedRoadBSet.20] +TileSet=Paved Roads B +TileIndices=39 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Top + +[TemperatePavedRoadBSet.21] +TileSet=Paved Roads B +TileIndices=40 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=Left + +[TemperatePavedRoadBSet.22] +TileSet=Paved Roads B +TileIndices=41 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=Bottom + +[TemperatePavedRoadBSet.23] +TileSet=Paved Roads B +TileIndices=42 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=Right + [TemperatePavedRoadCSet] Name=Temperate Paved Road C @@ -5454,29 +5694,77 @@ ConnectionPoint1=1,0 ConnectionPoint1.Directions=00000100 ;Left ExtraPriority=99999 +[TemperatePavedRoadCSet.16] +TileSet=Paved Roads C +TileIndices=15 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopLeft -[SnowPavedRoadSet] -Name=Snow Paved Road -AllowedTheaters=Temperate -Color=200,200,255 - -[SnowPavedRoadSet.0] -TileSet=~~~Paved Roads -TileIndices=0 +[TemperatePavedRoadCSet.17] +TileSet=Paved Roads C +TileIndices=16 ConnectionPoint0=0,0 -ConnectionPoint0.Directions=00000010 ;Top-Left -ConnectionPoint1=0,0 -ConnectionPoint1.Directions=00100000 ;Bottom-Right +ConnectionPoint0.Directions=BottomLeft -[SnowPavedRoadSet.1] -TileSet=~~~Paved Roads -TileIndices=1 +[TemperatePavedRoadCSet.18] +TileSet=Paved Roads C +TileIndices=17 ConnectionPoint0=0,0 -ConnectionPoint0.Directions=10000000 ;Top-Right -ConnectionPoint1=0,0 -ConnectionPoint1.Directions=00001000 ;Bottom-Left +ConnectionPoint0.Directions=BottomRight -[SnowPavedRoadSet.2] +[TemperatePavedRoadCSet.19] +TileSet=Paved Roads C +TileIndices=18 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopRight + +[TemperatePavedRoadCSet.20] +TileSet=Paved Roads C +TileIndices=39 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Top + +[TemperatePavedRoadCSet.21] +TileSet=Paved Roads C +TileIndices=40 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=Left + +[TemperatePavedRoadCSet.22] +TileSet=Paved Roads C +TileIndices=41 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=Bottom + +[TemperatePavedRoadCSet.23] +TileSet=Paved Roads C +TileIndices=42 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=Right + + +[SnowPavedRoadSet] +Name=Snow Paved Road +AllowedTheaters=Temperate +Color=200,200,255 + +[SnowPavedRoadSet.0] +TileSet=~~~Paved Roads +TileIndices=0 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint1=0,0 +ConnectionPoint1.Directions=00100000 ;Bottom-Right + +[SnowPavedRoadSet.1] +TileSet=~~~Paved Roads +TileIndices=1 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=10000000 ;Top-Right +ConnectionPoint1=0,0 +ConnectionPoint1.Directions=00001000 ;Bottom-Left + +[SnowPavedRoadSet.2] TileSet=~~~Paved Roads TileIndices=9 ConnectionPoint0=0,0 @@ -5508,6 +5796,54 @@ ConnectionPoint0.Directions=00001000 ;Bottom-Left ConnectionPoint1=1,0 ConnectionPoint1.Directions=00100000 ;Bottom-Right +[SnowPavedRoadSet.6] +TileSet=~~~Paved Roads +TileIndices=13 +ConnectionPoint0=2,0 +ConnectionPoint0.Directions=BottomRight + +[SnowPavedRoadSet.7] +TileSet=~~~Paved Roads +TileIndices=21 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=BottomRight + +[SnowPavedRoadSet.8] +TileSet=~~~Paved Roads +TileIndices=14 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopLeft + +[SnowPavedRoadSet.9] +TileSet=~~~Paved Roads +TileIndices=19 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopLeft + +[SnowPavedRoadSet.10] +TileSet=~~~Paved Roads +TileIndices=16 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopRight + +[SnowPavedRoadSet.11] +TileSet=~~~Paved Roads +TileIndices=22 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopRight + +[SnowPavedRoadSet.12] +TileSet=~~~Paved Roads +TileIndices=17 +ConnectionPoint0=0,2 +ConnectionPoint0.Directions=BottomLeft + +[SnowPavedRoadSet.13] +TileSet=~~~Paved Roads +TileIndices=20 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=BottomLeft + [TemperateRiverSet] Name=Temperate Rivers @@ -6167,6 +6503,13 @@ Color=255,180,0 [DesertCliffSet.0] TileSet=Cliffs +TileIndices=0 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00100000 ;Bottom-Right +ConnectionPoint0.Side=Front + +[DesertCliffSet.1] +TileSet=Cliffs TileIndices=1,2,3 ConnectionPoint0=1,1 ConnectionPoint0.Directions=00000010 ;Top-Left @@ -6176,7 +6519,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=0,0|1,0|1,1|2,1|2,2|3,2 -[DesertCliffSet.1] +[DesertCliffSet.2] TileSet=Cliffs TileIndices=4,5,6 ConnectionPoint0=1,2 @@ -6187,7 +6530,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Front Foundation=0,0|0,1|1,1|1,2|2,2 -[DesertCliffSet.2] +[DesertCliffSet.3] TileSet=Cliffs TileIndices=7,8,9 ConnectionPoint0=1,1 @@ -6198,9 +6541,23 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=0,0|1,0|1,1|2,1|2,2 +[DesertCliffSet.4] +TileSet=Cliffs +TileIndices=10 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Side=Front + ; N-S front cliffs -[DesertCliffSet.3] +[DesertCliffSet.5] +TileSet=Cliffs +TileIndices=11 +ConnectionPoint0=2,2 +ConnectionPoint0.Directions=00010000 ;Bottom +ConnectionPoint0.Side=Front + +[DesertCliffSet.6] TileSet=Cliffs TileIndices=13,15,16 ConnectionPoint0=1,1 @@ -6211,7 +6568,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[DesertCliffSet.4] +[DesertCliffSet.7] TileSet=Cliffs TileIndices=12 ConnectionPoint0=1,1 @@ -6222,7 +6579,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=1,0|2,0|0,1|1,1|2,1|3,1|4,1|2,2|3,2|4,2|3,3 -[DesertCliffSet.5] +[DesertCliffSet.8] TileSet=Cliffs TileIndices=17 ConnectionPoint0=1,1 @@ -6233,9 +6590,23 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,1|1,0|1,1|2,1|3,1|2,1|2,2|2,3 +[DesertCliffSet.9] +TileSet=Cliffs +TileIndices=18 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00000001 ;Top +ConnectionPoint0.Side=Front + ; E-W back cliffs -[DesertCliffSet.6] +[DesertCliffSet.10] +TileSet=Cliffs +TileIndices=19 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=00100000 ;Bottom-Right +ConnectionPoint0.Side=Back + +[DesertCliffSet.11] TileSet=Cliffs TileIndices=20,21,22 ConnectionPoint0=1,1 @@ -6246,7 +6617,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|1,0|1,1|2,1 -[DesertCliffSet.7] +[DesertCliffSet.12] TileSet=Cliffs TileIndices=23 ConnectionPoint0=1,2 @@ -6259,7 +6630,7 @@ ConnectionPoint0.ForbiddenTiles=7 ; Force some variety ConnectionPoint1.ForbiddenTiles=7 Foundation=0,0|0,1|1,1|1,2|2,2 -[DesertCliffSet.8] +[DesertCliffSet.13] TileSet=Cliffs TileIndices=24 ConnectionPoint0=1,1 @@ -6272,7 +6643,7 @@ ConnectionPoint0.ForbiddenTiles=8 ; Force some variety ConnectionPoint1.ForbiddenTiles=8 Foundation=0,0|1,0|1,1 -[DesertCliffSet.9] +[DesertCliffSet.14] TileSet=Cliffs TileIndices=25 ConnectionPoint0=1,1 @@ -6285,7 +6656,7 @@ ConnectionPoint0.ForbiddenTiles=9 ; Force some variety ConnectionPoint1.ForbiddenTiles=9 Foundation=0,0|1,0|1,1|2,1 -[DesertCliffSet.10] +[DesertCliffSet.15] TileSet=Cliffs TileIndices=26,27,28 ConnectionPoint0=1,2 @@ -6296,9 +6667,23 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|0,1|1,1|1,2 +[DesertCliffSet.16] +TileSet=Cliffs +TileIndices=29 +ConnectionPoint0=1,2 +ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Side=Back + ; N-S back cliffs -[DesertCliffSet.11] +[DesertCliffSet.17] +TileSet=Cliffs +TileIndices=30 +ConnectionPoint0=2,2 +ConnectionPoint0.Directions=00010000 ;Bottom +ConnectionPoint0.Side=Back + +[DesertCliffSet.18] TileSet=Cliffs TileIndices=31 ConnectionPoint0=1,1 @@ -6309,7 +6694,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,1|1,0|1,1|2,1|3,1|2,1|2,2|2,3 -[DesertCliffSet.12] +[DesertCliffSet.19] TileSet=Cliffs TileIndices=32,34,35 ConnectionPoint0=1,1 @@ -6320,7 +6705,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[DesertCliffSet.13] +[DesertCliffSet.20] TileSet=Cliffs TileIndices=36 ConnectionPoint0=1,1 @@ -6331,9 +6716,16 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|2,0|0,1|1,1|2,1 +[DesertCliffSet.21] +TileSet=Cliffs +TileIndices=37 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00000001 ;Top +ConnectionPoint0.Side=Back + ; Turns -[DesertCliffSet.14] +[DesertCliffSet.22] TileSet=Cliffs TileIndices=38 ConnectionPoint0=1,1 @@ -6344,7 +6736,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[DesertCliffSet.15] +[DesertCliffSet.23] TileSet=Cliffs TileIndices=39 ConnectionPoint0=1,0 @@ -6355,7 +6747,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|1,0|0,1|1,1|0,2|1,2 -[DesertCliffSet.16] +[DesertCliffSet.24] TileSet=Cliffs TileIndices=40 ConnectionPoint0=1,2 @@ -6366,7 +6758,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,0|1,0|2,0|0,1|1,1|2,1|3,1|1,2|2,2|3,2 -[DesertCliffSet.17] +[DesertCliffSet.25] TileSet=Cliffs TileIndices=41 ConnectionPoint0=1,1 @@ -6377,7 +6769,7 @@ ConnectionPoint1.Directions=00000010 ;Top-Left ConnectionPoint1.Side=Front Foundation=1,0|2,0|0,1|1,1|2,1|1,2|2,2|2,3 -[DesertCliffSet.18] +[DesertCliffSet.26] TileSet=Cliffs TileIndices=42 ConnectionPoint0=2,1 @@ -6388,7 +6780,7 @@ ConnectionPoint1.Directions=00000001 ;Top ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2|2,3 -[DesertCliffSet.19] +[DesertCliffSet.27] TileSet=Cliffs TileIndices=43 ConnectionPoint0=2,1 @@ -6399,7 +6791,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2|2,3|3,3 -[DesertCliffSet.20] +[DesertCliffSet.28] TileSet=Cliffs TileIndices=44 ConnectionPoint0=1,2 @@ -6410,7 +6802,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|1,0|0,1|1,1|2,1|1,2|2,2 -[DesertCliffSet.21] +[DesertCliffSet.29] TileSet=Cliffs TileIndices=45 ConnectionPoint0=1,1 @@ -6421,7 +6813,7 @@ ConnectionPoint1.Directions=00000010 ;Top-Left ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|2,3 -[DesertCliffSet.22] +[DesertCliffSet.30] TileSet=Cliffs TileIndices=48 ConnectionPoint0=1,1 @@ -6432,7 +6824,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2 -[DesertCliffSet.23] +[DesertCliffSet.31] TileSet=Cliffs TileIndices=49 ConnectionPoint0=0,0 @@ -6443,7 +6835,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|0,1|0,2|1,1|1,2 -[DesertCliffSet.24] +[DesertCliffSet.32] TileSet=Cliffs TileIndices=50 ConnectionPoint0=1,1 @@ -6454,7 +6846,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,0|1,1|2,1|3,1|2,2|3,2 -[DesertCliffSet.25] +[DesertCliffSet.33] TileSet=Cliffs TileIndices=51 ConnectionPoint0=1,1 @@ -6475,6 +6867,13 @@ Color=200,200,0 [LightSandCliffSet.0] TileSet=~~~Cliffs +TileIndices=0 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00100000 ;Bottom-Right +ConnectionPoint0.Side=Front + +[LightSandCliffSet.1] +TileSet=~~~Cliffs TileIndices=1,2,3 ConnectionPoint0=1,1 ConnectionPoint0.Directions=00000010 ;Top-Left @@ -6484,7 +6883,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=0,0|1,0|1,1|2,1|2,2|3,2 -[LightSandCliffSet.1] +[LightSandCliffSet.2] TileSet=~~~Cliffs TileIndices=4,5,6 ConnectionPoint0=1,2 @@ -6495,7 +6894,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Front Foundation=0,0|0,1|1,1|1,2|2,2 -[LightSandCliffSet.2] +[LightSandCliffSet.3] TileSet=~~~Cliffs TileIndices=7,8,9 ConnectionPoint0=1,1 @@ -6506,9 +6905,23 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=0,0|1,0|1,1|2,1|2,2 +[LightSandCliffSet.4] +TileSet=~~~Cliffs +TileIndices=10 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Side=Front + ; N-S front cliffs -[LightSandCliffSet.3] +[LightSandCliffSet.5] +TileSet=~~~Cliffs +TileIndices=11 +ConnectionPoint0=2,2 +ConnectionPoint0.Directions=00010000 ;Bottom +ConnectionPoint0.Side=Front + +[LightSandCliffSet.6] TileSet=~~~Cliffs TileIndices=13,15,16 ConnectionPoint0=1,1 @@ -6519,7 +6932,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[LightSandCliffSet.4] +[LightSandCliffSet.7] TileSet=~~~Cliffs TileIndices=12 ConnectionPoint0=1,1 @@ -6530,7 +6943,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=1,0|2,0|0,1|1,1|2,1|3,1|4,1|2,2|3,2|4,2|3,3 -[LightSandCliffSet.5] +[LightSandCliffSet.8] TileSet=~~~Cliffs TileIndices=17 ConnectionPoint0=1,1 @@ -6541,9 +6954,23 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,1|1,0|1,1|2,1|3,1|2,1|2,2|2,3 +[LightSandCliffSet.9] +TileSet=~~~Cliffs +TileIndices=18 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00000001 ;Top +ConnectionPoint0.Side=Front + ; E-W back cliffs -[LightSandCliffSet.6] +[LightSandCliffSet.10] +TileSet=~~~Cliffs +TileIndices=19 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=00100000 ;Bottom-Right +ConnectionPoint0.Side=Back + +[LightSandCliffSet.11] TileSet=~~~Cliffs TileIndices=20,21,22 ConnectionPoint0=1,1 @@ -6554,7 +6981,7 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|1,0|1,1|2,1 -[LightSandCliffSet.7] +[LightSandCliffSet.12] TileSet=~~~Cliffs TileIndices=23 ConnectionPoint0=1,2 @@ -6563,11 +6990,11 @@ ConnectionPoint0.Side=Back ConnectionPoint1=1,1 ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back -ConnectionPoint0.ForbiddenTiles=23 ; Force some variety -ConnectionPoint1.ForbiddenTiles=23 +ConnectionPoint0.ForbiddenTiles=7 ; Force some variety +ConnectionPoint1.ForbiddenTiles=7 Foundation=0,0|0,1|1,1|1,2|2,2 -[LightSandCliffSet.8] +[LightSandCliffSet.13] TileSet=~~~Cliffs TileIndices=24 ConnectionPoint0=1,1 @@ -6576,11 +7003,11 @@ ConnectionPoint0.Side=Back ConnectionPoint1=1,0 ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back -ConnectionPoint0.ForbiddenTiles=24 ; Force some variety -ConnectionPoint1.ForbiddenTiles=24 +ConnectionPoint0.ForbiddenTiles=8 ; Force some variety +ConnectionPoint1.ForbiddenTiles=8 Foundation=0,0|1,0|1,1 -[LightSandCliffSet.9] +[LightSandCliffSet.14] TileSet=~~~Cliffs TileIndices=25 ConnectionPoint0=1,1 @@ -6589,11 +7016,11 @@ ConnectionPoint0.Side=Back ConnectionPoint1=1,0 ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back -ConnectionPoint0.ForbiddenTiles=25 ; Force some variety -ConnectionPoint1.ForbiddenTiles=25 +ConnectionPoint0.ForbiddenTiles=9 ; Force some variety +ConnectionPoint1.ForbiddenTiles=9 Foundation=0,0|1,0|1,1|2,1 -[LightSandCliffSet.10] +[LightSandCliffSet.15] TileSet=~~~Cliffs TileIndices=26,27,28 ConnectionPoint0=1,2 @@ -6604,9 +7031,23 @@ ConnectionPoint1.Directions=00100000 ;Bottom-Right ConnectionPoint1.Side=Back Foundation=0,0|0,1|1,1|1,2 +[LightSandCliffSet.16] +TileSet=~~~Cliffs +TileIndices=29 +ConnectionPoint0=1,2 +ConnectionPoint0.Directions=00000010 ;Top-Left +ConnectionPoint0.Side=Back + ; N-S back cliffs -[LightSandCliffSet.11] +[LightSandCliffSet.17] +TileSet=~~~Cliffs +TileIndices=30 +ConnectionPoint0=2,2 +ConnectionPoint0.Directions=00010000 ;Bottom +ConnectionPoint0.Side=Back + +[LightSandCliffSet.18] TileSet=~~~Cliffs TileIndices=31 ConnectionPoint0=1,1 @@ -6617,7 +7058,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,1|1,0|1,1|2,1|3,1|2,1|2,2|2,3 -[LightSandCliffSet.12] +[LightSandCliffSet.19] TileSet=~~~Cliffs TileIndices=32,34,35 ConnectionPoint0=1,1 @@ -6628,7 +7069,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[LightSandCliffSet.13] +[LightSandCliffSet.20] TileSet=~~~Cliffs TileIndices=36 ConnectionPoint0=1,1 @@ -6639,9 +7080,16 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|2,0|0,1|1,1|2,1 +[LightSandCliffSet.21] +TileSet=~~~Cliffs +TileIndices=37 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=00000001 ;Top +ConnectionPoint0.Side=Back + ; Turns -[LightSandCliffSet.14] +[LightSandCliffSet.22] TileSet=~~~Cliffs TileIndices=38 ConnectionPoint0=1,1 @@ -6652,7 +7100,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2 -[LightSandCliffSet.15] +[LightSandCliffSet.23] TileSet=~~~Cliffs TileIndices=39 ConnectionPoint0=1,0 @@ -6663,7 +7111,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|1,0|0,1|1,1|0,2|1,2 -[LightSandCliffSet.16] +[LightSandCliffSet.24] TileSet=~~~Cliffs TileIndices=40 ConnectionPoint0=1,2 @@ -6674,7 +7122,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,0|1,0|2,0|0,1|1,1|2,1|3,1|1,2|2,2|3,2 -[LightSandCliffSet.17] +[LightSandCliffSet.25] TileSet=~~~Cliffs TileIndices=41 ConnectionPoint0=1,1 @@ -6685,7 +7133,7 @@ ConnectionPoint1.Directions=00000010 ;Top-Left ConnectionPoint1.Side=Front Foundation=1,0|2,0|0,1|1,1|2,1|1,2|2,2|2,3 -[LightSandCliffSet.18] +[LightSandCliffSet.26] TileSet=~~~Cliffs TileIndices=42 ConnectionPoint0=2,1 @@ -6696,7 +7144,7 @@ ConnectionPoint1.Directions=00000001 ;Top ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2|2,3 -[LightSandCliffSet.19] +[LightSandCliffSet.27] TileSet=~~~Cliffs TileIndices=43 ConnectionPoint0=2,1 @@ -6707,7 +7155,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2|2,3|3,3 -[LightSandCliffSet.20] +[LightSandCliffSet.28] TileSet=~~~Cliffs TileIndices=44 ConnectionPoint0=1,2 @@ -6718,7 +7166,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|1,0|0,1|1,1|2,1|1,2|2,2 -[LightSandCliffSet.21] +[LightSandCliffSet.29] TileSet=~~~Cliffs TileIndices=45 ConnectionPoint0=1,1 @@ -6729,7 +7177,7 @@ ConnectionPoint1.Directions=00000010 ;Top-Left ConnectionPoint1.Side=Back Foundation=1,0|0,1|1,1|2,1|1,2|2,2|2,3 -[LightSandCliffSet.22] +[LightSandCliffSet.30] TileSet=~~~Cliffs TileIndices=48 ConnectionPoint0=1,1 @@ -6740,7 +7188,7 @@ ConnectionPoint1.Directions=10100000 ;🡭 + 🡮 ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2|3,2 -[LightSandCliffSet.23] +[LightSandCliffSet.31] TileSet=~~~Cliffs TileIndices=49 ConnectionPoint0=0,0 @@ -6751,7 +7199,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Front Foundation=0,0|0,1|0,2|1,1|1,2 -[LightSandCliffSet.24] +[LightSandCliffSet.32] TileSet=~~~Cliffs TileIndices=50 ConnectionPoint0=1,1 @@ -6762,7 +7210,7 @@ ConnectionPoint1.Directions=00010000 ;Bottom ConnectionPoint1.Side=Back Foundation=0,0|1,1|2,1|3,1|2,2|3,2 -[LightSandCliffSet.25] +[LightSandCliffSet.33] TileSet=~~~Cliffs TileIndices=51 ConnectionPoint0=1,1 @@ -6773,6 +7221,7 @@ ConnectionPoint1.Directions=00001000 ;Bottom-Left ConnectionPoint1.Side=Front Foundation=1,0|0,1|1,1|2,1|1,2|2,2|2,3|3,3|3,4 + [DesertWaterCliffSet] Name=Desert Water Cliffs AllowedTheaters=Desert @@ -8956,6 +9405,54 @@ ConnectionPoint1.Directions=01000000 ;Right ConnectionPoint1.ForbiddenTiles=26 ExtraPriority=99999 +[DesertDirtRoadSet.27] +TileSet=Straight Dirt Roads +TileIndices=13 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=TopRight + +[DesertDirtRoadSet.28] +TileSet=Straight Dirt Roads +TileIndices=14 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=BottomRight + +[DesertDirtRoadSet.29] +TileSet=Straight Dirt Roads +TileIndices=15 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=BottomLeft + +[DesertDirtRoadSet.30] +TileSet=Straight Dirt Roads +TileIndices=16 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=TopLeft + +[DesertDirtRoadSet.31] +TileSet=Straight Dirt Roads +TileIndices=17 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Top + +[DesertDirtRoadSet.32] +TileSet=Straight Dirt Roads +TileIndices=18 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=Right + +[DesertDirtRoadSet.33] +TileSet=Straight Dirt Roads +TileIndices=19 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Bottom + +[DesertDirtRoadSet.34] +TileSet=Straight Dirt Roads +TileIndices=20 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Left + [DesertPavedRoadASet] Name=Desert Paved Road A @@ -9011,6 +9508,54 @@ ConnectionPoint0.Directions=00001000 ;Bottom-Left ConnectionPoint1=1,0 ConnectionPoint1.Directions=00100000 ;Bottom-Right +[DesertPavedRoadASet.6] +TileSet=Paved Roads A +TileIndices=13 +ConnectionPoint0=2,0 +ConnectionPoint0.Directions=BottomRight + +[DesertPavedRoadASet.7] +TileSet=Paved Roads A +TileIndices=21 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=BottomRight + +[DesertPavedRoadASet.8] +TileSet=Paved Roads A +TileIndices=14 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopLeft + +[DesertPavedRoadASet.9] +TileSet=Paved Roads A +TileIndices=19 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopLeft + +[DesertPavedRoadASet.10] +TileSet=Paved Roads A +TileIndices=16 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopRight + +[DesertPavedRoadASet.11] +TileSet=Paved Roads A +TileIndices=22 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopRight + +[DesertPavedRoadASet.12] +TileSet=Paved Roads A +TileIndices=17 +ConnectionPoint0=0,2 +ConnectionPoint0.Directions=BottomLeft + +[DesertPavedRoadASet.13] +TileSet=Paved Roads A +TileIndices=20 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=BottomLeft + [DesertPavedRoadBSet] Name=Desert Paved Road B @@ -9066,6 +9611,54 @@ ConnectionPoint0.Directions=00001000 ;Bottom-Left ConnectionPoint1=1,0 ConnectionPoint1.Directions=00100000 ;Bottom-Right +[DesertPavedRoadBSet.6] +TileSet=Paved Roads B +TileIndices=13 +ConnectionPoint0=2,0 +ConnectionPoint0.Directions=BottomRight + +[DesertPavedRoadBSet.7] +TileSet=Paved Roads B +TileIndices=21 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=BottomRight + +[DesertPavedRoadBSet.8] +TileSet=Paved Roads B +TileIndices=14 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopLeft + +[DesertPavedRoadBSet.9] +TileSet=Paved Roads B +TileIndices=19 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopLeft + +[DesertPavedRoadBSet.10] +TileSet=Paved Roads B +TileIndices=16 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopRight + +[DesertPavedRoadBSet.11] +TileSet=Paved Roads B +TileIndices=22 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopRight + +[DesertPavedRoadBSet.12] +TileSet=Paved Roads B +TileIndices=17 +ConnectionPoint0=0,2 +ConnectionPoint0.Directions=BottomLeft + +[DesertPavedRoadBSet.13] +TileSet=Paved Roads B +TileIndices=20 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=BottomLeft + [LightSandDirtRoadSet] Name=Light Sand Dirt Roads @@ -9330,6 +9923,54 @@ ConnectionPoint1.Directions=01000000 ;Right ConnectionPoint1.ForbiddenTiles=26 ExtraPriority=99999 +[LightSandDirtRoadSet.27] +TileSet=~~~Straight Dirt Roads +TileIndices=13 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=TopRight + +[LightSandDirtRoadSet.28] +TileSet=~~~Straight Dirt Roads +TileIndices=14 +ConnectionPoint0=1,0 +ConnectionPoint0.Directions=BottomRight + +[LightSandDirtRoadSet.29] +TileSet=~~~Straight Dirt Roads +TileIndices=15 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=BottomLeft + +[LightSandDirtRoadSet.30] +TileSet=~~~Straight Dirt Roads +TileIndices=16 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=TopLeft + +[LightSandDirtRoadSet.31] +TileSet=~~~Straight Dirt Roads +TileIndices=17 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Top + +[LightSandDirtRoadSet.32] +TileSet=~~~Straight Dirt Roads +TileIndices=18 +ConnectionPoint0=0,1 +ConnectionPoint0.Directions=Right + +[LightSandDirtRoadSet.33] +TileSet=~~~Straight Dirt Roads +TileIndices=19 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Bottom + +[LightSandDirtRoadSet.34] +TileSet=~~~Straight Dirt Roads +TileIndices=20 +ConnectionPoint0=1,1 +ConnectionPoint0.Directions=Left + [LightSandPavedRoadASet] Name=Light Sand Paved Road A @@ -9385,6 +10026,54 @@ ConnectionPoint0.Directions=00001000 ;Bottom-Left ConnectionPoint1=1,0 ConnectionPoint1.Directions=00100000 ;Bottom-Right +[LightSandPavedRoadASet.6] +TileSet=~~~Paved Roads A +TileIndices=13 +ConnectionPoint0=2,0 +ConnectionPoint0.Directions=BottomRight + +[LightSandPavedRoadASet.7] +TileSet=~~~Paved Roads A +TileIndices=21 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=BottomRight + +[LightSandPavedRoadASet.8] +TileSet=~~~Paved Roads A +TileIndices=14 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopLeft + +[LightSandPavedRoadASet.9] +TileSet=~~~Paved Roads A +TileIndices=19 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopLeft + +[LightSandPavedRoadASet.10] +TileSet=~~~Paved Roads A +TileIndices=16 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopRight + +[LightSandPavedRoadASet.11] +TileSet=~~~Paved Roads A +TileIndices=22 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopRight + +[LightSandPavedRoadASet.12] +TileSet=~~~Paved Roads A +TileIndices=17 +ConnectionPoint0=0,2 +ConnectionPoint0.Directions=BottomLeft + +[LightSandPavedRoadASet.13] +TileSet=~~~Paved Roads A +TileIndices=20 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=BottomLeft + [LightSandPavedRoadBSet] Name=Light Sand Paved Road B @@ -9440,6 +10129,54 @@ ConnectionPoint0.Directions=00001000 ;Bottom-Left ConnectionPoint1=1,0 ConnectionPoint1.Directions=00100000 ;Bottom-Right +[LightSandPavedRoadBSet.6] +TileSet=~~~Paved Roads B +TileIndices=13 +ConnectionPoint0=2,0 +ConnectionPoint0.Directions=BottomRight + +[LightSandPavedRoadBSet.7] +TileSet=~~~Paved Roads B +TileIndices=21 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=BottomRight + +[LightSandPavedRoadBSet.8] +TileSet=~~~Paved Roads B +TileIndices=14 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopLeft + +[LightSandPavedRoadBSet.9] +TileSet=~~~Paved Roads B +TileIndices=19 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopLeft + +[LightSandPavedRoadBSet.10] +TileSet=~~~Paved Roads B +TileIndices=16 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopRight + +[LightSandPavedRoadBSet.11] +TileSet=~~~Paved Roads B +TileIndices=22 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=TopRight + +[LightSandPavedRoadBSet.12] +TileSet=~~~Paved Roads B +TileIndices=17 +ConnectionPoint0=0,2 +ConnectionPoint0.Directions=BottomLeft + +[LightSandPavedRoadBSet.13] +TileSet=~~~Paved Roads B +TileIndices=20 +ConnectionPoint0=0,0 +ConnectionPoint0.Directions=BottomLeft + [DesertRiverSet] Name=Desert Rivers diff --git a/src/TSMapEditor/Constants.cs b/src/TSMapEditor/Constants.cs index d042bca70..ed3ede8db 100644 --- a/src/TSMapEditor/Constants.cs +++ b/src/TSMapEditor/Constants.cs @@ -4,7 +4,7 @@ namespace TSMapEditor { public static class Constants { - public const string ReleaseVersion = "1.9.0"; + public const string ReleaseVersion = "1.8.999"; public static int CellSizeX = 48; public static int CellSizeY = 24; From f22310c06f5bebbbc96716a042a7eefcf0bb9f76 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Mon, 3 Aug 2026 00:32:41 +0300 Subject: [PATCH 17/27] Fix issue where switching front/back when drawing connected tiles made no effective difference --- src/TSMapEditor/Models/ConnectedTilePlanner.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/TSMapEditor/Models/ConnectedTilePlanner.cs b/src/TSMapEditor/Models/ConnectedTilePlanner.cs index e6089535c..7e636f55a 100644 --- a/src/TSMapEditor/Models/ConnectedTilePlanner.cs +++ b/src/TSMapEditor/Models/ConnectedTilePlanner.cs @@ -95,6 +95,7 @@ public static class ConnectedTilePlanner private const int MaxQueuedNodesPerStrictSegment = 100_000; private const int MaxPlacementCount = 2_048; private const int MaxBoundaryStates = 24; + private const int MaxInitialBoundaryStates = 8; private const int MaxStackAllocatedPathVertexCount = 256; private const float TileSizePenaltyPerFoundationCell = 0.07f; @@ -371,7 +372,16 @@ public static ConnectedTilePlanResult Plan(ConnectedTileType type, IReadOnlyList ? SegmentGoal.EndingPiece : SegmentGoal.Point; - int exactResultLimit = strict && !isLastSegment ? MaxBoundaryStates : 1; + // A broad initial frontier can include paths that reach the first vertex by circling + // most of a closed outline, effectively erasing the requested starting side. + int exactResultLimit; + if (!strict || isLastSegment) + exactResultLimit = 1; + else if (isFirstSegment) + exactResultLimit = MaxInitialBoundaryStates; + else + exactResultLimit = MaxBoundaryStates; + SegmentSearchResult searchResult = SearchSegment( openSet, frontier, From f187b2695c35e91732168fd63bbc7d3f0820a2fa Mon Sep 17 00:00:00 2001 From: Rampastring Date: Mon, 3 Aug 2026 02:02:19 +0300 Subject: [PATCH 18/27] Add initial AI mapping instructions file --- src/TSMapEditor/AI/MapTools.cs | 42 ++++++++++ .../Config/Default/AIMappingInstructions.md | 79 +++++++++++++++++++ src/TSMapEditor/TSMapEditor.csproj | 3 + 3 files changed, 124 insertions(+) create mode 100644 src/TSMapEditor/Config/Default/AIMappingInstructions.md diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index 4bcbe8e87..95aba5c56 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -3,8 +3,10 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Rampastring.Tools; +using System; using System.Collections.Generic; using System.ComponentModel; +using System.IO; using System.Threading; using System.Threading.Tasks; using TSMapEditor.Rendering; @@ -17,6 +19,7 @@ public sealed class MapTools private const int MaxRegionDimension = 256; private const int MaxRegionCellCount = 10_000; private const int MaxScreenshotPixelCount = 8_000_000; + private const string MappingInstructionsFileName = "AIMappingInstructions.md"; public MapTools(MapFacade mapFacade, GameThreadDispatcher gameThreadDispatcher, IMapScreenCropper mapScreenCropper) { @@ -29,6 +32,45 @@ public MapTools(MapFacade mapFacade, GameThreadDispatcher gameThreadDispatcher, private readonly GameThreadDispatcher gameThreadDispatcher; private readonly IMapScreenCropper mapScreenCropper; + [McpServerTool(Name = "get_mapping_instructions", ReadOnly = true, OpenWorld = false)] + [Description("Returns the active mod's AI mapping instructions as Markdown.")] + public async Task GetMappingInstructions(CancellationToken cancellationToken) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetMappingInstructions)}"); + + string customPath = Path.Combine(Environment.CurrentDirectory, "Config", MappingInstructionsFileName); + string defaultPath = Path.Combine(Environment.CurrentDirectory, "Config", "Default", MappingInstructionsFileName); + string instructionsPath; + + if (File.Exists(customPath)) + instructionsPath = customPath; + else if (File.Exists(defaultPath)) + instructionsPath = defaultPath; + else + throw new McpException("No mapping instruction file exists for the active mod."); + + try + { + return await File.ReadAllTextAsync(instructionsPath, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (FileNotFoundException) + { + throw new McpException("No mapping instruction file exists for the active mod."); + } + catch (DirectoryNotFoundException) + { + throw new McpException("No mapping instruction file exists for the active mod."); + } + catch (Exception ex) + { + throw new McpException($"Failed to read the mapping instruction file for the active mod: {ex.Message}"); + } + } + [McpServerTool(Name = "get_map_info", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] [Description("Returns basic information about the map currently open in the World-Altering Editor.")] public Task GetMapInfo(CancellationToken cancellationToken) diff --git a/src/TSMapEditor/Config/Default/AIMappingInstructions.md b/src/TSMapEditor/Config/Default/AIMappingInstructions.md new file mode 100644 index 000000000..a4d73ec69 --- /dev/null +++ b/src/TSMapEditor/Config/Default/AIMappingInstructions.md @@ -0,0 +1,79 @@ +# Mapping Instructions + +Mod: Dawn of the Tiberium Age + +## Isometric perspective + +The visual perspective of a Tiberian Sun / Red Alert 2 game world is rotated to achieve the isometric perspective. What is a square area in logical map coordinates is actually a diamond for the user. Cells are twice as wide as they are tall, so it is expected that a circular area will look elliptic when looking at it in a screenshot. + +## LAT Terrain Placement + +LAT is a system that smoothly connects basic ground terrain to other terrain. + +Prefer "snake-like" LAT detail placement over large plots of the same LAT. Grass or dirt spots in nature tend to exist in a somewhat "chaotic", imperfect manner, and not as a simple circular or rectangular area. + +For an example, let's denote clear space with a dash (-), and a LAT-terrain cell with X, each character representing one cell. For a LAT, the following often looks poor and artificial to the human eye looking at a map: + +``` +------ +-XXXX- +-XXXX- +-XXXX- +-XXXX- +------ +``` + +The following usually looks better and more natural: + +``` +--XXX- +--X-X- +-XX-X- +--XXX- +-X-X-- +--XX-- +``` + +## Detailing Areas + +Aside from LATs, try to also use various other pieces when detailing large areas. Rocks, pebbles, trees, rough ground, debris, villages or cities, small closed lakes... there's usually a lot you can detail a map with. Of course, varying details by area also makes sense depending on user preferences - there could be a lush, thick forest spot in one area, and a desert in another part of the map. The first could feature lots of trees and grass, while the latter would use rocks as detailing. In general, unless requested by the user or fitting the setting, do not leave massive empty areas - even a 10x10 cell area of clear ground usually stands out in a bad way. + +## Layouting + +The Tiberian Sun and Red Alert 2 game engines and gameplay design don't work well with very tight bottlenecks. When designing layouts, ensure that each bottleneck has, at a minimum, a 3-cell row of passable ground at its tightest spot. More is generally preferred, though. Much past 10 cells it starts getting questionable whether something functions as a bottleneck anymore however. + +Layouts are often planned with cliffs and shorelines. You can place these by invoking the Connected Tiles tool. Often other kinds of more complicated elements, like thick forests and cities, can also be used as "soft" layout elements because they obstruct movement of large armies. + +## Connected Tile Facings + +When placing connected tiles, consider their facing. For example, if you are creating a hill surrounded by cliffs, you need to consider whether to place front or back facing cliffs to give the illusion of the cliff being higher than the surrounding terrain. You can always ask the user, or use the MCP server's screen-cropping endpoint for visual verification. + +## Placement Order + +Prefer to design a layout first, then details. When detailing, place objects like buildings and trees first, then terrain. This is because if you are, for example, creating a city, it is easier to place dirt or pavement LAT under buildings and grass LAT under trees after they have been placed down, than it is to first place dirt/grass and then fit objects on top of them. + +## Asymmetry + +While an RTS game, classic Command & Conquer maps, especially Tiberian Sun maps, were usually asymmetric. Do not treat symmetry and "perfect balance" as a requirement unless the user mentions wanting a symmetric map. Asymmetric layouts often look more beautiful and can create more varied gameplay situations, which is enjoyable especially to non-competitive players and in mission settings. + +## Resource Placement + +There are two types of resource fields in Command & Conquer games: regrowing and non-regrowing. + +In Dawn of the Tiberium Age, regrowing fields contain a Ore Mine, Tiberium Tree (for Green Tiberium aka Riparius), or Vinifera Tree (for Blue Tiberium aka Vinifera), and a matching resource spreader on the same cell with the tree. Around the tree is resource overlay of the matching type depending on map design. Small fields are around 8 cells in diameter, while large fields can be double that. + +Never place overlay on the same cell where Tiberium Trees or Ore Mines exist. + +A good baseline for economy is 2 Ore Mines or Tiberium Trees per player. Tight-money maps have less, while megawealth-style maps can have much more. Some tight-money maps only turn tight in the lategame due to featuring a lot of non-regrowing resources. + +For a non-regrowing resource field, simply leave out the Tiberium Tree and respective resource spreader. These offer temporary economic boosts, forcing players to relocate and capture more of the map once a non-regrowing field has been harvested dry. + +There are 5 types of resources. Ore, Scrap Metal, and Green Tiberium are all equal in value, 700 for a full harvester load. Blue Tiberium is 1120, while Gems are 1680. + +## Player Starting Waypoints + +When making multiplayer maps, waypoints 0 to 7 denote player starting locations. If the map has less than 8 players, waypoints are simply left out: a 4-player map has waypoints 0, 1, 2 and 3. + +Additional waypoints, with IDs greater than 7, can be used for various map triggers, like scripted unit spawns or ambient sounds. + +In singleplayer missions, no waypoints have special meaning, aside from 99 which is typically the "home cell". Do not use waypoint 100 for anything. \ No newline at end of file diff --git a/src/TSMapEditor/TSMapEditor.csproj b/src/TSMapEditor/TSMapEditor.csproj index 636ce0f5c..ec2eee7f7 100644 --- a/src/TSMapEditor/TSMapEditor.csproj +++ b/src/TSMapEditor/TSMapEditor.csproj @@ -47,6 +47,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest From b4e9615bbac7df7dc986d0c84b531d50a7d71176 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Mon, 3 Aug 2026 02:49:57 +0300 Subject: [PATCH 19/27] Update mapping instructions, fix bug where connected tiles could be given extra height despite the world being flat --- src/TSMapEditor/AI/MapFacade.cs | 9 +++++++-- src/TSMapEditor/AI/MapTools.cs | 2 +- .../Config/Default/AIMappingInstructions.md | 12 +++++++++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index 0bf7f8b7c..075540d1e 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -210,16 +210,18 @@ public static MapTechnoInfo FromTechno(Map map, TechnoBase techno) public class MapInfo { - public MapInfo(string theaterName, int width, int height) + public MapInfo(string theaterName, int width, int height, bool isFlatWorld) { TheaterName = theaterName; Width = width; Height = height; + IsFlatWorld = isFlatWorld; } public string TheaterName { get; } public int Width { get; } public int Height { get; } + public bool IsFlatWorld { get; } } public class MapObjectTypeInfo @@ -337,7 +339,7 @@ public MapFacade(Map map, MutationManager mutationManager, IMutationTarget mutat public MapInfo GetMapInfo() { - return new MapInfo(map.LoadedTheaterName, map.Size.X, map.Size.Y); + return new MapInfo(map.LoadedTheaterName, map.Size.X, map.Size.Y, Constants.IsFlatWorld); } public int GetMapRevision() @@ -820,6 +822,9 @@ public MapEditResult DrawConnectedTiles(string connectedTileTypeName, List DrawConnectedTiles( [Description("Ordered polyline vertices for the connected terrain path. Each consecutive pair defines one segment. Open paths require at least two vertices; closed paths require at least three distinct vertices. At most 256 vertices are supported.")] List path, [Description("Starting side of the connected terrain: Front or Back. Front-only types require Front.")] string side = "Front", [Description("Seed used to select and score tile variants. Change it to request a different pattern while keeping the same path. Defaults to 0.")] int randomSeed = 0, - [Description("Non-negative height offset added to the first vertex's current level before the connected tiles' own height offsets are applied. Defaults to 0.")] int extraHeight = 0, + [Description("Non-negative height offset added to the first vertex's current level before the connected tiles' own height offsets are applied. Defaults to 0. Must be 0 if the active mod has flat maps.")] int extraHeight = 0, [Description("Whether to cap both ends of an open path with configured one-connection-point ending pieces. The selected type must support ending pieces. Ignored for closed paths. Defaults to false.")] bool useEndPieces = false, [Description("Whether to connect the last vertex back to the first. Closed paths require at least three distinct vertices and never use ending pieces. Defaults to false.")] bool closed = false, CancellationToken cancellationToken = default) diff --git a/src/TSMapEditor/Config/Default/AIMappingInstructions.md b/src/TSMapEditor/Config/Default/AIMappingInstructions.md index a4d73ec69..bb4eb502a 100644 --- a/src/TSMapEditor/Config/Default/AIMappingInstructions.md +++ b/src/TSMapEditor/Config/Default/AIMappingInstructions.md @@ -4,7 +4,17 @@ Mod: Dawn of the Tiberium Age ## Isometric perspective -The visual perspective of a Tiberian Sun / Red Alert 2 game world is rotated to achieve the isometric perspective. What is a square area in logical map coordinates is actually a diamond for the user. Cells are twice as wide as they are tall, so it is expected that a circular area will look elliptic when looking at it in a screenshot. +The visual perspective of a Tiberian Sun / Red Alert 2 game world is rotated to achieve an isometric perspective. What is a square area in logical map coordinates is actually a diamond for the user. Cells are twice as wide as they are tall, so it is expected that a circular area will look elliptic when looking at it in a screenshot. + +## Map Shape and Valid Coordinates + +Despite the isometric perspective, a TS/RA2 map appears rectangular in-game. In logical map coordinates, however, its valid cells form a diamond. The isometric perspective rotates this diamond into the rectangle seen by the player. + +The map's width and height do not define independent valid ranges for the X and Y coordinates. Do not assume that a 100×100 map uses coordinates from (0, 0) through (99, 99). Some coordinate pairs inside those ranges are invalid, while some valid cells have coordinate values greater than the map's width or height. + +Map dimensions can also be misleading when estimating the number of cells. Each unit of map height contains two logical rows to produce the isometric layout. A map with dimensions `width × height` therefore contains `2 × width × height` cells. For example, a 100×100 map contains 20,000 cells rather than 10,000. + +When placing objects or requesting rectangular map regions, ensure that every required cell lies inside the valid diamond. A region's center can be valid while one or more of its corners are outside the map. ## LAT Terrain Placement From 9c63943cfa71be4a95cdb9dd8a23c9dfa3da95f3 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Mon, 3 Aug 2026 18:47:56 +0300 Subject: [PATCH 20/27] Allow MCP to render screenshots even if the requested area is partially outside of the map --- src/TSMapEditor/AI/MapTools.cs | 2 +- src/TSMapEditor/Rendering/MapView.cs | 93 +++++++++++++++++----------- 2 files changed, 59 insertions(+), 36 deletions(-) diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index 9ff358091..2e9c409b3 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -268,7 +268,7 @@ public Task> InspectMapRegion( } [McpServerTool(Name = "screenshot_map_region", ReadOnly = true, OpenWorld = false)] - [Description("Renders the entire open map and returns a PNG screenshot of the axis-aligned pixel bounds for a rectangular region of cells. In normal 3D mode, the image includes fixed vertical padding above those bounds for terrain at the maximum supported height. Because the map is isometric, the requested cells form a diamond within the returned rectangular image, whose corners can contain map content outside the requested cells.")] + [Description("Renders the entire open map and returns a PNG screenshot of the axis-aligned pixel bounds for a rectangular region of cells. In normal 3D mode, the image includes fixed vertical padding above those bounds for terrain at the maximum supported height. Because the map is isometric, the requested cells form a diamond within the returned rectangular image, whose corners can contain map content outside the requested cells. Regions may partially cross the map boundary, with pixels outside the map's render bounds left transparent, but must overlap the map.")] public async Task ScreenshotMapRegion( [Description("X coordinate of the region's top-left cell.")] int x, [Description("Y coordinate of the region's top-left cell.")] int y, diff --git a/src/TSMapEditor/Rendering/MapView.cs b/src/TSMapEditor/Rendering/MapView.cs index 4128990f8..21e382053 100644 --- a/src/TSMapEditor/Rendering/MapView.cs +++ b/src/TSMapEditor/Rendering/MapView.cs @@ -73,7 +73,7 @@ public ScreenCropRequest( private readonly CancellationTokenRegistration cancellationTokenRegistration; public Rectangle CellRectangle { get; } - public Rectangle CalculatedPixelRectangle { get; set; } + public ScreenCropLayout CalculatedPixelLayout { get; set; } public CancellationToken CancellationToken { get; } public bool IsProcessing { get; set; } public Task Task => completionSource.Task; @@ -84,6 +84,22 @@ public ScreenCropRequest( public void Dispose() => cancellationTokenRegistration.Dispose(); } + private readonly struct ScreenCropLayout + { + public ScreenCropLayout(int outputWidth, int outputHeight, Rectangle sourceRectangle, Rectangle destinationRectangle) + { + OutputWidth = outputWidth; + OutputHeight = outputHeight; + SourceRectangle = sourceRectangle; + DestinationRectangle = destinationRectangle; + } + + public int OutputWidth { get; } + public int OutputHeight { get; } + public Rectangle SourceRectangle { get; } + public Rectangle DestinationRectangle { get; } + } + struct WaypointDrawStruct { public Waypoint Waypoint; @@ -253,8 +269,9 @@ public bool TryRequestScreenCrop(Rectangle cellRectangle, CancellationToken canc return false; } + ScreenCropLayout pixelLayout = GetScreenCropLayout(cellRectangle); var request = new ScreenCropRequest(cellRectangle, cancellationToken, ScreenCropRequest_Canceled); - request.CalculatedPixelRectangle = GetScreenCropSourceRectangle(cellRectangle); + request.CalculatedPixelLayout = pixelLayout; screenCropRequest = request; screenCropTask = request.Task; @@ -328,53 +345,63 @@ private ScreenCropRequest TryBeginScreenCropRequest() return null; } - private Rectangle GetScreenCropSourceRectangle(Rectangle cellRectangle) + private ScreenCropLayout GetScreenCropLayout(Rectangle cellRectangle) { if (compositeRenderTarget == null) throw new MapScreenCropException("The map renderer is not available."); - long rightCellX = (long)cellRectangle.X + cellRectangle.Width - 1L; - long bottomCellY = (long)cellRectangle.Y + cellRectangle.Height - 1L; - long halfCellWidth = Constants.CellSizeX / 2L; - long halfCellHeight = Constants.CellSizeY / 2L; + int rightCellX = cellRectangle.X + cellRectangle.Width - 1; + int bottomCellY = cellRectangle.Y + cellRectangle.Height - 1; + int halfCellWidth = Constants.CellSizeX / 2; + int halfCellHeight = Constants.CellSizeY / 2; - long left = ((long)cellRectangle.X - 1L) * halfCellWidth + ((long)Map.Size.X - bottomCellY) * halfCellWidth; - long top = ((long)cellRectangle.X - 1L) * halfCellHeight - ((long)Map.Size.X - cellRectangle.Y) * halfCellHeight + Constants.MapYBaseline; - long right = (rightCellX - 1L) * halfCellWidth + ((long)Map.Size.X - cellRectangle.Y) * halfCellWidth + Constants.CellSizeX; - long bottom = (rightCellX - 1L) * halfCellHeight - ((long)Map.Size.X - bottomCellY) * halfCellHeight + Constants.MapYBaseline + Constants.CellSizeY; + int left = (cellRectangle.X - 1) * halfCellWidth + (Map.Size.X - bottomCellY) * halfCellWidth; + int top = (cellRectangle.X - 1) * halfCellHeight - (Map.Size.X - cellRectangle.Y) * halfCellHeight + Constants.MapYBaseline; + int right = (rightCellX - 1) * halfCellWidth + (Map.Size.X - cellRectangle.Y) * halfCellWidth + Constants.CellSizeX; + int bottom = (rightCellX - 1) * halfCellHeight - (Map.Size.X - bottomCellY) * halfCellHeight + Constants.MapYBaseline + Constants.CellSizeY; // Terrain is drawn upwards from its flat cell position. Keep a stable logical-cell crop while // reserving enough space above it for terrain at the maximum supported height level. if (!EditorState.Is2DMode) top -= Constants.MapYBaseline; - if (left < 0L || top < 0L || right > compositeRenderTarget.Width || bottom > compositeRenderTarget.Height || - right <= left || bottom <= top) + int outputWidth = right - left; + int outputHeight = bottom - top; + if (outputWidth <= 0L || outputHeight <= 0L || outputWidth > RenderingConstants.MaximumDX11TextureSize || outputHeight > RenderingConstants.MaximumDX11TextureSize) { - throw new MapScreenCropException("The requested screenshot region projects outside the map texture."); + throw new MapScreenCropException("The requested screenshot region has invalid projected dimensions."); } - return new Rectangle((int)left, (int)top, (int)(right - left), (int)(bottom - top)); + int clippedLeft = Math.Max(0, left); + int clippedTop = Math.Max(0, top); + int clippedRight = Math.Min(compositeRenderTarget.Width, right); + int clippedBottom = Math.Min(compositeRenderTarget.Height, bottom); + + if (clippedRight <= clippedLeft || clippedBottom <= clippedTop) + { + throw new MapScreenCropException("The requested screenshot region is completely outside of the map."); + } + + var sourceRectangle = new Rectangle(clippedLeft, clippedTop, clippedRight - clippedLeft, clippedBottom - clippedTop); + var destinationRectangle = new Rectangle(clippedLeft - left, clippedTop - top, sourceRectangle.Width, sourceRectangle.Height); + return new ScreenCropLayout(outputWidth, outputHeight, sourceRectangle, destinationRectangle); } - private byte[] CaptureScreenCrop(Rectangle sourceRectangle) + private byte[] CaptureScreenCrop(ScreenCropLayout layout) { using var cropRenderTarget = new RenderTarget2D( GraphicsDevice, - sourceRectangle.Width, - sourceRectangle.Height, + layout.OutputWidth, + layout.OutputHeight, false, SurfaceFormat.Color, DepthFormat.None); Renderer.PushRenderTarget(cropRenderTarget); - GraphicsDevice.Clear(Color.Black); - Renderer.DrawTexture( - compositeRenderTarget, - sourceRectangle, - new Rectangle(0, 0, cropRenderTarget.Width, cropRenderTarget.Height), - Color.White); + GraphicsDevice.Clear(Color.Transparent); + + Renderer.DrawTexture(compositeRenderTarget, layout.SourceRectangle, layout.DestinationRectangle, Color.White); Renderer.PopRenderTarget(); @@ -383,11 +410,11 @@ private byte[] CaptureScreenCrop(Rectangle sourceRectangle) return stream.ToArray(); } - private void CompleteScreenCropRequest(ScreenCropRequest request, Rectangle sourceRectangle) + private void CompleteScreenCropRequest(ScreenCropRequest request, ScreenCropLayout layout) { // No need for exception handling here because the caller already has a try-catch if (!request.CancellationToken.IsCancellationRequested && !request.Task.IsCompleted) - request.TrySetResult(CaptureScreenCrop(sourceRectangle)); + request.TrySetResult(CaptureScreenCrop(layout)); renderingWholeMapForScreenCrop = false; ReleaseScreenCropRequest(request); @@ -1847,12 +1874,11 @@ private static void DrawArrow(Vector2 start, Vector2 end, public void Draw(bool isActive, TechnoBase technoUnderCursor, MapTile tileUnderCursor, CursorAction cursorAction) { ScreenCropRequest currentScreenCropRequest = TryBeginScreenCropRequest(); - Rectangle screenCropSourceRectangle = Rectangle.Empty; + ScreenCropLayout screenCropLayout = default; if (currentScreenCropRequest != null) { - screenCropSourceRectangle = currentScreenCropRequest.CalculatedPixelRectangle; - + screenCropLayout = currentScreenCropRequest.CalculatedPixelLayout; renderingWholeMapForScreenCrop = true; InvalidateMap(); } @@ -1881,16 +1907,13 @@ public void Draw(bool isActive, TechnoBase technoUnderCursor, MapTile tileUnderC { ScreenCropRequest requestToComplete = currentScreenCropRequest; currentScreenCropRequest = null; - CompleteScreenCropRequest(requestToComplete, screenCropSourceRectangle); + CompleteScreenCropRequest(requestToComplete, screenCropLayout); } if (EditorState.DrawMapWideOverlay) { - MapWideOverlay.Draw(new Rectangle( - (int)(-Camera.TopLeftPoint.X * Camera.ZoomLevel), - (int)((-Camera.TopLeftPoint.Y + Constants.MapYBaseline) * Camera.ZoomLevel), - (int)(mapRenderTarget.Width * Camera.ZoomLevel), - (int)((mapRenderTarget.Height - Constants.MapYBaseline) * Camera.ZoomLevel))); + MapWideOverlay.Draw(new Rectangle(Camera.ScaleIntWithZoom(-Camera.TopLeftPoint.X), Camera.ScaleIntWithZoom(-Camera.TopLeftPoint.Y + Constants.MapYBaseline), + Camera.ScaleIntWithZoom(mapRenderTarget.Width), Camera.ScaleIntWithZoom(mapRenderTarget.Height - Constants.MapYBaseline))); } if (isActive && tileUnderCursor != null && cursorAction != null) From be99e26403bbb0d0cc8a15d24e669cd784a404f7 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Mon, 3 Aug 2026 21:09:03 +0300 Subject: [PATCH 21/27] Include land type and passability information in cell information --- src/TSMapEditor/AI/MapFacade.cs | 17 +++++++++++++++-- src/TSMapEditor/Helpers.cs | 12 +++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index 075540d1e..3783fb82d 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -6,6 +6,7 @@ using TSMapEditor.CCEngine.TileData; using TSMapEditor.GameMath; using TSMapEditor.Models; +using TSMapEditor.Models.Enums; using TSMapEditor.Mutations; using TSMapEditor.Mutations.Classes; using TSMapEditor.Mutations.Classes.AIMutations; @@ -112,7 +113,8 @@ public MapFootInfo(string rtti, int objectId, int index, int x, int y, string in public class CellInfo { - public CellInfo(int x, int y, string tileSetName, int tileIndex, int tileIndexInTileSet, int subTileIndex, int height, + public CellInfo(int x, int y, string tileSetName, int tileIndex, int tileIndexInTileSet, int subTileIndex, string landType, + bool passableForLandUnits, bool passableForNavalUnits, int height, MapObjectInfo terrainObjectInfo, MapOverlayInfo overlayInfo, List buildingInfos, List footInfos, List waypointInfos) { @@ -122,6 +124,9 @@ public CellInfo(int x, int y, string tileSetName, int tileIndex, int tileIndexIn TileIndex = tileIndex; TileIndexInTileSet = tileIndexInTileSet; SubTileIndex = subTileIndex; + LandType = landType; + PassableForLandUnits = passableForLandUnits; + PassableForNavalUnits = passableForNavalUnits; Height = height; TerrainObjectInfo = terrainObjectInfo; OverlayInfo = overlayInfo; @@ -136,6 +141,9 @@ public CellInfo(int x, int y, string tileSetName, int tileIndex, int tileIndexIn public int TileIndex { get; } public int TileIndexInTileSet { get; } public int SubTileIndex { get; } + public string LandType { get; } + public bool PassableForLandUnits { get; } + public bool PassableForNavalUnits { get; } public int Height { get; } public MapObjectInfo TerrainObjectInfo { get; } public MapOverlayInfo OverlayInfo { get; } @@ -157,8 +165,13 @@ public static CellInfo FromMapCell(Map map, MapTile mapTile) var aircraftInfos = mapTile.Aircraft.Select(a => (MapFootInfo)FromTechno(map, a)); var waypointInfos = mapTile.Waypoints.OrderBy(waypoint => waypoint.Identifier).Select(FromWaypoint).ToList(); + var tileInfo = theater.GetTile(mapTile.TileIndex); + var subTileInfo = tileInfo.GetSubTile(mapTile.SubTileIndex); + LandType landType = (LandType)subTileInfo.TmpImage.TerrainType; + return new CellInfo(mapTile.X, mapTile.Y, tileSet.SetName, mapTile.TileIndex, mapTile.TileIndex - tileSet.StartTileIndex, - mapTile.SubTileIndex, mapTile.Level, terrainObjectInfo, overlayInfo, buildingInfos, + mapTile.SubTileIndex, landType.ToString(), !Helpers.IsLandTypeImpassable(landType, true), !Helpers.IsLandTypeImpassableForNavalUnits(landType), + mapTile.Level, terrainObjectInfo, overlayInfo, buildingInfos, vehicleInfos.Concat(infantryInfos).Concat(aircraftInfos).ToList(), waypointInfos); } diff --git a/src/TSMapEditor/Helpers.cs b/src/TSMapEditor/Helpers.cs index cce8f1f68..48bf39c2c 100644 --- a/src/TSMapEditor/Helpers.cs +++ b/src/TSMapEditor/Helpers.cs @@ -147,6 +147,13 @@ public static bool IsLandTypeImpassable(int landType, bool considerLandUnitsOnly } } + public static bool IsLandTypeImpassable(LandType landType, bool considerLandUnitsOnly) + { + return landType == LandType.Rock || (considerLandUnitsOnly && landType == LandType.Water); + } + + public static bool IsLandTypeImpassableForNavalUnits(LandType landType) => IsLandTypeImpassableForNavalUnits((int)landType); + public static bool IsLandTypeImpassableForNavalUnits(int landType) { // TODO make this dependent on SpeedType and Rules.ini values @@ -165,11 +172,6 @@ public static bool IsLandTypeImpassableForNavalUnits(int landType) } } - public static bool IsLandTypeImpassable(LandType landType, bool considerLandUnitsOnly) - { - return landType == LandType.Rock || (considerLandUnitsOnly && landType == LandType.Water); - } - public static bool IsLandTypeWater(int landType) { return landType == 0x9; From adae34181ed4d6e47186a0161c9f1e6cc8a54f17 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Tue, 4 Aug 2026 03:59:06 +0300 Subject: [PATCH 22/27] Various improvements Update instructions, allow MCP to undo/redo mutations, use terrain object and overlay collections, calculate resource field value, validate the map for errors, and screenshot the entire map --- src/TSMapEditor/AI/GameThreadDispatcher.cs | 2 - src/TSMapEditor/AI/MCPServer.cs | 55 +- src/TSMapEditor/AI/MapAnalysisInfo.cs | 48 ++ src/TSMapEditor/AI/MapCollectionInfo.cs | 57 ++ src/TSMapEditor/AI/MapFacade.cs | 626 ++++++++++++++++-- src/TSMapEditor/AI/MapMutationHistory.cs | 73 ++ .../AI/MapTerrainObjectPlacement.cs | 15 + src/TSMapEditor/AI/MapTools.cs | 220 +++++- .../Config/Default/AIMappingInstructions.md | 78 ++- .../PlaceTerrainObjectBatchMutation.cs | 68 ++ ...aceTerrainObjectCollectionBatchMutation.cs | 73 ++ .../Classes/PlaceOverlayCollectionMutation.cs | 9 +- src/TSMapEditor/Mutations/IMutation.cs | 3 +- src/TSMapEditor/Mutations/Mutation.cs | 12 + .../Mutations/MutationHistoryMetadata.cs | 43 ++ src/TSMapEditor/Mutations/MutationManager.cs | 22 +- src/TSMapEditor/Rendering/MapView.cs | 53 +- src/TSMapEditor/UI/MapUI.cs | 3 + 18 files changed, 1387 insertions(+), 73 deletions(-) create mode 100644 src/TSMapEditor/AI/MapAnalysisInfo.cs create mode 100644 src/TSMapEditor/AI/MapCollectionInfo.cs create mode 100644 src/TSMapEditor/AI/MapMutationHistory.cs create mode 100644 src/TSMapEditor/AI/MapTerrainObjectPlacement.cs create mode 100644 src/TSMapEditor/Mutations/Classes/AIMutations/PlaceTerrainObjectBatchMutation.cs create mode 100644 src/TSMapEditor/Mutations/Classes/AIMutations/PlaceTerrainObjectCollectionBatchMutation.cs create mode 100644 src/TSMapEditor/Mutations/MutationHistoryMetadata.cs diff --git a/src/TSMapEditor/AI/GameThreadDispatcher.cs b/src/TSMapEditor/AI/GameThreadDispatcher.cs index 3ec623f37..027092c69 100644 --- a/src/TSMapEditor/AI/GameThreadDispatcher.cs +++ b/src/TSMapEditor/AI/GameThreadDispatcher.cs @@ -22,8 +22,6 @@ public GameThreadDispatcher(WindowManager windowManager, CancellationToken shutd public async Task InvokeAsync(Func operation, CancellationToken cancellationToken = default) { - Logger.Log("GameThreadDispatcher: Adding WindowManager callback."); - ArgumentNullException.ThrowIfNull(operation); using var linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource( diff --git a/src/TSMapEditor/AI/MCPServer.cs b/src/TSMapEditor/AI/MCPServer.cs index 3a1de37da..2f139b5e7 100644 --- a/src/TSMapEditor/AI/MCPServer.cs +++ b/src/TSMapEditor/AI/MCPServer.cs @@ -2,6 +2,9 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Rampastring.Tools; using Rampastring.XNAUI; @@ -49,12 +52,17 @@ public async Task StartAsync(CancellationToken cancellationToken = default) builder.WebHost.UseUrls(ServerUrl); builder.Configuration["AllowedHosts"] = "localhost;127.0.0.1;[::1]"; + builder.Logging.AddProvider(new MapEditorMcpLogger()); builder.Services.AddSingleton(mapFacade); builder.Services.AddSingleton(mapScreenCropper); builder.Services.AddSingleton(new GameThreadDispatcher(windowManager, shutdownCancellationTokenSource.Token)); builder.Services - .AddMcpServer() + .AddMcpServer(options => options.Filters.Request.CallToolFilters.Add(next => + (request, requestCancellationToken) => InvokeToolWithDetailedErrors( + next, + request, + requestCancellationToken))) .WithHttpTransport(options => options.Stateless = true) .WithTools(); @@ -114,4 +122,49 @@ private async Task StopAndDisposeAsync(WebApplication applicationToDispose) shutdownCancellationTokenSource.Dispose(); } } + + private static async ValueTask InvokeToolWithDetailedErrors( + McpRequestHandler next, + RequestContext request, + CancellationToken cancellationToken) + { + try + { + return await next(request, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not McpException && + ex is not McpProtocolException && + ex is not InputRequiredException && + ex is not OperationCanceledException) + { + throw new McpException(ex.Message, ex); + } + } + + private sealed class MapEditorMcpLogger : ILoggerProvider, ILogger + { + public ILogger CreateLogger(string categoryName) => this; + + public void Dispose() + { + } + + public IDisposable BeginScope(TState state) => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + return; + + string exceptionDetails = exception == null ? string.Empty : Environment.NewLine + exception; + Logger.Log($"MCP server {logLevel}: {formatter(state, exception)}{exceptionDetails}"); + } + } } diff --git a/src/TSMapEditor/AI/MapAnalysisInfo.cs b/src/TSMapEditor/AI/MapAnalysisInfo.cs new file mode 100644 index 000000000..9d5f96da5 --- /dev/null +++ b/src/TSMapEditor/AI/MapAnalysisInfo.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; + +namespace TSMapEditor.AI; + +public sealed class MapCellArea +{ + public MapCellArea(int x, int y, int width, int height) + { + X = x; + Y = y; + Width = width; + Height = height; + } + + public int X { get; } + public int Y { get; } + public int Width { get; } + public int Height { get; } +} + +public sealed class MapResourceFieldInfo +{ + public MapResourceFieldInfo(long totalValue, int cellCount, MapCellArea area) + { + TotalValue = totalValue; + CellCount = cellCount; + Area = area; + } + + public long TotalValue { get; } + public int CellCount { get; } + public MapCellArea Area { get; } +} + +public sealed class MapValidationResult +{ + public MapValidationResult(int revision, List issues, List underdetailedAreas) + { + Revision = revision; + Issues = issues; + UnderdetailedAreas = underdetailedAreas; + } + + public int Revision { get; } + public bool HasIssues => Issues.Count > 0; + public List Issues { get; } + public List UnderdetailedAreas { get; } +} diff --git a/src/TSMapEditor/AI/MapCollectionInfo.cs b/src/TSMapEditor/AI/MapCollectionInfo.cs new file mode 100644 index 000000000..bdedd6c0b --- /dev/null +++ b/src/TSMapEditor/AI/MapCollectionInfo.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; + +namespace TSMapEditor.AI; + +public sealed class MapTerrainObjectCollectionEntryInfo +{ + public MapTerrainObjectCollectionEntryInfo(string iniName, string uiName) + { + ININame = iniName; + UIName = uiName; + } + + public string ININame { get; } + public string UIName { get; } +} + +public sealed class MapTerrainObjectCollectionInfo +{ + public MapTerrainObjectCollectionInfo(string name, string uiName, List entries) + { + Name = name; + UIName = uiName; + Entries = entries; + } + + public string Name { get; } + public string UIName { get; } + public List Entries { get; } +} + +public sealed class MapOverlayCollectionEntryInfo +{ + public MapOverlayCollectionEntryInfo(string iniName, string uiName, int frameIndex) + { + ININame = iniName; + UIName = uiName; + FrameIndex = frameIndex; + } + + public string ININame { get; } + public string UIName { get; } + public int FrameIndex { get; } +} + +public sealed class MapOverlayCollectionInfo +{ + public MapOverlayCollectionInfo(string name, string uiName, List entries) + { + Name = name; + UIName = uiName; + Entries = entries; + } + + public string Name { get; } + public string UIName { get; } + public List Entries { get; } +} diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index 3783fb82d..6e1b278e4 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -330,6 +330,8 @@ public class MapFacade private const int MaxConnectedTilePathVertexCount = 256; private const int MaxMapOperationDimension = 256; private const int MaxMapOperationCellCount = 10_000; + private const int MaxMutationHistoryEntries = 1_000; + private const int UnderdetailedAreaSize = 10; private static readonly string[] ValidMissions = new[] { @@ -339,6 +341,13 @@ public class MapFacade private static readonly int[] ValidVeterancyLevels = new[] { 0, 50, 100, 150, 200 }; + private static readonly Point2D[] ResourceFieldNeighborOffsets = new[] + { + new Point2D(-1, -1), new Point2D(0, -1), new Point2D(1, -1), + new Point2D(-1, 0), new Point2D(1, 0), + new Point2D(-1, 1), new Point2D(0, 1), new Point2D(1, 1) + }; + public MapFacade(Map map, MutationManager mutationManager, IMutationTarget mutationTarget) { this.map = map; @@ -360,6 +369,92 @@ public int GetMapRevision() return mutationManager.Revision; } + public MapMutationHistoryInfo GetMutationHistory(int limit) + { + if (limit < 1 || limit > MaxMutationHistoryEntries) + { + throw new MapFacadeValidationException( + $"Mutation history limit must be from 1 through {MaxMutationHistoryEntries}."); + } + + var undoHistory = new List(Math.Min(limit, mutationManager.UndoList.Count)); + for (int i = mutationManager.UndoList.Count - 1; i >= 0 && undoHistory.Count < limit; i--) + { + undoHistory.Add(CreateMutationHistoryEntry( + mutationManager.UndoList[i], + canUndo: i == mutationManager.UndoList.Count - 1, + canRedo: false)); + } + + var redoHistory = new List(Math.Min(limit, mutationManager.RedoList.Count)); + for (int i = mutationManager.RedoList.Count - 1; i >= 0 && redoHistory.Count < limit; i--) + { + redoHistory.Add(CreateMutationHistoryEntry( + mutationManager.RedoList[i], + canUndo: false, + canRedo: i == mutationManager.RedoList.Count - 1)); + } + + return new MapMutationHistoryInfo( + mutationManager.Revision, + mutationManager.UndoList.Count, + mutationManager.RedoList.Count, + undoHistory, + redoHistory); + } + + public MapMutationOperationResult UndoLatest(int expectedRevision, long expectedMutationId) + { + ValidateExpectedRevision(expectedRevision, "undoing the latest mutation"); + + if (!mutationManager.CanUndo()) + throw new MapFacadeValidationException("The editor undo history is empty."); + + IMutation mutation = mutationManager.UndoList[^1]; + MutationHistoryMetadata metadata = GetMutationMetadata(mutation); + if (metadata.MutationId != expectedMutationId) + { + throw new MapFacadeValidationException( + $"The latest undo mutation changed from ID {expectedMutationId} to {metadata.MutationId}. Query mutation history again before undoing it."); + } + + mutationManager.UndoOne(); + + return new MapMutationOperationResult( + mutationManager.Revision, + CreateMutationHistoryEntry(mutation, canUndo: false, canRedo: true), + mutationManager.UndoList.Count, + mutationManager.RedoList.Count, + CanUndoLatestMutationThroughMCP(), + CanRedoLatestMutationThroughMCP()); + } + + public MapMutationOperationResult RedoLatest(int expectedRevision, long expectedMutationId) + { + ValidateExpectedRevision(expectedRevision, "redoing the latest mutation"); + + if (!mutationManager.CanRedo()) + throw new MapFacadeValidationException("The editor redo history is empty."); + + IMutation mutation = mutationManager.RedoList[^1]; + MutationHistoryMetadata metadata = GetMutationMetadata(mutation); + if (metadata.MutationId != expectedMutationId) + { + throw new MapFacadeValidationException( + $"The latest redo mutation changed from ID {expectedMutationId} to {metadata.MutationId}. Query mutation history again before redoing it."); + } + + mutationManager.Redo(); + + return new MapMutationOperationResult( + mutationManager.Revision, + CreateMutationHistoryEntry(mutation, canUndo: true, canRedo: false), + mutationManager.UndoList.Count, + mutationManager.RedoList.Count, + CanUndoLatestMutationThroughMCP(), + CanRedoLatestMutationThroughMCP()); + } + public List GetTerrainTypes(string nameFilter = null) { string normalizedFilter = nameFilter?.Trim(); @@ -380,6 +475,31 @@ public List GetTerrainTypes(string nameFilter = null) .ToList(); } + public List GetTerrainObjectCollections(string nameFilter = null) + { + string normalizedFilter = nameFilter?.Trim(); + + return map.EditorConfig.TerrainObjectCollections + .Where(collection => collection.Entries.Length > 0 && collection.IsValidForTheater(map.LoadedTheaterName)) + .Select(collection => new MapTerrainObjectCollectionInfo( + collection.Name, + collection.UIName, + collection.Entries + .Select(entry => new MapTerrainObjectCollectionEntryInfo( + entry.TerrainType.ININame, + entry.TerrainType.GetEditorDisplayName())) + .ToList())) + .Where(collectionInfo => string.IsNullOrWhiteSpace(normalizedFilter) || + ContainsIgnoringCase(collectionInfo.Name, normalizedFilter) || + ContainsIgnoringCase(collectionInfo.UIName, normalizedFilter) || + collectionInfo.Entries.Exists(entry => + ContainsIgnoringCase(entry.ININame, normalizedFilter) || + ContainsIgnoringCase(entry.UIName, normalizedFilter))) + .OrderBy(collectionInfo => collectionInfo.UIName) + .ThenBy(collectionInfo => collectionInfo.Name) + .ToList(); + } + public List GetOverlayTypes(string nameFilter = null) { string normalizedFilter = nameFilter?.Trim(); @@ -412,6 +532,32 @@ public List GetOverlayTypes(string nameFilter = null) .ToList(); } + public List GetOverlayCollections(string nameFilter = null) + { + string normalizedFilter = nameFilter?.Trim(); + + return map.EditorConfig.OverlayCollections + .Where(collection => collection.Entries.Length > 0 && collection.IsValidForTheater(map.LoadedTheaterName)) + .Select(collection => new MapOverlayCollectionInfo( + collection.Name, + collection.UIName, + collection.Entries + .Select(entry => new MapOverlayCollectionEntryInfo( + entry.OverlayType.ININame, + entry.OverlayType.GetEditorDisplayName(), + entry.Frame)) + .ToList())) + .Where(collectionInfo => string.IsNullOrWhiteSpace(normalizedFilter) || + ContainsIgnoringCase(collectionInfo.Name, normalizedFilter) || + ContainsIgnoringCase(collectionInfo.UIName, normalizedFilter) || + collectionInfo.Entries.Exists(entry => + ContainsIgnoringCase(entry.ININame, normalizedFilter) || + ContainsIgnoringCase(entry.UIName, normalizedFilter))) + .OrderBy(collectionInfo => collectionInfo.UIName) + .ThenBy(collectionInfo => collectionInfo.Name) + .ToList(); + } + public List GetConnectedOverlayTypes(string nameFilter = null) { string normalizedFilter = nameFilter?.Trim(); @@ -620,6 +766,73 @@ public List InspectRegion(Rectangle rectangle) return returnValue; } + public MapResourceFieldInfo CalculateResourceFieldValue(int x, int y) + { + var startCoords = new Point2D(x, y); + var startTile = map.GetTile(startCoords); + if (startTile == null) + throw new MapFacadeValidationException($"Cell ({x}, {y}) is outside the map."); + if (!startTile.HasTiberium()) + throw new MapFacadeValidationException($"Cell ({x}, {y}) does not contain a harvestable resource."); + + var fieldCellCoords = new HashSet { startCoords }; + var cellsToVisit = new Queue(); + cellsToVisit.Enqueue(startCoords); + + long totalValue = 0; + int minX = x; + int minY = y; + int maxX = x; + int maxY = y; + + while (cellsToVisit.Count > 0) + { + Point2D cellCoords = cellsToVisit.Dequeue(); + MapTile mapTile = map.GetTile(cellCoords); + TiberiumType tiberiumType = mapTile.Overlay.OverlayType.TiberiumType; + if (tiberiumType != null) + totalValue += (long)mapTile.Overlay.FrameIndex * tiberiumType.Value; + + minX = Math.Min(minX, cellCoords.X); + minY = Math.Min(minY, cellCoords.Y); + maxX = Math.Max(maxX, cellCoords.X); + maxY = Math.Max(maxY, cellCoords.Y); + + foreach (Point2D neighborOffset in ResourceFieldNeighborOffsets) + { + Point2D neighborCoords = cellCoords + neighborOffset; + if (fieldCellCoords.Contains(neighborCoords)) + continue; + + MapTile neighborTile = map.GetTile(neighborCoords); + if (neighborTile == null || !neighborTile.HasTiberium()) + continue; + + fieldCellCoords.Add(neighborCoords); + cellsToVisit.Enqueue(neighborCoords); + } + } + + return new MapResourceFieldInfo( + totalValue, + fieldCellCoords.Count, + new MapCellArea(minX, minY, maxX - minX + 1, maxY - minY + 1)); + } + + public MapValidationResult ValidateMap() + { + List issues = map.CheckForIssues(); + List underdetailedAreas = FindUnderdetailedAreas(); + + foreach (MapCellArea area in underdetailedAreas) + { + issues.Add( + $"Underdetailed area at ({area.X}, {area.Y}) with size {area.Width}x{area.Height} contains only clear terrain and no map objects or overlays."); + } + + return new MapValidationResult(mutationManager.Revision, issues, underdetailedAreas); + } + public MapEditResult ModifyTechnos(List technoReferences, MapTechnoModificationProperties properties, int? expectedRevision) { if (expectedRevision.HasValue && expectedRevision.Value != mutationManager.Revision) @@ -655,12 +868,15 @@ public MapEditResult ModifyTechnos(List technoReferences, Ma if (changes.Count == 0) throw new MapFacadeValidationException("All selected technos already have the requested property values."); - mutationManager.PerformMutation(new ModifyTechnosMutation(mutationTarget, changes)); - - var affectedCells = technos + var affectedMapTiles = technos .Select(techno => map.GetTile(techno.Position)) .Where(mapTile => mapTile != null) .Distinct() + .ToList(); + + PerformMCPMutation(new ModifyTechnosMutation(mutationTarget, changes), "modify_technos", affectedMapTiles); + + var affectedCells = affectedMapTiles .Select(mapTile => CellInfo.FromMapCell(map, mapTile)) .ToList(); @@ -701,7 +917,7 @@ public MapEditResult DeleteObjects(List technoReferences, Li throw new MapFacadeValidationException("The object reference lists contain duplicates."); var affectedMapTiles = GetAffectedMapTiles(objects); - mutationManager.PerformMutation(new DeleteMapObjectsMutation(mutationTarget, objects)); + PerformMCPMutation(new DeleteMapObjectsMutation(mutationTarget, objects), "delete_objects", affectedMapTiles); return new MapEditResult( mutationManager.Revision, @@ -739,7 +955,7 @@ public MapEditResult EraseOverlay(int x, int y, int width, int height) .ToList(); var mutation = new PlaceOverlayMutation(mutationTarget, null, null, new Point2D(x, y), new BrushSize(width, height)); - mutationManager.PerformMutation(mutation); + PerformMCPMutation(mutation, "erase_overlay", affectedMapTiles); return new MapEditResult( mutationManager.Revision, @@ -774,7 +990,51 @@ public MapEditResult PlaceOverlay(string overlayTypeName, int x, int y, int widt if (!mutation.ShouldPerform()) throw new MapFacadeValidationException($"The requested area already contains overlay '{overlayType.ININame}' with the requested frame settings."); - mutationManager.PerformMutation(mutation); + PerformMCPMutation(mutation, "place_overlay", affectedMapTiles); + + return new MapEditResult( + mutationManager.Revision, + affectedMapTiles.Select(mapTile => CellInfo.FromMapCell(map, mapTile)).ToList()); + } + + public MapEditResult PlaceOverlayCollection(string collectionName, int x, int y, int width, int height) + { + if (string.IsNullOrWhiteSpace(collectionName)) + throw new MapFacadeValidationException("An overlay collection name must be provided."); + + var collection = map.EditorConfig.OverlayCollections.Find( + candidate => string.Equals(candidate.Name, collectionName, StringComparison.OrdinalIgnoreCase)); + if (collection == null) + throw new MapFacadeValidationException($"Overlay collection '{collectionName}' does not exist in the editor configuration."); + if (collection.Entries.Length == 0) + throw new MapFacadeValidationException($"Overlay collection '{collection.Name}' contains no entries."); + if (!collection.IsValidForTheater(map.LoadedTheaterName)) + throw new MapFacadeValidationException($"Overlay collection '{collection.Name}' is not valid for theater '{map.LoadedTheaterName}'."); + + foreach (var entry in collection.Entries) + { + if (!entry.OverlayType.IsValidForTheater(map.LoadedTheaterName)) + { + throw new MapFacadeValidationException( + $"Overlay collection '{collection.Name}' contains overlay '{entry.OverlayType.ININame}', which is not valid for theater '{map.LoadedTheaterName}'."); + } + + ValidateOverlayFrame(entry.OverlayType, entry.Frame); + } + + var targetMapTiles = GetValidatedMapTilesInArea(x, y, width, height, "overlay collection placement"); + var affectedMapTiles = targetMapTiles + .SelectMany(mapTile => GetMapTileAndSurroundings(mapTile.CoordsToPoint())) + .Distinct() + .ToList(); + + var mutation = new PlaceOverlayCollectionMutation( + mutationTarget, + collection, + new Point2D(x, y), + new BrushSize(width, height)); + + PerformMCPMutation(mutation, "place_overlay_collection", affectedMapTiles); return new MapEditResult( mutationManager.Revision, @@ -802,11 +1062,14 @@ public MapEditResult PlaceConnectedOverlay(string connectedOverlayName, int x, i .Distinct() .ToList(); - mutationManager.PerformMutation(new PlaceConnectedOverlayMutation( - mutationTarget, - connectedOverlay, - new Point2D(x, y), - new BrushSize(width, height))); + PerformMCPMutation( + new PlaceConnectedOverlayMutation( + mutationTarget, + connectedOverlay, + new Point2D(x, y), + new BrushSize(width, height)), + "place_connected_overlay", + affectedMapTiles); return new MapEditResult( mutationManager.Revision, @@ -919,7 +1182,7 @@ public MapEditResult DrawConnectedTiles(string connectedTileTypeName, List { CellInfo.FromMapCell(map, mapTile) }); } - public MapEditResult PlaceTerrainObject(string terrainTypeName, int x, int y) + public MapEditResult PlaceTerrainObjectsBatch( + List placements, + int? expectedRevision) { - if (string.IsNullOrWhiteSpace(terrainTypeName)) - throw new MapFacadeValidationException("A terrain object type INI name must be provided."); + if (expectedRevision.HasValue && expectedRevision.Value != mutationManager.Revision) + { + throw new MapFacadeValidationException( + $"The map revision changed from {expectedRevision.Value} to {mutationManager.Revision}. Query the map again before placing terrain objects."); + } - var cellCoords = new Point2D(x, y); - if (!map.IsCoordWithinMap(cellCoords)) - throw new MapFacadeValidationException($"Cell ({x}, {y}) is outside the map."); + if (placements == null || placements.Count == 0) + throw new MapFacadeValidationException("At least one terrain object placement must be provided."); + if (placements.Count > MaxMapOperationCellCount) + throw new MapFacadeValidationException($"At most {MaxMapOperationCellCount} terrain objects can be placed in one batch."); - var mapTile = map.GetTile(cellCoords); - if (mapTile == null) - throw new MapFacadeValidationException($"Cell ({x}, {y}) is outside the map."); + var resolvedPlacements = new List<(TerrainType TerrainType, Point2D CellCoords)>(placements.Count); + var distinctCellCoords = new HashSet(); + for (int i = 0; i < placements.Count; i++) + { + MapTerrainObjectPlacement placement = placements[i]; + if (placement == null) + throw new MapFacadeValidationException($"Terrain object placement {i} cannot be null."); + if (string.IsNullOrWhiteSpace(placement.TerrainTypeName)) + throw new MapFacadeValidationException($"Terrain object placement {i} must specify a terrain object type INI name."); + + TerrainType terrainType = map.Rules.TerrainTypes.Find( + candidate => string.Equals(candidate.ININame, placement.TerrainTypeName, StringComparison.OrdinalIgnoreCase)); + if (terrainType == null) + throw new MapFacadeValidationException($"Terrain object type '{placement.TerrainTypeName}' does not exist in the loaded rules."); + if (!terrainType.EditorVisible) + throw new MapFacadeValidationException($"Terrain object type '{terrainType.ININame}' is not available for placement in the editor."); + if (!terrainType.IsValidForTheater(map.LoadedTheaterName)) + { + throw new MapFacadeValidationException( + $"Terrain object type '{terrainType.ININame}' is not valid for theater '{map.LoadedTheaterName}'."); + } - var terrainType = map.Rules.TerrainTypes.Find(tt => string.Equals(tt.ININame, terrainTypeName, StringComparison.OrdinalIgnoreCase)); - if (terrainType == null) - throw new MapFacadeValidationException($"Terrain object type '{terrainTypeName}' does not exist in the loaded rules."); + var cellCoords = new Point2D(placement.X, placement.Y); + MapTile mapTile = map.GetTile(cellCoords); + if (mapTile == null) + throw new MapFacadeValidationException($"Terrain object placement {i} at ({placement.X}, {placement.Y}) is outside the map."); + if (!distinctCellCoords.Add(cellCoords)) + throw new MapFacadeValidationException($"Cell coordinate ({placement.X}, {placement.Y}) is included more than once."); + if (mapTile.TerrainObject != null) + { + throw new MapFacadeValidationException( + $"Cell ({placement.X}, {placement.Y}) already contains terrain object '{mapTile.TerrainObject.TerrainType.ININame}'."); + } - if (!terrainType.EditorVisible) - throw new MapFacadeValidationException($"Terrain object type '{terrainType.ININame}' is not available for placement in the editor."); + resolvedPlacements.Add((terrainType, cellCoords)); + } - if (!terrainType.IsValidForTheater(map.LoadedTheaterName)) - throw new MapFacadeValidationException($"Terrain object type '{terrainType.ININame}' is not valid for theater '{map.LoadedTheaterName}'."); + var mutation = new PlaceTerrainObjectBatchMutation(mutationTarget, resolvedPlacements); + PerformMCPMutation( + mutation, + "place_terrain_objects_batch", + resolvedPlacements.Select(placement => placement.CellCoords).ToList()); - if (mapTile.TerrainObject != null) - throw new MapFacadeValidationException($"Cell ({x}, {y}) already contains terrain object '{mapTile.TerrainObject.TerrainType.ININame}'."); + return new MapEditResult( + mutationManager.Revision, + resolvedPlacements + .Select(placement => CellInfo.FromMapCell(map, map.GetTile(placement.CellCoords))) + .ToList()); + } - var mutation = new PlaceTerrainObjectMutation(mutationTarget, terrainType, cellCoords); - if (!mutation.ShouldPerform()) - throw new MapFacadeValidationException($"Terrain object '{terrainType.ININame}' cannot be placed at ({x}, {y})."); + public MapEditResult PlaceTerrainObjectCollectionBatch( + string collectionName, + List cells, + int? expectedRevision) + { + if (expectedRevision.HasValue && expectedRevision.Value != mutationManager.Revision) + { + throw new MapFacadeValidationException( + $"The map revision changed from {expectedRevision.Value} to {mutationManager.Revision}. Query the map again before placing the terrain object collection."); + } + + if (string.IsNullOrWhiteSpace(collectionName)) + throw new MapFacadeValidationException("A terrain object collection name must be provided."); + if (cells == null || cells.Count == 0) + throw new MapFacadeValidationException("At least one cell coordinate must be provided."); + if (cells.Count > MaxMapOperationCellCount) + throw new MapFacadeValidationException($"At most {MaxMapOperationCellCount} terrain objects can be placed in one batch."); + + var collection = map.EditorConfig.TerrainObjectCollections.Find( + candidate => string.Equals(candidate.Name, collectionName, StringComparison.OrdinalIgnoreCase)); + if (collection == null) + throw new MapFacadeValidationException($"Terrain object collection '{collectionName}' does not exist in the editor configuration."); + if (collection.Entries.Length == 0) + throw new MapFacadeValidationException($"Terrain object collection '{collection.Name}' contains no entries."); + if (!collection.IsValidForTheater(map.LoadedTheaterName)) + throw new MapFacadeValidationException($"Terrain object collection '{collection.Name}' is not valid for theater '{map.LoadedTheaterName}'."); + + var distinctCellCoords = new HashSet(); + for (int i = 0; i < cells.Count; i++) + { + MapCellCoordinate cell = cells[i]; + if (cell == null) + throw new MapFacadeValidationException($"Cell coordinate {i} cannot be null."); + + var cellCoords = new Point2D(cell.X, cell.Y); + MapTile mapTile = map.GetTile(cellCoords); + if (mapTile == null) + throw new MapFacadeValidationException($"Cell coordinate {i} at ({cell.X}, {cell.Y}) is outside the map."); + if (!distinctCellCoords.Add(cellCoords)) + throw new MapFacadeValidationException($"Cell coordinate ({cell.X}, {cell.Y}) is included more than once."); + if (mapTile.TerrainObject != null) + { + throw new MapFacadeValidationException( + $"Cell ({cell.X}, {cell.Y}) already contains terrain object '{mapTile.TerrainObject.TerrainType.ININame}'."); + } + } - mutationManager.PerformMutation(mutation); + var orderedCellCoords = distinctCellCoords + .OrderBy(coords => coords.Y) + .ThenBy(coords => coords.X) + .ToList(); + + var mutation = new PlaceTerrainObjectCollectionBatchMutation(mutationTarget, collection, orderedCellCoords); + PerformMCPMutation(mutation, "place_terrain_object_collection_batch", orderedCellCoords); return new MapEditResult( mutationManager.Revision, - new List { CellInfo.FromMapCell(map, mapTile) }); + orderedCellCoords.Select(coords => CellInfo.FromMapCell(map, map.GetTile(coords))).ToList()); } public MapEditResult PlaceBuilding(string buildingTypeName, string ownerName, int x, int y, bool allowOverlap, MapBuildingPlacementProperties properties) @@ -1053,7 +1407,7 @@ public MapEditResult PlaceBuilding(string buildingTypeName, string ownerName, in $"Building '{buildingType.ININame}' cannot be placed at ({x}, {y}) because its foundation overlaps another building."); } - mutationManager.PerformMutation(new PlaceBuildingMutation(mutationTarget, structure)); + PerformMCPMutation(new PlaceBuildingMutation(mutationTarget, structure), "place_building", foundationCells); return new MapEditResult( mutationManager.Revision, @@ -1099,7 +1453,10 @@ public MapEditResult PlaceAircraft(string aircraftTypeName, string ownerName, in throw new MapFacadeValidationException($"Aircraft '{aircraftType.ININame}' cannot be placed at ({x}, {y}) because the cell already contains aircraft."); } - mutationManager.PerformMutation(new PlaceAircraftMutation(mutationTarget, aircraft)); + PerformMCPMutation( + new PlaceAircraftMutation(mutationTarget, aircraft), + "place_aircraft", + new[] { map.GetTile(cellCoords) }); return new MapEditResult( mutationManager.Revision, @@ -1148,7 +1505,7 @@ public MapEditResult PlaceInfantry(string infantryTypeName, string ownerName, in ApplyFootPlacementProperties(infantry, properties, true, true); - mutationManager.PerformMutation(new PlaceInfantryMutation(mutationTarget, infantry)); + PerformMCPMutation(new PlaceInfantryMutation(mutationTarget, infantry), "place_infantry", new[] { mapTile }); return new MapEditResult( mutationManager.Revision, @@ -1194,7 +1551,10 @@ public MapEditResult PlaceVehicle(string vehicleTypeName, string ownerName, int throw new MapFacadeValidationException($"Vehicle '{vehicleType.ININame}' cannot be placed at ({x}, {y}) because the cell already contains a vehicle."); } - mutationManager.PerformMutation(new PlaceVehicleMutation(mutationTarget, vehicle)); + PerformMCPMutation( + new PlaceVehicleMutation(mutationTarget, vehicle), + "place_vehicle", + new[] { map.GetTile(cellCoords) }); return new MapEditResult( mutationManager.Revision, @@ -1255,15 +1615,18 @@ public MapEditResult PlaceTerrainTile(string tileSetName, int tileIndexInTileSet throw new MapFacadeValidationException($"Tile {tileIndexInTileSet} from tile set '{tileSet.SetName}' cannot be placed at ({x}, {y})."); } - mutationManager.PerformMutation(mutation); - int footprintWidth = tile.Width * brushSize.Width; int footprintHeight = tile.Height * brushSize.Height; var affectedArea = autoLAT ? new Rectangle(x - 1, y - 1, footprintWidth + 3, footprintHeight + 3) : new Rectangle(x, y, footprintWidth, footprintHeight); - return new MapEditResult(mutationManager.Revision, InspectRegion(affectedArea)); + var affectedMapTiles = GetMapTilesInRectangle(affectedArea); + PerformMCPMutation(mutation, "place_terrain_tile", affectedMapTiles); + + return new MapEditResult( + mutationManager.Revision, + affectedMapTiles.Select(mapTile => CellInfo.FromMapCell(map, mapTile)).ToList()); } public MapEditResult SetCellsTerrain(List cells, int tileIndex, int subTileIndex, int? expectedRevision) @@ -1319,17 +1682,168 @@ public MapEditResult SetCellsTerrain(List cells, int tileInde if (changedCoords.Count == 0) return new MapEditResult(mutationManager.Revision, new List()); - mutationManager.PerformMutation(new SetCellsTerrainMutation( - mutationTarget, - changedCoords, - tileIndex, - (byte)subTileIndex)); + PerformMCPMutation( + new SetCellsTerrainMutation( + mutationTarget, + changedCoords, + tileIndex, + (byte)subTileIndex), + "set_cells_terrain", + changedCoords); return new MapEditResult( mutationManager.Revision, changedCoords.Select(coords => CellInfo.FromMapCell(map, map.GetTile(coords))).ToList()); } + private void PerformMCPMutation(Mutation mutation, string toolName, IEnumerable affectedMapTiles) + { + PerformMCPMutation( + mutation, + toolName, + affectedMapTiles.Where(mapTile => mapTile != null).Select(mapTile => mapTile.CoordsToPoint())); + } + + private void PerformMCPMutation(Mutation mutation, string toolName, IEnumerable affectedCellCoords) + { + MutationAffectedCells affectedCells = CreateMutationAffectedCells(affectedCellCoords); + mutationManager.PerformMutation(mutation, MutationManager.MCPMutationOrigin, toolName, affectedCells); + } + + private static MutationAffectedCells CreateMutationAffectedCells(IEnumerable affectedCellCoords) + { + var distinctCoords = affectedCellCoords.Distinct().ToList(); + if (distinctCoords.Count == 0) + return new MutationAffectedCells(true, 0, null, null, null, null); + + return new MutationAffectedCells( + true, + distinctCoords.Count, + distinctCoords.Min(coords => coords.X), + distinctCoords.Min(coords => coords.Y), + distinctCoords.Max(coords => coords.X), + distinctCoords.Max(coords => coords.Y)); + } + + private List FindUnderdetailedAreas() + { + int mapArrayHeight = map.Tiles.Length; + int mapArrayWidth = map.Tiles.Length == 0 ? 0 : map.Tiles.Max(row => row.Length); + if (mapArrayWidth < UnderdetailedAreaSize || mapArrayHeight < UnderdetailedAreaSize) + return new List(); + + var blockedCellPrefixSums = new int[mapArrayHeight + 1, mapArrayWidth + 1]; + for (int y = 0; y < mapArrayHeight; y++) + { + for (int x = 0; x < mapArrayWidth; x++) + { + MapTile mapTile = x < map.Tiles[y].Length ? map.Tiles[y][x] : null; + int blockedCell = mapTile == null || !IsUnderdetailedMapTile(mapTile) ? 1 : 0; + blockedCellPrefixSums[y + 1, x + 1] = blockedCell + + blockedCellPrefixSums[y, x + 1] + + blockedCellPrefixSums[y + 1, x] - + blockedCellPrefixSums[y, x]; + } + } + + var claimedCells = new bool[mapArrayHeight, mapArrayWidth]; + var underdetailedAreas = new List(); + for (int y = 0; y <= mapArrayHeight - UnderdetailedAreaSize; y++) + { + for (int x = 0; x <= mapArrayWidth - UnderdetailedAreaSize; x++) + { + if (GetPrefixRectangleSum(blockedCellPrefixSums, x, y, UnderdetailedAreaSize, UnderdetailedAreaSize) > 0 || + IsAnyCellClaimed(claimedCells, x, y, UnderdetailedAreaSize, UnderdetailedAreaSize)) + { + continue; + } + + underdetailedAreas.Add(new MapCellArea(x, y, UnderdetailedAreaSize, UnderdetailedAreaSize)); + SetCellsClaimed(claimedCells, x, y, UnderdetailedAreaSize, UnderdetailedAreaSize); + } + } + + return underdetailedAreas; + } + + private static bool IsUnderdetailedMapTile(MapTile mapTile) + { + return mapTile.IsClearGround() && + mapTile.TerrainObject == null && + mapTile.Overlay == null && + mapTile.Smudge == null && + mapTile.Structures.Count == 0 && + mapTile.Vehicles.Count == 0 && + mapTile.Aircraft.Count == 0 && + mapTile.Infantry.All(infantry => infantry == null); + } + + private static int GetPrefixRectangleSum(int[,] prefixSums, int x, int y, int width, int height) + { + int right = x + width; + int bottom = y + height; + return prefixSums[bottom, right] - prefixSums[y, right] - prefixSums[bottom, x] + prefixSums[y, x]; + } + + private static bool IsAnyCellClaimed(bool[,] claimedCells, int x, int y, int width, int height) + { + for (int cellY = y; cellY < y + height; cellY++) + { + for (int cellX = x; cellX < x + width; cellX++) + { + if (claimedCells[cellY, cellX]) + return true; + } + } + + return false; + } + + private static void SetCellsClaimed(bool[,] claimedCells, int x, int y, int width, int height) + { + for (int cellY = y; cellY < y + height; cellY++) + { + for (int cellX = x; cellX < x + width; cellX++) + claimedCells[cellY, cellX] = true; + } + } + + private MapMutationHistoryEntry CreateMutationHistoryEntry(IMutation mutation, bool canUndo, bool canRedo) + { + return new MapMutationHistoryEntry( + mutation.HistoryMetadata, + mutationManager.Revision, + mutation.GetType().Name, + mutation.GetDisplayString(), + canUndo, + canRedo); + } + + private MutationHistoryMetadata GetMutationMetadata(IMutation mutation) + { + return mutation.HistoryMetadata ?? + throw new MapFacadeValidationException("The latest history entry was performed by the human user and cannot be undone or redone through the MCP server."); + } + + private bool CanUndoLatestMutationThroughMCP() + { + return mutationManager.CanUndo() && mutationManager.UndoList[^1].HistoryMetadata != null; + } + + private bool CanRedoLatestMutationThroughMCP() + { + return mutationManager.CanRedo() && mutationManager.RedoList[^1].HistoryMetadata != null; + } + + private void ValidateExpectedRevision(int expectedRevision, string operationDescription) + { + if (expectedRevision != mutationManager.Revision) + { + throw new MapFacadeValidationException( + $"The map revision changed from {expectedRevision} to {mutationManager.Revision}. Query mutation history again before {operationDescription}."); + } + } + private void ApplyModificationProperties(TechnoBase techno, TechnoPropertiesSnapshot snapshot, MapTechnoModificationProperties properties) { if (properties.Owner != null) @@ -1540,6 +2054,22 @@ private List GetValidatedMapTilesInArea(int x, int y, int width, int he return mapTiles; } + private List GetMapTilesInRectangle(Rectangle rectangle) + { + var mapTiles = new List(); + for (int y = rectangle.Y; y < rectangle.Bottom; y++) + { + for (int x = rectangle.X; x < rectangle.Right; x++) + { + var mapTile = map.GetTile(x, y); + if (mapTile != null) + mapTiles.Add(mapTile); + } + } + + return mapTiles; + } + private int GetPlaceableOverlayFrameCount(OverlayType overlayType) { var overlayTextures = mutationTarget.TheaterGraphics.OverlayTextures; diff --git a/src/TSMapEditor/AI/MapMutationHistory.cs b/src/TSMapEditor/AI/MapMutationHistory.cs new file mode 100644 index 000000000..4d9aff52c --- /dev/null +++ b/src/TSMapEditor/AI/MapMutationHistory.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using TSMapEditor.Mutations; + +namespace TSMapEditor.AI; + +public sealed class MapMutationHistoryEntry +{ + public MapMutationHistoryEntry(MutationHistoryMetadata metadata, int revision, string editorToolName, string description, bool canUndo, bool canRedo) + { + HasMetadata = metadata != null; + MutationId = metadata?.MutationId; + Revision = revision; + CreatedRevision = metadata?.CreatedRevision; + Origin = metadata?.Origin ?? "Editor"; + ToolName = metadata?.ToolName ?? editorToolName; + Description = description; + AffectedCells = metadata?.AffectedCells; + CanUndo = canUndo && HasMetadata; + CanRedo = canRedo && HasMetadata; + } + + public bool HasMetadata { get; } + public long? MutationId { get; } + public int Revision { get; } + public int? CreatedRevision { get; } + public string Origin { get; } + public string ToolName { get; } + public string Description { get; } + public MutationAffectedCells AffectedCells { get; } + public bool CanUndo { get; } + public bool CanRedo { get; } +} + +public sealed class MapMutationHistoryInfo +{ + public MapMutationHistoryInfo(int revision, int undoCount, int redoCount, + List undoHistory, List redoHistory) + { + Revision = revision; + UndoCount = undoCount; + RedoCount = redoCount; + UndoHistory = undoHistory; + RedoHistory = redoHistory; + } + + public int Revision { get; } + public int UndoCount { get; } + public int RedoCount { get; } + public bool CanUndo => UndoHistory.Count > 0 && UndoHistory[0].CanUndo; + public bool CanRedo => RedoHistory.Count > 0 && RedoHistory[0].CanRedo; + public List UndoHistory { get; } + public List RedoHistory { get; } +} + +public sealed class MapMutationOperationResult +{ + public MapMutationOperationResult(int revision, MapMutationHistoryEntry mutation, int undoCount, int redoCount, bool canUndo, bool canRedo) + { + Revision = revision; + Mutation = mutation; + UndoCount = undoCount; + RedoCount = redoCount; + CanUndo = canUndo; + CanRedo = canRedo; + } + + public int Revision { get; } + public MapMutationHistoryEntry Mutation { get; } + public int UndoCount { get; } + public int RedoCount { get; } + public bool CanUndo { get; } + public bool CanRedo { get; } +} diff --git a/src/TSMapEditor/AI/MapTerrainObjectPlacement.cs b/src/TSMapEditor/AI/MapTerrainObjectPlacement.cs new file mode 100644 index 000000000..500b2062a --- /dev/null +++ b/src/TSMapEditor/AI/MapTerrainObjectPlacement.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace TSMapEditor.AI; + +public sealed class MapTerrainObjectPlacement +{ + [Description("INI name of the terrain object type to place.")] + public string TerrainTypeName { get; set; } + + [Description("X coordinate of the destination map cell.")] + public int X { get; set; } + + [Description("Y coordinate of the destination map cell.")] + public int Y { get; set; } +} diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index 2e9c409b3..49fa5f011 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -19,6 +19,8 @@ public sealed class MapTools private const int MaxRegionDimension = 256; private const int MaxRegionCellCount = 10_000; private const int MaxScreenshotPixelCount = 8_000_000; + private const int MaxWholeMapPreviewDimension = 4_096; + private const int DefaultWholeMapPreviewDimension = 2_048; private const string MappingInstructionsFileName = "AIMappingInstructions.md"; public MapTools(MapFacade mapFacade, GameThreadDispatcher gameThreadDispatcher, IMapScreenCropper mapScreenCropper) @@ -87,6 +89,68 @@ public Task GetMapRevision(CancellationToken cancellationToken) return gameThreadDispatcher.InvokeAsync(mapFacade.GetMapRevision, cancellationToken); } + [McpServerTool(Name = "get_mutation_history", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns the real editor undo and redo stacks in newest-first order, along with the current map revision. MCP mutations include stable IDs, tool names, and affected-cell summaries. Editor-driven mutations remain visible but have no metadata and cannot be undone or redone through MCP.")] + public async Task GetMutationHistory( + [Description("Maximum number of entries returned from each stack. Defaults to 50 and may be at most 1000.")] int limit = 50, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetMutationHistory)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.GetMutationHistory(limit), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + + [McpServerTool(Name = "undo_latest", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Undoes exactly the current top MCP mutation on the real editor undo stack. Both guards are required and must match get_mutation_history, preventing an interleaved editor or MCP edit from being undone accidentally. Editor-driven entries cannot be undone through MCP.")] + public async Task UndoLatest( + [Description("Exact current map revision returned by get_mutation_history.")] int expectedRevision, + [Description("Mutation ID of the first undoHistory entry returned by get_mutation_history.")] long expectedMutationId, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(UndoLatest)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.UndoLatest(expectedRevision, expectedMutationId), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + + [McpServerTool(Name = "redo_latest", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Redoes exactly the current top MCP mutation on the real editor redo stack. Both guards are required and must match get_mutation_history, preventing an interleaved editor or MCP action from being redone accidentally. Editor-driven entries cannot be redone through MCP.")] + public async Task RedoLatest( + [Description("Exact current map revision returned by get_mutation_history.")] int expectedRevision, + [Description("Mutation ID of the first redoHistory entry returned by get_mutation_history.")] long expectedMutationId, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(RedoLatest)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.RedoLatest(expectedRevision, expectedMutationId), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + [McpServerTool(Name = "get_terrain_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] [Description("Returns terrain object types that are visible in the editor and valid for the current map's theater.")] public Task> GetTerrainTypes( @@ -97,6 +161,16 @@ public Task> GetTerrainTypes( return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetTerrainTypes(nameFilter), cancellationToken); } + [McpServerTool(Name = "get_terrain_object_collections", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns non-empty terrain object collections available in the current map's theater. Entries are returned in configured order and may repeat because duplicate entries increase their random placement weight.")] + public Task> GetTerrainObjectCollections( + [Description("Optional case-insensitive filter matched against collection names and entry INI/UI names.")] string nameFilter = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetTerrainObjectCollections)}"); + return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetTerrainObjectCollections(nameFilter), cancellationToken); + } + [McpServerTool(Name = "get_overlay_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] [Description("Returns regular overlay types that are visible in the editor and valid for the current map's theater, including placeable frame counts and connected-overlay memberships. frameCount counts placeable artwork only; higher raw SHP frames, conventionally the upper half, are engine-managed shadow data.")] public Task> GetOverlayTypes( @@ -107,6 +181,16 @@ public Task> GetOverlayTypes( return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetOverlayTypes(nameFilter), cancellationToken); } + [McpServerTool(Name = "get_overlay_collections", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns non-empty overlay collections available in the current map's theater, including every overlay type and configured frame. Entries are returned in configured order and may repeat because duplicate entries increase their random placement weight.")] + public Task> GetOverlayCollections( + [Description("Optional case-insensitive filter matched against collection names and entry INI/UI names.")] string nameFilter = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetOverlayCollections)}"); + return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetOverlayCollections(nameFilter), cancellationToken); + } + [McpServerTool(Name = "get_connected_overlay_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] [Description("Returns WAE connected-overlay configurations valid for the current map's theater, including their connection masks and underlying overlay frames. Use place_connected_overlay for automatic connections, or place_overlay with this frame data for exact manual placement.")] public Task> GetConnectedOverlayTypes( @@ -246,7 +330,7 @@ public async Task GetTechnos( } [McpServerTool(Name = "inspect_map_region", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] - [Description("Returns terrain, overlays, waypoints, and placed map objects from a rectangular region of the open map.")] + [Description("Returns terrain, overlays, waypoints, and placed map objects from a rectangular region of the open map. Each dimension may be at most 256 cells and the region may contain at most 10,000 cells.")] public Task> InspectMapRegion( [Description("X coordinate of the region's top-left cell.")] int x, [Description("Y coordinate of the region's top-left cell.")] int y, @@ -267,13 +351,42 @@ public Task> InspectMapRegion( cancellationToken); } + [McpServerTool(Name = "calculate_resource_field_value", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Calculates the total credit value, resource-cell count, and bounding rectangle of the contiguous resource field containing a given cell. Diagonally touching harvestable resource cells are considered part of the same field. Resource values use the same frame-index calculation as WAE's Calculate Credits cursor tool.")] + public async Task CalculateResourceFieldValue( + [Description("X coordinate of a cell containing a harvestable resource.")] int x, + [Description("Y coordinate of a cell containing a harvestable resource.")] int y, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(CalculateResourceFieldValue)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.CalculateResourceFieldValue(x, y), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + + [McpServerTool(Name = "validate_map", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Runs WAE's standard map issue checks and scans for non-overlapping 10x10 underdetailed areas. An underdetailed area contains only clear terrain and has no terrain objects, overlays, smudges, technos, infantry, or aircraft.")] + public Task ValidateMap(CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(ValidateMap)}"); + return gameThreadDispatcher.InvokeAsync(mapFacade.ValidateMap, cancellationToken); + } + [McpServerTool(Name = "screenshot_map_region", ReadOnly = true, OpenWorld = false)] - [Description("Renders the entire open map and returns a PNG screenshot of the axis-aligned pixel bounds for a rectangular region of cells. In normal 3D mode, the image includes fixed vertical padding above those bounds for terrain at the maximum supported height. Because the map is isometric, the requested cells form a diamond within the returned rectangular image, whose corners can contain map content outside the requested cells. Regions may partially cross the map boundary, with pixels outside the map's render bounds left transparent, but must overlap the map.")] + [Description("Renders the entire open map and returns a PNG screenshot of the axis-aligned pixel bounds for a rectangular region of cells. Each dimension may be at most 256 cells, the region may contain at most 10,000 cells, and the projected output may contain at most 8,000,000 pixels. In normal 3D mode, the image includes fixed vertical padding above those bounds for terrain at the maximum supported height. Because the map is isometric, the requested cells form a diamond within the returned rectangular image, whose corners can contain map content outside the requested cells. Regions may partially cross the map boundary, with pixels outside the map's render bounds left transparent, but must overlap the map.")] public async Task ScreenshotMapRegion( [Description("X coordinate of the region's top-left cell.")] int x, [Description("Y coordinate of the region's top-left cell.")] int y, - [Description("Width of the region in cells.")] int width, - [Description("Height of the region in cells.")] int height, + [Description("Width of the region in cells. Must be at most 256; width multiplied by height must be at most 10,000.")] int width, + [Description("Height of the region in cells. Must be at most 256; width multiplied by height must be at most 10,000.")] int height, CancellationToken cancellationToken) { Logger.Log($"{nameof(MapTools)}.{nameof(ScreenshotMapRegion)}"); @@ -312,6 +425,42 @@ public async Task ScreenshotMapRegion( } } + [McpServerTool(Name = "screenshot_whole_map", ReadOnly = true, OpenWorld = false)] + [Description("Renders the entire open map, scales the existing full-map render directly into a bounded preview, and returns it as PNG. The aspect ratio is preserved, neither requested dimension may exceed 4,096 pixels, and their product may not exceed 8,000,000 pixels. Defaults to a 2,048x2,048 envelope. This avoids allocating or encoding an additional full-resolution screenshot.")] + public async Task ScreenshotWholeMap( + [Description("Maximum preview width in pixels. Defaults to 2,048 and may be at most 4,096.")] int maxWidth = DefaultWholeMapPreviewDimension, + [Description("Maximum preview height in pixels. Defaults to 2,048 and may be at most 4,096.")] int maxHeight = DefaultWholeMapPreviewDimension, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(ScreenshotWholeMap)}"); + + if (maxWidth <= 0 || maxHeight <= 0) + throw new McpException("The maximum preview width and height must both be greater than zero."); + if (maxWidth > MaxWholeMapPreviewDimension || maxHeight > MaxWholeMapPreviewDimension) + { + throw new McpException( + $"The maximum preview width and height may each be at most {MaxWholeMapPreviewDimension} pixels."); + } + if ((long)maxWidth * maxHeight > MaxScreenshotPixelCount) + { + throw new McpException( + $"The maximum preview dimensions may contain at most {MaxScreenshotPixelCount} pixels."); + } + + if (!mapScreenCropper.TryRequestWholeMapPreview(maxWidth, maxHeight, cancellationToken, out Task previewTask)) + throw new McpException("The renderer is already busy with a previous screenshot request."); + + try + { + byte[] pngData = await previewTask.ConfigureAwait(false); + return ImageContentBlock.FromBytes(pngData, "image/png"); + } + catch (MapScreenCropException ex) + { + throw new McpException(ex.Message); + } + } + [McpServerTool(Name = "modify_technos", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] [Description("Atomically modifies properties of explicitly referenced technos. The entire batch is one undo entry and one revision bump.")] public async Task ModifyTechnos( @@ -403,6 +552,30 @@ public async Task PlaceOverlay( } } + [McpServerTool(Name = "place_overlay_collection", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Places random entries from an editor overlay collection across a rectangular brush area, replacing existing overlay. This uses WAE's existing collection randomizer and Tiberium placement behavior. The operation is one undo entry and one revision bump.")] + public async Task PlaceOverlayCollection( + [Description("Configuration name of an overlay collection returned by get_overlay_collections.")] string collectionName, + [Description("X coordinate of the area's top-left cell.")] int x, + [Description("Y coordinate of the area's top-left cell.")] int y, + [Description("Area width in cells. Defaults to 1.")] int width = 1, + [Description("Area height in cells. Defaults to 1.")] int height = 1, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(PlaceOverlayCollection)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.PlaceOverlayCollection(collectionName, x, y, width, height), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + [McpServerTool(Name = "place_connected_overlay", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] [Description("Places a WAE connected-overlay configuration in a rectangular map area, replacing existing overlay and automatically selecting frames and reconnecting neighboring members. The operation is one undo entry and one revision bump.")] public async Task PlaceConnectedOverlay( @@ -488,20 +661,41 @@ public async Task PlaceWaypoint( } } - [McpServerTool(Name = "place_terrain_object", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] - [Description("Places one terrain object, such as a tree, on an empty map cell. The placement is added to the editor's undo history.")] - public async Task PlaceTerrainObject( - [Description("INI name of the terrain object type to place.")] string terrainTypeName, - [Description("X coordinate of the destination cell.")] int x, - [Description("Y coordinate of the destination cell.")] int y, - CancellationToken cancellationToken) + [McpServerTool(Name = "place_terrain_objects_batch", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Atomically places multiple explicitly selected terrain object types on empty cells. Every placement is validated before any object is added; an invalid, duplicate, or occupied cell rejects the whole batch. The operation is one undo entry and one revision bump.")] + public async Task PlaceTerrainObjectsBatch( + [Description("One or more terrain object types and destination cells. At most 10,000 placements are supported.")] List placements, + [Description("Optional map revision returned by get_map_revision or another map tool. When supplied, placement fails if the map has changed; omit it to allow concurrent human edits.")] int? expectedRevision = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(PlaceTerrainObjectsBatch)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.PlaceTerrainObjectsBatch(placements, expectedRevision), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + + [McpServerTool(Name = "place_terrain_object_collection_batch", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Atomically places random entries from one editor terrain object collection on multiple explicit empty cells. Every coordinate is validated before placement; an invalid, duplicate, or occupied cell rejects the whole batch. The operation is one undo entry and one revision bump.")] + public async Task PlaceTerrainObjectCollectionBatch( + [Description("Configuration name of a terrain object collection returned by get_terrain_object_collections.")] string collectionName, + [Description("One or more distinct empty map-cell coordinates. At most 10,000 entries are supported.")] List cells, + [Description("Optional map revision returned by get_map_revision or another map tool. When supplied, placement fails if the map has changed; omit it to allow concurrent human edits.")] int? expectedRevision = null, + CancellationToken cancellationToken = default) { - Logger.Log($"{nameof(MapTools)}.{nameof(PlaceTerrainObject)}"); + Logger.Log($"{nameof(MapTools)}.{nameof(PlaceTerrainObjectCollectionBatch)}"); try { return await gameThreadDispatcher.InvokeAsync( - () => mapFacade.PlaceTerrainObject(terrainTypeName, x, y), + () => mapFacade.PlaceTerrainObjectCollectionBatch(collectionName, cells, expectedRevision), cancellationToken); } catch (MapFacadeValidationException ex) diff --git a/src/TSMapEditor/Config/Default/AIMappingInstructions.md b/src/TSMapEditor/Config/Default/AIMappingInstructions.md index bb4eb502a..fffc14eb4 100644 --- a/src/TSMapEditor/Config/Default/AIMappingInstructions.md +++ b/src/TSMapEditor/Config/Default/AIMappingInstructions.md @@ -16,6 +16,10 @@ Map dimensions can also be misleading when estimating the number of cells. Each When placing objects or requesting rectangular map regions, ensure that every required cell lies inside the valid diamond. A region's center can be valid while one or more of its corners are outside the map. +## Flat Ground + +While the Tiberian Sun game engine supports terrain height, the Dawn of the Tiberium Age mod does not use height. Everything that looks like "height" is just an illusion made with the graphical style of assets. All terrain is at height 0 and cannot be raised. + ## LAT Terrain Placement LAT is a system that smoothly connects basic ground terrain to other terrain. @@ -44,10 +48,28 @@ The following usually looks better and more natural: --XX-- ``` +Isolated AutoLAT placements may produce only transition tiles. Connected rows with sufficient thickness are needed to create core LAT. + +### AutoLAT Behaviour + +Always keep AutoLAT enabled unless you have a good reason not to, like a user request. + +AutoLAT preserves neighboring non-clear tilesets such as roads and cliffs. Do not disable AutoLAT merely to protect these tiles. + +Disable AutoLAT only when: + +- The user explicitly requests manual tile placement. + +- Performing a deliberate, localized correction requiring an exact tile index. + +Do not use individual raw tiles as decorative accents from a LAT-capable tileset. Paint connected or clustered areas through AutoLAT so their edges transition correctly. + ## Detailing Areas Aside from LATs, try to also use various other pieces when detailing large areas. Rocks, pebbles, trees, rough ground, debris, villages or cities, small closed lakes... there's usually a lot you can detail a map with. Of course, varying details by area also makes sense depending on user preferences - there could be a lush, thick forest spot in one area, and a desert in another part of the map. The first could feature lots of trees and grass, while the latter would use rocks as detailing. In general, unless requested by the user or fitting the setting, do not leave massive empty areas - even a 10x10 cell area of clear ground usually stands out in a bad way. +Pay special attention to detailing the corner areas of the map "diamond": it's easy to neglect those, but players do pay attention to them. + ## Layouting The Tiberian Sun and Red Alert 2 game engines and gameplay design don't work well with very tight bottlenecks. When designing layouts, ensure that each bottleneck has, at a minimum, a 3-cell row of passable ground at its tightest spot. More is generally preferred, though. Much past 10 cells it starts getting questionable whether something functions as a bottleneck anymore however. @@ -70,7 +92,7 @@ While an RTS game, classic Command & Conquer maps, especially Tiberian Sun maps, There are two types of resource fields in Command & Conquer games: regrowing and non-regrowing. -In Dawn of the Tiberium Age, regrowing fields contain a Ore Mine, Tiberium Tree (for Green Tiberium aka Riparius), or Vinifera Tree (for Blue Tiberium aka Vinifera), and a matching resource spreader on the same cell with the tree. Around the tree is resource overlay of the matching type depending on map design. Small fields are around 8 cells in diameter, while large fields can be double that. +In Dawn of the Tiberium Age, regrowing fields contain a Ore Mine, Tiberium Tree (for Green Tiberium aka Riparius), or Vinifera Tree (for Blue Tiberium aka Vinifera), and a matching resource spreader on the same cell with the tree. Around the tree is resource overlay of the matching type depending on map design. Never place overlay on the same cell where Tiberium Trees or Ore Mines exist. @@ -78,7 +100,21 @@ A good baseline for economy is 2 Ore Mines or Tiberium Trees per player. Tight-m For a non-regrowing resource field, simply leave out the Tiberium Tree and respective resource spreader. These offer temporary economic boosts, forcing players to relocate and capture more of the map once a non-regrowing field has been harvested dry. -There are 5 types of resources. Ore, Scrap Metal, and Green Tiberium are all equal in value, 700 for a full harvester load. Blue Tiberium is 1120, while Gems are 1680. +There are 5 types of resources. Ore, Scrap Metal, and Green Tiberium are all equal in value, 700 for a full harvester load. Blue Tiberium is 1120, while Gems are 1680. + +Prefer resource placement that leads to aggressive gameplay. Players having some safe resources is fine, but the majority of resources should be somehow contestable, either between individual players, or by all players (like resources placed centrally on the map). This forces players to expand and contest each other for resources. + +### Resource Field Size + +- A small resource field should span approximately 8–10 map cells from edge to edge. + +- A large resource field should span approximately 14–16 cells. + +- If a placement tool accepts a radius, use roughly radius 4 for a small field and radius 7–8 for a large field. Remember that radius 2 produces only a 5-cell-diameter field. + +- Measure the occupied resource footprint—not the surrounding empty area. + +Non-regrowing fields must be evaluated by their total initial resource value because they cannot replenish. A central contestable field should offer a meaningful reward relative to the risk of securing it. Higher-value resources such as blue Tiberium may use a somewhat smaller footprint, but not so small that the total harvestable value becomes strategically insignificant. ## Player Starting Waypoints @@ -86,4 +122,40 @@ When making multiplayer maps, waypoints 0 to 7 denote player starting locations. Additional waypoints, with IDs greater than 7, can be used for various map triggers, like scripted unit spawns or ambient sounds. -In singleplayer missions, no waypoints have special meaning, aside from 99 which is typically the "home cell". Do not use waypoint 100 for anything. \ No newline at end of file +In singleplayer missions, no waypoints have special meaning, aside from 99 which is typically the "home cell". Do not use waypoint 100 for anything. + +## Invidual Detailing Element Tips + +### Lakes + +Lakes are created by making an enclosed shoreline with the Connected Tile Tool and filling its interior area with water tiles. + +### Villages + +Villages are made by placing a cluster of Civilian Village buildings near each other, each a few cells apart. Dirt roads or paved roads can be placed around the village to form coherent paths, often with houses placed on both sides of the road. You might sometimes want to place the road first. Road pieces can be placed manually or with the Connected Tiles tool, though the tool often isn't very good at short distances. + +Under village buildings and around them should be Dirt LAT, in an irregular pattern. For example, an identical 2x2 dirt patch under each building looks bad - make sure to vary it. + +Around village buildings and on yards can be trees, pebbles, and/or rocks, depending on the biome you are aiming to create. If you place trees, you might consider placing a greener LAT like Tall Grass around the trees. + +### Cities + +Cities are a lot like villages, but use Civilian City buildings instead of Civilian Village, and the buildings are a bit larger. Make sure to use paved roads. Under buildings, you can use either Dirt LAT or Pavement LAT, depending on exactly how urbanized the area should look. + +### Forests + +Forests use a mix of trees (either conifer trees or leafy trees, or autumn trees, or in case of desert, cacti). Around the trees, there should be Tall Grass LAT or a similar LAT type, and pebbles. + +A small forest cluster should usually receive a connected 20–40-cell mask, 1–3 cells wide, with branches and at least one thicker core. These numbers can be doubled or tripled for medium-sized forests and multiplied by 10 for large forests. + +A recommended approach for placing LAT in forests is connecting tree anchors with an irregular walk, then adding occasional side branches and holes. + +### Base Areas + +Player starting locations cannot be very heavy on impassable details. Pebbles and tiles of the "Debris/Dirt" TileSet are suitable for detailing starting locations, as are roads and other details. + +## Avoid overlap + +The Connected Tiles tool does not prevent you from overlapping tiles, and will happily rewrite previously placed tiles. Make sure you don't overlap tiles accidentally - verify the results especially if you use the Connected Tiles tool multiple times in the same area of the map. + +Never place trees, overlays, or anything else on Rock-type cells. Those cells are impassable to everything and placing objects on top of them can cause issues in-game. diff --git a/src/TSMapEditor/Mutations/Classes/AIMutations/PlaceTerrainObjectBatchMutation.cs b/src/TSMapEditor/Mutations/Classes/AIMutations/PlaceTerrainObjectBatchMutation.cs new file mode 100644 index 000000000..db69a0a77 --- /dev/null +++ b/src/TSMapEditor/Mutations/Classes/AIMutations/PlaceTerrainObjectBatchMutation.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using TSMapEditor.GameMath; +using TSMapEditor.Models; +using TSMapEditor.UI; + +namespace TSMapEditor.Mutations.Classes.AIMutations; + +/// +/// Places multiple explicitly selected terrain objects as one mutation. +/// +public sealed class PlaceTerrainObjectBatchMutation : Mutation +{ + public PlaceTerrainObjectBatchMutation( + IMutationTarget mutationTarget, + List<(TerrainType TerrainType, Point2D CellCoords)> placements) + : base(mutationTarget) + { + this.placements = placements ?? throw new ArgumentNullException(nameof(placements)); + + if (placements.Count == 0) + throw new ArgumentException("At least one terrain object placement must be provided.", nameof(placements)); + } + + private readonly List<(TerrainType TerrainType, Point2D CellCoords)> placements; + private readonly List placedCellCoords = new(); + + public override string GetDisplayString() + { + return $"Place {placements.Count} terrain object(s)"; + } + + public override void Perform() + { + foreach ((TerrainType _, Point2D cellCoords) in placements) + { + var mapTile = Map.GetTile(cellCoords); + if (mapTile == null) + throw new InvalidOperationException($"Cell {cellCoords} does not exist."); + if (mapTile.TerrainObject != null) + throw new InvalidOperationException($"Cell {cellCoords} already contains a terrain object."); + } + + placedCellCoords.Clear(); + + foreach ((TerrainType terrainType, Point2D cellCoords) in placements) + { + Map.AddTerrainObject(new TerrainObject(terrainType, cellCoords)); + placedCellCoords.Add(cellCoords); + } + + MutationTarget.InvalidateMap(); + } + + public override void Undo() + { + RemovePlacedTerrainObjects(); + MutationTarget.InvalidateMap(); + } + + private void RemovePlacedTerrainObjects() + { + for (int i = placedCellCoords.Count - 1; i >= 0; i--) + Map.RemoveTerrainObject(placedCellCoords[i]); + + placedCellCoords.Clear(); + } +} diff --git a/src/TSMapEditor/Mutations/Classes/AIMutations/PlaceTerrainObjectCollectionBatchMutation.cs b/src/TSMapEditor/Mutations/Classes/AIMutations/PlaceTerrainObjectCollectionBatchMutation.cs new file mode 100644 index 000000000..99af4fa52 --- /dev/null +++ b/src/TSMapEditor/Mutations/Classes/AIMutations/PlaceTerrainObjectCollectionBatchMutation.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using TSMapEditor.GameMath; +using TSMapEditor.Models; +using TSMapEditor.UI; + +namespace TSMapEditor.Mutations.Classes.AIMutations; + +/// +/// Places random entries from a terrain object collection on multiple explicit map cells. +/// +public sealed class PlaceTerrainObjectCollectionBatchMutation : Mutation +{ + public PlaceTerrainObjectCollectionBatchMutation( + IMutationTarget mutationTarget, + TerrainObjectCollection terrainObjectCollection, + List cellCoords) + : base(mutationTarget) + { + this.terrainObjectCollection = terrainObjectCollection ?? throw new ArgumentNullException(nameof(terrainObjectCollection)); + this.cellCoords = cellCoords ?? throw new ArgumentNullException(nameof(cellCoords)); + + if (cellCoords.Count == 0) + throw new ArgumentException("At least one cell coordinate must be provided.", nameof(cellCoords)); + } + + private readonly TerrainObjectCollection terrainObjectCollection; + private readonly List cellCoords; + private readonly List placedCellCoords = new(); + + public override string GetDisplayString() + { + return $"Place terrain object collection '{terrainObjectCollection.Name}' on {cellCoords.Count} map cell(s)"; + } + + public override void Perform() + { + foreach (Point2D coords in cellCoords) + { + var mapTile = Map.GetTile(coords); + if (mapTile == null) + throw new InvalidOperationException($"Cell {coords} does not exist."); + if (mapTile.TerrainObject != null) + throw new InvalidOperationException($"Cell {coords} already contains a terrain object."); + } + + placedCellCoords.Clear(); + + foreach (Point2D coords in cellCoords) + { + var collectionEntry = terrainObjectCollection.Entries[ + MutationTarget.Randomizer.GetRandomNumber(0, terrainObjectCollection.Entries.Length - 1)]; + Map.AddTerrainObject(new TerrainObject(collectionEntry.TerrainType, coords)); + placedCellCoords.Add(coords); + } + + MutationTarget.InvalidateMap(); + } + + public override void Undo() + { + RemovePlacedTerrainObjects(); + MutationTarget.InvalidateMap(); + } + + private void RemovePlacedTerrainObjects() + { + for (int i = placedCellCoords.Count - 1; i >= 0; i--) + Map.RemoveTerrainObject(placedCellCoords[i]); + + placedCellCoords.Clear(); + } +} diff --git a/src/TSMapEditor/Mutations/Classes/PlaceOverlayCollectionMutation.cs b/src/TSMapEditor/Mutations/Classes/PlaceOverlayCollectionMutation.cs index 1fb39bd17..436fa3d31 100644 --- a/src/TSMapEditor/Mutations/Classes/PlaceOverlayCollectionMutation.cs +++ b/src/TSMapEditor/Mutations/Classes/PlaceOverlayCollectionMutation.cs @@ -12,10 +12,15 @@ namespace TSMapEditor.Mutations.Classes /// class PlaceOverlayCollectionMutation : Mutation, ICheckableMutation { - public PlaceOverlayCollectionMutation(IMutationTarget mutationTarget, OverlayCollection overlayCollection, Point2D cellCoords) : base(mutationTarget) + public PlaceOverlayCollectionMutation(IMutationTarget mutationTarget, OverlayCollection overlayCollection, Point2D cellCoords) + : this(mutationTarget, overlayCollection, cellCoords, mutationTarget.BrushSize) + { + } + + public PlaceOverlayCollectionMutation(IMutationTarget mutationTarget, OverlayCollection overlayCollection, Point2D cellCoords, BrushSize brush) : base(mutationTarget) { this.overlayCollection = overlayCollection; - this.brush = mutationTarget.BrushSize; + this.brush = brush ?? throw new ArgumentNullException(nameof(brush)); this.cellCoords = cellCoords; } diff --git a/src/TSMapEditor/Mutations/IMutation.cs b/src/TSMapEditor/Mutations/IMutation.cs index c73044d43..3b8488bd7 100644 --- a/src/TSMapEditor/Mutations/IMutation.cs +++ b/src/TSMapEditor/Mutations/IMutation.cs @@ -3,9 +3,10 @@ public interface IMutation { int EventID { get; } + MutationHistoryMetadata HistoryMetadata { get; } string GetDisplayString(); void Perform(); void Undo(); } -} \ No newline at end of file +} diff --git a/src/TSMapEditor/Mutations/Mutation.cs b/src/TSMapEditor/Mutations/Mutation.cs index a710222eb..adc5b3d66 100644 --- a/src/TSMapEditor/Mutations/Mutation.cs +++ b/src/TSMapEditor/Mutations/Mutation.cs @@ -32,6 +32,18 @@ public Mutation(IMutationTarget mutationTarget) public int EventID { get; protected set; } = -1; + public MutationHistoryMetadata HistoryMetadata { get; private set; } + + public void SetHistoryMetadata(MutationHistoryMetadata historyMetadata) + { + ArgumentNullException.ThrowIfNull(historyMetadata); + + if (HistoryMetadata != null) + throw new InvalidOperationException("Mutation history metadata has already been set."); + + HistoryMetadata = historyMetadata; + } + private static readonly Point2D[] surroundingTiles = new Point2D[] { new Point2D(-1, 0), new Point2D(1, 0), new Point2D(0, -1), new Point2D(0, 1) }; diff --git a/src/TSMapEditor/Mutations/MutationHistoryMetadata.cs b/src/TSMapEditor/Mutations/MutationHistoryMetadata.cs new file mode 100644 index 000000000..fd2f40ae8 --- /dev/null +++ b/src/TSMapEditor/Mutations/MutationHistoryMetadata.cs @@ -0,0 +1,43 @@ +using System; + +namespace TSMapEditor.Mutations +{ + public class MutationAffectedCells + { + public MutationAffectedCells(bool isKnown, int? count, int? minX, int? minY, int? maxX, int? maxY) + { + IsKnown = isKnown; + Count = count; + MinX = minX; + MinY = minY; + MaxX = maxX; + MaxY = maxY; + } + + public bool IsKnown { get; } + public int? Count { get; } + public int? MinX { get; } + public int? MinY { get; } + public int? MaxX { get; } + public int? MaxY { get; } + + } + + public class MutationHistoryMetadata + { + public MutationHistoryMetadata(long mutationId, int createdRevision, string origin, string toolName, MutationAffectedCells affectedCells) + { + MutationId = mutationId; + CreatedRevision = createdRevision; + Origin = origin; + ToolName = toolName; + AffectedCells = affectedCells ?? throw new ArgumentNullException(nameof(affectedCells)); + } + + public long MutationId { get; } + public int CreatedRevision { get; } + public string Origin { get; } + public string ToolName { get; } + public MutationAffectedCells AffectedCells { get; } + } +} diff --git a/src/TSMapEditor/Mutations/MutationManager.cs b/src/TSMapEditor/Mutations/MutationManager.cs index 8fd7da2c4..c2f599287 100644 --- a/src/TSMapEditor/Mutations/MutationManager.cs +++ b/src/TSMapEditor/Mutations/MutationManager.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; namespace TSMapEditor.Mutations { @@ -7,11 +8,15 @@ namespace TSMapEditor.Mutations /// public class MutationManager { + public const string MCPMutationOrigin = "MCP"; + public List UndoList { get; } = new List(); public List RedoList { get; } = new List(); public int Revision { get; private set; } + private long nextMutationId = 1; + /// /// Performs a new mutation on the map. /// @@ -24,6 +29,21 @@ public void PerformMutation(IMutation mutation) Revision++; } + /// + /// Performs a new MCP mutation on the map and records information about its source. + /// + public void PerformMutation(Mutation mutation, string origin, string toolName, MutationAffectedCells affectedCells) + { + PerformMutation(mutation); + + mutation.SetHistoryMetadata(new MutationHistoryMetadata(nextMutationId, Revision, + string.IsNullOrWhiteSpace(origin) ? MCPMutationOrigin : origin, + string.IsNullOrWhiteSpace(toolName) ? mutation.GetType().Name : toolName, + affectedCells ?? throw new ArgumentNullException(nameof(affectedCells)))); + + nextMutationId++; + } + public bool CanUndo() => UndoList.Count > 0; /// diff --git a/src/TSMapEditor/Rendering/MapView.cs b/src/TSMapEditor/Rendering/MapView.cs index 21e382053..afcddd50b 100644 --- a/src/TSMapEditor/Rendering/MapView.cs +++ b/src/TSMapEditor/Rendering/MapView.cs @@ -24,6 +24,7 @@ namespace TSMapEditor.Rendering public interface IMapScreenCropper { bool TryRequestScreenCrop(Rectangle cellRectangle, CancellationToken cancellationToken, out Task screenCropTask); + bool TryRequestWholeMapPreview(int maxPixelWidth, int maxPixelHeight, CancellationToken cancellationToken, out Task previewTask); void StopScreenCropRequests(); } @@ -240,6 +241,32 @@ public void AddRefreshPoint(Point2D point, int size = 1) #region Screen-crop support for MCP public bool TryRequestScreenCrop(Rectangle cellRectangle, CancellationToken cancellationToken, out Task screenCropTask) + { + return TryRequestScreenCapture( + cellRectangle, + () => GetScreenCropLayout(cellRectangle), + cancellationToken, + out screenCropTask); + } + + public bool TryRequestWholeMapPreview( + int maxPixelWidth, + int maxPixelHeight, + CancellationToken cancellationToken, + out Task previewTask) + { + return TryRequestScreenCapture( + Rectangle.Empty, + () => GetWholeMapPreviewLayout(maxPixelWidth, maxPixelHeight), + cancellationToken, + out previewTask); + } + + private bool TryRequestScreenCapture( + Rectangle cellRectangle, + Func getPixelLayout, + CancellationToken cancellationToken, + out Task screenCropTask) { cancellationToken.ThrowIfCancellationRequested(); @@ -269,7 +296,7 @@ public bool TryRequestScreenCrop(Rectangle cellRectangle, CancellationToken canc return false; } - ScreenCropLayout pixelLayout = GetScreenCropLayout(cellRectangle); + ScreenCropLayout pixelLayout = getPixelLayout(); var request = new ScreenCropRequest(cellRectangle, cancellationToken, ScreenCropRequest_Canceled); request.CalculatedPixelLayout = pixelLayout; screenCropRequest = request; @@ -387,6 +414,26 @@ private ScreenCropLayout GetScreenCropLayout(Rectangle cellRectangle) return new ScreenCropLayout(outputWidth, outputHeight, sourceRectangle, destinationRectangle); } + private ScreenCropLayout GetWholeMapPreviewLayout(int maxPixelWidth, int maxPixelHeight) + { + if (compositeRenderTarget == null) + throw new MapScreenCropException("The map renderer is not available."); + if (maxPixelWidth <= 0 || maxPixelHeight <= 0) + throw new MapScreenCropException("The maximum preview width and height must both be greater than zero."); + + int sourceWidth = compositeRenderTarget.Width; + int sourceHeight = compositeRenderTarget.Height; + double scale = Math.Min( + 1.0, + Math.Min((double)maxPixelWidth / sourceWidth, (double)maxPixelHeight / sourceHeight)); + int outputWidth = Math.Max(1, (int)Math.Floor(sourceWidth * scale)); + int outputHeight = Math.Max(1, (int)Math.Floor(sourceHeight * scale)); + + var sourceRectangle = new Rectangle(0, 0, sourceWidth, sourceHeight); + var destinationRectangle = new Rectangle(0, 0, outputWidth, outputHeight); + return new ScreenCropLayout(outputWidth, outputHeight, sourceRectangle, destinationRectangle); + } + private byte[] CaptureScreenCrop(ScreenCropLayout layout) { using var cropRenderTarget = new RenderTarget2D( @@ -397,7 +444,9 @@ private byte[] CaptureScreenCrop(ScreenCropLayout layout) SurfaceFormat.Color, DepthFormat.None); - Renderer.PushRenderTarget(cropRenderTarget); + Renderer.PushRenderTarget( + cropRenderTarget, + new SpriteBatchSettings(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, null, null, null)); GraphicsDevice.Clear(Color.Transparent); diff --git a/src/TSMapEditor/UI/MapUI.cs b/src/TSMapEditor/UI/MapUI.cs index fb0a66661..96a983697 100644 --- a/src/TSMapEditor/UI/MapUI.cs +++ b/src/TSMapEditor/UI/MapUI.cs @@ -123,6 +123,9 @@ public CopiedMapData CopiedMapData public bool TryRequestScreenCrop(Rectangle cellRectangle, CancellationToken cancellationToken, out Task screenCropTask) => mapView.TryRequestScreenCrop(cellRectangle, cancellationToken, out screenCropTask); + public bool TryRequestWholeMapPreview(int maxPixelWidth, int maxPixelHeight, CancellationToken cancellationToken, out Task previewTask) + => mapView.TryRequestWholeMapPreview(maxPixelWidth, maxPixelHeight, cancellationToken, out previewTask); + public void StopScreenCropRequests() => mapView.StopScreenCropRequests(); public Camera Camera => mapView.Camera; From 05c9f39e0cc56e87a44d8266b0d88b0a957f07f2 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Wed, 5 Aug 2026 23:13:29 +0300 Subject: [PATCH 23/27] Make it possible to clear selection of Copy Custom Shape tool --- .../Config/Translations/en/Translation_en.ini | 1 + .../CopyCustomShapedTerrainCursorAction.cs | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/TSMapEditor/Config/Translations/en/Translation_en.ini b/src/TSMapEditor/Config/Translations/en/Translation_en.ini index ced57ab4f..e841493f6 100644 --- a/src/TSMapEditor/Config/Translations/en/Translation_en.ini +++ b/src/TSMapEditor/Config/Translations/en/Translation_en.ini @@ -1387,6 +1387,7 @@ ConnectedOverlayPlacementAction.Name=Place Connected Overlay CopyCustomShapedTerrainCursorAction.Name=Copy Terrain (Custom Shape) CopyCustomShapedTerrainCursorAction.LeftClickText=Press left click on cells to mark them to be copied. CopyCustomShapedTerrainCursorAction.ShiftText=Hold SHIFT while pressing to remove cells. +CopyCustomShapedTerrainCursorAction.ClearText=Press ESC to clear selection. CopyCustomShapedTerrainCursorAction.EnterText=Press ENTER when ready to copy the cells to the clipboard. CopyRectangularTerrainCursorAction.Name=Copy Terrain (Rectangular) diff --git a/src/TSMapEditor/UI/CursorActions/CopyCustomShapedTerrainCursorAction.cs b/src/TSMapEditor/UI/CursorActions/CopyCustomShapedTerrainCursorAction.cs index 7eaedf935..6082e87c9 100644 --- a/src/TSMapEditor/UI/CursorActions/CopyCustomShapedTerrainCursorAction.cs +++ b/src/TSMapEditor/UI/CursorActions/CopyCustomShapedTerrainCursorAction.cs @@ -33,6 +33,13 @@ public CopyCustomShapedTerrainCursorAction(ICursorActionTarget cursorActionTarge public override void OnActionEnter() => modified = true; + private void Clear() + { + cellsToCopy.Clear(); + cellsToCopyList.Clear(); + modified = true; + } + public override void LeftDown(Point2D cellCoords) { CursorActionTarget.BrushSize.DoForBrushSize(offset => @@ -60,7 +67,12 @@ public override void OnKeyPressed(KeyPressEventArgs e, Point2D cellCoords) { base.OnKeyPressed(e, cellCoords); - if (e.PressedKey == Microsoft.Xna.Framework.Input.Keys.Enter) + if (e.PressedKey == Microsoft.Xna.Framework.Input.Keys.Escape) + { + Clear(); + e.Handled = true; + } + else if (e.PressedKey == Microsoft.Xna.Framework.Input.Keys.Enter) { CopyFromCells(cellsToCopyList); cellsToCopy.Clear(); @@ -147,7 +159,8 @@ public override void DrawPreview(Point2D cellCoords, Point2D cameraTopLeftPoint) } string text = Translate("LeftClickText", "Press left click on cells to mark them to be copied.") + Environment.NewLine + Environment.NewLine + - Translate("ShiftText", "Hold SHIFT while pressing to remove cells.") + Environment.NewLine + Environment.NewLine + + Translate("ShiftText", "Hold SHIFT while pressing to remove cells.") + Environment.NewLine + + Translate("ClearText", "Press ESC to clear selection.") + Environment.NewLine + Environment.NewLine + Translate("EnterText", "Press ENTER when ready to copy the cells to the clipboard."); DrawText(cellCoords, cameraTopLeftPoint, 90, -200, text, Color.Yellow); From dd216b5aee6289f5537864ab2d2b29a0589677d6 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Wed, 5 Aug 2026 23:14:23 +0300 Subject: [PATCH 24/27] Fix Map.GetCellCount using incorrect formula --- src/TSMapEditor/Models/Map.cs | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/src/TSMapEditor/Models/Map.cs b/src/TSMapEditor/Models/Map.cs index 81a46adb1..5fe2b7b4c 100644 --- a/src/TSMapEditor/Models/Map.cs +++ b/src/TSMapEditor/Models/Map.cs @@ -574,27 +574,9 @@ public void SetTileData(List tiles, byte defaultLevel = 0, bool overrid public int GetCellCount() { - int cellCount = 0; - - int ox = 1; - int oy = Size.X; - while (ox <= Size.Y) - { - int tx = ox; - int ty = oy; - while (tx < Size.X + ox) - { - cellCount += 2; - - tx++; - ty--; - } - - ox++; - oy++; - } - - return cellCount; + // Each unit of map height consists of a full row and an adjacent row. + // The adjacent row has one fewer cell because of the isometric map border. + return Size.Y * (Size.X + (Size.X - 1)); } /// From de14d7de2e4f83cf06fbf9a827e2c82a411af85d Mon Sep 17 00:00:00 2001 From: Rampastring Date: Wed, 5 Aug 2026 23:16:59 +0300 Subject: [PATCH 25/27] Make it possible to use terrain generator presets through the MCP server --- src/TSMapEditor/AI/MapFacade.cs | 669 ++++++++++++++++-- src/TSMapEditor/AI/MapOverlayPlacement.cs | 18 + .../AI/MapTerrainGeneratorPresetInfo.cs | 239 +++++++ src/TSMapEditor/AI/MapTerrainTileInfo.cs | 102 +++ src/TSMapEditor/AI/MapTools.cs | 129 +++- .../Config/Default/AIMappingInstructions.md | 30 +- .../AIMutations/OverlayBatchMutationBase.cs | 86 +++ .../AIMutations/PlaceOverlayBatchMutation.cs | 88 +++ .../PlaceOverlayCollectionBatchMutation.cs | 93 +++ .../Classes/TerrainGenerationMutation.cs | 34 +- 10 files changed, 1413 insertions(+), 75 deletions(-) create mode 100644 src/TSMapEditor/AI/MapOverlayPlacement.cs create mode 100644 src/TSMapEditor/AI/MapTerrainGeneratorPresetInfo.cs create mode 100644 src/TSMapEditor/AI/MapTerrainTileInfo.cs create mode 100644 src/TSMapEditor/Mutations/Classes/AIMutations/OverlayBatchMutationBase.cs create mode 100644 src/TSMapEditor/Mutations/Classes/AIMutations/PlaceOverlayBatchMutation.cs create mode 100644 src/TSMapEditor/Mutations/Classes/AIMutations/PlaceOverlayCollectionBatchMutation.cs diff --git a/src/TSMapEditor/AI/MapFacade.cs b/src/TSMapEditor/AI/MapFacade.cs index 6e1b278e4..297438f41 100644 --- a/src/TSMapEditor/AI/MapFacade.cs +++ b/src/TSMapEditor/AI/MapFacade.cs @@ -1,6 +1,7 @@ using Microsoft.Xna.Framework; using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using TSMapEditor.CCEngine; using TSMapEditor.CCEngine.TileData; @@ -11,6 +12,7 @@ using TSMapEditor.Mutations.Classes; using TSMapEditor.Mutations.Classes.AIMutations; using TSMapEditor.Rendering; +using TSMapEditor.Settings; using TSMapEditor.UI; namespace TSMapEditor.AI; @@ -283,7 +285,15 @@ public MapHouseInfo(string iniName, string houseTypeName, string color) public class MapTileSetInfo { - public MapTileSetInfo(int index, string setName, string uiName, int startTileIndex, int tileCount, bool only1x1) + public MapTileSetInfo( + int index, + string setName, + string uiName, + int startTileIndex, + int tileCount, + bool only1x1, + List tilesWithUsableGraphics, + List tilesWithoutUsableGraphics) { Index = index; SetName = setName; @@ -291,14 +301,39 @@ public MapTileSetInfo(int index, string setName, string uiName, int startTileInd StartTileIndex = startTileIndex; TileCount = tileCount; Only1x1 = only1x1; + TilesWithUsableGraphics = tilesWithUsableGraphics; + TilesWithoutUsableGraphics = tilesWithoutUsableGraphics; } + [Description("Zero-based tile-set index in the loaded theater.")] public int Index { get; } + + [Description("Internal tile-set name used by place_terrain_tile.")] public string SetName { get; } + + [Description("Localized tile-set name shown by the editor.")] public string UIName { get; } + + [Description("Absolute theater index of the tile set's first tile.")] public int StartTileIndex { get; } + + [Description("Total number of tile entries in the set, including entries with missing or unusable graphics.")] public int TileCount { get; } + + [Description("Whether the editor restricts this tile set to a 1x1 placement brush.")] public bool Only1x1 { get; } + + [Description("Number of tile entries that have at least one valid graphical subtile.")] + public int UsableTileCount => TilesWithUsableGraphics.Count; + + [Description("Number of tile entries whose graphics are missing or unusable.")] + public int UnusableTileCount => TilesWithoutUsableGraphics.Count; + + [Description("Tiles that can be placed, identified by both absolute and tile-set-relative index.")] + public List TilesWithUsableGraphics { get; } + + [Description("Tiles whose graphics are missing or unusable, identified by both absolute and tile-set-relative index.")] + public List TilesWithoutUsableGraphics { get; } } public class MapEditResult @@ -728,13 +763,33 @@ public List GetTileSets(string nameFilter = null) return map.TheaterInstance.Theater.TileSets .Where(IsTileSetPlaceable) - .Select(tileSet => new MapTileSetInfo( - tileSet.Index, - tileSet.SetName, - tileSet.TranslatedName, - tileSet.StartTileIndex, - tileSet.LoadedTileCount, - tileSet.Only1x1)) + .Select(tileSet => + { + var tilesWithUsableGraphics = new List(); + var tilesWithoutUsableGraphics = new List(); + + for (int tileIndexInTileSet = 0; tileIndexInTileSet < tileSet.LoadedTileCount; tileIndexInTileSet++) + { + int absoluteTileIndex = tileSet.StartTileIndex + tileIndexInTileSet; + var tileIndexInfo = new MapTileIndexInfo(absoluteTileIndex, tileIndexInTileSet); + TileImage tile = mutationTarget.TheaterGraphics.GetTileImage(absoluteTileIndex); + + if (HasUsableTileGraphics(tile)) + tilesWithUsableGraphics.Add(tileIndexInfo); + else + tilesWithoutUsableGraphics.Add(tileIndexInfo); + } + + return new MapTileSetInfo( + tileSet.Index, + tileSet.SetName, + tileSet.TranslatedName, + tileSet.StartTileIndex, + tileSet.LoadedTileCount, + tileSet.Only1x1, + tilesWithUsableGraphics, + tilesWithoutUsableGraphics); + }) .Where(tileSetInfo => string.IsNullOrWhiteSpace(normalizedFilter) || ContainsIgnoringCase(tileSetInfo.SetName, normalizedFilter) || ContainsIgnoringCase(tileSetInfo.UIName, normalizedFilter)) @@ -743,6 +798,119 @@ public List GetTileSets(string nameFilter = null) .ToList(); } + public MapTerrainTileInfo GetTileDetails(int absoluteTileIndex) + { + if (absoluteTileIndex < 0 || absoluteTileIndex >= mutationTarget.TheaterGraphics.TileCount) + { + throw new MapFacadeValidationException( + $"Absolute tile index {absoluteTileIndex} is outside the loaded theater's range of 0 through {mutationTarget.TheaterGraphics.TileCount - 1}."); + } + + TileImage tile = mutationTarget.TheaterGraphics.GetTileImage(absoluteTileIndex); + if (tile == null || tile.TileSetId < 0 || tile.TileSetId >= map.TheaterInstance.Theater.TileSets.Count) + throw new MapFacadeValidationException($"Absolute tile index {absoluteTileIndex} has invalid tile-set metadata."); + + TileSet tileSet = map.TheaterInstance.Theater.TileSets[tile.TileSetId]; + var validSubTiles = new List(); + if (tile.Width > 0 && tile.Height > 0) + { + for (int subTileIndex = 0; subTileIndex < tile.SubTileCount; subTileIndex++) + { + ISubTileImage subTile = tile.GetSubTile(subTileIndex); + Point2D? coordOffset = tile.GetSubTileCoordOffset(subTileIndex); + if (subTile?.TmpImage == null || !coordOffset.HasValue) + continue; + + validSubTiles.Add(new MapTerrainSubTileInfo( + subTileIndex, + coordOffset.Value.X, + coordOffset.Value.Y, + subTile.TmpImage.Height)); + } + } + + return new MapTerrainTileInfo( + absoluteTileIndex, + tileSet.Index, + tileSet.SetName, + tileSet.TranslatedName, + tile.TileIndexInTileSet, + HasUsableTileGraphics(tile), + tile.Width, + tile.Height, + tile.SubTileCount, + validSubTiles); + } + + public List GetTerrainGeneratorPresets() + { + return LoadTerrainGeneratorPresets() + .Select(preset => + { + TerrainGeneratorConfiguration configuration = preset.Configuration; + return new MapTerrainGeneratorPresetSummary( + preset.PresetId, + configuration.Name, + configuration.Theater, + configuration.IsUserConfiguration, + GetTerrainGeneratorValidationErrors(configuration).Count == 0, + configuration.TerrainTypeGroups.Count, + configuration.TileGroups.Count, + configuration.OverlayGroups.Count, + configuration.SmudgeGroups.Count); + }) + .OrderBy(preset => preset.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(preset => preset.PresetId, StringComparer.Ordinal) + .ToList(); + } + + public MapTerrainGeneratorPresetInfo GetTerrainGeneratorPreset(string presetId) + { + TerrainGeneratorPresetDefinition preset = FindTerrainGeneratorPreset(presetId); + TerrainGeneratorConfiguration configuration = preset.Configuration; + + return new MapTerrainGeneratorPresetInfo( + preset.PresetId, + configuration.Name, + configuration.Theater, + configuration.IsUserConfiguration, + GetTerrainGeneratorValidationErrors(configuration), + configuration.TerrainTypeGroups + .Select(group => new MapTerrainGeneratorTerrainTypeGroupInfo( + group.OpenChance, + group.OverlapChance, + group.TerrainTypes + .Where(terrainType => terrainType != null) + .Select(terrainType => terrainType.ININame) + .ToList())) + .ToList(), + configuration.TileGroups + .Select(group => new MapTerrainGeneratorTileGroupInfo( + group.OpenChance, + group.OverlapChance, + group.TileSet?.SetName, + group.TileIndicesInSet == null || group.TileIndicesInSet.Count == 0, + group.TileIndicesInSet?.ToList() ?? new List())) + .ToList(), + configuration.OverlayGroups + .Select(group => new MapTerrainGeneratorOverlayGroupInfo( + group.OpenChance, + group.OverlapChance, + group.OverlayType?.ININame, + group.FrameIndices == null || group.FrameIndices.Count == 0, + group.FrameIndices?.ToList() ?? new List())) + .ToList(), + configuration.SmudgeGroups + .Select(group => new MapTerrainGeneratorSmudgeGroupInfo( + group.OpenChance, + group.OverlapChance, + group.SmudgeTypes + .Where(smudgeType => smudgeType != null) + .Select(smudgeType => smudgeType.ININame) + .ToList())) + .ToList()); + } + public List InspectRegion(Rectangle rectangle) { var returnValue = new List(); @@ -962,45 +1130,93 @@ public MapEditResult EraseOverlay(int x, int y, int width, int height) affectedMapTiles.Select(mapTile => CellInfo.FromMapCell(map, mapTile)).ToList()); } - public MapEditResult PlaceOverlay(string overlayTypeName, int x, int y, int width, int height, int? frameIndex) + public MapEditResult PlaceOverlaysBatch(List placements, int? expectedRevision) { - if (string.IsNullOrWhiteSpace(overlayTypeName)) - throw new MapFacadeValidationException("An overlay type INI name must be provided."); + if (expectedRevision.HasValue && expectedRevision.Value != mutationManager.Revision) + { + throw new MapFacadeValidationException( + $"The map revision changed from {expectedRevision.Value} to {mutationManager.Revision}. Query the map again before placing overlays."); + } - var overlayType = map.Rules.OverlayTypes.Find(candidate => string.Equals(candidate.ININame, overlayTypeName, StringComparison.OrdinalIgnoreCase)); + if (placements == null || placements.Count == 0) + throw new MapFacadeValidationException("At least one overlay placement must be provided."); + if (placements.Count > MaxMapOperationCellCount) + throw new MapFacadeValidationException($"At most {MaxMapOperationCellCount} overlays can be placed in one batch."); - if (overlayType == null) - throw new MapFacadeValidationException($"Overlay type '{overlayTypeName}' does not exist in the loaded rules."); - if (!overlayType.EditorVisible) - throw new MapFacadeValidationException($"Overlay type '{overlayType.ININame}' is not available for placement in the editor."); - if (!overlayType.IsValidForTheater(map.LoadedTheaterName)) - throw new MapFacadeValidationException($"Overlay type '{overlayType.ININame}' is not valid for theater '{map.LoadedTheaterName}'."); + var resolvedPlacements = new List<(OverlayType OverlayType, Point2D CellCoords, int? FrameIndex)>(placements.Count); + var distinctCellCoords = new HashSet(); + bool hasChange = false; - ValidateOverlayFrame(overlayType, frameIndex ?? 0); + for (int i = 0; i < placements.Count; i++) + { + MapOverlayPlacement placement = placements[i]; + if (placement == null) + throw new MapFacadeValidationException($"Overlay placement {i} cannot be null."); + if (string.IsNullOrWhiteSpace(placement.OverlayTypeName)) + throw new MapFacadeValidationException($"Overlay placement {i} must specify an overlay type INI name."); - var targetMapTiles = GetValidatedMapTilesInArea(x, y, width, height, "overlay placement"); + OverlayType overlayType = map.Rules.OverlayTypes.Find( + candidate => string.Equals(candidate.ININame, placement.OverlayTypeName, StringComparison.OrdinalIgnoreCase)); + if (overlayType == null) + throw new MapFacadeValidationException($"Overlay type '{placement.OverlayTypeName}' does not exist in the loaded rules."); + if (!overlayType.EditorVisible) + throw new MapFacadeValidationException($"Overlay type '{overlayType.ININame}' is not available for placement in the editor."); + if (!overlayType.IsValidForTheater(map.LoadedTheaterName)) + throw new MapFacadeValidationException($"Overlay type '{overlayType.ININame}' is not valid for theater '{map.LoadedTheaterName}'."); - var affectedMapTiles = targetMapTiles - .SelectMany(mapTile => GetMapTileAndSurroundings(mapTile.CoordsToPoint())) - .Distinct() - .ToList(); + ValidateOverlayFrame(overlayType, placement.FrameIndex ?? 0); - var mutation = new PlaceOverlayMutation(mutationTarget, overlayType, frameIndex, new Point2D(x, y), new BrushSize(width, height)); + var cellCoords = new Point2D(placement.X, placement.Y); + MapTile mapTile = map.GetTile(cellCoords); + if (mapTile == null) + throw new MapFacadeValidationException($"Overlay placement {i} at ({placement.X}, {placement.Y}) is outside the map."); + if (!distinctCellCoords.Add(cellCoords)) + throw new MapFacadeValidationException($"Cell coordinate ({placement.X}, {placement.Y}) is included more than once."); - if (!mutation.ShouldPerform()) - throw new MapFacadeValidationException($"The requested area already contains overlay '{overlayType.ININame}' with the requested frame settings."); + int requestedFrameIndex = placement.FrameIndex ?? 0; + if (mapTile.Overlay?.OverlayType != overlayType || mapTile.Overlay.FrameIndex != requestedFrameIndex) + hasChange = true; - PerformMCPMutation(mutation, "place_overlay", affectedMapTiles); + resolvedPlacements.Add((overlayType, cellCoords, placement.FrameIndex)); + } + + if (!hasChange) + throw new MapFacadeValidationException("Every requested cell already contains the requested overlay and frame settings."); + + var affectedMapTiles = distinctCellCoords + .SelectMany(GetMapTileAndSurroundings) + .Distinct() + .OrderBy(mapTile => mapTile.Y) + .ThenBy(mapTile => mapTile.X) + .ToList(); + + PerformMCPMutation( + new PlaceOverlayBatchMutation(mutationTarget, resolvedPlacements), + "place_overlays_batch", + affectedMapTiles); return new MapEditResult( mutationManager.Revision, affectedMapTiles.Select(mapTile => CellInfo.FromMapCell(map, mapTile)).ToList()); } - public MapEditResult PlaceOverlayCollection(string collectionName, int x, int y, int width, int height) + public MapEditResult PlaceOverlayCollectionBatch( + string collectionName, + List cells, + int? expectedRevision) { + if (expectedRevision.HasValue && expectedRevision.Value != mutationManager.Revision) + { + throw new MapFacadeValidationException( + $"The map revision changed from {expectedRevision.Value} to {mutationManager.Revision}. Query the map again before placing the overlay collection."); + } + if (string.IsNullOrWhiteSpace(collectionName)) throw new MapFacadeValidationException("An overlay collection name must be provided."); + if (cells == null || cells.Count == 0) + throw new MapFacadeValidationException("At least one cell coordinate must be provided."); + if (cells.Count > MaxMapOperationCellCount) + throw new MapFacadeValidationException($"At most {MaxMapOperationCellCount} overlay collection entries can be placed in one batch."); var collection = map.EditorConfig.OverlayCollections.Find( candidate => string.Equals(candidate.Name, collectionName, StringComparison.OrdinalIgnoreCase)); @@ -1022,19 +1238,35 @@ public MapEditResult PlaceOverlayCollection(string collectionName, int x, int y, ValidateOverlayFrame(entry.OverlayType, entry.Frame); } - var targetMapTiles = GetValidatedMapTilesInArea(x, y, width, height, "overlay collection placement"); - var affectedMapTiles = targetMapTiles - .SelectMany(mapTile => GetMapTileAndSurroundings(mapTile.CoordsToPoint())) + var distinctCellCoords = new HashSet(); + for (int i = 0; i < cells.Count; i++) + { + MapCellCoordinate cell = cells[i]; + if (cell == null) + throw new MapFacadeValidationException($"Cell coordinate {i} cannot be null."); + + var cellCoords = new Point2D(cell.X, cell.Y); + if (map.GetTile(cellCoords) == null) + throw new MapFacadeValidationException($"Cell coordinate {i} at ({cell.X}, {cell.Y}) is outside the map."); + if (!distinctCellCoords.Add(cellCoords)) + throw new MapFacadeValidationException($"Cell coordinate ({cell.X}, {cell.Y}) is included more than once."); + } + + var orderedCellCoords = distinctCellCoords + .OrderBy(coords => coords.Y) + .ThenBy(coords => coords.X) + .ToList(); + var affectedMapTiles = orderedCellCoords + .SelectMany(GetMapTileAndSurroundings) .Distinct() + .OrderBy(mapTile => mapTile.Y) + .ThenBy(mapTile => mapTile.X) .ToList(); - var mutation = new PlaceOverlayCollectionMutation( - mutationTarget, - collection, - new Point2D(x, y), - new BrushSize(width, height)); - - PerformMCPMutation(mutation, "place_overlay_collection", affectedMapTiles); + PerformMCPMutation( + new PlaceOverlayCollectionBatchMutation(mutationTarget, collection, orderedCellCoords), + "place_overlay_collection_batch", + affectedMapTiles); return new MapEditResult( mutationManager.Revision, @@ -1593,7 +1825,7 @@ public MapEditResult PlaceTerrainTile(string tileSetName, int tileIndexInTileSet throw new MapFacadeValidationException($"Absolute tile index {tileIndex} is not loaded."); ITileImage tile = map.TheaterInstance.GetTile(tileIndex); - if (tile == null || tile.Width <= 0 || tile.Height <= 0 || tile.SubTileCount <= 0) + if (!HasUsableTileGraphics(tile)) { throw new MapFacadeValidationException($"Tile {tileIndexInTileSet} from tile set '{tileSet.SetName}' has no usable tile graphics."); } @@ -1646,10 +1878,11 @@ public MapEditResult SetCellsTerrain(List cells, int tileInde throw new MapFacadeValidationException($"Absolute tile index {tileIndex} is not loaded."); TileImage tile = mutationTarget.TheaterGraphics.GetTileImage(tileIndex); - if (tile == null || tile.SubTileCount <= 0) + if (!HasUsableTileGraphics(tile)) throw new MapFacadeValidationException($"Absolute tile index {tileIndex} has no usable tile graphics."); - if (subTileIndex < 0 || subTileIndex >= tile.SubTileCount || subTileIndex > byte.MaxValue || tile.GetSubTile(subTileIndex) == null) + if (subTileIndex < 0 || subTileIndex >= tile.SubTileCount || subTileIndex > byte.MaxValue || + tile.GetSubTile(subTileIndex)?.TmpImage == null || !tile.GetSubTileCoordOffset(subTileIndex).HasValue) { throw new MapFacadeValidationException( $"Sub-tile index {subTileIndex} is not valid for absolute tile index {tileIndex}."); @@ -1696,6 +1929,78 @@ public MapEditResult SetCellsTerrain(List cells, int tileInde changedCoords.Select(coords => CellInfo.FromMapCell(map, map.GetTile(coords))).ToList()); } + public MapTerrainGenerationResult GenerateTerrain( + string presetId, + int x, + int y, + int width, + int height, + bool autoLAT, + int? expectedRevision) + { + if (expectedRevision.HasValue && expectedRevision.Value != mutationManager.Revision) + { + throw new MapFacadeValidationException( + $"The map revision changed from {expectedRevision.Value} to {mutationManager.Revision}. Query the map again before generating terrain, or omit expectedRevision to allow concurrent edits."); + } + + if (width <= 0 || height <= 0) + throw new MapFacadeValidationException("The terrain generation width and height must both be greater than zero."); + + if (width > MaxMapOperationDimension || height > MaxMapOperationDimension || (long)width * height > MaxMapOperationCellCount) + { + throw new MapFacadeValidationException( + $"The terrain generation area is too large. Each dimension may be at most {MaxMapOperationDimension} cells and the total rectangular area may be at most {MaxMapOperationCellCount} cells."); + } + + long lastX = (long)x + width - 1; + long lastY = (long)y + height - 1; + if (lastX > int.MaxValue || lastX < int.MinValue || lastY > int.MaxValue || lastY < int.MinValue) + throw new MapFacadeValidationException("The terrain generation rectangle exceeds the supported coordinate range."); + + TerrainGeneratorPresetDefinition preset = FindTerrainGeneratorPreset(presetId); + List validationErrors = GetTerrainGeneratorValidationErrors(preset.Configuration); + if (validationErrors.Count > 0) + { + throw new MapFacadeValidationException( + $"Terrain Generator preset '{preset.Configuration.Name}' is not usable: {string.Join(" ", validationErrors)}"); + } + + var cells = new List(width * height); + for (int yOffset = 0; yOffset < height; yOffset++) + { + for (int xOffset = 0; xOffset < width; xOffset++) + { + var cellCoords = new Point2D((int)((long)x + xOffset), (int)((long)y + yOffset)); + if (map.GetTile(cellCoords) != null) + cells.Add(cellCoords); + } + } + + if (cells.Count == 0) + throw new MapFacadeValidationException("The terrain generation rectangle contains no valid map cells."); + + List affectedMapTiles = GetPotentialTerrainGenerationAffectedMapTiles(cells, preset.Configuration, autoLAT); + MutationAffectedCells potentialAffectedCells = CreateMutationAffectedCells( + affectedMapTiles.Select(mapTile => mapTile.CoordsToPoint())); + var mutation = new TerrainGenerationMutation(mutationTarget, cells, preset.Configuration, autoLAT); + PerformMCPMutation( + mutation, + "generate_terrain", + affectedMapTiles); + + return new MapTerrainGenerationResult( + mutationManager.Revision, + preset.PresetId, + cells.Count, + mutation.TerrainCellWriteCount, + mutation.PlacedTerrainObjectCount, + mutation.PlacedOverlayCount, + mutation.PlacedSmudgeCount, + mutation.AutoLATApplied, + potentialAffectedCells); + } + private void PerformMCPMutation(Mutation mutation, string toolName, IEnumerable affectedMapTiles) { PerformMCPMutation( @@ -2070,6 +2375,260 @@ private List GetMapTilesInRectangle(Rectangle rectangle) return mapTiles; } + private List LoadTerrainGeneratorPresets() + { + try + { + var presets = new List(); + var presetsIni = Helpers.ReadConfigINI("TerrainGeneratorPresets.ini"); + + foreach (string sectionName in presetsIni.GetSections()) + { + string theater = presetsIni.GetStringValue(sectionName, "Theater", string.Empty); + if (!string.IsNullOrWhiteSpace(theater) && + !theater.Equals(map.TheaterName, StringComparison.InvariantCultureIgnoreCase)) + { + continue; + } + + TerrainGeneratorConfiguration configuration = TerrainGeneratorConfiguration.FromConfigSection( + presetsIni.GetSection(sectionName), + false, + map.Rules.TerrainTypes, + map.TheaterInstance.Theater.TileSets, + map.Rules.OverlayTypes, + map.Rules.SmudgeTypes); + + if (configuration != null) + presets.Add(new TerrainGeneratorPresetDefinition($"built-in:{sectionName}", configuration)); + } + + var userPresets = new TerrainGeneratorUserPresets(map); + userPresets.Load(); + foreach (TerrainGeneratorConfiguration configuration in userPresets.GetConfigurationsForCurrentTheater()) + { + presets.Add(new TerrainGeneratorPresetDefinition($"user:{configuration.Name}", configuration)); + } + + return presets; + } + catch (Exception ex) + { + throw new MapFacadeValidationException($"Failed to load Terrain Generator presets: {ex.Message}"); + } + } + + private TerrainGeneratorPresetDefinition FindTerrainGeneratorPreset(string presetId) + { + if (string.IsNullOrWhiteSpace(presetId)) + throw new MapFacadeValidationException("A Terrain Generator preset ID must be provided."); + + TerrainGeneratorPresetDefinition preset = LoadTerrainGeneratorPresets() + .Find(candidate => string.Equals(candidate.PresetId, presetId, StringComparison.Ordinal)); + + if (preset == null) + { + throw new MapFacadeValidationException( + $"Terrain Generator preset ID '{presetId}' does not exist for the loaded theater. Query get_terrain_generator_presets for exact IDs."); + } + + return preset; + } + + private List GetTerrainGeneratorValidationErrors(TerrainGeneratorConfiguration configuration) + { + var errors = new List(); + if (configuration == null) + { + errors.Add("The preset has no configuration."); + return errors; + } + + if (!string.IsNullOrWhiteSpace(configuration.Theater) && + !configuration.Theater.Equals(map.TheaterName, StringComparison.OrdinalIgnoreCase) && + !configuration.Theater.Equals(map.LoadedTheaterName, StringComparison.OrdinalIgnoreCase)) + { + errors.Add($"The preset is for theater '{configuration.Theater}', not '{map.LoadedTheaterName}'."); + } + + if (configuration.TerrainTypeGroups.Count == 0 && configuration.TileGroups.Count == 0 && + configuration.OverlayGroups.Count == 0 && configuration.SmudgeGroups.Count == 0) + { + errors.Add("The preset contains no generator groups."); + } + + for (int i = 0; i < configuration.TerrainTypeGroups.Count; i++) + { + TerrainGeneratorTerrainTypeGroup group = configuration.TerrainTypeGroups[i]; + ValidateTerrainGeneratorChances(group.OpenChance, group.OverlapChance, $"Terrain-object group {i}", errors); + + if (group.TerrainTypes == null || group.TerrainTypes.Count == 0) + { + errors.Add($"Terrain-object group {i} contains no loaded terrain types."); + continue; + } + + if (group.TerrainTypes.Any(terrainType => terrainType == null || !terrainType.IsValidForTheater(map.LoadedTheaterName))) + errors.Add($"Terrain-object group {i} contains a terrain type that is not valid for the loaded theater."); + } + + for (int i = 0; i < configuration.TileGroups.Count; i++) + { + TerrainGeneratorTileGroup group = configuration.TileGroups[i]; + ValidateTerrainGeneratorChances(group.OpenChance, group.OverlapChance, $"Tile group {i}", errors); + + if (group.TileSet == null || !group.TileSet.AllowToPlace || group.TileSet.LoadedTileCount <= 0) + { + errors.Add($"Tile group {i} does not reference a loaded, placeable tile set."); + continue; + } + + IEnumerable tileIndices = group.TileIndicesInSet == null || group.TileIndicesInSet.Count == 0 + ? Enumerable.Range(0, group.TileSet.LoadedTileCount) + : group.TileIndicesInSet; + bool hasUsableTile = false; + + foreach (int tileIndexInSet in tileIndices) + { + if (tileIndexInSet < 0 || tileIndexInSet >= group.TileSet.LoadedTileCount) + { + errors.Add($"Tile group {i} references invalid relative tile index {tileIndexInSet} in tile set '{group.TileSet.SetName}'."); + continue; + } + + int absoluteTileIndex = group.TileSet.StartTileIndex + tileIndexInSet; + if (absoluteTileIndex >= 0 && absoluteTileIndex < mutationTarget.TheaterGraphics.TileCount && + HasUsableTileGraphics(mutationTarget.TheaterGraphics.GetTileImage(absoluteTileIndex))) + { + hasUsableTile = true; + } + } + + if (!hasUsableTile) + errors.Add($"Tile group {i} contains no tile with usable graphics."); + } + + for (int i = 0; i < configuration.OverlayGroups.Count; i++) + { + TerrainGeneratorOverlayGroup group = configuration.OverlayGroups[i]; + ValidateTerrainGeneratorChances(group.OpenChance, group.OverlapChance, $"Overlay group {i}", errors); + + if (group.OverlayType == null || !group.OverlayType.IsValidForTheater(map.LoadedTheaterName)) + { + errors.Add($"Overlay group {i} does not reference an overlay valid for the loaded theater."); + continue; + } + + int frameCount = map.TheaterInstance.GetOverlayFrameCount(group.OverlayType); + if (frameCount <= 0) + { + errors.Add($"Overlay group {i} references overlay '{group.OverlayType.ININame}', which has no loaded frames."); + continue; + } + + if (group.FrameIndices != null) + { + foreach (int frameIndex in group.FrameIndices) + { + if (frameIndex < 0 || frameIndex >= frameCount) + { + errors.Add($"Overlay group {i} references invalid frame {frameIndex} for overlay '{group.OverlayType.ININame}'."); + } + } + } + } + + for (int i = 0; i < configuration.SmudgeGroups.Count; i++) + { + TerrainGeneratorSmudgeGroup group = configuration.SmudgeGroups[i]; + ValidateTerrainGeneratorChances(group.OpenChance, group.OverlapChance, $"Smudge group {i}", errors); + + if (group.SmudgeTypes == null || group.SmudgeTypes.Count == 0) + { + errors.Add($"Smudge group {i} contains no loaded smudge types."); + continue; + } + + if (group.SmudgeTypes.Any(smudgeType => smudgeType == null || !smudgeType.IsValidForTheater(map.LoadedTheaterName))) + errors.Add($"Smudge group {i} contains a smudge type that is not valid for the loaded theater."); + } + + return errors; + } + + private static void ValidateTerrainGeneratorChances( + double openChance, + double occupiedChance, + string groupName, + List errors) + { + if (double.IsNaN(openChance) || double.IsInfinity(openChance) || openChance < 0.0 || openChance > 1.0) + errors.Add($"{groupName} has open-cell chance {openChance}, which is outside 0.0 through 1.0."); + + if (double.IsNaN(occupiedChance) || double.IsInfinity(occupiedChance) || occupiedChance < 0.0 || occupiedChance > 1.0) + errors.Add($"{groupName} has occupied-cell chance {occupiedChance}, which is outside 0.0 through 1.0."); + } + + private List GetPotentialTerrainGenerationAffectedMapTiles( + List cells, + TerrainGeneratorConfiguration configuration, + bool autoLAT) + { + int maxTileOffsetX = 0; + int maxTileOffsetY = 0; + + foreach (TerrainGeneratorTileGroup group in configuration.TileGroups) + { + IEnumerable tileIndices = group.TileIndicesInSet == null || group.TileIndicesInSet.Count == 0 + ? Enumerable.Range(0, group.TileSet.LoadedTileCount) + : group.TileIndicesInSet; + + foreach (int tileIndexInSet in tileIndices) + { + if (tileIndexInSet < 0 || tileIndexInSet >= group.TileSet.LoadedTileCount) + continue; + + ITileImage tile = mutationTarget.TheaterGraphics.GetTileImage(group.TileSet.StartTileIndex + tileIndexInSet); + if (tile == null) + continue; + + maxTileOffsetX = Math.Max(maxTileOffsetX, tile.Width - 1); + maxTileOffsetY = Math.Max(maxTileOffsetY, tile.Height - 1); + } + } + + int minX = cells.Min(coords => coords.X); + int minY = cells.Min(coords => coords.Y); + int maxX = cells.Max(coords => coords.X) + maxTileOffsetX; + int maxY = cells.Max(coords => coords.Y) + maxTileOffsetY; + + if (autoLAT) + { + minX--; + minY--; + maxX = Math.Max(maxX, cells.Max(coords => coords.X) + 1); + maxY = Math.Max(maxY, cells.Max(coords => coords.Y) + 1); + } + + minX = Math.Max(0, minX); + minY = Math.Max(0, minY); + maxX = Math.Min(Map.TileBufferSize - 1, maxX); + maxY = Math.Min(Map.TileBufferSize - 1, maxY); + + var affectedMapTiles = new List(); + for (int targetY = minY; targetY <= maxY; targetY++) + { + for (int targetX = minX; targetX <= maxX; targetX++) + { + MapTile mapTile = map.GetTile(targetX, targetY); + if (mapTile != null) + affectedMapTiles.Add(mapTile); + } + } + + return affectedMapTiles; + } + private int GetPlaceableOverlayFrameCount(OverlayType overlayType) { var overlayTextures = mutationTarget.TheaterGraphics.OverlayTextures; @@ -2395,6 +2954,20 @@ private static bool IsTileSetPlaceable(TileSet tileSet) return tileSet.AllowToPlace && tileSet.LoadedTileCount > 0 && tileSet.NonMarbleMadness < 0; } + private static bool HasUsableTileGraphics(ITileImage tile) + { + if (tile == null || tile.Width <= 0 || tile.Height <= 0 || tile.SubTileCount <= 0) + return false; + + for (int subTileIndex = 0; subTileIndex < tile.SubTileCount; subTileIndex++) + { + if (tile.GetSubTile(subTileIndex)?.TmpImage != null && tile.GetSubTileCoordOffset(subTileIndex).HasValue) + return true; + } + + return false; + } + private static string GetEffectiveEditorCategory(GameObjectType gameObjectType) { string editorCategory = gameObjectType.EditorCategory; @@ -2428,4 +3001,16 @@ private static bool ContainsIgnoringCase(string value, string searchValue) { return value?.IndexOf(searchValue, StringComparison.OrdinalIgnoreCase) >= 0; } + + private sealed class TerrainGeneratorPresetDefinition + { + public TerrainGeneratorPresetDefinition(string presetId, TerrainGeneratorConfiguration configuration) + { + PresetId = presetId; + Configuration = configuration; + } + + public string PresetId { get; } + public TerrainGeneratorConfiguration Configuration { get; } + } } diff --git a/src/TSMapEditor/AI/MapOverlayPlacement.cs b/src/TSMapEditor/AI/MapOverlayPlacement.cs new file mode 100644 index 000000000..faaabce28 --- /dev/null +++ b/src/TSMapEditor/AI/MapOverlayPlacement.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; + +namespace TSMapEditor.AI; + +public sealed class MapOverlayPlacement +{ + [Description("INI name of the overlay type to place.")] + public string OverlayTypeName { get; set; } + + [Description("X coordinate of the destination map cell.")] + public int X { get; set; } + + [Description("Y coordinate of the destination map cell.")] + public int Y { get; set; } + + [Description("Optional zero-based placeable artwork frame index. Omit it for WAE's normal placement behavior and automatic resource smoothing.")] + public int? FrameIndex { get; set; } +} diff --git a/src/TSMapEditor/AI/MapTerrainGeneratorPresetInfo.cs b/src/TSMapEditor/AI/MapTerrainGeneratorPresetInfo.cs new file mode 100644 index 000000000..8e73637f3 --- /dev/null +++ b/src/TSMapEditor/AI/MapTerrainGeneratorPresetInfo.cs @@ -0,0 +1,239 @@ +using System.Collections.Generic; +using System.ComponentModel; +using TSMapEditor.Mutations; + +namespace TSMapEditor.AI; + +public sealed class MapTerrainGeneratorPresetSummary +{ + public MapTerrainGeneratorPresetSummary( + string presetId, + string name, + string theater, + bool isUserPreset, + bool isUsable, + int terrainTypeGroupCount, + int tileGroupCount, + int overlayGroupCount, + int smudgeGroupCount) + { + PresetId = presetId; + Name = name; + Theater = theater; + IsUserPreset = isUserPreset; + IsUsable = isUsable; + TerrainTypeGroupCount = terrainTypeGroupCount; + TileGroupCount = tileGroupCount; + OverlayGroupCount = overlayGroupCount; + SmudgeGroupCount = smudgeGroupCount; + } + + [Description("Stable, unambiguous preset identifier accepted by get_terrain_generator_preset and generate_terrain.")] + public string PresetId { get; } + + [Description("Human-readable preset name shown by the editor.")] + public string Name { get; } + + [Description("Theater named by the preset. An empty value means the built-in preset is available in all theaters.")] + public string Theater { get; } + + [Description("Whether this is a user-saved preset rather than a built-in mod preset.")] + public bool IsUserPreset { get; } + + [Description("Whether the effective preset configuration can currently be generated safely. Inspect unusable presets with get_terrain_generator_preset for validation errors.")] + public bool IsUsable { get; } + + public int TerrainTypeGroupCount { get; } + public int TileGroupCount { get; } + public int OverlayGroupCount { get; } + public int SmudgeGroupCount { get; } +} + +public class MapTerrainGeneratorChanceGroupInfo +{ + public MapTerrainGeneratorChanceGroupInfo(double openCellChance, double occupiedCellChance) + { + OpenCellChance = openCellChance; + OccupiedCellChance = occupiedCellChance; + } + + [Description("Independent probability from 0.0 through 1.0 of placing an entry on an open candidate cell.")] + public double OpenCellChance { get; } + + [Description("Independent probability from 0.0 through 1.0 of placing an entry on a candidate cell occupied by an earlier generator placement.")] + public double OccupiedCellChance { get; } +} + +public sealed class MapTerrainGeneratorTerrainTypeGroupInfo : MapTerrainGeneratorChanceGroupInfo +{ + public MapTerrainGeneratorTerrainTypeGroupInfo(double openCellChance, double occupiedCellChance, List terrainTypeNames) + : base(openCellChance, occupiedCellChance) + { + TerrainTypeNames = terrainTypeNames; + } + + [Description("Terrain-object INI names from which one entry is chosen randomly when this group places an object.")] + public List TerrainTypeNames { get; } +} + +public sealed class MapTerrainGeneratorTileGroupInfo : MapTerrainGeneratorChanceGroupInfo +{ + public MapTerrainGeneratorTileGroupInfo( + double openCellChance, + double occupiedCellChance, + string tileSetName, + bool usesAllTilesInSet, + List tileIndicesInSet) + : base(openCellChance, occupiedCellChance) + { + TileSetName = tileSetName; + UsesAllTilesInSet = usesAllTilesInSet; + TileIndicesInSet = tileIndicesInSet; + } + + [Description("Internal tile-set name.")] + public string TileSetName { get; } + + [Description("Whether the generator randomly chooses from every tile entry in the tile set.")] + public bool UsesAllTilesInSet { get; } + + [Description("Zero-based tile-set-relative indices used when usesAllTilesInSet is false; otherwise empty.")] + public List TileIndicesInSet { get; } +} + +public sealed class MapTerrainGeneratorOverlayGroupInfo : MapTerrainGeneratorChanceGroupInfo +{ + public MapTerrainGeneratorOverlayGroupInfo( + double openCellChance, + double occupiedCellChance, + string overlayTypeName, + bool usesAllFrames, + List frameIndices) + : base(openCellChance, occupiedCellChance) + { + OverlayTypeName = overlayTypeName; + UsesAllFrames = usesAllFrames; + FrameIndices = frameIndices; + } + + [Description("Overlay-type INI name.")] + public string OverlayTypeName { get; } + + [Description("Whether the generator randomly chooses from every loaded frame of the overlay.")] + public bool UsesAllFrames { get; } + + [Description("Zero-based overlay frame indices used when usesAllFrames is false; otherwise empty.")] + public List FrameIndices { get; } +} + +public sealed class MapTerrainGeneratorSmudgeGroupInfo : MapTerrainGeneratorChanceGroupInfo +{ + public MapTerrainGeneratorSmudgeGroupInfo(double openCellChance, double occupiedCellChance, List smudgeTypeNames) + : base(openCellChance, occupiedCellChance) + { + SmudgeTypeNames = smudgeTypeNames; + } + + [Description("Smudge-type INI names from which one entry is chosen randomly when this group places a smudge.")] + public List SmudgeTypeNames { get; } +} + +public sealed class MapTerrainGeneratorPresetInfo +{ + public MapTerrainGeneratorPresetInfo( + string presetId, + string name, + string theater, + bool isUserPreset, + List validationErrors, + List terrainTypeGroups, + List tileGroups, + List overlayGroups, + List smudgeGroups) + { + PresetId = presetId; + Name = name; + Theater = theater; + IsUserPreset = isUserPreset; + ValidationErrors = validationErrors; + TerrainTypeGroups = terrainTypeGroups; + TileGroups = tileGroups; + OverlayGroups = overlayGroups; + SmudgeGroups = smudgeGroups; + } + + [Description("Stable, unambiguous preset identifier accepted by generate_terrain.")] + public string PresetId { get; } + + [Description("Human-readable preset name shown by the editor.")] + public string Name { get; } + + [Description("Theater named by the preset. An empty value means the built-in preset is available in all theaters.")] + public string Theater { get; } + + [Description("Whether this is a user-saved preset rather than a built-in mod preset.")] + public bool IsUserPreset { get; } + + [Description("Whether the effective preset configuration can currently be generated safely.")] + public bool IsUsable => ValidationErrors.Count == 0; + + [Description("Configuration problems that prevent generation; empty when the preset is usable.")] + public List ValidationErrors { get; } + + public List TerrainTypeGroups { get; } + public List TileGroups { get; } + public List OverlayGroups { get; } + public List SmudgeGroups { get; } +} + +public sealed class MapTerrainGenerationResult +{ + public MapTerrainGenerationResult( + int revision, + string presetId, + int candidateCellCount, + int terrainCellWriteCount, + int placedTerrainObjectCount, + int placedOverlayCount, + int placedSmudgeCount, + bool autoLATApplied, + MutationAffectedCells potentialAffectedCells) + { + Revision = revision; + PresetId = presetId; + CandidateCellCount = candidateCellCount; + TerrainCellWriteCount = terrainCellWriteCount; + PlacedTerrainObjectCount = placedTerrainObjectCount; + PlacedOverlayCount = placedOverlayCount; + PlacedSmudgeCount = placedSmudgeCount; + AutoLATApplied = autoLATApplied; + PotentialAffectedCells = potentialAffectedCells; + } + + [Description("Map revision after generation.")] + public int Revision { get; } + + [Description("Exact preset ID used for generation.")] + public string PresetId { get; } + + [Description("Number of valid map cells from the requested rectangle considered by every generator group.")] + public int CandidateCellCount { get; } + + [Description("Number of distinct map cells directly written by generated full terrain tiles, before AutoLAT transitions.")] + public int TerrainCellWriteCount { get; } + + [Description("Number of terrain objects placed by the generator.")] + public int PlacedTerrainObjectCount { get; } + + [Description("Number of overlays placed by the generator.")] + public int PlacedOverlayCount { get; } + + [Description("Number of smudges placed by the generator.")] + public int PlacedSmudgeCount { get; } + + [Description("Whether AutoLAT was applied after generation.")] + public bool AutoLATApplied { get; } + + [Description("Summary of the cells that could have changed, including full-tile footprints and possible AutoLAT neighbors. Individual random placements can affect fewer cells.")] + public MutationAffectedCells PotentialAffectedCells { get; } +} diff --git a/src/TSMapEditor/AI/MapTerrainTileInfo.cs b/src/TSMapEditor/AI/MapTerrainTileInfo.cs new file mode 100644 index 000000000..c12c3be68 --- /dev/null +++ b/src/TSMapEditor/AI/MapTerrainTileInfo.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using System.ComponentModel; + +namespace TSMapEditor.AI; + +public sealed class MapTileIndexInfo +{ + public MapTileIndexInfo(int absoluteTileIndex, int tileIndexInTileSet) + { + AbsoluteTileIndex = absoluteTileIndex; + TileIndexInTileSet = tileIndexInTileSet; + } + + [Description("Absolute tile index in the loaded theater.")] + public int AbsoluteTileIndex { get; } + + [Description("Zero-based tile index relative to the start of its tile set.")] + public int TileIndexInTileSet { get; } +} + +public sealed class MapTerrainSubTileInfo +{ + public MapTerrainSubTileInfo(int subTileIndex, int x, int y, int height) + { + SubTileIndex = subTileIndex; + X = x; + Y = y; + Height = height; + } + + [Description("Zero-based sub-tile slot index used by set_cells_terrain.")] + public int SubTileIndex { get; } + + [Description("X coordinate of this sub-tile within the full tile's footprint.")] + public int X { get; } + + [Description("Y coordinate of this sub-tile within the full tile's footprint.")] + public int Y { get; } + + [Description("Height level added to the placement origin when this sub-tile is placed as part of the full tile.")] + public int Height { get; } +} + +public sealed class MapTerrainTileInfo +{ + public MapTerrainTileInfo( + int absoluteTileIndex, + int tileSetIndex, + string tileSetName, + string tileSetUIName, + int tileIndexInTileSet, + bool hasUsableGraphics, + int footprintWidth, + int footprintHeight, + int subTileSlotCount, + List validSubTiles) + { + AbsoluteTileIndex = absoluteTileIndex; + TileSetIndex = tileSetIndex; + TileSetName = tileSetName; + TileSetUIName = tileSetUIName; + TileIndexInTileSet = tileIndexInTileSet; + HasUsableGraphics = hasUsableGraphics; + FootprintWidth = footprintWidth; + FootprintHeight = footprintHeight; + SubTileSlotCount = subTileSlotCount; + ValidSubTiles = validSubTiles; + } + + [Description("Absolute tile index in the loaded theater.")] + public int AbsoluteTileIndex { get; } + + [Description("Zero-based tile-set index in the loaded theater.")] + public int TileSetIndex { get; } + + [Description("Internal name of the tile's tile set.")] + public string TileSetName { get; } + + [Description("Localized name of the tile's tile set shown by the editor.")] + public string TileSetUIName { get; } + + [Description("Zero-based tile index relative to the start of its tile set.")] + public int TileIndexInTileSet { get; } + + [Description("Whether the tile has a positive footprint and at least one valid graphical subtile.")] + public bool HasUsableGraphics { get; } + + [Description("Width of the full tile's map-cell footprint.")] + public int FootprintWidth { get; } + + [Description("Height of the full tile's map-cell footprint.")] + public int FootprintHeight { get; } + + [Description("Total number of subtile slots, including empty slots in irregular full tiles.")] + public int SubTileSlotCount { get; } + + [Description("Number of subtile slots that contain usable graphics.")] + public int ValidSubTileCount => ValidSubTiles.Count; + + [Description("Usable subtiles with the indices accepted by set_cells_terrain, their footprint coordinates, and their heights.")] + public List ValidSubTiles { get; } +} diff --git a/src/TSMapEditor/AI/MapTools.cs b/src/TSMapEditor/AI/MapTools.cs index 49fa5f011..f6fbf547b 100644 --- a/src/TSMapEditor/AI/MapTools.cs +++ b/src/TSMapEditor/AI/MapTools.cs @@ -192,7 +192,7 @@ public Task> GetOverlayCollections( } [McpServerTool(Name = "get_connected_overlay_types", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] - [Description("Returns WAE connected-overlay configurations valid for the current map's theater, including their connection masks and underlying overlay frames. Use place_connected_overlay for automatic connections, or place_overlay with this frame data for exact manual placement.")] + [Description("Returns WAE connected-overlay configurations valid for the current map's theater, including their connection masks and underlying overlay frames. Use place_connected_overlay for automatic connections, or place_overlays_batch with this frame data for exact manual placement.")] public Task> GetConnectedOverlayTypes( [Description("Optional case-insensitive filter matched against configuration name, UI name, related configuration names, and underlying overlay INI names.")] string nameFilter = null, CancellationToken cancellationToken = default) @@ -280,7 +280,7 @@ public async Task> GetWaypoints( } [McpServerTool(Name = "get_tile_sets", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] - [Description("Returns tile sets that are available for placement in the current map's theater.")] + [Description("Returns tile sets available for placement in the current map's theater. Each result explicitly lists both usable and unusable tiles with their absolute and tile-set-relative indices; tileCount includes entries whose graphics files are missing or unusable.")] public Task> GetTileSets( [Description("Optional case-insensitive filter matched against tile set name and UI name.")] string nameFilter = null, CancellationToken cancellationToken = default) @@ -289,6 +289,65 @@ public Task> GetTileSets( return gameThreadDispatcher.InvokeAsync(() => mapFacade.GetTileSets(nameFilter), cancellationToken); } + [McpServerTool(Name = "get_tile_details", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns deterministic base-graphics metadata for one absolute terrain tile index, including whether it is usable, its tile set and relative index, full-tile footprint, and every valid sub-tile's slot index, coordinate offset, and height. Missing-graphics tiles return hasUsableGraphics=false and an empty validSubTiles list rather than failing.")] + public async Task GetTileDetails( + [Description("Absolute tile index returned by get_tile_sets, inspect_map_region, or another map tool.")] int absoluteTileIndex, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetTileDetails)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.GetTileDetails(absoluteTileIndex), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + + [McpServerTool(Name = "get_terrain_generator_presets", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns built-in mod presets and saved user presets available to the Terrain Generator in the current theater. Results include human-readable names, exact stable preset IDs, source, usability, and group counts; use get_terrain_generator_preset for full contents.")] + public async Task> GetTerrainGeneratorPresets( + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetTerrainGeneratorPresets)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + mapFacade.GetTerrainGeneratorPresets, + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + + [McpServerTool(Name = "get_terrain_generator_preset", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Returns the effective configuration of one Terrain Generator preset, including placement chances and the terrain objects, tile sets and relative indices, overlays and frames, and smudges used by each group. Also reports validation errors that would prevent generation.")] + public async Task GetTerrainGeneratorPreset( + [Description("Exact stable preset ID returned by get_terrain_generator_presets.")] string presetId, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GetTerrainGeneratorPreset)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.GetTerrainGeneratorPreset(presetId), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + [McpServerTool(Name = "get_technos", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] [Description("Returns unique buildings, vehicles, infantry, and aircraft from the current map with stable object IDs for use with modify_technos.")] public async Task GetTechnos( @@ -527,23 +586,19 @@ public async Task EraseOverlay( } } - [McpServerTool(Name = "place_overlay", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] - [Description("Places a regular overlay in a rectangular map area, replacing existing overlay. Omit frameIndex to use frame 0 and let WAE automatically smooth Tiberium; specify a placeable artwork frame for exact manual placement. Raw SHP frames at or above the frameCount returned by get_overlay_types are engine-managed shadows and are rejected. The operation is one undo entry and one revision bump.")] - public async Task PlaceOverlay( - [Description("INI name of an overlay type returned by get_overlay_types.")] string overlayTypeName, - [Description("X coordinate of the area's top-left cell.")] int x, - [Description("Y coordinate of the area's top-left cell.")] int y, - [Description("Area width in cells. Defaults to 1.")] int width = 1, - [Description("Area height in cells. Defaults to 1.")] int height = 1, - [Description("Optional zero-based placeable artwork frame index, which must be lower than frameCount from get_overlay_types. The upper raw SHP half contains engine-managed shadow frames and cannot be placed. Omit it for WAE's normal placement behavior and automatic Tiberium smoothing.")] int? frameIndex = null, + [McpServerTool(Name = "place_overlays_batch", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Atomically places explicitly selected overlays on individual cells using a 1x1 brush per placement, replacing existing overlay. Omit a placement's frameIndex to use frame 0 and automatically smooth neighboring resources; specify a placeable artwork frame for exact manual placement. Manually framed target cells remain exact during this batch. Raw SHP shadow frames are rejected. The operation is one undo entry and one revision bump.")] + public async Task PlaceOverlaysBatch( + [Description("One or more overlay types, optional frames, and destination cells. Coordinates must be distinct. At most 10,000 placements are supported.")] List placements, + [Description("Optional map revision returned by get_map_revision or another map tool. When supplied, placement fails if the map has changed; omit it to allow concurrent human edits.")] int? expectedRevision = null, CancellationToken cancellationToken = default) { - Logger.Log($"{nameof(MapTools)}.{nameof(PlaceOverlay)}"); + Logger.Log($"{nameof(MapTools)}.{nameof(PlaceOverlaysBatch)}"); try { return await gameThreadDispatcher.InvokeAsync( - () => mapFacade.PlaceOverlay(overlayTypeName, x, y, width, height, frameIndex), + () => mapFacade.PlaceOverlaysBatch(placements, expectedRevision), cancellationToken); } catch (MapFacadeValidationException ex) @@ -552,22 +607,20 @@ public async Task PlaceOverlay( } } - [McpServerTool(Name = "place_overlay_collection", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] - [Description("Places random entries from an editor overlay collection across a rectangular brush area, replacing existing overlay. This uses WAE's existing collection randomizer and Tiberium placement behavior. The operation is one undo entry and one revision bump.")] - public async Task PlaceOverlayCollection( + [McpServerTool(Name = "place_overlay_collection_batch", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Atomically places random entries from one editor overlay collection on multiple individual cells using a 1x1 brush per cell, replacing existing overlay. This preserves WAE's collection randomizer, impassable-resource avoidance, and resource smoothing behavior. Coordinates must be distinct. The operation is one undo entry and one revision bump.")] + public async Task PlaceOverlayCollectionBatch( [Description("Configuration name of an overlay collection returned by get_overlay_collections.")] string collectionName, - [Description("X coordinate of the area's top-left cell.")] int x, - [Description("Y coordinate of the area's top-left cell.")] int y, - [Description("Area width in cells. Defaults to 1.")] int width = 1, - [Description("Area height in cells. Defaults to 1.")] int height = 1, + [Description("One or more distinct destination map-cell coordinates. At most 10,000 entries are supported.")] List cells, + [Description("Optional map revision returned by get_map_revision or another map tool. When supplied, placement fails if the map has changed; omit it to allow concurrent human edits.")] int? expectedRevision = null, CancellationToken cancellationToken = default) { - Logger.Log($"{nameof(MapTools)}.{nameof(PlaceOverlayCollection)}"); + Logger.Log($"{nameof(MapTools)}.{nameof(PlaceOverlayCollectionBatch)}"); try { return await gameThreadDispatcher.InvokeAsync( - () => mapFacade.PlaceOverlayCollection(collectionName, x, y, width, height), + () => mapFacade.PlaceOverlayCollectionBatch(collectionName, cells, expectedRevision), cancellationToken); } catch (MapFacadeValidationException ex) @@ -807,7 +860,7 @@ public async Task PlaceVehicle( [Description("Places a full terrain tile by tile set and tile-set-relative index. Supports configured brush sizes and optional AutoLAT. Existing terrain in the footprint may be replaced, and the edit is added to undo history.")] public async Task PlaceTerrainTile( [Description("Internal name of the tile set returned by get_tile_sets.")] string tileSetName, - [Description("Zero-based tile index relative to the start of the tile set.")] int tileIndexInTileSet, + [Description("Zero-based relative index from a get_tile_sets tilesWithUsableGraphics entry.")] int tileIndexInTileSet, [Description("X coordinate of the placement's top-left cell.")] int x, [Description("Y coordinate of the placement's top-left cell.")] int y, [Description("Width of the configured brush in repeated full tiles. Defaults to 1.")] int brushWidth = 1, @@ -829,12 +882,38 @@ public async Task PlaceTerrainTile( } } + [McpServerTool(Name = "generate_terrain", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + [Description("Runs the editor's Terrain Generator over every valid map cell inside a rectangular coordinate area using an existing preset. The generator randomly attempts the preset's terrain objects, full terrain tiles, overlays, and smudges while preserving its normal placement restrictions. Cells outside the map diamond are clipped from the rectangle. The operation is one undo entry and one revision bump and returns compact placement counts plus a potential affected-cell summary.")] + public async Task GenerateTerrain( + [Description("Exact stable preset ID returned by get_terrain_generator_presets.")] string presetId, + [Description("X coordinate of the rectangular area's top-left cell.")] int x, + [Description("Y coordinate of the rectangular area's top-left cell.")] int y, + [Description("Rectangle width in logical cells. Each dimension may be at most 256 and total rectangular area at most 10,000 cells.")] int width, + [Description("Rectangle height in logical cells. Each dimension may be at most 256 and total rectangular area at most 10,000 cells.")] int height, + [Description("Whether to apply automatic LAT transitions after generation. Defaults to true and is captured for consistent undo and redo behavior.")] bool autoLAT = true, + [Description("Optional map revision returned by get_map_revision or another map tool. When supplied, generation fails if the map has changed; omit it to allow concurrent human edits.")] int? expectedRevision = null, + CancellationToken cancellationToken = default) + { + Logger.Log($"{nameof(MapTools)}.{nameof(GenerateTerrain)}"); + + try + { + return await gameThreadDispatcher.InvokeAsync( + () => mapFacade.GenerateTerrain(presetId, x, y, width, height, autoLAT, expectedRevision), + cancellationToken); + } + catch (MapFacadeValidationException ex) + { + throw new McpException(ex.Message); + } + } + [McpServerTool(Name = "set_cells_terrain", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] [Description("Directly sets the same absolute tile index and sub-tile index on one or more map cells. Duplicate coordinates and cells that already have the requested terrain are ignored. The batch is one atomic undo entry and one revision bump when any cells change. This low-level operation does not apply a brush or AutoLAT.")] public async Task SetCellsTerrain( [Description("One or more map-cell coordinates. At most 10,000 entries are supported.")] List cells, - [Description("Absolute tile index in the loaded theater.")] int tileIndex, - [Description("Sub-tile index within the selected full tile.")] int subTileIndex, + [Description("Absolute tile index from a get_tile_sets tilesWithUsableGraphics entry or get_tile_details.")] int tileIndex, + [Description("Sub-tile index from get_tile_details.validSubTiles for the selected full tile.")] int subTileIndex, [Description("Optional map revision returned by get_map_revision or another map tool. When supplied, the operation fails if the map has changed; omit it to allow concurrent human edits.")] int? expectedRevision = null, CancellationToken cancellationToken = default) { diff --git a/src/TSMapEditor/Config/Default/AIMappingInstructions.md b/src/TSMapEditor/Config/Default/AIMappingInstructions.md index fffc14eb4..8f5bc5c9c 100644 --- a/src/TSMapEditor/Config/Default/AIMappingInstructions.md +++ b/src/TSMapEditor/Config/Default/AIMappingInstructions.md @@ -12,7 +12,7 @@ Despite the isometric perspective, a TS/RA2 map appears rectangular in-game. In The map's width and height do not define independent valid ranges for the X and Y coordinates. Do not assume that a 100×100 map uses coordinates from (0, 0) through (99, 99). Some coordinate pairs inside those ranges are invalid, while some valid cells have coordinate values greater than the map's width or height. -Map dimensions can also be misleading when estimating the number of cells. Each unit of map height contains two logical rows to produce the isometric layout. A map with dimensions `width × height` therefore contains `2 × width × height` cells. For example, a 100×100 map contains 20,000 cells rather than 10,000. +Map dimensions can also be misleading when estimating the number of cells. Each unit of map height contains two logical rows to produce the isometric layout: a primary row containing `width` cells and an adjacent row containing `width - 1` cells because of the isometric map border. A map with dimensions `width × height` therefore contains `height × (2 × width - 1)` valid cells. For example, a 100×100 map contains 19,900 cells rather than 10,000. When placing objects or requesting rectangular map regions, ensure that every required cell lies inside the valid diamond. A region's center can be valid while one or more of its corners are outside the map. @@ -48,6 +48,8 @@ The following usually looks better and more natural: --XX-- ``` +AutoLAT smooths tile transtitions: it DOES NOT make a rectangular large-brush placement organically shaped. + Isolated AutoLAT placements may produce only transition tiles. Connected rows with sufficient thickness are needed to create core LAT. ### AutoLAT Behaviour @@ -64,6 +66,16 @@ Disable AutoLAT only when: Do not use individual raw tiles as decorative accents from a LAT-capable tileset. Paint connected or clustered areas through AutoLAT so their edges transition correctly. +### Natural LAT Masks + +For natural terrain such as Tall Grass or Dirt: + +- Build a connected mask predominantly 1–3 cells wide. +- Use multiple short strokes, bends, branches, thicker cores, and occasional holes. +- Avoid any single rectangular brush whose width and height are both greater than 3, unless the shape represents something intentionally regular. + +Before accepting the result, inspect the mask visually. If its rectangular brush origins are obvious, revise it. + ## Detailing Areas Aside from LATs, try to also use various other pieces when detailing large areas. Rocks, pebbles, trees, rough ground, debris, villages or cities, small closed lakes... there's usually a lot you can detail a map with. Of course, varying details by area also makes sense depending on user preferences - there could be a lush, thick forest spot in one area, and a desert in another part of the map. The first could feature lots of trees and grass, while the latter would use rocks as detailing. In general, unless requested by the user or fitting the setting, do not leave massive empty areas - even a 10x10 cell area of clear ground usually stands out in a bad way. @@ -76,10 +88,26 @@ The Tiberian Sun and Red Alert 2 game engines and gameplay design don't work wel Layouts are often planned with cliffs and shorelines. You can place these by invoking the Connected Tiles tool. Often other kinds of more complicated elements, like thick forests and cities, can also be used as "soft" layout elements because they obstruct movement of large armies. +## Map Validation + +`validate_map` is a diagnostic tool, not a map-quality target. Do not resolve under-detailed-area warnings primarily by scattering isolated pebbles, decals, or other passable accents. + +Group nearby warnings into larger thematic regions. Resolve them with intentional features such as forests, rocky clearings, burned woodland, farms, ruins, villages, or mixed terrain. Pebbles and similar details should support those features rather than substitute for them. + ## Connected Tile Facings When placing connected tiles, consider their facing. For example, if you are creating a hill surrounded by cliffs, you need to consider whether to place front or back facing cliffs to give the illusion of the cliff being higher than the surrounding terrain. You can always ask the user, or use the MCP server's screen-cropping endpoint for visual verification. +### Closed Shoreline Verification + +For lakes and other closed shorelines: + +- Draw the shoreline before filling the interior. +- Render a close regional preview. +- Verify that the water-facing artwork points toward the enclosed area. +- If it faces outward, completely clear the failed formation before redrawing it with the opposite side. +- Fill the interior by flood-filling the region enclosed by the finished shore tiles, rather than estimating a smaller geometric mask. + ## Placement Order Prefer to design a layout first, then details. When detailing, place objects like buildings and trees first, then terrain. This is because if you are, for example, creating a city, it is easier to place dirt or pavement LAT under buildings and grass LAT under trees after they have been placed down, than it is to first place dirt/grass and then fit objects on top of them. diff --git a/src/TSMapEditor/Mutations/Classes/AIMutations/OverlayBatchMutationBase.cs b/src/TSMapEditor/Mutations/Classes/AIMutations/OverlayBatchMutationBase.cs new file mode 100644 index 000000000..930c7972e --- /dev/null +++ b/src/TSMapEditor/Mutations/Classes/AIMutations/OverlayBatchMutationBase.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Linq; +using TSMapEditor.GameMath; +using TSMapEditor.Models; +using TSMapEditor.UI; + +namespace TSMapEditor.Mutations.Classes.AIMutations; + +public abstract class OverlayBatchMutationBase : Mutation +{ + protected OverlayBatchMutationBase(IMutationTarget mutationTarget) + : base(mutationTarget) + { + } + + private OriginalOverlayInfo[] undoData; + + protected HashSet GetCellsWithinRadius(IEnumerable cellCoords, int radius) + { + var affectedCellCoords = new HashSet(); + foreach (Point2D coords in cellCoords) + { + for (int yOffset = -radius; yOffset <= radius; yOffset++) + { + for (int xOffset = -radius; xOffset <= radius; xOffset++) + { + Point2D affectedCoords = coords + new Point2D(xOffset, yOffset); + if (Map.GetTile(affectedCoords) != null) + affectedCellCoords.Add(affectedCoords); + } + } + } + + return affectedCellCoords; + } + + protected void CaptureOriginalOverlays(IEnumerable cellCoords) + { + undoData = cellCoords + .Distinct() + .OrderBy(coords => coords.Y) + .ThenBy(coords => coords.X) + .Select(coords => + { + MapTile mapTile = Map.GetTile(coords); + return new OriginalOverlayInfo( + mapTile.Overlay?.OverlayType.Index ?? -1, + mapTile.Overlay?.FrameIndex ?? -1, + coords); + }) + .ToArray(); + } + + protected void SmoothResourceOverlays(IEnumerable cellCoords, ISet excludedCellCoords = null) + { + foreach (Point2D coords in cellCoords.OrderBy(coords => coords.Y).ThenBy(coords => coords.X)) + { + if (excludedCellCoords?.Contains(coords) == true) + continue; + + MapTile mapTile = Map.GetTile(coords); + if (mapTile?.HasTiberium() == true) + mapTile.Overlay.FrameIndex = Map.GetOverlayFrameIndex(coords); + } + } + + protected void RestoreOriginalOverlays() + { + foreach (OriginalOverlayInfo info in undoData) + { + MapTile mapTile = Map.GetTile(info.CellCoords); + if (info.OverlayTypeIndex < 0) + { + mapTile.Overlay = null; + continue; + } + + mapTile.Overlay = new Overlay + { + OverlayType = Map.Rules.OverlayTypes[info.OverlayTypeIndex], + Position = info.CellCoords, + FrameIndex = info.FrameIndex + }; + } + } +} diff --git a/src/TSMapEditor/Mutations/Classes/AIMutations/PlaceOverlayBatchMutation.cs b/src/TSMapEditor/Mutations/Classes/AIMutations/PlaceOverlayBatchMutation.cs new file mode 100644 index 000000000..2c6fd8a07 --- /dev/null +++ b/src/TSMapEditor/Mutations/Classes/AIMutations/PlaceOverlayBatchMutation.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using TSMapEditor.GameMath; +using TSMapEditor.Models; +using TSMapEditor.UI; + +namespace TSMapEditor.Mutations.Classes.AIMutations; + +/// +/// Places multiple explicitly selected overlays on individual map cells as one mutation. +/// +public sealed class PlaceOverlayBatchMutation : OverlayBatchMutationBase +{ + public PlaceOverlayBatchMutation( + IMutationTarget mutationTarget, + List<(OverlayType OverlayType, Point2D CellCoords, int? FrameIndex)> placements) + : base(mutationTarget) + { + this.placements = placements ?? throw new ArgumentNullException(nameof(placements)); + + if (placements.Count == 0) + throw new ArgumentException("At least one overlay placement must be provided.", nameof(placements)); + } + + private readonly List<(OverlayType OverlayType, Point2D CellCoords, int? FrameIndex)> placements; + + public override string GetDisplayString() + { + return $"Place {placements.Count} overlay(s)"; + } + + public override void Perform() + { + var distinctCellCoords = new HashSet(); + foreach ((OverlayType overlayType, Point2D cellCoords, int? _) in placements) + { + if (overlayType == null) + throw new InvalidOperationException("An overlay placement has no overlay type."); + if (Map.GetTile(cellCoords) == null) + throw new InvalidOperationException($"Cell {cellCoords} does not exist."); + if (!distinctCellCoords.Add(cellCoords)) + throw new InvalidOperationException($"Cell {cellCoords} is included more than once."); + } + + List automaticallyFramedCellCoords = placements + .Where(placement => !placement.FrameIndex.HasValue) + .Select(placement => placement.CellCoords) + .ToList(); + HashSet cellsToSmooth = GetCellsWithinRadius(automaticallyFramedCellCoords, 2); + HashSet cellsToCapture = new(distinctCellCoords); + cellsToCapture.UnionWith(cellsToSmooth); + CaptureOriginalOverlays(cellsToCapture); + + try + { + foreach ((OverlayType overlayType, Point2D cellCoords, int? frameIndex) in placements) + { + Map.GetTile(cellCoords).Overlay = new Overlay + { + Position = cellCoords, + OverlayType = overlayType, + FrameIndex = frameIndex ?? 0 + }; + } + + var manuallyFramedCellCoords = placements + .Where(placement => placement.FrameIndex.HasValue) + .Select(placement => placement.CellCoords) + .ToHashSet(); + SmoothResourceOverlays(cellsToSmooth, manuallyFramedCellCoords); + } + catch + { + RestoreOriginalOverlays(); + MutationTarget.InvalidateMap(); + throw; + } + + MutationTarget.InvalidateMap(); + } + + public override void Undo() + { + RestoreOriginalOverlays(); + MutationTarget.InvalidateMap(); + } +} diff --git a/src/TSMapEditor/Mutations/Classes/AIMutations/PlaceOverlayCollectionBatchMutation.cs b/src/TSMapEditor/Mutations/Classes/AIMutations/PlaceOverlayCollectionBatchMutation.cs new file mode 100644 index 000000000..c9081bd29 --- /dev/null +++ b/src/TSMapEditor/Mutations/Classes/AIMutations/PlaceOverlayCollectionBatchMutation.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using TSMapEditor.CCEngine.TileData; +using TSMapEditor.GameMath; +using TSMapEditor.Models; +using TSMapEditor.UI; + +namespace TSMapEditor.Mutations.Classes.AIMutations; + +/// +/// Places random entries from an overlay collection on multiple individual map cells as one mutation. +/// +public sealed class PlaceOverlayCollectionBatchMutation : OverlayBatchMutationBase +{ + public PlaceOverlayCollectionBatchMutation( + IMutationTarget mutationTarget, + OverlayCollection overlayCollection, + List cellCoords) + : base(mutationTarget) + { + this.overlayCollection = overlayCollection ?? throw new ArgumentNullException(nameof(overlayCollection)); + this.cellCoords = cellCoords ?? throw new ArgumentNullException(nameof(cellCoords)); + + if (overlayCollection.Entries.Length == 0) + throw new ArgumentException("The overlay collection must contain at least one entry.", nameof(overlayCollection)); + if (cellCoords.Count == 0) + throw new ArgumentException("At least one cell coordinate must be provided.", nameof(cellCoords)); + } + + private readonly OverlayCollection overlayCollection; + private readonly List cellCoords; + + public override string GetDisplayString() + { + return $"Place overlay collection '{overlayCollection.Name}' on {cellCoords.Count} map cell(s)"; + } + + public override void Perform() + { + var distinctCellCoords = new HashSet(); + foreach (Point2D coords in cellCoords) + { + if (Map.GetTile(coords) == null) + throw new InvalidOperationException($"Cell {coords} does not exist."); + if (!distinctCellCoords.Add(coords)) + throw new InvalidOperationException($"Cell {coords} is included more than once."); + } + + HashSet affectedCellCoords = GetCellsWithinRadius(distinctCellCoords, 2); + CaptureOriginalOverlays(affectedCellCoords); + + try + { + foreach (Point2D coords in cellCoords) + { + MapTile mapTile = Map.GetTile(coords); + var collectionEntry = overlayCollection.Entries[ + MutationTarget.Randomizer.GetRandomNumber(0, overlayCollection.Entries.Length - 1)]; + + if (collectionEntry.OverlayType.Tiberium) + { + ITileImage tileGraphics = Map.TheaterInstance.GetTile(mapTile.TileIndex); + ISubTileImage subCellImage = tileGraphics.GetSubTile(mapTile.SubTileIndex); + if (Helpers.IsLandTypeImpassable(subCellImage.TmpImage.TerrainType, true)) + continue; + } + + mapTile.Overlay = new Overlay + { + Position = coords, + OverlayType = collectionEntry.OverlayType, + FrameIndex = collectionEntry.Frame + }; + } + + SmoothResourceOverlays(affectedCellCoords); + } + catch + { + RestoreOriginalOverlays(); + MutationTarget.InvalidateMap(); + throw; + } + + MutationTarget.InvalidateMap(); + } + + public override void Undo() + { + RestoreOriginalOverlays(); + MutationTarget.InvalidateMap(); + } +} diff --git a/src/TSMapEditor/Mutations/Classes/TerrainGenerationMutation.cs b/src/TSMapEditor/Mutations/Classes/TerrainGenerationMutation.cs index d77fddf42..e78db4ed6 100644 --- a/src/TSMapEditor/Mutations/Classes/TerrainGenerationMutation.cs +++ b/src/TSMapEditor/Mutations/Classes/TerrainGenerationMutation.cs @@ -380,17 +380,29 @@ public static TerrainGeneratorSmudgeGroup FromConfigString(List allS public class TerrainGenerationMutation : Mutation { - public TerrainGenerationMutation(IMutationTarget mutationTarget, List cells, TerrainGeneratorConfiguration configuration) : base(mutationTarget) + public TerrainGenerationMutation(IMutationTarget mutationTarget, List cells, TerrainGeneratorConfiguration configuration) + : this(mutationTarget, cells, configuration, mutationTarget.AutoLATEnabled) + { + } + + public TerrainGenerationMutation( + IMutationTarget mutationTarget, + List cells, + TerrainGeneratorConfiguration configuration, + bool autoLATEnabled) + : base(mutationTarget) { seed = DateTime.Now.Millisecond; random = new Random(); this.cells = cells; this.terrainGeneratorConfiguration = configuration; + this.autoLATEnabled = autoLATEnabled; } private readonly int seed; private readonly List cells; private readonly TerrainGeneratorConfiguration terrainGeneratorConfiguration; + private readonly bool autoLATEnabled; private HashSet occupiedCells = new HashSet(); private Random random; @@ -402,6 +414,12 @@ public TerrainGenerationMutation(IMutationTarget mutationTarget, List c private bool wasPerformedWithAutoLatOn; + public int TerrainCellWriteCount => undoData?.Select(data => data.CellCoords).Distinct().Count() ?? 0; + public int PlacedTerrainObjectCount => placedTerrainObjects?.Count ?? 0; + public int PlacedOverlayCount => placedOverlayCellCoords?.Count ?? 0; + public int PlacedSmudgeCount => placedSmudgeCellCoords?.Count ?? 0; + public bool AutoLATApplied => wasPerformedWithAutoLatOn; + public override string GetDisplayString() { return string.Format(Translate(this, "DisplayString", @@ -415,8 +433,9 @@ public override void Perform() public override void Undo() { - foreach (var originalTerrainData in undoData) + for (int i = undoData.Count - 1; i >= 0; i--) { + OriginalCellTerrainData originalTerrainData = undoData[i]; var mapCell = MutationTarget.Map.GetTile(originalTerrainData.CellCoords); mapCell.ChangeTileIndex(originalTerrainData.TileIndex, originalTerrainData.SubTileIndex); mapCell.Level = originalTerrainData.HeightLevel; @@ -457,6 +476,7 @@ public void Generate() placedTerrainObjects = new List(); placedOverlayCellCoords = new List(); placedSmudgeCellCoords = new List(); + wasPerformedWithAutoLatOn = false; var terrainTypeGroups = terrainGeneratorConfiguration.TerrainTypeGroups; var tileGroups = terrainGeneratorConfiguration.TileGroups; @@ -572,16 +592,17 @@ public void Generate() int index = random.Next(0, smudgeGroup.SmudgeTypes.Count); var smudgeType = smudgeGroup.SmudgeTypes[index]; var mapCell = MutationTarget.Map.GetTile(cellCoords); - if (mapCell.Smudge == null) - mapCell.Smudge = new Smudge() { SmudgeType = smudgeType, Position = cellCoords }; + if (mapCell.Smudge != null) + continue; + mapCell.Smudge = new Smudge() { SmudgeType = smudgeType, Position = cellCoords }; placedSmudgeCellCoords.Add(cellCoords); } } } // Apply auto-LAT - if (MutationTarget.AutoLATEnabled) + if (autoLATEnabled) { ApplyAutoLATOnArea(); wasPerformedWithAutoLatOn = true; @@ -722,9 +743,8 @@ private bool AllowTreeGroupOnCell(Point2D cellCoords, TerrainType treeGroup) private void PlaceTreeGroupOnCell(Point2D cellCoords, TerrainType treeGroup) { - var cell = MutationTarget.Map.GetTile(cellCoords); var terrainObject = new TerrainObject(treeGroup, cellCoords); - MutationTarget.Map.AddTerrainObject(new TerrainObject(treeGroup, cellCoords)); + MutationTarget.Map.AddTerrainObject(terrainObject); if (treeGroup.ImpassableCells != null) { From 052d3c3011712b6617b601580a7d1edfdf5a82f6 Mon Sep 17 00:00:00 2001 From: Rampastring Date: Thu, 6 Aug 2026 23:25:42 +0300 Subject: [PATCH 26/27] Split map editor project into three assemblies (core DLL + MCP DLL + UI/graphical editor main executable), implement scripting capabilities for MCP server, The assembly split enforces cleaner architecture, particularly prevents the rest of the codebase from having significant dependencies on the MCP server. Also, some technical debt (things relying on things they should not rely on) was cleared up. --- docs/Contributing.md | 26 + docs/MCP-Scripting-API.md | 328 ++ src/MapEditorLibrary/CCEngine/AutoLATType.cs | 153 + .../CCEngine/BuildingWithPropertyType.cs | 25 + .../CCEngine/CCCrypto.cs | 7 +- .../CCEngine/CCFileManager.cs | 338 ++ src/MapEditorLibrary/CCEngine/CsfFile.cs | 228 + .../CCEngine/GameConfigINIFiles.cs | 37 + src/MapEditorLibrary/CCEngine/HvaFile.cs | 100 + src/MapEditorLibrary/CCEngine/LayerType.cs | 10 + src/MapEditorLibrary/CCEngine/MixCRC.cs | 56 + src/MapEditorLibrary/CCEngine/MixFile.cs | 317 ++ src/MapEditorLibrary/CCEngine/Palette.cs | 119 + src/MapEditorLibrary/CCEngine/RGBColor.cs | 26 + src/MapEditorLibrary/CCEngine/RampType.cs | 42 + src/MapEditorLibrary/CCEngine/ScriptAction.cs | 97 + src/MapEditorLibrary/CCEngine/ShpFile.cs | 267 + src/MapEditorLibrary/CCEngine/Theater.cs | 210 + .../CCEngine/TileData/ISubTileImage.cs | 9 + .../CCEngine/TileData/ITileImage.cs | 40 + .../CCEngine/TileData/TheaterTileData.cs | 239 + .../CCEngine/TileData/TileImage.cs | 244 + .../TileData/UntexturedSubTileImage.cs | 14 + .../CCEngine/TileData/UntexturedTileImage.cs | 20 + src/MapEditorLibrary/CCEngine/TileSet.cs | 95 + src/MapEditorLibrary/CCEngine/TmpFile.cs | 215 + .../CCEngine/TriggerActionType.cs | 78 + .../CCEngine/TriggerEventType.cs | 95 + src/MapEditorLibrary/CCEngine/VplFile.cs | 54 + src/MapEditorLibrary/CCEngine/VxlFile.cs | 661 +++ .../Configuration/BrushSize.cs | 63 + .../Configuration/ObjectTypeCollection.cs | 17 + .../Configuration/OverlayCollection.cs | 74 + .../Configuration/SmudgeCollection.cs | 54 + .../TerrainGeneratorUserPresets.cs | 117 + .../Configuration/TerrainObjectCollection.cs | 54 + src/MapEditorLibrary/Constants.cs | 202 + .../Extensions/IniFileEx.cs | 8 +- .../Extensions/ListExtensions.cs | 161 + src/MapEditorLibrary/GameMath/CellMath.cs | 289 + src/MapEditorLibrary/GameMath/Direction.cs | 21 + src/MapEditorLibrary/GameMath/Point2D.cs | 160 + src/MapEditorLibrary/GameMath/Randomizer.cs | 29 + .../Graphics}/GraphicalBaseNode.cs | 4 +- .../Graphics/GraphicsPreparationClass.cs | 58 + .../Graphics}/MGSubTileImage.cs | 6 +- .../Graphics}/MGTileImage.cs | 4 +- .../Graphics/PositionedTexture.cs | 30 + .../Graphics/RenderingConstants.cs | 10 + src/MapEditorLibrary/Graphics/ShapeImage.cs | 297 + .../Graphics/SpriteSheetPreparation.cs | 105 + src/MapEditorLibrary/Helpers.cs | 877 +++ src/MapEditorLibrary/ITheater.cs | 18 + .../Initialization/IInitializer.cs | 11 + src/MapEditorLibrary/Initialization/IMap.cs | 75 + .../Initialization/Initializer.cs | 246 + .../Initialization/MapLoader.cs | 1400 +++++ .../Initialization/MapWriter.cs | 736 +++ src/MapEditorLibrary/MapEditorLibrary.csproj | 26 + .../Misc/DeletionMode.cs | 4 +- .../Misc/INIConfigException.cs | 11 + src/MapEditorLibrary/Misc/INIExtension.cs | 30 + src/MapEditorLibrary/Misc/ListExtensions.cs | 44 + src/MapEditorLibrary/Misc/MapFileWatcher.cs | 119 + src/MapEditorLibrary/Misc/MapIssueChecker.cs | 676 +++ src/MapEditorLibrary/Misc/NamedColors.cs | 37 + src/MapEditorLibrary/Misc/StreamHelpers.cs | 118 + src/MapEditorLibrary/Misc/Translator.cs | 287 + src/MapEditorLibrary/Models/AITriggerType.cs | 129 + src/MapEditorLibrary/Models/AbstractObject.cs | 42 + src/MapEditorLibrary/Models/Aircraft.cs | 19 + src/MapEditorLibrary/Models/AircraftType.cs | 16 + src/MapEditorLibrary/Models/AnimType.cs | 16 + src/MapEditorLibrary/Models/Animation.cs | 92 + .../Models/ArtConfig/AircraftArtConfig.cs | 23 + .../Models/ArtConfig/AnimArtConfig.cs | 57 + .../Models/ArtConfig/BuildingArtConfig.cs | 257 + .../Models/ArtConfig/IArtConfig.cs | 9 + .../Models/ArtConfig/IArtConfigContainer.cs | 6 + .../Models/ArtConfig/InfantryArtConfig.cs | 23 + .../Models/ArtConfig/InfantrySequence.cs | 49 + .../Models/ArtConfig/OverlayArtConfig.cs | 24 + .../Models/ArtConfig/TerrainArtConfig.cs | 28 + .../Models/ArtConfig/VehicleArtConfig.cs | 52 + src/MapEditorLibrary/Models/BasicSection.cs | 40 + src/MapEditorLibrary/Models/BridgeType.cs | 96 + src/MapEditorLibrary/Models/BuildingType.cs | 43 + src/MapEditorLibrary/Models/CellTag.cs | 27 + .../Models/ConnectedOverlayType.cs | 148 + .../Models/ConnectedTilePlanner.cs | 958 ++++ .../Models/ConnectedTileType.cs | 374 ++ src/MapEditorLibrary/Models/CsfString.cs | 13 + src/MapEditorLibrary/Models/EditorConfig.cs | 455 ++ .../Models/Enums/AITriggerConditionType.cs | 16 + .../Models/Enums/Difficulty.cs | 9 + src/MapEditorLibrary/Models/Enums/LandType.cs | 16 + .../Models/Enums/LightingPreviewMode.cs | 9 + src/MapEditorLibrary/Models/Enums/RTTIType.cs | 44 + .../Models/Enums/SpotlightType.cs | 19 + .../Models/Enums/TerrainOccupation.cs | 10 + .../Models/Enums/TheaterType.cs | 7 + .../Models/Enums/TriggerParamType.cs | 45 + src/MapEditorLibrary/Models/EvaSpeeches.cs | 74 + src/MapEditorLibrary/Models/Foot.cs | 31 + src/MapEditorLibrary/Models/GameObject.cs | 60 + src/MapEditorLibrary/Models/GameObjectType.cs | 49 + src/MapEditorLibrary/Models/GlobalVariable.cs | 13 + src/MapEditorLibrary/Models/House.cs | 204 + src/MapEditorLibrary/Models/HouseType.cs | 134 + src/MapEditorLibrary/Models/IHintable.cs | 7 + src/MapEditorLibrary/Models/IIDContainer.cs | 7 + src/MapEditorLibrary/Models/INIDefineable.cs | 263 + src/MapEditorLibrary/Models/INIDefined.cs | 6 + src/MapEditorLibrary/Models/IPositioned.cs | 10 + src/MapEditorLibrary/Models/Infantry.cs | 42 + src/MapEditorLibrary/Models/InfantryType.cs | 16 + src/MapEditorLibrary/Models/Lighting.cs | 260 + src/MapEditorLibrary/Models/LocalVariable.cs | 13 + src/MapEditorLibrary/Models/Map.cs | 1959 +++++++ .../Models/MapFormat/Format5.cs | 68 + .../Models/MapFormat/Format80.cs | 185 + .../Models/MapFormat/IsoMapPack5Tile.cs | 28 + .../Models/MapFormat/MemoryFile.cs | 14 + .../Models/MapFormat/MiniLZO.cs | 517 ++ .../Models/MapFormat/VirtualFile.cs | 250 + src/MapEditorLibrary/Models/MapTile.cs | 492 ++ src/MapEditorLibrary/Models/Mission.cs | 6 + src/MapEditorLibrary/Models/Overlay.cs | 49 + src/MapEditorLibrary/Models/OverlayType.cs | 36 + .../Models/ParticleSystemType.cs | 24 + src/MapEditorLibrary/Models/Rules.cs | 501 ++ src/MapEditorLibrary/Models/RulesColor.cs | 75 + src/MapEditorLibrary/Models/Script.cs | 153 + src/MapEditorLibrary/Models/Smudge.cs | 17 + src/MapEditorLibrary/Models/SmudgeType.cs | 26 + src/MapEditorLibrary/Models/Sounds.cs | 59 + src/MapEditorLibrary/Models/StringTable.cs | 35 + src/MapEditorLibrary/Models/Structure.cs | 341 ++ src/MapEditorLibrary/Models/SubCell.cs | 12 + .../Models/SuperWeaponType.cs | 26 + src/MapEditorLibrary/Models/Tag.cs | 39 + src/MapEditorLibrary/Models/TaskForce.cs | 224 + src/MapEditorLibrary/Models/TeamType.cs | 213 + src/MapEditorLibrary/Models/TeamTypeFlag.cs | 15 + src/MapEditorLibrary/Models/Techno.cs | 72 + src/MapEditorLibrary/Models/TechnoType.cs | 33 + src/MapEditorLibrary/Models/TerrainObject.cs | 38 + src/MapEditorLibrary/Models/TerrainType.cs | 35 + src/MapEditorLibrary/Models/Themes.cs | 93 + src/MapEditorLibrary/Models/TiberiumType.cs | 29 + src/MapEditorLibrary/Models/Trigger.cs | 362 ++ src/MapEditorLibrary/Models/TriggerAction.cs | 75 + .../Models/TriggerCondition.cs | 113 + src/MapEditorLibrary/Models/Tube.cs | 100 + src/MapEditorLibrary/Models/TutorialLines.cs | 148 + src/MapEditorLibrary/Models/Unit.cs | 122 + src/MapEditorLibrary/Models/UnitType.cs | 91 + src/MapEditorLibrary/Models/Waypoint.cs | 80 + src/MapEditorLibrary/Models/Weapon.cs | 15 + .../AIMutations/DeleteMapObjectsMutation.cs | 7 +- .../AIMutations/ModifyTechnosMutation.cs | 9 +- .../AIMutations/OverlayBatchMutationBase.cs | 9 +- .../AIMutations/PlaceOverlayBatchMutation.cs | 10 +- .../PlaceOverlayCollectionBatchMutation.cs | 12 +- .../PlaceTerrainObjectBatchMutation.cs | 9 +- ...aceTerrainObjectCollectionBatchMutation.cs | 10 +- .../AIMutations/SetCellsTerrainMutation.cs | 9 +- .../Classes/ChangeAttachedTagMutation.cs | 39 + .../Classes/ChangeTechnoOwnerMutation.cs | 39 + .../Mutations/Classes/CloneObjectMutation.cs | 105 + .../Mutations/Classes/DeleteObjectMutation.cs | 116 + .../Mutations/Classes/DeleteTubeMutation.cs | 47 + .../Classes/DrawConnectedTilesMutation.cs | 142 + .../Classes/FillTerrainAreaMutation.cs | 61 + .../AlterElevationMutationBase.cs | 143 + .../AlterGroundElevationUndoData.cs | 22 + .../HeightMutations/CornerHeightField.cs | 679 +++ .../HeightMutations/FSLowerGroundMutation.cs | 25 + .../HeightMutations/FSRaiseGroundMutation.cs | 25 + .../HeightMutations/FlattenGroundMutation.cs | 102 + .../HeightMutations/LowerCellsMutation.cs | 90 + .../HeightMutations/LowerGroundMutation.cs | 25 + .../LowerGroundMutationBase.cs | 105 + .../HeightMutations/RaiseCellsMutation.cs | 91 + .../HeightMutations/RaiseGroundMutation.cs | 26 + .../RaiseGroundMutationBase.cs | 133 + .../Mutations/Classes/MoveObjectMutation.cs | 71 + .../Classes/OriginalCellTerrainData.cs | 19 + .../Mutations/Classes/OriginalOverlayInfo.cs | 20 + .../Mutations/Classes/OriginalSmudgeInfo.cs | 18 + .../Mutations/Classes/PasteTerrainMutation.cs | 813 +++ .../Classes/PlaceAircraftMutation.cs | 65 + .../Mutations/Classes/PlaceBridgeMutation.cs | 225 + .../Classes/PlaceBuildingMutation.cs | 68 + .../Mutations/Classes/PlaceCellTagMutation.cs | 38 + .../PlaceConnectedOverlayLineMutation.cs | 113 + .../Classes/PlaceConnectedOverlayMutation.cs | 145 + .../Classes/PlaceInfantryMutation.cs | 75 + .../PlaceOverlayCollectionLineMutation.cs | 134 + .../Classes/PlaceOverlayCollectionMutation.cs | 132 + .../Classes/PlaceOverlayLineMutation.cs | 131 + .../Mutations/Classes/PlaceOverlayMutation.cs | 180 + .../PlaceSmudgeCollectionLineMutation.cs | 71 + .../Classes/PlaceSmudgeCollectionMutation.cs | 79 + .../Classes/PlaceSmudgeLineMutation.cs | 88 + .../Mutations/Classes/PlaceSmudgeMutation.cs | 114 + .../Classes/PlaceTerrainLineMutation.cs | 178 + ...laceTerrainObjectCollectionLineMutation.cs | 68 + .../PlaceTerrainObjectCollectionMutation.cs | 49 + .../Classes/PlaceTerrainObjectLineMutation.cs | 66 + .../Classes/PlaceTerrainObjectMutation.cs | 49 + .../Classes/PlaceTerrainTileMutation.cs | 186 + .../Mutations/Classes/PlaceTubeMutation.cs | 32 + .../Mutations/Classes/PlaceVehicleMutation.cs | 63 + .../Classes/PlaceVeinholeMonsterMutation.cs | 99 + .../Classes/PlaceWaypointMutation.cs | 43 + .../Mutations/Classes/SetFollowerMutation.cs | 36 + .../Mutations/Classes/SetIceGrowthMutation.cs | 85 + .../Classes/TerrainGenerationMutation.cs | 757 +++ .../Mutations/ICheckableMutation.cs | 6 + src/MapEditorLibrary/Mutations/IMutation.cs | 11 + .../Mutations/IMutationTarget.cs | 24 + src/MapEditorLibrary/Mutations/Mutation.cs | 274 + .../Mutations/MutationHistoryMetadata.cs | 39 + .../Mutations/MutationManager.cs | 113 + .../Properties/AssemblyInfo.cs | 25 + src/MapEditorLibrary/Version.cs | 3 + .../GameThreadDispatcher.cs | 8 +- src/MapEditorMCP/IMapScreenCropper.cs | 11 + src/MapEditorMCP/Infos/CellInfo.cs | 123 + .../Infos/ConnectedTileTypeInfo.cs} | 10 +- .../Infos}/MapAnalysisInfo.cs | 10 +- .../Infos}/MapBuildingPlacementProperties.cs | 4 +- .../Infos}/MapCellCoordinate.cs | 4 +- .../Infos}/MapFootPlacementProperties.cs | 7 +- src/MapEditorMCP/Infos/MapHouseInfo.cs | 15 + src/MapEditorMCP/Infos/MapInfo.cs | 17 + .../Infos}/MapMutationHistory.cs | 11 +- src/MapEditorMCP/Infos/MapObjectInfo.cs | 102 + src/MapEditorMCP/Infos/MapObjectTypeInfo.cs | 31 + .../Infos}/MapOverlayPlacement.cs | 4 +- .../Infos}/MapTechnoModificationProperties.cs | 9 +- .../Infos}/MapTerrainObjectPlacement.cs | 4 +- .../Infos}/MapTerrainObjectReference.cs | 4 +- src/MapEditorMCP/Infos/MapTileSetInfo.cs | 56 + .../Infos}/MapWaypointInfo.cs | 4 +- .../Infos/ObjectTypeCollectionInfo.cs | 55 + .../Infos/OverlayTypeInfo.cs} | 20 +- .../Infos/TerrainGeneratorPresetInfo.cs} | 59 +- .../Infos/TerrainTileInfo.cs} | 19 +- .../AI => MapEditorMCP}/MCPServer.cs | 38 +- src/MapEditorMCP/MapEditorMCP.csproj | 33 + .../AI => MapEditorMCP}/MapFacade.cs | 599 +- src/MapEditorMCP/MapScreenCropException.cs | 8 + .../AI => MapEditorMCP}/MapTools.cs | 340 +- src/MapEditorMCP/Properties/AssemblyInfo.cs | 25 + .../Scripting/ScriptingCatalogService.cs | 616 ++ .../Scripting/ScriptingChangePlanner.cs | 1986 +++++++ .../Scripting/ScriptingContentHasher.cs | 424 ++ src/MapEditorMCP/Scripting/ScriptingDtos.cs | 776 +++ src/MapEditorMCP/Scripting/ScriptingFacade.cs | 405 ++ .../Scripting/ScriptingIdAllocator.cs | 59 + .../Scripting/ScriptingParameterCodec.cs | 534 ++ .../Scripting/ScriptingReferenceService.cs | 466 ++ src/MapEditorMCP/Scripting/ScriptingTools.cs | 189 + src/TSMapEditor.sln | 16 +- src/TSMapEditor/AI/MapCollectionInfo.cs | 57 - src/TSMapEditor/CCEngine/AutoLATType.cs | 154 - .../CCEngine/BuildingWithPropertyType.cs | 26 - src/TSMapEditor/CCEngine/CCFileManager.cs | 340 -- src/TSMapEditor/CCEngine/CsfFile.cs | 232 - .../CCEngine/GameConfigINIFiles.cs | 39 - src/TSMapEditor/CCEngine/HvaFile.cs | 104 - src/TSMapEditor/CCEngine/LayerType.cs | 11 - src/TSMapEditor/CCEngine/MixCRC.cs | 57 - src/TSMapEditor/CCEngine/MixFile.cs | 321 -- src/TSMapEditor/CCEngine/Palette.cs | 120 - src/TSMapEditor/CCEngine/RGBColor.cs | 27 - src/TSMapEditor/CCEngine/RampType.cs | 43 - src/TSMapEditor/CCEngine/ScriptAction.cs | 100 - src/TSMapEditor/CCEngine/ShpFile.cs | 274 - src/TSMapEditor/CCEngine/Theater.cs | 214 - .../CCEngine/TileData/ISubTileImage.cs | 10 - .../CCEngine/TileData/ITileImage.cs | 41 - .../CCEngine/TileData/TheaterTileData.cs | 243 - .../CCEngine/TileData/TileImage.cs | 246 - .../TileData/UntexturedSubTileImage.cs | 15 - .../CCEngine/TileData/UntexturedTileImage.cs | 23 - src/TSMapEditor/CCEngine/TileSet.cs | 97 - src/TSMapEditor/CCEngine/TmpFile.cs | 220 - src/TSMapEditor/CCEngine/TriggerActionType.cs | 81 - src/TSMapEditor/CCEngine/TriggerEventType.cs | 98 - src/TSMapEditor/CCEngine/VplFile.cs | 57 - src/TSMapEditor/CCEngine/VxlFile.cs | 665 --- .../Config/Default/AIMappingInstructions.md | 4 +- .../Default/UI/Windows/SettingsPanel.ini | 18 + .../Config/Scripts/Count Credits On Map.cs | 80 +- .../Config/Translations/en/Translation_en.ini | 10 +- src/TSMapEditor/Constants.cs | 202 - src/TSMapEditor/Extensions/ListExtensions.cs | 164 - src/TSMapEditor/GameMath/CellMath.cs | 290 - src/TSMapEditor/GameMath/Direction.cs | 22 - src/TSMapEditor/GameMath/Point2D.cs | 162 - src/TSMapEditor/GameMath/Randomizer.cs | 32 - src/TSMapEditor/Helpers.cs | 882 --- src/TSMapEditor/INIExtension.cs | 32 - .../Initialization/IInitializer.cs | 12 - src/TSMapEditor/Initialization/IMap.cs | 78 - src/TSMapEditor/Initialization/Initializer.cs | 249 - src/TSMapEditor/Initialization/MapLoader.cs | 1406 ----- src/TSMapEditor/Initialization/MapWriter.cs | 740 --- src/TSMapEditor/Misc/AutosaveTimer.cs | 159 +- src/TSMapEditor/Misc/INIConfigException.cs | 14 - src/TSMapEditor/Misc/ListExtensions.cs | 48 - src/TSMapEditor/Misc/MapFileWatcher.cs | 122 - src/TSMapEditor/Misc/MapIssueChecker.cs | 680 --- src/TSMapEditor/Misc/NamedColors.cs | 38 - src/TSMapEditor/Misc/Translator.cs | 290 - src/TSMapEditor/Models/AITriggerType.cs | 129 - src/TSMapEditor/Models/AbstractObject.cs | 41 - src/TSMapEditor/Models/Aircraft.cs | 18 - src/TSMapEditor/Models/AircraftType.cs | 16 - src/TSMapEditor/Models/AnimType.cs | 16 - src/TSMapEditor/Models/Animation.cs | 92 - .../Models/ArtConfig/AircraftArtConfig.cs | 24 - .../Models/ArtConfig/AnimArtConfig.cs | 59 - .../Models/ArtConfig/BuildingArtConfig.cs | 259 - .../Models/ArtConfig/IArtConfig.cs | 10 - .../Models/ArtConfig/IArtConfigContainer.cs | 7 - .../Models/ArtConfig/InfantryArtConfig.cs | 24 - .../Models/ArtConfig/InfantrySequence.cs | 51 - .../Models/ArtConfig/OverlayArtConfig.cs | 25 - .../Models/ArtConfig/TerrainArtConfig.cs | 29 - .../Models/ArtConfig/VehicleArtConfig.cs | 53 - src/TSMapEditor/Models/BasicSection.cs | 41 - src/TSMapEditor/Models/BridgeType.cs | 99 - src/TSMapEditor/Models/BuildingType.cs | 43 - src/TSMapEditor/Models/CellTag.cs | 27 - .../Models/ConnectedOverlayType.cs | 152 - .../Models/ConnectedTilePlanner.cs | 961 ---- src/TSMapEditor/Models/ConnectedTileType.cs | 378 -- src/TSMapEditor/Models/CsfString.cs | 14 - src/TSMapEditor/Models/EditorConfig.cs | 458 -- .../Models/Enums/AITriggerConditionType.cs | 17 - src/TSMapEditor/Models/Enums/Difficulty.cs | 10 - src/TSMapEditor/Models/Enums/LandType.cs | 17 - .../Models/Enums/LightingPreviewMode.cs | 10 - src/TSMapEditor/Models/Enums/RTTIType.cs | 45 - src/TSMapEditor/Models/Enums/SpotlightType.cs | 20 - .../Models/Enums/TerrainOccupation.cs | 13 - src/TSMapEditor/Models/Enums/TheaterType.cs | 8 - .../Models/Enums/TriggerParamType.cs | 46 - src/TSMapEditor/Models/EvaSpeeches.cs | 76 - src/TSMapEditor/Models/Foot.cs | 32 - src/TSMapEditor/Models/GameObject.cs | 60 - src/TSMapEditor/Models/GameObjectType.cs | 52 - src/TSMapEditor/Models/GlobalVariable.cs | 14 - src/TSMapEditor/Models/House.cs | 207 - src/TSMapEditor/Models/HouseType.cs | 135 - src/TSMapEditor/Models/IHintable.cs | 8 - src/TSMapEditor/Models/IIDContainer.cs | 8 - src/TSMapEditor/Models/INIDefineable.cs | 266 - src/TSMapEditor/Models/INIDefined.cs | 7 - src/TSMapEditor/Models/IPositioned.cs | 11 - src/TSMapEditor/Models/Infantry.cs | 41 - src/TSMapEditor/Models/InfantryType.cs | 16 - src/TSMapEditor/Models/Lighting.cs | 263 - src/TSMapEditor/Models/LocalVariable.cs | 14 - src/TSMapEditor/Models/Map.cs | 1933 ------- src/TSMapEditor/Models/MapFormat/Format5.cs | 67 - src/TSMapEditor/Models/MapFormat/Format80.cs | 186 - .../Models/MapFormat/IsoMapPack5Tile.cs | 31 - .../Models/MapFormat/MemoryFile.cs | 14 - src/TSMapEditor/Models/MapFormat/MiniLZO.cs | 515 -- .../Models/MapFormat/VirtualFile.cs | 251 - src/TSMapEditor/Models/MapTile.cs | 495 -- src/TSMapEditor/Models/Mission.cs | 7 - src/TSMapEditor/Models/Overlay.cs | 50 - src/TSMapEditor/Models/OverlayType.cs | 37 - src/TSMapEditor/Models/ParticleSystemType.cs | 24 - src/TSMapEditor/Models/Rules.cs | 504 -- src/TSMapEditor/Models/RulesColor.cs | 77 - src/TSMapEditor/Models/Script.cs | 156 - src/TSMapEditor/Models/Smudge.cs | 16 - src/TSMapEditor/Models/SmudgeType.cs | 25 - src/TSMapEditor/Models/Sounds.cs | 61 - src/TSMapEditor/Models/StringTable.cs | 37 - src/TSMapEditor/Models/Structure.cs | 345 -- src/TSMapEditor/Models/SubCell.cs | 13 - src/TSMapEditor/Models/SuperWeaponType.cs | 26 - src/TSMapEditor/Models/Tag.cs | 40 - src/TSMapEditor/Models/TaskForce.cs | 226 - src/TSMapEditor/Models/TeamType.cs | 215 - src/TSMapEditor/Models/TeamTypeFlag.cs | 16 - src/TSMapEditor/Models/Techno.cs | 73 - src/TSMapEditor/Models/TechnoType.cs | 36 - src/TSMapEditor/Models/TerrainObject.cs | 38 - src/TSMapEditor/Models/TerrainType.cs | 37 - src/TSMapEditor/Models/Themes.cs | 95 - src/TSMapEditor/Models/TiberiumType.cs | 31 - src/TSMapEditor/Models/Trigger.cs | 366 -- src/TSMapEditor/Models/TriggerAction.cs | 77 - src/TSMapEditor/Models/TriggerCondition.cs | 115 - src/TSMapEditor/Models/Tube.cs | 103 - src/TSMapEditor/Models/TutorialLines.cs | 153 - src/TSMapEditor/Models/Unit.cs | 121 - src/TSMapEditor/Models/UnitType.cs | 92 - src/TSMapEditor/Models/Waypoint.cs | 81 - src/TSMapEditor/Models/Weapon.cs | 16 - .../Classes/ChangeAttachedTagMutation.cs | 41 - .../Classes/ChangeTechnoOwnerMutation.cs | 41 - .../Mutations/Classes/CloneObjectMutation.cs | 107 - .../Mutations/Classes/DeleteObjectMutation.cs | 118 - .../Mutations/Classes/DeleteTubeMutation.cs | 52 - .../Classes/DrawConnectedTilesMutation.cs | 147 - .../Classes/FillTerrainAreaMutation.cs | 65 - .../AlterElevationMutationBase.cs | 146 - .../AlterGroundElevationUndoData.cs | 23 - .../HeightMutations/CornerHeightField.cs | 682 --- .../HeightMutations/FSLowerGroundMutation.cs | 26 - .../HeightMutations/FSRaiseGroundMutation.cs | 26 - .../HeightMutations/FlattenGroundMutation.cs | 104 - .../HeightMutations/LowerCellsMutation.cs | 92 - .../HeightMutations/LowerGroundMutation.cs | 26 - .../LowerGroundMutationBase.cs | 107 - .../HeightMutations/RaiseCellsMutation.cs | 93 - .../HeightMutations/RaiseGroundMutation.cs | 27 - .../RaiseGroundMutationBase.cs | 135 - .../Mutations/Classes/MoveObjectMutation.cs | 72 - .../Classes/OriginalCellTerrainData.cs | 20 - .../Mutations/Classes/OriginalOverlayInfo.cs | 21 - .../Mutations/Classes/OriginalSmudgeInfo.cs | 19 - .../Mutations/Classes/PasteTerrainMutation.cs | 817 --- .../Classes/PlaceAircraftMutation.cs | 67 - .../Mutations/Classes/PlaceBridgeMutation.cs | 229 - .../Classes/PlaceBuildingMutation.cs | 71 - .../Mutations/Classes/PlaceCellTagMutation.cs | 40 - .../PlaceConnectedOverlayLineMutation.cs | 116 - .../Classes/PlaceConnectedOverlayMutation.cs | 148 - .../Classes/PlaceInfantryMutation.cs | 77 - .../PlaceOverlayCollectionLineMutation.cs | 136 - .../Classes/PlaceOverlayCollectionMutation.cs | 134 - .../Classes/PlaceOverlayLineMutation.cs | 135 - .../Mutations/Classes/PlaceOverlayMutation.cs | 182 - .../PlaceSmudgeCollectionLineMutation.cs | 73 - .../Classes/PlaceSmudgeCollectionMutation.cs | 82 - .../Classes/PlaceSmudgeLineMutation.cs | 91 - .../Mutations/Classes/PlaceSmudgeMutation.cs | 117 - .../Classes/PlaceTerrainLineMutation.cs | 181 - ...laceTerrainObjectCollectionLineMutation.cs | 70 - .../PlaceTerrainObjectCollectionMutation.cs | 51 - .../Classes/PlaceTerrainObjectLineMutation.cs | 69 - .../Classes/PlaceTerrainObjectMutation.cs | 52 - .../Classes/PlaceTerrainTileMutation.cs | 188 - .../Mutations/Classes/PlaceTubeMutation.cs | 35 - .../Mutations/Classes/PlaceVehicleMutation.cs | 65 - .../Classes/PlaceVeinholeMonsterMutation.cs | 103 - .../Classes/PlaceWaypointMutation.cs | 45 - .../Mutations/Classes/SetFollowerMutation.cs | 38 - .../Mutations/Classes/SetIceGrowthMutation.cs | 88 - .../Classes/TerrainGenerationMutation.cs | 762 --- .../Mutations/ICheckableMutation.cs | 7 - src/TSMapEditor/Mutations/IMutation.cs | 12 - src/TSMapEditor/Mutations/Mutation.cs | 278 - .../Mutations/MutationHistoryMetadata.cs | 43 - src/TSMapEditor/Mutations/MutationManager.cs | 115 - src/TSMapEditor/NativeMethods.cs | 69 +- src/TSMapEditor/Program.cs | 83 +- src/TSMapEditor/Properties/AssemblyInfo.cs | 14 +- .../Properties/Resources.Designer.cs | 87 +- .../Properties/Settings.Designer.cs | 21 +- .../Rendering/AlphaImageRenderStruct.cs | 28 +- .../Rendering/Batching/AbstractBatcher.cs | 138 +- .../Rendering/Batching/GameObjectBatcher.cs | 146 +- .../Rendering/Batching/RenderingConstants.cs | 11 - .../Rendering/Batching/TerrainBatcher.cs | 122 +- .../Rendering/Batching/TextureBatch.cs | 62 +- src/TSMapEditor/Rendering/Camera.cs | 218 +- src/TSMapEditor/Rendering/DepthRectangle.cs | 45 +- src/TSMapEditor/Rendering/EditorGraphics.cs | 65 +- src/TSMapEditor/Rendering/EditorState.cs | 374 +- src/TSMapEditor/Rendering/GameClass.cs | 307 +- src/TSMapEditor/Rendering/MapView.cs | 3504 ++++++------ src/TSMapEditor/Rendering/MapWideOverlay.cs | 85 +- .../Rendering/MegamapRenderOptions.cs | 19 +- .../ObjectRenderers/AircraftRenderer.cs | 68 +- .../Rendering/ObjectRenderers/AnimRenderer.cs | 184 +- .../ObjectRenderers/BuildingRenderer.cs | 881 +-- .../ObjectRenderers/CommonDrawParams.cs | 19 +- .../ObjectRenderers/InfantryRenderer.cs | 98 +- .../ObjectRenderers/ObjectDepthAdjustments.cs | 27 +- .../ObjectRenderers/ObjectRenderer.cs | 804 +-- .../ObjectRenderers/OverlayRenderer.cs | 231 +- .../ObjectRenderers/RenderDependencies.cs | 67 +- .../ObjectRenderers/SmudgeRenderer.cs | 88 +- .../ObjectRenderers/TerrainRenderer.cs | 84 +- .../Rendering/ObjectRenderers/UnitRenderer.cs | 281 +- .../Rendering/ObjectRenderers/VxlRenderer.cs | 508 +- .../Rendering/ObjectSpriteRecord.cs | 282 +- .../Rendering/RenderObjectFlags.cs | 39 +- .../Rendering/RendererExtensions.cs | 40 +- src/TSMapEditor/Rendering/ShapeImage.cs | 301 - src/TSMapEditor/Rendering/TheaterGraphics.cs | 2542 ++++----- .../Rendering/TubeRefreshHelper.cs | 40 - src/TSMapEditor/Scripts/ScriptRunner.cs | 367 +- src/TSMapEditor/Scripts/SmoothenIceScript.cs | 590 +- src/TSMapEditor/Settings/BoolSetting.cs | 25 +- src/TSMapEditor/Settings/DoubleSetting.cs | 25 +- src/TSMapEditor/Settings/IntSetting.cs | 25 +- src/TSMapEditor/Settings/RecentFiles.cs | 101 +- src/TSMapEditor/Settings/SettingBase.cs | 111 +- src/TSMapEditor/Settings/StringSetting.cs | 15 +- .../Settings/TerrainGeneratorUserPresets.cs | 121 - src/TSMapEditor/Settings/UserSettings.cs | 208 +- src/TSMapEditor/StreamHelpers.cs | 121 - src/TSMapEditor/TSMapEditor.csproj | 8 +- src/TSMapEditor/UI/BrushSize.cs | 65 - src/TSMapEditor/UI/Controls/DarkeningPanel.cs | 225 +- src/TSMapEditor/UI/Controls/EditorButton.cs | 76 +- .../UI/Controls/EditorDescriptionPanel.cs | 44 +- .../UI/Controls/EditorGUICreator.cs | 51 +- .../UI/Controls/EditorLinkLabel.cs | 61 +- src/TSMapEditor/UI/Controls/EditorListBox.cs | 27 +- .../UI/Controls/EditorListBoxSearchTextBox.cs | 61 +- .../UI/Controls/EditorNumberTextBox.cs | 107 +- .../UI/Controls/EditorPopUpSelector.cs | 104 +- .../UI/Controls/EditorSuggestionTextBox.cs | 14 +- src/TSMapEditor/UI/Controls/EditorTextBox.cs | 56 +- src/TSMapEditor/UI/Controls/EditorWindow.cs | 233 +- .../UI/Controls/FileBrowserListBox.cs | 209 +- .../UI/Controls/INItializableWindow.cs | 497 +- src/TSMapEditor/UI/Controls/MenuButton.cs | 188 +- src/TSMapEditor/UI/Controls/SortButton.cs | 24 +- src/TSMapEditor/UI/Controls/TileSetListBox.cs | 47 +- src/TSMapEditor/UI/Controls/ToolTip.cs | 262 +- src/TSMapEditor/UI/CursorAction.cs | 340 +- .../CursorActions/AircraftPlacementAction.cs | 139 +- .../CursorActions/BuildingPlacementAction.cs | 147 +- .../CalculateTiberiumValueCursorAction.cs | 148 +- .../ChangeAttachedTagCursorAction.cs | 84 +- .../CursorActions/ChangeTechnoOwnerAction.cs | 92 +- .../CheckDistanceCursorAction.cs | 195 +- .../CheckDistancePathfindingCursorAction.cs | 664 +-- .../ConnectedOverlayPlacementAction.cs | 170 +- .../CopyCustomShapedTerrainCursorAction.cs | 247 +- .../CopyRectangularTerrainCursorAction.cs | 110 +- .../CopyTerrainCursorActionBase.cs | 180 +- .../CursorActions/DeleteTubeCursorAction.cs | 84 +- .../CursorActions/DeletionModeCursorAction.cs | 168 +- .../DrawConnectedTilesCursorAction.cs | 484 +- .../GenerateTerrainCursorAction.cs | 142 +- .../FSLowerGroundCursorAction.cs | 43 +- .../FSRaiseGroundCursorAction.cs | 43 +- .../FlattenGroundCursorAction.cs | 173 +- .../HeightActions/LowerCellsCursorAction.cs | 33 +- .../HeightActions/LowerGroundCursorAction.cs | 37 +- .../HeightActions/RaiseCellsCursorAction.cs | 33 +- .../HeightActions/RaiseGroundCursorAction.cs | 37 +- .../CursorActions/InfantryPlacementAction.cs | 145 +- .../LineAndRegularPaintingAction.cs | 264 +- .../ManageBaseNodesCursorAction.cs | 519 +- .../OverlayCollectionPlacementAction.cs | 291 +- .../CursorActions/OverlayPlacementAction.cs | 254 +- .../CursorActions/PasteTerrainCursorAction.cs | 486 +- .../CursorActions/PlaceBridgeCursorAction.cs | 298 +- .../CursorActions/PlaceCellTagCursorAction.cs | 93 +- .../PlaceSmudgeCollectionCursorAction.cs | 194 +- .../CursorActions/PlaceSmudgeCursorAction.cs | 177 +- .../CursorActions/PlaceTerrainCursorAction.cs | 607 +- .../UI/CursorActions/PlaceTubeCursorAction.cs | 367 +- .../PlaceVeinholeMonsterCursorAction.cs | 60 +- .../PlaceWaypointCursorAction.cs | 62 +- .../CursorActions/SelectCellCursorAction.cs | 35 +- .../CursorActions/SetFollowerCursorAction.cs | 122 +- .../TerrainObjectCollectionPlacementAction.cs | 140 +- .../TerrainObjectPlacementAction.cs | 151 +- .../ToggleIceGrowthCursorAction.cs | 100 +- .../UI/CursorActions/UnitPlacementAction.cs | 147 +- src/TSMapEditor/UI/EditorContextMenu.cs | 52 +- src/TSMapEditor/UI/EditorPanel.cs | 97 +- src/TSMapEditor/UI/EditorThemes.cs | 88 +- src/TSMapEditor/UI/KeyboardCommand.cs | 311 +- src/TSMapEditor/UI/KeyboardCommands.cs | 272 +- src/TSMapEditor/UI/MainMenu.cs | 857 +-- src/TSMapEditor/UI/MapUI.cs | 1149 ++-- .../UI/Notifications/Notification.cs | 82 +- .../UI/Notifications/NotificationManager.cs | 82 +- src/TSMapEditor/UI/ObjectTypeCollection.cs | 21 - src/TSMapEditor/UI/OverlayCollection.cs | 76 - src/TSMapEditor/UI/OverlayFrameSelector.cs | 521 +- src/TSMapEditor/UI/Parser.cs | 808 +-- src/TSMapEditor/UI/RecentFilesPanel.cs | 70 +- src/TSMapEditor/UI/SettingsPanel.cs | 375 +- .../UI/Sidebar/AircraftListPanel.cs | 88 +- .../UI/Sidebar/BuildingListPanel.cs | 287 +- src/TSMapEditor/UI/Sidebar/EditorSidebar.cs | 334 +- .../UI/Sidebar/ISearchBoxContainer.cs | 9 +- .../UI/Sidebar/InfantryListPanel.cs | 59 +- src/TSMapEditor/UI/Sidebar/ObjectListPanel.cs | 557 +- .../UI/Sidebar/OverlayListPanel.cs | 497 +- src/TSMapEditor/UI/Sidebar/SidebarMode.cs | 31 +- src/TSMapEditor/UI/Sidebar/SmudgeListPanel.cs | 369 +- .../UI/Sidebar/TerrainObjectListPanel.cs | 347 +- src/TSMapEditor/UI/Sidebar/TreeView.cs | 758 +-- src/TSMapEditor/UI/Sidebar/UnitListPanel.cs | 140 +- src/TSMapEditor/UI/SmudgeCollection.cs | 55 - src/TSMapEditor/UI/TagEventArgs.cs | 19 +- src/TSMapEditor/UI/TerrainObjectCollection.cs | 55 - src/TSMapEditor/UI/TileDisplay.cs | 539 +- src/TSMapEditor/UI/TileInfoDisplay.cs | 495 +- src/TSMapEditor/UI/TileSelector.cs | 469 +- .../UI/TopBar/EditorControlsPanel.cs | 500 +- src/TSMapEditor/UI/TopBar/TopBarMenu.cs | 834 +-- src/TSMapEditor/UI/UIHelpers.cs | 94 +- src/TSMapEditor/UI/UIManager.cs | 1230 ++-- .../UI/Windows/AITriggersWindow.cs | 1084 ++-- src/TSMapEditor/UI/Windows/AboutWindow.cs | 33 +- .../UI/Windows/AircraftOptionsWindow.cs | 209 +- .../UI/Windows/ApplyINICodeWindow.cs | 164 +- .../AutoApplyImpassableOverlayWindow.cs | 160 +- .../UI/Windows/BasicSectionConfigWindow.cs | 290 +- .../UI/Windows/ChangeHeightWindow.cs | 65 +- .../UI/Windows/ConfigureAlliesWindow.cs | 144 +- .../UI/Windows/CopiedEntryTypesWindow.cs | 105 +- .../UI/Windows/CopiedTriggerData.cs | 238 +- .../UI/Windows/CreateAllianceWindow.cs | 138 +- .../Windows/CreateRandomTriggerSetWindow.cs | 417 +- .../DeletionModeConfigurationWindow.cs | 113 +- .../UI/Windows/EditHouseTypeWindow.cs | 436 +- .../UI/Windows/EditorMessageBox.cs | 348 +- src/TSMapEditor/UI/Windows/ExpandMapWindow.cs | 160 +- .../UI/Windows/FindWaypointWindow.cs | 83 +- .../Windows/GenerateStandardHousesWindow.cs | 164 +- src/TSMapEditor/UI/Windows/HistoryWindow.cs | 145 +- .../UI/Windows/HotkeyConfigurationWindow.cs | 359 +- src/TSMapEditor/UI/Windows/HousesWindow.cs | 1048 ++-- .../UI/Windows/InfantryOptionsWindow.cs | 241 +- .../UI/Windows/LightingSettingsWindow.cs | 256 +- .../UI/Windows/LocalVariablesWindow.cs | 356 +- .../MainMenuWindows/CreateNewMapWindow.cs | 202 +- .../UI/Windows/MainMenuWindows/MapSetup.cs | 385 +- src/TSMapEditor/UI/Windows/MapSizeWindow.cs | 111 +- .../Windows/MegamapGenerationOptionsWindow.cs | 93 +- src/TSMapEditor/UI/Windows/MegamapWindow.cs | 434 +- src/TSMapEditor/UI/Windows/NewHouseWindow.cs | 218 +- src/TSMapEditor/UI/Windows/OpenMapWindow.cs | 184 +- .../UI/Windows/PlaceWaypointWindow.cs | 194 +- .../RenderedObjectsConfigurationWindow.cs | 159 +- src/TSMapEditor/UI/Windows/RunScriptWindow.cs | 208 +- src/TSMapEditor/UI/Windows/SaveMapAsWindow.cs | 220 +- src/TSMapEditor/UI/Windows/ScriptsWindow.cs | 1902 +++---- .../UI/Windows/SelectActionWindow.cs | 61 +- .../UI/Windows/SelectAnimationWindow.cs | 65 +- .../UI/Windows/SelectBridgeWindow.cs | 63 +- .../UI/Windows/SelectBuildingTargetWindow.cs | 99 +- .../UI/Windows/SelectBuildingTypeWindow.cs | 63 +- .../UI/Windows/SelectColorsWindow.cs | 59 +- .../UI/Windows/SelectConnectedTileWindow.cs | 74 +- .../UI/Windows/SelectEventWindow.cs | 61 +- .../UI/Windows/SelectGlobalVariableWindow.cs | 57 +- .../UI/Windows/SelectHouseTypeWindow.cs | 74 +- .../UI/Windows/SelectHouseWindow.cs | 59 +- .../UI/Windows/SelectLocalVariableWindow.cs | 71 +- .../UI/Windows/SelectObjectWindow.cs | 274 +- .../UI/Windows/SelectObjectWindowInfoPanel.cs | 154 +- .../Windows/SelectParticleSystemTypeWindow.cs | 57 +- .../SelectScriptActionPresetOptionWindow.cs | 103 +- .../UI/Windows/SelectScriptActionWindow.cs | 61 +- .../UI/Windows/SelectScriptWindow.cs | 98 +- .../UI/Windows/SelectSoundWindow.cs | 51 +- .../UI/Windows/SelectSpeechWindow.cs | 56 +- .../UI/Windows/SelectStringWindow.cs | 89 +- .../UI/Windows/SelectSuperWeaponTypeWindow.cs | 61 +- src/TSMapEditor/UI/Windows/SelectTagWindow.cs | 105 +- .../UI/Windows/SelectTaskForceWindow.cs | 92 +- .../UI/Windows/SelectTeamTypeWindow.cs | 96 +- .../UI/Windows/SelectTechnoTypeWindow.cs | 67 +- .../UI/Windows/SelectThemeWindow.cs | 71 +- .../UI/Windows/SelectTileSetWindow.cs | 75 +- .../UI/Windows/SelectTriggerWindow.cs | 87 +- .../UI/Windows/SelectTutorialLineWindow.cs | 77 +- .../UI/Windows/StructureOptionsWindow.cs | 310 +- src/TSMapEditor/UI/Windows/TagsWindow.cs | 340 +- .../UI/Windows/TaskforcesWindow.cs | 1024 ++-- src/TSMapEditor/UI/Windows/TeamTypesWindow.cs | 1297 ++--- .../DeleteTerrainGeneratorPresetWindow.cs | 61 +- .../InputTerrainGeneratorPresetNameWindow.cs | 85 +- .../TerrainGeneratorConfigWindow.cs | 612 +- .../TerrainGeneratorOverlayGroupsPanel.cs | 300 +- .../TerrainGeneratorSmudgeGroupsPanel.cs | 240 +- .../TerrainGeneratorTerrainTypeGroupsPanel.cs | 242 +- .../TerrainGeneratorTileGroupsPanel.cs | 330 +- src/TSMapEditor/UI/Windows/TriggersWindow.cs | 4936 +++++++++-------- .../UI/Windows/VehicleOptionsWindow.cs | 271 +- .../UI/Windows/WindowController.cs | 595 +- 695 files changed, 65253 insertions(+), 59451 deletions(-) create mode 100644 docs/MCP-Scripting-API.md create mode 100644 src/MapEditorLibrary/CCEngine/AutoLATType.cs create mode 100644 src/MapEditorLibrary/CCEngine/BuildingWithPropertyType.cs rename src/{TSMapEditor => MapEditorLibrary}/CCEngine/CCCrypto.cs (99%) create mode 100644 src/MapEditorLibrary/CCEngine/CCFileManager.cs create mode 100644 src/MapEditorLibrary/CCEngine/CsfFile.cs create mode 100644 src/MapEditorLibrary/CCEngine/GameConfigINIFiles.cs create mode 100644 src/MapEditorLibrary/CCEngine/HvaFile.cs create mode 100644 src/MapEditorLibrary/CCEngine/LayerType.cs create mode 100644 src/MapEditorLibrary/CCEngine/MixCRC.cs create mode 100644 src/MapEditorLibrary/CCEngine/MixFile.cs create mode 100644 src/MapEditorLibrary/CCEngine/Palette.cs create mode 100644 src/MapEditorLibrary/CCEngine/RGBColor.cs create mode 100644 src/MapEditorLibrary/CCEngine/RampType.cs create mode 100644 src/MapEditorLibrary/CCEngine/ScriptAction.cs create mode 100644 src/MapEditorLibrary/CCEngine/ShpFile.cs create mode 100644 src/MapEditorLibrary/CCEngine/Theater.cs create mode 100644 src/MapEditorLibrary/CCEngine/TileData/ISubTileImage.cs create mode 100644 src/MapEditorLibrary/CCEngine/TileData/ITileImage.cs create mode 100644 src/MapEditorLibrary/CCEngine/TileData/TheaterTileData.cs create mode 100644 src/MapEditorLibrary/CCEngine/TileData/TileImage.cs create mode 100644 src/MapEditorLibrary/CCEngine/TileData/UntexturedSubTileImage.cs create mode 100644 src/MapEditorLibrary/CCEngine/TileData/UntexturedTileImage.cs create mode 100644 src/MapEditorLibrary/CCEngine/TileSet.cs create mode 100644 src/MapEditorLibrary/CCEngine/TmpFile.cs create mode 100644 src/MapEditorLibrary/CCEngine/TriggerActionType.cs create mode 100644 src/MapEditorLibrary/CCEngine/TriggerEventType.cs create mode 100644 src/MapEditorLibrary/CCEngine/VplFile.cs create mode 100644 src/MapEditorLibrary/CCEngine/VxlFile.cs create mode 100644 src/MapEditorLibrary/Configuration/BrushSize.cs create mode 100644 src/MapEditorLibrary/Configuration/ObjectTypeCollection.cs create mode 100644 src/MapEditorLibrary/Configuration/OverlayCollection.cs create mode 100644 src/MapEditorLibrary/Configuration/SmudgeCollection.cs create mode 100644 src/MapEditorLibrary/Configuration/TerrainGeneratorUserPresets.cs create mode 100644 src/MapEditorLibrary/Configuration/TerrainObjectCollection.cs create mode 100644 src/MapEditorLibrary/Constants.cs rename src/{TSMapEditor => MapEditorLibrary}/Extensions/IniFileEx.cs (97%) create mode 100644 src/MapEditorLibrary/Extensions/ListExtensions.cs create mode 100644 src/MapEditorLibrary/GameMath/CellMath.cs create mode 100644 src/MapEditorLibrary/GameMath/Direction.cs create mode 100644 src/MapEditorLibrary/GameMath/Point2D.cs create mode 100644 src/MapEditorLibrary/GameMath/Randomizer.cs rename src/{TSMapEditor/Rendering => MapEditorLibrary/Graphics}/GraphicalBaseNode.cs (86%) create mode 100644 src/MapEditorLibrary/Graphics/GraphicsPreparationClass.cs rename src/{TSMapEditor/Rendering => MapEditorLibrary/Graphics}/MGSubTileImage.cs (98%) rename src/{TSMapEditor/Rendering => MapEditorLibrary/Graphics}/MGTileImage.cs (92%) create mode 100644 src/MapEditorLibrary/Graphics/PositionedTexture.cs create mode 100644 src/MapEditorLibrary/Graphics/RenderingConstants.cs create mode 100644 src/MapEditorLibrary/Graphics/ShapeImage.cs create mode 100644 src/MapEditorLibrary/Graphics/SpriteSheetPreparation.cs create mode 100644 src/MapEditorLibrary/Helpers.cs create mode 100644 src/MapEditorLibrary/ITheater.cs create mode 100644 src/MapEditorLibrary/Initialization/IInitializer.cs create mode 100644 src/MapEditorLibrary/Initialization/IMap.cs create mode 100644 src/MapEditorLibrary/Initialization/Initializer.cs create mode 100644 src/MapEditorLibrary/Initialization/MapLoader.cs create mode 100644 src/MapEditorLibrary/Initialization/MapWriter.cs create mode 100644 src/MapEditorLibrary/MapEditorLibrary.csproj rename src/{TSMapEditor => MapEditorLibrary}/Misc/DeletionMode.cs (91%) create mode 100644 src/MapEditorLibrary/Misc/INIConfigException.cs create mode 100644 src/MapEditorLibrary/Misc/INIExtension.cs create mode 100644 src/MapEditorLibrary/Misc/ListExtensions.cs create mode 100644 src/MapEditorLibrary/Misc/MapFileWatcher.cs create mode 100644 src/MapEditorLibrary/Misc/MapIssueChecker.cs create mode 100644 src/MapEditorLibrary/Misc/NamedColors.cs create mode 100644 src/MapEditorLibrary/Misc/StreamHelpers.cs create mode 100644 src/MapEditorLibrary/Misc/Translator.cs create mode 100644 src/MapEditorLibrary/Models/AITriggerType.cs create mode 100644 src/MapEditorLibrary/Models/AbstractObject.cs create mode 100644 src/MapEditorLibrary/Models/Aircraft.cs create mode 100644 src/MapEditorLibrary/Models/AircraftType.cs create mode 100644 src/MapEditorLibrary/Models/AnimType.cs create mode 100644 src/MapEditorLibrary/Models/Animation.cs create mode 100644 src/MapEditorLibrary/Models/ArtConfig/AircraftArtConfig.cs create mode 100644 src/MapEditorLibrary/Models/ArtConfig/AnimArtConfig.cs create mode 100644 src/MapEditorLibrary/Models/ArtConfig/BuildingArtConfig.cs create mode 100644 src/MapEditorLibrary/Models/ArtConfig/IArtConfig.cs create mode 100644 src/MapEditorLibrary/Models/ArtConfig/IArtConfigContainer.cs create mode 100644 src/MapEditorLibrary/Models/ArtConfig/InfantryArtConfig.cs create mode 100644 src/MapEditorLibrary/Models/ArtConfig/InfantrySequence.cs create mode 100644 src/MapEditorLibrary/Models/ArtConfig/OverlayArtConfig.cs create mode 100644 src/MapEditorLibrary/Models/ArtConfig/TerrainArtConfig.cs create mode 100644 src/MapEditorLibrary/Models/ArtConfig/VehicleArtConfig.cs create mode 100644 src/MapEditorLibrary/Models/BasicSection.cs create mode 100644 src/MapEditorLibrary/Models/BridgeType.cs create mode 100644 src/MapEditorLibrary/Models/BuildingType.cs create mode 100644 src/MapEditorLibrary/Models/CellTag.cs create mode 100644 src/MapEditorLibrary/Models/ConnectedOverlayType.cs create mode 100644 src/MapEditorLibrary/Models/ConnectedTilePlanner.cs create mode 100644 src/MapEditorLibrary/Models/ConnectedTileType.cs create mode 100644 src/MapEditorLibrary/Models/CsfString.cs create mode 100644 src/MapEditorLibrary/Models/EditorConfig.cs create mode 100644 src/MapEditorLibrary/Models/Enums/AITriggerConditionType.cs create mode 100644 src/MapEditorLibrary/Models/Enums/Difficulty.cs create mode 100644 src/MapEditorLibrary/Models/Enums/LandType.cs create mode 100644 src/MapEditorLibrary/Models/Enums/LightingPreviewMode.cs create mode 100644 src/MapEditorLibrary/Models/Enums/RTTIType.cs create mode 100644 src/MapEditorLibrary/Models/Enums/SpotlightType.cs create mode 100644 src/MapEditorLibrary/Models/Enums/TerrainOccupation.cs create mode 100644 src/MapEditorLibrary/Models/Enums/TheaterType.cs create mode 100644 src/MapEditorLibrary/Models/Enums/TriggerParamType.cs create mode 100644 src/MapEditorLibrary/Models/EvaSpeeches.cs create mode 100644 src/MapEditorLibrary/Models/Foot.cs create mode 100644 src/MapEditorLibrary/Models/GameObject.cs create mode 100644 src/MapEditorLibrary/Models/GameObjectType.cs create mode 100644 src/MapEditorLibrary/Models/GlobalVariable.cs create mode 100644 src/MapEditorLibrary/Models/House.cs create mode 100644 src/MapEditorLibrary/Models/HouseType.cs create mode 100644 src/MapEditorLibrary/Models/IHintable.cs create mode 100644 src/MapEditorLibrary/Models/IIDContainer.cs create mode 100644 src/MapEditorLibrary/Models/INIDefineable.cs create mode 100644 src/MapEditorLibrary/Models/INIDefined.cs create mode 100644 src/MapEditorLibrary/Models/IPositioned.cs create mode 100644 src/MapEditorLibrary/Models/Infantry.cs create mode 100644 src/MapEditorLibrary/Models/InfantryType.cs create mode 100644 src/MapEditorLibrary/Models/Lighting.cs create mode 100644 src/MapEditorLibrary/Models/LocalVariable.cs create mode 100644 src/MapEditorLibrary/Models/Map.cs create mode 100644 src/MapEditorLibrary/Models/MapFormat/Format5.cs create mode 100644 src/MapEditorLibrary/Models/MapFormat/Format80.cs create mode 100644 src/MapEditorLibrary/Models/MapFormat/IsoMapPack5Tile.cs create mode 100644 src/MapEditorLibrary/Models/MapFormat/MemoryFile.cs create mode 100644 src/MapEditorLibrary/Models/MapFormat/MiniLZO.cs create mode 100644 src/MapEditorLibrary/Models/MapFormat/VirtualFile.cs create mode 100644 src/MapEditorLibrary/Models/MapTile.cs create mode 100644 src/MapEditorLibrary/Models/Mission.cs create mode 100644 src/MapEditorLibrary/Models/Overlay.cs create mode 100644 src/MapEditorLibrary/Models/OverlayType.cs create mode 100644 src/MapEditorLibrary/Models/ParticleSystemType.cs create mode 100644 src/MapEditorLibrary/Models/Rules.cs create mode 100644 src/MapEditorLibrary/Models/RulesColor.cs create mode 100644 src/MapEditorLibrary/Models/Script.cs create mode 100644 src/MapEditorLibrary/Models/Smudge.cs create mode 100644 src/MapEditorLibrary/Models/SmudgeType.cs create mode 100644 src/MapEditorLibrary/Models/Sounds.cs create mode 100644 src/MapEditorLibrary/Models/StringTable.cs create mode 100644 src/MapEditorLibrary/Models/Structure.cs create mode 100644 src/MapEditorLibrary/Models/SubCell.cs create mode 100644 src/MapEditorLibrary/Models/SuperWeaponType.cs create mode 100644 src/MapEditorLibrary/Models/Tag.cs create mode 100644 src/MapEditorLibrary/Models/TaskForce.cs create mode 100644 src/MapEditorLibrary/Models/TeamType.cs create mode 100644 src/MapEditorLibrary/Models/TeamTypeFlag.cs create mode 100644 src/MapEditorLibrary/Models/Techno.cs create mode 100644 src/MapEditorLibrary/Models/TechnoType.cs create mode 100644 src/MapEditorLibrary/Models/TerrainObject.cs create mode 100644 src/MapEditorLibrary/Models/TerrainType.cs create mode 100644 src/MapEditorLibrary/Models/Themes.cs create mode 100644 src/MapEditorLibrary/Models/TiberiumType.cs create mode 100644 src/MapEditorLibrary/Models/Trigger.cs create mode 100644 src/MapEditorLibrary/Models/TriggerAction.cs create mode 100644 src/MapEditorLibrary/Models/TriggerCondition.cs create mode 100644 src/MapEditorLibrary/Models/Tube.cs create mode 100644 src/MapEditorLibrary/Models/TutorialLines.cs create mode 100644 src/MapEditorLibrary/Models/Unit.cs create mode 100644 src/MapEditorLibrary/Models/UnitType.cs create mode 100644 src/MapEditorLibrary/Models/Waypoint.cs create mode 100644 src/MapEditorLibrary/Models/Weapon.cs rename src/{TSMapEditor => MapEditorLibrary}/Mutations/Classes/AIMutations/DeleteMapObjectsMutation.cs (94%) rename src/{TSMapEditor => MapEditorLibrary}/Mutations/Classes/AIMutations/ModifyTechnosMutation.cs (97%) rename src/{TSMapEditor => MapEditorLibrary}/Mutations/Classes/AIMutations/OverlayBatchMutationBase.cs (93%) rename src/{TSMapEditor => MapEditorLibrary}/Mutations/Classes/AIMutations/PlaceOverlayBatchMutation.cs (93%) rename src/{TSMapEditor => MapEditorLibrary}/Mutations/Classes/AIMutations/PlaceOverlayCollectionBatchMutation.cs (93%) rename src/{TSMapEditor => MapEditorLibrary}/Mutations/Classes/AIMutations/PlaceTerrainObjectBatchMutation.cs (91%) rename src/{TSMapEditor => MapEditorLibrary}/Mutations/Classes/AIMutations/PlaceTerrainObjectCollectionBatchMutation.cs (93%) rename src/{TSMapEditor => MapEditorLibrary}/Mutations/Classes/AIMutations/SetCellsTerrainMutation.cs (93%) create mode 100644 src/MapEditorLibrary/Mutations/Classes/ChangeAttachedTagMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/ChangeTechnoOwnerMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/CloneObjectMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/DeleteObjectMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/DeleteTubeMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/DrawConnectedTilesMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/FillTerrainAreaMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/HeightMutations/AlterElevationMutationBase.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/HeightMutations/AlterGroundElevationUndoData.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/HeightMutations/CornerHeightField.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/HeightMutations/FSLowerGroundMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/HeightMutations/FSRaiseGroundMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/HeightMutations/FlattenGroundMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/HeightMutations/LowerCellsMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/HeightMutations/LowerGroundMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/HeightMutations/LowerGroundMutationBase.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/HeightMutations/RaiseCellsMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/HeightMutations/RaiseGroundMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/HeightMutations/RaiseGroundMutationBase.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/MoveObjectMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/OriginalCellTerrainData.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/OriginalOverlayInfo.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/OriginalSmudgeInfo.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PasteTerrainMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceAircraftMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceBridgeMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceBuildingMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceCellTagMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceConnectedOverlayLineMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceConnectedOverlayMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceInfantryMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceOverlayCollectionLineMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceOverlayCollectionMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceOverlayLineMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceOverlayMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceSmudgeCollectionLineMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceSmudgeCollectionMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceSmudgeLineMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceSmudgeMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceTerrainLineMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceTerrainObjectCollectionLineMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceTerrainObjectCollectionMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceTerrainObjectLineMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceTerrainObjectMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceTerrainTileMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceTubeMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceVehicleMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceVeinholeMonsterMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/PlaceWaypointMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/SetFollowerMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/SetIceGrowthMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/Classes/TerrainGenerationMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/ICheckableMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/IMutation.cs create mode 100644 src/MapEditorLibrary/Mutations/IMutationTarget.cs create mode 100644 src/MapEditorLibrary/Mutations/Mutation.cs create mode 100644 src/MapEditorLibrary/Mutations/MutationHistoryMetadata.cs create mode 100644 src/MapEditorLibrary/Mutations/MutationManager.cs create mode 100644 src/MapEditorLibrary/Properties/AssemblyInfo.cs create mode 100644 src/MapEditorLibrary/Version.cs rename src/{TSMapEditor/AI => MapEditorMCP}/GameThreadDispatcher.cs (92%) create mode 100644 src/MapEditorMCP/IMapScreenCropper.cs create mode 100644 src/MapEditorMCP/Infos/CellInfo.cs rename src/{TSMapEditor/AI/MapConnectedTileTypeInfo.cs => MapEditorMCP/Infos/ConnectedTileTypeInfo.cs} (66%) rename src/{TSMapEditor/AI => MapEditorMCP/Infos}/MapAnalysisInfo.cs (84%) rename src/{TSMapEditor/AI => MapEditorMCP/Infos}/MapBuildingPlacementProperties.cs (93%) rename src/{TSMapEditor/AI => MapEditorMCP/Infos}/MapCellCoordinate.cs (74%) rename src/{TSMapEditor/AI => MapEditorMCP/Infos}/MapFootPlacementProperties.cs (82%) create mode 100644 src/MapEditorMCP/Infos/MapHouseInfo.cs create mode 100644 src/MapEditorMCP/Infos/MapInfo.cs rename src/{TSMapEditor/AI => MapEditorMCP/Infos}/MapMutationHistory.cs (91%) create mode 100644 src/MapEditorMCP/Infos/MapObjectInfo.cs create mode 100644 src/MapEditorMCP/Infos/MapObjectTypeInfo.cs rename src/{TSMapEditor/AI => MapEditorMCP/Infos}/MapOverlayPlacement.cs (87%) rename src/{TSMapEditor/AI => MapEditorMCP/Infos}/MapTechnoModificationProperties.cs (92%) rename src/{TSMapEditor/AI => MapEditorMCP/Infos}/MapTerrainObjectPlacement.cs (81%) rename src/{TSMapEditor/AI => MapEditorMCP/Infos}/MapTerrainObjectReference.cs (86%) create mode 100644 src/MapEditorMCP/Infos/MapTileSetInfo.cs rename src/{TSMapEditor/AI => MapEditorMCP/Infos}/MapWaypointInfo.cs (88%) create mode 100644 src/MapEditorMCP/Infos/ObjectTypeCollectionInfo.cs rename src/{TSMapEditor/AI/MapOverlayTypeInfo.cs => MapEditorMCP/Infos/OverlayTypeInfo.cs} (68%) rename src/{TSMapEditor/AI/MapTerrainGeneratorPresetInfo.cs => MapEditorMCP/Infos/TerrainGeneratorPresetInfo.cs} (77%) rename src/{TSMapEditor/AI/MapTerrainTileInfo.cs => MapEditorMCP/Infos/TerrainTileInfo.cs} (86%) rename src/{TSMapEditor/AI => MapEditorMCP}/MCPServer.cs (83%) create mode 100644 src/MapEditorMCP/MapEditorMCP.csproj rename src/{TSMapEditor/AI => MapEditorMCP}/MapFacade.cs (83%) create mode 100644 src/MapEditorMCP/MapScreenCropException.cs rename src/{TSMapEditor/AI => MapEditorMCP}/MapTools.cs (61%) create mode 100644 src/MapEditorMCP/Properties/AssemblyInfo.cs create mode 100644 src/MapEditorMCP/Scripting/ScriptingCatalogService.cs create mode 100644 src/MapEditorMCP/Scripting/ScriptingChangePlanner.cs create mode 100644 src/MapEditorMCP/Scripting/ScriptingContentHasher.cs create mode 100644 src/MapEditorMCP/Scripting/ScriptingDtos.cs create mode 100644 src/MapEditorMCP/Scripting/ScriptingFacade.cs create mode 100644 src/MapEditorMCP/Scripting/ScriptingIdAllocator.cs create mode 100644 src/MapEditorMCP/Scripting/ScriptingParameterCodec.cs create mode 100644 src/MapEditorMCP/Scripting/ScriptingReferenceService.cs create mode 100644 src/MapEditorMCP/Scripting/ScriptingTools.cs delete mode 100644 src/TSMapEditor/AI/MapCollectionInfo.cs delete mode 100644 src/TSMapEditor/CCEngine/AutoLATType.cs delete mode 100644 src/TSMapEditor/CCEngine/BuildingWithPropertyType.cs delete mode 100644 src/TSMapEditor/CCEngine/CCFileManager.cs delete mode 100644 src/TSMapEditor/CCEngine/CsfFile.cs delete mode 100644 src/TSMapEditor/CCEngine/GameConfigINIFiles.cs delete mode 100644 src/TSMapEditor/CCEngine/HvaFile.cs delete mode 100644 src/TSMapEditor/CCEngine/LayerType.cs delete mode 100644 src/TSMapEditor/CCEngine/MixCRC.cs delete mode 100644 src/TSMapEditor/CCEngine/MixFile.cs delete mode 100644 src/TSMapEditor/CCEngine/Palette.cs delete mode 100644 src/TSMapEditor/CCEngine/RGBColor.cs delete mode 100644 src/TSMapEditor/CCEngine/RampType.cs delete mode 100644 src/TSMapEditor/CCEngine/ScriptAction.cs delete mode 100644 src/TSMapEditor/CCEngine/ShpFile.cs delete mode 100644 src/TSMapEditor/CCEngine/Theater.cs delete mode 100644 src/TSMapEditor/CCEngine/TileData/ISubTileImage.cs delete mode 100644 src/TSMapEditor/CCEngine/TileData/ITileImage.cs delete mode 100644 src/TSMapEditor/CCEngine/TileData/TheaterTileData.cs delete mode 100644 src/TSMapEditor/CCEngine/TileData/TileImage.cs delete mode 100644 src/TSMapEditor/CCEngine/TileData/UntexturedSubTileImage.cs delete mode 100644 src/TSMapEditor/CCEngine/TileData/UntexturedTileImage.cs delete mode 100644 src/TSMapEditor/CCEngine/TileSet.cs delete mode 100644 src/TSMapEditor/CCEngine/TmpFile.cs delete mode 100644 src/TSMapEditor/CCEngine/TriggerActionType.cs delete mode 100644 src/TSMapEditor/CCEngine/TriggerEventType.cs delete mode 100644 src/TSMapEditor/CCEngine/VplFile.cs delete mode 100644 src/TSMapEditor/CCEngine/VxlFile.cs delete mode 100644 src/TSMapEditor/Constants.cs delete mode 100644 src/TSMapEditor/Extensions/ListExtensions.cs delete mode 100644 src/TSMapEditor/GameMath/CellMath.cs delete mode 100644 src/TSMapEditor/GameMath/Direction.cs delete mode 100644 src/TSMapEditor/GameMath/Point2D.cs delete mode 100644 src/TSMapEditor/GameMath/Randomizer.cs delete mode 100644 src/TSMapEditor/Helpers.cs delete mode 100644 src/TSMapEditor/INIExtension.cs delete mode 100644 src/TSMapEditor/Initialization/IInitializer.cs delete mode 100644 src/TSMapEditor/Initialization/IMap.cs delete mode 100644 src/TSMapEditor/Initialization/Initializer.cs delete mode 100644 src/TSMapEditor/Initialization/MapLoader.cs delete mode 100644 src/TSMapEditor/Initialization/MapWriter.cs delete mode 100644 src/TSMapEditor/Misc/INIConfigException.cs delete mode 100644 src/TSMapEditor/Misc/ListExtensions.cs delete mode 100644 src/TSMapEditor/Misc/MapFileWatcher.cs delete mode 100644 src/TSMapEditor/Misc/MapIssueChecker.cs delete mode 100644 src/TSMapEditor/Misc/NamedColors.cs delete mode 100644 src/TSMapEditor/Misc/Translator.cs delete mode 100644 src/TSMapEditor/Models/AITriggerType.cs delete mode 100644 src/TSMapEditor/Models/AbstractObject.cs delete mode 100644 src/TSMapEditor/Models/Aircraft.cs delete mode 100644 src/TSMapEditor/Models/AircraftType.cs delete mode 100644 src/TSMapEditor/Models/AnimType.cs delete mode 100644 src/TSMapEditor/Models/Animation.cs delete mode 100644 src/TSMapEditor/Models/ArtConfig/AircraftArtConfig.cs delete mode 100644 src/TSMapEditor/Models/ArtConfig/AnimArtConfig.cs delete mode 100644 src/TSMapEditor/Models/ArtConfig/BuildingArtConfig.cs delete mode 100644 src/TSMapEditor/Models/ArtConfig/IArtConfig.cs delete mode 100644 src/TSMapEditor/Models/ArtConfig/IArtConfigContainer.cs delete mode 100644 src/TSMapEditor/Models/ArtConfig/InfantryArtConfig.cs delete mode 100644 src/TSMapEditor/Models/ArtConfig/InfantrySequence.cs delete mode 100644 src/TSMapEditor/Models/ArtConfig/OverlayArtConfig.cs delete mode 100644 src/TSMapEditor/Models/ArtConfig/TerrainArtConfig.cs delete mode 100644 src/TSMapEditor/Models/ArtConfig/VehicleArtConfig.cs delete mode 100644 src/TSMapEditor/Models/BasicSection.cs delete mode 100644 src/TSMapEditor/Models/BridgeType.cs delete mode 100644 src/TSMapEditor/Models/BuildingType.cs delete mode 100644 src/TSMapEditor/Models/CellTag.cs delete mode 100644 src/TSMapEditor/Models/ConnectedOverlayType.cs delete mode 100644 src/TSMapEditor/Models/ConnectedTilePlanner.cs delete mode 100644 src/TSMapEditor/Models/ConnectedTileType.cs delete mode 100644 src/TSMapEditor/Models/CsfString.cs delete mode 100644 src/TSMapEditor/Models/EditorConfig.cs delete mode 100644 src/TSMapEditor/Models/Enums/AITriggerConditionType.cs delete mode 100644 src/TSMapEditor/Models/Enums/Difficulty.cs delete mode 100644 src/TSMapEditor/Models/Enums/LandType.cs delete mode 100644 src/TSMapEditor/Models/Enums/LightingPreviewMode.cs delete mode 100644 src/TSMapEditor/Models/Enums/RTTIType.cs delete mode 100644 src/TSMapEditor/Models/Enums/SpotlightType.cs delete mode 100644 src/TSMapEditor/Models/Enums/TerrainOccupation.cs delete mode 100644 src/TSMapEditor/Models/Enums/TheaterType.cs delete mode 100644 src/TSMapEditor/Models/Enums/TriggerParamType.cs delete mode 100644 src/TSMapEditor/Models/EvaSpeeches.cs delete mode 100644 src/TSMapEditor/Models/Foot.cs delete mode 100644 src/TSMapEditor/Models/GameObject.cs delete mode 100644 src/TSMapEditor/Models/GameObjectType.cs delete mode 100644 src/TSMapEditor/Models/GlobalVariable.cs delete mode 100644 src/TSMapEditor/Models/House.cs delete mode 100644 src/TSMapEditor/Models/HouseType.cs delete mode 100644 src/TSMapEditor/Models/IHintable.cs delete mode 100644 src/TSMapEditor/Models/IIDContainer.cs delete mode 100644 src/TSMapEditor/Models/INIDefineable.cs delete mode 100644 src/TSMapEditor/Models/INIDefined.cs delete mode 100644 src/TSMapEditor/Models/IPositioned.cs delete mode 100644 src/TSMapEditor/Models/Infantry.cs delete mode 100644 src/TSMapEditor/Models/InfantryType.cs delete mode 100644 src/TSMapEditor/Models/Lighting.cs delete mode 100644 src/TSMapEditor/Models/LocalVariable.cs delete mode 100644 src/TSMapEditor/Models/Map.cs delete mode 100644 src/TSMapEditor/Models/MapFormat/Format5.cs delete mode 100644 src/TSMapEditor/Models/MapFormat/Format80.cs delete mode 100644 src/TSMapEditor/Models/MapFormat/IsoMapPack5Tile.cs delete mode 100644 src/TSMapEditor/Models/MapFormat/MemoryFile.cs delete mode 100644 src/TSMapEditor/Models/MapFormat/MiniLZO.cs delete mode 100644 src/TSMapEditor/Models/MapFormat/VirtualFile.cs delete mode 100644 src/TSMapEditor/Models/MapTile.cs delete mode 100644 src/TSMapEditor/Models/Mission.cs delete mode 100644 src/TSMapEditor/Models/Overlay.cs delete mode 100644 src/TSMapEditor/Models/OverlayType.cs delete mode 100644 src/TSMapEditor/Models/ParticleSystemType.cs delete mode 100644 src/TSMapEditor/Models/Rules.cs delete mode 100644 src/TSMapEditor/Models/RulesColor.cs delete mode 100644 src/TSMapEditor/Models/Script.cs delete mode 100644 src/TSMapEditor/Models/Smudge.cs delete mode 100644 src/TSMapEditor/Models/SmudgeType.cs delete mode 100644 src/TSMapEditor/Models/Sounds.cs delete mode 100644 src/TSMapEditor/Models/StringTable.cs delete mode 100644 src/TSMapEditor/Models/Structure.cs delete mode 100644 src/TSMapEditor/Models/SubCell.cs delete mode 100644 src/TSMapEditor/Models/SuperWeaponType.cs delete mode 100644 src/TSMapEditor/Models/Tag.cs delete mode 100644 src/TSMapEditor/Models/TaskForce.cs delete mode 100644 src/TSMapEditor/Models/TeamType.cs delete mode 100644 src/TSMapEditor/Models/TeamTypeFlag.cs delete mode 100644 src/TSMapEditor/Models/Techno.cs delete mode 100644 src/TSMapEditor/Models/TechnoType.cs delete mode 100644 src/TSMapEditor/Models/TerrainObject.cs delete mode 100644 src/TSMapEditor/Models/TerrainType.cs delete mode 100644 src/TSMapEditor/Models/Themes.cs delete mode 100644 src/TSMapEditor/Models/TiberiumType.cs delete mode 100644 src/TSMapEditor/Models/Trigger.cs delete mode 100644 src/TSMapEditor/Models/TriggerAction.cs delete mode 100644 src/TSMapEditor/Models/TriggerCondition.cs delete mode 100644 src/TSMapEditor/Models/Tube.cs delete mode 100644 src/TSMapEditor/Models/TutorialLines.cs delete mode 100644 src/TSMapEditor/Models/Unit.cs delete mode 100644 src/TSMapEditor/Models/UnitType.cs delete mode 100644 src/TSMapEditor/Models/Waypoint.cs delete mode 100644 src/TSMapEditor/Models/Weapon.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/ChangeAttachedTagMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/ChangeTechnoOwnerMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/CloneObjectMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/DeleteObjectMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/DeleteTubeMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/DrawConnectedTilesMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/FillTerrainAreaMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/HeightMutations/AlterElevationMutationBase.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/HeightMutations/AlterGroundElevationUndoData.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/HeightMutations/CornerHeightField.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/HeightMutations/FSLowerGroundMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/HeightMutations/FSRaiseGroundMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/HeightMutations/FlattenGroundMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/HeightMutations/LowerCellsMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/HeightMutations/LowerGroundMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/HeightMutations/LowerGroundMutationBase.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/HeightMutations/RaiseCellsMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/HeightMutations/RaiseGroundMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/HeightMutations/RaiseGroundMutationBase.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/MoveObjectMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/OriginalCellTerrainData.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/OriginalOverlayInfo.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/OriginalSmudgeInfo.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PasteTerrainMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceAircraftMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceBridgeMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceBuildingMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceCellTagMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceConnectedOverlayLineMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceConnectedOverlayMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceInfantryMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceOverlayCollectionLineMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceOverlayCollectionMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceOverlayLineMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceOverlayMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceSmudgeCollectionLineMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceSmudgeCollectionMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceSmudgeLineMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceSmudgeMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceTerrainLineMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceTerrainObjectCollectionLineMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceTerrainObjectCollectionMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceTerrainObjectLineMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceTerrainObjectMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceTerrainTileMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceTubeMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceVehicleMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceVeinholeMonsterMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/PlaceWaypointMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/SetFollowerMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/SetIceGrowthMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Classes/TerrainGenerationMutation.cs delete mode 100644 src/TSMapEditor/Mutations/ICheckableMutation.cs delete mode 100644 src/TSMapEditor/Mutations/IMutation.cs delete mode 100644 src/TSMapEditor/Mutations/Mutation.cs delete mode 100644 src/TSMapEditor/Mutations/MutationHistoryMetadata.cs delete mode 100644 src/TSMapEditor/Mutations/MutationManager.cs delete mode 100644 src/TSMapEditor/Rendering/Batching/RenderingConstants.cs delete mode 100644 src/TSMapEditor/Rendering/ShapeImage.cs delete mode 100644 src/TSMapEditor/Rendering/TubeRefreshHelper.cs delete mode 100644 src/TSMapEditor/Settings/TerrainGeneratorUserPresets.cs delete mode 100644 src/TSMapEditor/StreamHelpers.cs delete mode 100644 src/TSMapEditor/UI/BrushSize.cs delete mode 100644 src/TSMapEditor/UI/ObjectTypeCollection.cs delete mode 100644 src/TSMapEditor/UI/OverlayCollection.cs delete mode 100644 src/TSMapEditor/UI/SmudgeCollection.cs delete mode 100644 src/TSMapEditor/UI/TerrainObjectCollection.cs diff --git a/docs/Contributing.md b/docs/Contributing.md index 99f96dac7..7580b4412 100644 --- a/docs/Contributing.md +++ b/docs/Contributing.md @@ -36,6 +36,12 @@ WAE supports all target games through a single executable. All submitted code _m There are 3 branches: `master` is for Dawn of the Tiberium Age, `tsclient` is for Tiberian Sun (CnCNet Client version), and `yr` is for Yuri's Revenge. Code-wise these branches are identical, but `tsclient` and `yr` have one additional commit that gives them different INI configurations compared to `master`. +### Fail-Fast Design + +Do not write defensive code. Incorrect mod configurations or unexpected/potentially corrupted state should raise an exception with a clear message instead of being tolerated. This allows us to catch possible bugs and issues early. The only exception to this is config mistakes that are present in original Tiberian Sun or Red Alert 2 or their expansion packs - we have to tolerate those due to the nature of the application. + +This naturally does not apply to MCP server message parsing or other data received from an outside application. Such data should always be validated before it is acted upon. Potential validation failures should be logged and related operations skipped. + ### Translations If your code introduces new strings that are displayed in the user interface, the strings _must_ go through the fitting `Translate` function to enable multi-language support. The codebase has a lot of examples on how to do this. Also, you need to add the new strings in a fitting location in the `Translation_en.ini` reference translation file. @@ -82,6 +88,11 @@ if (SomeReallyLongCondition() || DoSomething(); } +// Not OK +if (SomeReallyLongCondition() || + ThatSplitsIntoMultipleLines()) + DoSomething(); + // OK if (SomeCondition()) { @@ -142,6 +153,21 @@ if (SomeCondition()) return; } ``` + +- Do not separate function call parameters on their own lines unless the code for the call would become prohibitely wide (over 160 characters). If the line becomes too wide, prefer splitting it at the parameter where it would become too wide, instead of at each parameter. + +``` +// OK +mcpServer = new MCPServer(WindowManager, new MapFacade(map, mutationManager, mapUI.MutationTarget), new ScriptingFacade(map), mapUI); + +// Not OK +mcpServer = new MCPServer( + WindowManager, + new MapFacade(map, mutationManager, mapUI.MutationTarget), + new ScriptingFacade(map), + mapUI); +``` + - Use `var` with local variables when the type of the variable is obvious from the code or the type is not relevant. Never use `var` with primitive types. ```cs // OK diff --git a/docs/MCP-Scripting-API.md b/docs/MCP-Scripting-API.md new file mode 100644 index 000000000..d18b5047e --- /dev/null +++ b/docs/MCP-Scripting-API.md @@ -0,0 +1,328 @@ +# MCP Scripting API + +## Purpose + +The World-Altering Editor MCP server exposes map-local TaskForces, Scripts, TeamTypes, AITriggers, Local Variables, Triggers, and Tags to AI agents. The API supports browsing the active game configuration and applying related scripting changes as one validated, atomic operation. + +Scripting changes are applied atomically and are not added to map undo/redo history. Saving the map persists the result, but normal Undo cannot revert it; an agent must read the new state and submit an explicit inverse change if one is needed. + +## Recommended workflow + +1. Discover active definitions, HouseTypes, object INI names, waypoints, flags, and other parameter options. +2. Browse any existing elements that will be updated, deleted, referenced, or guarded. +3. Build one change set, using `TemporaryKey` references between elements created together. +4. Call `validate_scripting_changes` for a dry run. +5. If validation succeeds, call `apply_scripting_changes` with the same logical request and still-current hashes. +6. Keep the generated IDs and `ContentHash` values returned in `created` and `updated`. + +Validation and application both run against the current map state. A successful validation is therefore not a lock: application can still reject the request if a guarded element changes in between. + +## Runtime discovery + +Do not assume that a familiar action, event, HouseType, unit, flag, side, or preset has its default ID or INI name. Discover these values from the active game configuration. + +- `get_scripting_definitions` returns Script actions, Trigger events and actions, their editable and hardcoded parameter definitions, preset options, TeamType flags and defaults, AITrigger conditions and comparators, and sides. +- `get_house_types` returns valid owner HouseType INI names. +- `get_scripting_parameter_options` returns current semantic options for types such as `Techno`, `TeamType`, `HouseType`, `Waypoint`, `WaypointZZ`, `LocalVariable`, `Sound`, and `Theme`. +- The element browsing tools return permanent IDs, references, raw values where relevant, and current content hashes. `includeGlobal` exposes read-only global TaskForces, Scripts, and TeamTypes where supported. + +The numeric definition IDs used in the examples below are the bundled defaults only. Always look up the matching definition by its runtime name and substitute the returned ID. Likewise, `Soviet`, `HTNK`, and `V2RL` are illustrative INI names that must be replaced with values returned for the loaded rules. + +## Content hashes and concurrent editing + +Every returned permanent scripting element includes a deterministic `ContentHash` in the form `sha256:...`. It is calculated on demand from the element's canonical persisted state and is independent of the global map revision. + +Every update and delete must include the element's current `ExpectedContentHash`. Immediately before applying a change set, the server recalculates all expected hashes and rejects the entire request if one differs. This prevents an agent from overwriting a human's concurrent edit to the same scripting element while allowing unrelated terrain, object, and scripting work to continue. + +`Preconditions` add the same guard to elements that influenced the requested change but are not themselves updated or deleted. They can use permanent identities returned by reference inspection, including global scripting elements and read-only `MapTechno` and `CellTag` reference sources. A precondition cannot use a `TemporaryKey`, because temporary elements do not exist before the change set. + +For example, an update is a complete replacement, not a patch: + +```json +{ + "request": { + "updates": { + "taskForces": [ + { + "id": "01000000", + "expectedContentHash": "sha256:current-task-force-hash", + "replacement": { + "name": "Updated Soviet armor", + "group": -1, + "members": [ + { "index": 0, "technoType": "HTNK", "count": 6 }, + { "index": 3, "technoType": "V2RL", "count": 4 } + ] + } + } + ] + }, + "preconditions": [ + { + "kind": "TeamType", + "id": "01000002", + "expectedContentHash": "sha256:current-team-type-hash" + } + ] + } +} +``` + +Copy every editable property from the latest browse result into `replacement`, changing only what the user requested. Omitted optional properties are cleared or reset to their documented defaults. Updates and deletes apply only to map-local editable elements; global elements and read-only reference sources may only be browsed, referenced, or guarded. + +## Temporary references + +The server generates permanent INI IDs, so a caller cannot know the IDs of several related elements before creating them in one operation. A create can therefore have a request-scoped `TemporaryKey`, and another value in the same change set can reference it with `TemporaryKey` plus the expected element `Kind`. + +A reference must specify exactly one of `Id`, `Index`, or `TemporaryKey`. Temporary keys are case-sensitive, may not have leading or trailing whitespace, are never stored in the map, and are distinct from permanent IDs and user-facing names. The result maps each key to its generated permanent ID or Local Variable index. + +## Atomic change sets + +`apply_scripting_changes` accepts typed create, update, delete, and precondition collections. The server performs these phases atomically: + +1. Validate request shapes, temporary keys, runtime definitions, references, and primitive values. +2. Verify expected content hashes and preconditions. +3. Reserve permanent scripting IDs and Local Variable indices. +4. Construct new elements and resolve permanent and temporary references. +5. Validate the projected reference graph and reject dangling references or unsafe deletions. +6. Commit the validated changes and return normalized results and generated IDs. + +Validation errors commit nothing, and unexpected commit-time failures are rolled back. Deletion never silently cascades or detaches references. + +## Parameter handling + +Script actions, Trigger events, and Trigger actions are identified by IDs returned from `get_scripting_definitions`. Callers provide semantic values rather than engine storage encodings: + +- A configured preset accepts its returned `Value`, full option text, or display label. For example, the bundled `Do This` action accepts either `14` or `Hunt`. +- Object-valued parameters accept the option's INI name or index as described by its parameter type. +- `HouseType` accepts a loaded INI name or a raw numeric value documented by the active definition, including sentinel values such as `-1` for any house. +- `Waypoint` and `WaypointZZ` both accept a numeric waypoint identifier, which must exist in the current map. +- Reference-valued parameters use a typed reference object and may point to an existing permanent element or a `TemporaryKey` in the same request. +- Trigger event/action parameter entries include the zero-based configured parameter `Index`. + +Supply only editable parameters. Omit parameters whose definition reports `IsUsed: false` or `IsHardcoded: true`; the server fills those fields with their configured defaults. Unknown existing Script or Trigger action definitions remain browseable through raw values and may be preserved unchanged, but a newly authored action requires an active runtime definition. + +TaskForce members similarly have an optional zero-based `Index` from 0 through 5. Omit it to use the first free slot. Preserve the indices returned by `get_task_forces` during a full-replacement update so sparse INI slots are not compacted unintentionally. + +## Discovery and browsing tools + +- `get_house_types` +- `get_scripting_definitions` +- `get_scripting_parameter_options` +- `get_task_forces` +- `get_scripts` +- `get_team_types` +- `get_ai_triggers` +- `get_local_variables` +- `get_triggers` +- `get_tags` +- `get_scripting_references` +- `validate_scripting_changes` +- `apply_scripting_changes` + +## End-to-end example: Hard-only Soviet AI attack + +Goal: produce five Heavy Tanks and four V2 Launchers for the Soviet HouseType on Hard difficulty, then run `Do This -> Hunt`. + +First discover the active values: + +```text +get_house_types { "nameFilter": "Soviet" } +get_scripting_definitions {} +get_scripting_parameter_options { "parameterType": "Techno", "nameFilter": "Heavy Tank" } +get_scripting_parameter_options { "parameterType": "Techno", "nameFilter": "V2 Launcher" } +``` + +From those results, select the exact HouseType and object INI names, the `Do This` action ID and `Hunt` preset, the unconditional AITrigger condition, comparator, and side. With the bundled illustrative values, one atomic `apply_scripting_changes` request is: + +```json +{ + "request": { + "creates": { + "taskForces": [ + { + "temporaryKey": "hard-soviet-force", + "value": { + "name": "H Soviet Armor", + "group": -1, + "members": [ + { "index": 0, "technoType": "HTNK", "count": 5 }, + { "index": 1, "technoType": "V2RL", "count": 4 } + ] + } + } + ], + "scripts": [ + { + "temporaryKey": "hard-soviet-hunt-script", + "value": { + "name": "Do This - Hunt", + "actions": [ + { "actionId": 11, "value": "Hunt" } + ] + } + } + ], + "teamTypes": [ + { + "temporaryKey": "hard-soviet-team", + "value": { + "name": "H Soviet Assault Team", + "group": -1, + "houseType": "Soviet", + "script": { "kind": "Script", "temporaryKey": "hard-soviet-hunt-script" }, + "taskForce": { "kind": "TaskForce", "temporaryKey": "hard-soviet-force" }, + "max": 1, + "priority": 7, + "techLevel": 0, + "veteranLevel": 1 + } + } + ], + "aiTriggers": [ + { + "temporaryKey": "hard-soviet-ai-trigger", + "value": { + "name": "H Soviet Armor Attack", + "primaryTeam": { "kind": "TeamType", "temporaryKey": "hard-soviet-team" }, + "ownerName": "Soviet", + "techLevel": 0, + "conditionType": -1, + "comparatorOperator": 0, + "comparatorQuantity": 0, + "initialWeight": 50, + "minimumWeight": 30, + "maximumWeight": 70, + "side": 0, + "easy": false, + "medium": false, + "hard": true, + "enabled": true + } + } + ] + } + } +} +``` + +Omitting `enabledFlags` on a new TeamType applies the active configuration defaults. The TeamType has `max: 1` because an AITrigger cannot produce a map-local team whose maximum is zero. + +## End-to-end example: timed off-map reinforcement + +Goal: after 300 in-game seconds, create eight Heavy Tanks at waypoint 44 and have them hunt the player. + +Discover the same HouseType, Heavy Tank, `Do This -> Hunt` values, plus the runtime definitions named `Elapsed Time` and `Reinforcement At Waypoint`. Confirm waypoint 44 exists: + +```text +get_scripting_definitions {} +get_scripting_parameter_options { "parameterType": "Waypoint", "nameFilter": "44" } +``` + +Using bundled default definition IDs 11, 13, and 80 for illustration: + +```json +{ + "request": { + "creates": { + "taskForces": [ + { + "temporaryKey": "reinforcement-force", + "value": { + "name": "Eight heavy tanks", + "group": -1, + "members": [ + { "index": 0, "technoType": "HTNK", "count": 8 } + ] + } + } + ], + "scripts": [ + { + "temporaryKey": "reinforcement-hunt-script", + "value": { + "name": "Reinforcement hunt", + "actions": [ + { "actionId": 11, "value": "Hunt" } + ] + } + } + ], + "teamTypes": [ + { + "temporaryKey": "reinforcement-team", + "value": { + "name": "Heavy tank reinforcement team", + "group": -1, + "houseType": "Soviet", + "script": { "kind": "Script", "temporaryKey": "reinforcement-hunt-script" }, + "taskForce": { "kind": "TaskForce", "temporaryKey": "reinforcement-force" }, + "max": 0, + "priority": 7, + "waypoint": 44, + "techLevel": 0, + "veteranLevel": 1 + } + } + ], + "triggers": [ + { + "temporaryKey": "reinforcement-trigger", + "value": { + "name": "Heavy tanks after 300 seconds", + "houseType": "Soviet", + "disabled": false, + "easy": true, + "normal": true, + "hard": true, + "events": [ + { + "eventId": 13, + "parameters": [ + { "index": 1, "value": "300" } + ] + } + ], + "actions": [ + { + "actionId": 80, + "parameters": [ + { + "index": 1, + "reference": { "kind": "TeamType", "temporaryKey": "reinforcement-team" } + }, + { "index": 6, "value": "44" } + ] + } + ] + } + } + ], + "tags": [ + { + "temporaryKey": "reinforcement-tag", + "value": { + "name": "Heavy tank reinforcement tag", + "repeating": 0, + "trigger": { "kind": "Trigger", "temporaryKey": "reinforcement-trigger" } + } + } + ] + } + } +} +``` + +Only the editable event/action parameters are present. For the bundled definitions, Elapsed Time uses parameter index 1; Reinforcement At Waypoint uses the TeamType at index 1 and waypoint at index 6. The server fills action parameter 0 with its hardcoded value and normalizes all unused storage fields. + +## Validation principles + +- TaskForces contain one through six positive-count member entries drawn from valid aircraft, infantry, or vehicle types. +- Scripts use active configured actions and respect the engine's supported action count. +- TeamTypes reference valid TaskForces, Scripts, optional Tags, HouseTypes, flags, and existing waypoints. +- AITriggers reference valid TeamTypes and game-appropriate condition types, owners, sides, and comparison objects. AI-produced map-local teams must have `Max` of at least 1. +- Local Variable indices are unique; an omitted create index allocates the first available value. +- Trigger definitions, parameter types, availability, owners, linked triggers, difficulty flags, and special encodings are validated. A Trigger may contain at most 18 actions. +- Tags reference a valid Trigger and use a repeating value from 0 through 2. +- Deletion checks incoming references from scripting elements, placed technos, and cell tags. +- Global TaskForces, Scripts, and TeamTypes may be browsed, referenced, and guarded where supported, but cannot be edited through map-local operations. diff --git a/src/MapEditorLibrary/CCEngine/AutoLATType.cs b/src/MapEditorLibrary/CCEngine/AutoLATType.cs new file mode 100644 index 000000000..c573e8e52 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/AutoLATType.cs @@ -0,0 +1,153 @@ +namespace MapEditorLibrary.CCEngine; + +public struct AutoLATData +{ + public int TransitionTypeIndex; + public int[] TransitionMatchArray; + + public AutoLATData(int transitionTypeTileSetIndex, int[] transitionMatchArray) + { + TransitionTypeIndex = transitionTypeTileSetIndex; + TransitionMatchArray = transitionMatchArray; + } +} + +public static class AutoLATType +{ + // Transition types and related tileset indexes + public const int SURROUNDS_TILE = 0; + public const int NE_TRANSITION = 1; + public const int SE_TRANSITION = 2; + public const int NE_SE_TRANSITION = 3; + public const int SW_TRANSITION = 4; + public const int NE_SW_TRANSITION = 5; + public const int SE_SW_TRANSITION = 6; + public const int NE_SE_SW_TRANSITION = 7; + public const int NW_TRANSITION = 8; + public const int NE_NW_TRANSITION = 9; + public const int NW_SE_TRANSITION = 10; + public const int NE_NW_SE_TRANSITION = 11; + public const int NW_SW_TRANSITION = 12; + public const int NE_NW_SW_TRANSITION = 13; + public const int NW_SE_SW_TRANSITION = 14; + public const int TRANSITION_TO_ALL_DIRECTIONS = 15; + + // Array / matrix index definitions + public const int NE_INDEX = 0; + public const int NW_INDEX = 1; + public const int CENTER_INDEX = 2; + public const int SE_INDEX = 3; + public const int SW_INDEX = 4; + + // Transition arrays for different transition types. + // These are used to check which transition type a tile should use by checking the nearby tiles. + // 1 means that the tile matches the ground type being placed, 0 means that the tile has something else. + // If you "rotate" these 45 degrees clockwise, these match the isometric perspective. + public static readonly int[] A_SURROUNDS_TILE = new int[] + { 1, + 1,0,1, + 1 }; + + public static readonly int[] A_NE_TRANSITION = new int[] + { 0, + 1,1,1, + 1 }; + + public static readonly int[] A_SE_TRANSITION = new int[] + { 1, + 1,1,0, + 1 }; + + public static readonly int[] A_NE_SE_TRANSITION = new int[] + { 0, + 1,1,0, + 1 }; + + public static readonly int[] A_SW_TRANSITION = new int[] + { 1, + 1,1,1, + 0 }; + + public static readonly int[] A_NE_SW_TRANSITION = new int[] + { 0, + 1,1,1, + 0 }; + + public static readonly int[] A_SE_SW_TRANSITION = new int[] + { 1, + 1,1,0, + 0 }; + + public static readonly int[] A_NE_SE_SW_TRANSITION = new int[] + { 0, + 1,1,0, + 0 }; + + public static readonly int[] A_NW_TRANSITION = new int[] + { 1, + 0,1,1, + 1 }; + + public static readonly int[] A_NE_NW_TRANSITION = new int[] + { 0, + 0,1,1, + 1 }; + + public static readonly int[] A_NW_SE_TRANSITION = new int[] + { 1, + 0,1,0, + 1 }; + + public static readonly int[] A_NE_NW_SE_TRANSITION = new int[] + { 0, + 0,1,0, + 1 }; + + public static readonly int[] A_NW_SW_TRANSITION = new int[] + { 1, + 0,1,1, + 0 }; + + public static readonly int[] A_NE_NW_SW_TRANSITION = new int[] + { 0, + 0,1,1, + 0 }; + + public static readonly int[] A_NW_SE_SW_TRANSITION = new int[] + { 1, + 0,1,0, + 0 }; + + public static readonly int[] A_TRANSITION_TO_ALL_DIRECTIONS = new int[] + { 0, + 0,1,0, + 0 }; + + /// + /// Links transition indexes and related data arrays. + /// + public static AutoLATData[] AutoLATData { get; private set; } + + public static void InitArray() + { + AutoLATData = new AutoLATData[] + { + new AutoLATData(SURROUNDS_TILE, A_SURROUNDS_TILE), + new AutoLATData(NE_TRANSITION, A_NE_TRANSITION), + new AutoLATData(SE_TRANSITION, A_SE_TRANSITION), + new AutoLATData(NE_SE_TRANSITION, A_NE_SE_TRANSITION), + new AutoLATData(SW_TRANSITION, A_SW_TRANSITION), + new AutoLATData(NE_SW_TRANSITION, A_NE_SW_TRANSITION), + new AutoLATData(SE_SW_TRANSITION, A_SE_SW_TRANSITION), + new AutoLATData(NE_SE_SW_TRANSITION, A_NE_SE_SW_TRANSITION), + new AutoLATData(NW_TRANSITION, A_NW_TRANSITION), + new AutoLATData(NE_NW_TRANSITION, A_NE_NW_TRANSITION), + new AutoLATData(NW_SE_TRANSITION, A_NW_SE_TRANSITION), + new AutoLATData(NE_NW_SE_TRANSITION, A_NE_NW_SE_TRANSITION), + new AutoLATData(NW_SW_TRANSITION, A_NW_SW_TRANSITION), + new AutoLATData(NE_NW_SW_TRANSITION, A_NE_NW_SW_TRANSITION), + new AutoLATData(NW_SE_SW_TRANSITION, A_NW_SE_SW_TRANSITION), + new AutoLATData(TRANSITION_TO_ALL_DIRECTIONS, A_TRANSITION_TO_ALL_DIRECTIONS), + }; + } +} diff --git a/src/MapEditorLibrary/CCEngine/BuildingWithPropertyType.cs b/src/MapEditorLibrary/CCEngine/BuildingWithPropertyType.cs new file mode 100644 index 000000000..9ec2afb4a --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/BuildingWithPropertyType.cs @@ -0,0 +1,25 @@ +namespace MapEditorLibrary.CCEngine; + +public enum BuildingWithPropertyType +{ + LeastThreat = 0x0, + HighestThreat = 0x10000, + Nearest = 0x20000, + Farthest = 0x30000, + Invalid = 0x40000 +} + +public static class BuildingWithPropertyTypeExtension +{ + public static string ToDescription(this BuildingWithPropertyType value) + { + return value switch + { + BuildingWithPropertyType.LeastThreat => Translate("BuildingWithPropertyTypeExtension.LeastThreat", "Least threat"), + BuildingWithPropertyType.HighestThreat => Translate("BuildingWithPropertyTypeExtension.HighestThreat", "Highest threat"), + BuildingWithPropertyType.Nearest => Translate("BuildingWithPropertyTypeExtension.Nearest", "Nearest"), + BuildingWithPropertyType.Farthest => Translate("BuildingWithPropertyTypeExtension.Farthest", "Farthest"), + _ => string.Empty, + }; + } +} diff --git a/src/TSMapEditor/CCEngine/CCCrypto.cs b/src/MapEditorLibrary/CCEngine/CCCrypto.cs similarity index 99% rename from src/TSMapEditor/CCEngine/CCCrypto.cs rename to src/MapEditorLibrary/CCEngine/CCCrypto.cs index 425737c7f..e8c4d8f73 100644 --- a/src/TSMapEditor/CCEngine/CCCrypto.cs +++ b/src/MapEditorLibrary/CCEngine/CCCrypto.cs @@ -1,10 +1,7 @@ -using System; -using System.Buffers.Binary; -using System.IO; -using System.Linq; +using System.Buffers.Binary; using System.Numerics; -namespace TSMapEditor.CCEngine; +namespace MapEditorLibrary.CCEngine; /// /// Allows to obtain Blowfish key from its encrypted form stored in MIX files. diff --git a/src/MapEditorLibrary/CCEngine/CCFileManager.cs b/src/MapEditorLibrary/CCEngine/CCFileManager.cs new file mode 100644 index 000000000..f6f8cc428 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/CCFileManager.cs @@ -0,0 +1,338 @@ +using MapEditorLibrary.Misc; +using Rampastring.Tools; + +namespace MapEditorLibrary.CCEngine; + +public class CCFileManager +{ + public string GameDirectory { get; set; } + + public bool LogFileLoading { get; set; } + + private List searchDirectories = new(); + + /// + /// Contains information on which MIX file each found game file can be loaded from. + /// + private Dictionary fileLocationInfos = new(); + + /// + /// List of all MIX files that have been registered with the file manager. + /// + private List mixFiles = new(); + + /// + /// List of all CSF files that have been registered with the file manager. + /// + public List CsfFiles { get; } = new(); + + public void ReadConfig() + { + var iniFile = Helpers.ReadConfigINI("FileManagerConfig.ini"); + + AddSearchDirectory(Environment.CurrentDirectory); + iniFile.DoForEveryValueInSection("SearchDirectories", v => AddSearchDirectory(Path.Combine(GameDirectory, v))); + iniFile.DoForEveryValueInSection("MIXFiles", ProcessMixFileEntry); + iniFile.DoForEveryValueInSection("StringTables", LoadStringTable); + } + + /// + /// Adds a directory to the list of directories where files will be + /// searched from. + /// + /// The path to the directory. + public void AddSearchDirectory(string path) + { + searchDirectories.Add(Helpers.NormalizePath(path)); + } + + /// + /// Processes an entry in the MIXFiles list. + /// Loads a required or optional MIX file + /// or handles a special entry. + /// + /// Contents of an entry of the MIXFiles list. + private void ProcessMixFileEntry(string entry) + { + var parts = entry.Split(',', StringSplitOptions.TrimEntries); + string mixName = parts[0]; + + if (IsSpecialMixName(mixName)) + { + HandleSpecialMixName(mixName); + return; + } + + bool isRequired = false; + + if (parts.Length > 1) + isRequired = Conversions.BooleanFromString(parts[1], isRequired); + + if (isRequired) + LoadRequiredMixFile(mixName); + else + LoadOptionalMixFile(mixName); + } + + /// + /// Loads a MIX file. + /// Searches for it from both the search directories + /// as well as already loaded MIX files. + /// + /// The name of the MIX file. + /// True if the MIX file was successfully loaded, otherwise false. + private bool LoadMixFile(string name) + { + uint identifier = MixFile.GetFileID(name); + + // First check from game directory, if not found then check from already loaded MIX files + if (!LoadMixFromDirectories(name)) + { + if (fileLocationInfos.TryGetValue(identifier, out FileLocationInfo value)) + { + // Logger.Log("Loading MIX file " + name + " from existing MIX file " + Path.GetFileName(value.MixFile.FilePath)); + var mixFile = new MixFile(value.MixFile, value.Offset); + mixFile.Parse(); + AddMix(mixFile); + return true; + } + + Logger.Log("Failed to find MIX file: " + name); + return false; + } + + return true; + } + + /// + /// Attempts to search for and load a MIX file from the search directories. + /// Returns true if loading the MIX file succeeds, otherwise false. + /// + /// The name of the MIX file. + /// True if the MIX file was successfully loaded, otherwise false. + private bool LoadMixFromDirectories(string name) + { + string searchDir = null; + + foreach (string dir in searchDirectories) + { + if (File.Exists(Path.Combine(dir, name))) + { + searchDir = dir; + break; + } + } + + if (searchDir == null) + return false; + + Logger.Log("Loading MIX file " + name + " from " + searchDir); + var mixFile = new MixFile(); + mixFile.Parse(Path.Combine(searchDir, name)); + AddMix(mixFile); + + return true; + } + + /// + /// Registers a MIX file to the file system. + /// Adds all file entries from the MIX file to the file location tracking system. + /// + /// The MIX file to register. + private void AddMix(MixFile mixFile) + { + mixFiles.Add(mixFile); + + if (LogFileLoading) + Logger.Log("Registering " + mixFile.GetEntries().Count + " file entries from " + Path.GetFileName(mixFile.FilePath)); + + foreach (MixFileEntry fileEntry in mixFile.GetEntries()) + { + if (fileLocationInfos.ContainsKey(fileEntry.Identifier)) + continue; + + fileLocationInfos[fileEntry.Identifier] = new FileLocationInfo(mixFile, fileEntry.Offset, fileEntry.Size); + } + } + + /// + /// Loads a required MIX file. + /// Throws a FileNotFoundException if the MIX file isn't found. + /// + /// The name of the MIX file. + public void LoadRequiredMixFile(string name) + { + if (!LoadMixFile(name)) + { + throw new FileNotFoundException("Required MIX file not found: " + name); + } + } + + /// + /// Loads an optional MIX file. + /// Does not throw an exception if the MIX file is not found. + /// + /// The name of the MIX file. + public void LoadOptionalMixFile(string name) + { + if (!LoadMixFile(name)) + { + Logger.Log("Optional MIX file not found: " + name); + } + } + + /// + /// Loads MIX files of the format NAME##. + /// + /// The common name of the MIX files. + public void LoadIndexedMixFiles(string name) + { + for (int i = 99; i >= 0; i--) + LoadMixFile($"{name}{i:00}.mix"); + } + + /// + /// Loads MIX files with a wildcard. + /// + /// The common name of the MIX files. + public void LoadWildcardMixFiles(string name) + { + foreach (string searchDirectory in searchDirectories) + { + if (!Directory.Exists(searchDirectory)) + continue; + + var files = Directory.GetFiles(searchDirectory, name); + foreach (string file in files) + LoadMixFile(Path.GetFileName(file)); + } + } + + /// + /// Loads a required CSF file. + /// Throws a FileNotFoundException if the CSF file isn't found. + /// + /// The name of the CSf file. + public void LoadStringTable(string name) + { + var data = LoadFile(name); + if (data == null) + throw new FileNotFoundException("CSF file not found: " + name); + var file = new CsfFile(name); + file.ParseFromBuffer(data); + CsfFiles.Add(file); + } + + /// + /// Searches for a file from all search directories. + /// If found, returns the full path to the found file. + /// Otherwise returns null. + /// + /// The name of the file to look for. + public string FindFileFromDirectories(string fileName) + { + foreach (string searchDirectory in searchDirectories) + { + string fullPath = Path.Combine(searchDirectory, fileName); + + if (File.Exists(fullPath)) + return fullPath; + } + + return null; + } + + public byte[] LoadFile(string name) + { + if (LogFileLoading) + Logger.Log("Loading file " + name); + + foreach (string searchDirectory in searchDirectories) + { + string looseFilePath = Path.Combine(searchDirectory, name); + if (File.Exists(looseFilePath)) + { + if (LogFileLoading) + Logger.Log(" File found from " + searchDirectory); + + return File.ReadAllBytes(looseFilePath); + } + } + + uint id = MixFile.GetFileID(name); + + if (fileLocationInfos.TryGetValue(id, out FileLocationInfo value)) + { + if (LogFileLoading) + Logger.Log(" File found from " + Path.GetFileName(value.MixFile.FilePath)); + + return value.MixFile.GetSingleFileData(value.Offset, value.Size); + } + + if (LogFileLoading) + Logger.Log(" FAILED to find file: " + name); + + return null; + } + + private bool IsSpecialMixName(string name) + { + name = name.ToUpper(); + switch (name) + { + case "$TSECACHE": + case "$RA2ECACHE": + case "$TSELOCAL": + case "$RA2ELOCAL": + case "$EXPAND": + case "$EXPANDMD": + return true; + default: + return false; + } + } + + private void HandleSpecialMixName(string name) + { + name = name.ToUpper(); + switch (name) + { + case "$TSECACHE": + LoadIndexedMixFiles("ecache"); + break; + case "$RA2ECACHE": + LoadWildcardMixFiles("ecache*.mix"); + break; + case "$TSELOCAL": + LoadIndexedMixFiles("elocal"); + break; + case "$RA2ELOCAL": + LoadWildcardMixFiles("elocal*.mix"); + break; + case "$EXPAND": + LoadIndexedMixFiles("expand"); + break; + case "$EXPANDMD": + LoadIndexedMixFiles("expandmd"); + break; + } + } +} + +/// +/// Struct for holding data on which MIX file a file exists in, +/// and where the file exists within the MIX file. +/// +internal struct FileLocationInfo +{ + public FileLocationInfo(MixFile mixFile, int offset, int size) + { + MixFile = mixFile; + Offset = offset; + Size = size; + } + + public MixFile MixFile { get; private set; } + public int Offset { get; private set; } + public int Size { get; private set; } +} diff --git a/src/MapEditorLibrary/CCEngine/CsfFile.cs b/src/MapEditorLibrary/CCEngine/CsfFile.cs new file mode 100644 index 000000000..f616c8663 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/CsfFile.cs @@ -0,0 +1,228 @@ +using MapEditorLibrary.Models; + +namespace MapEditorLibrary.CCEngine; + + +public class CsfLoadException : Exception +{ + public CsfLoadException(string message) : base(message) + { + } +} + +public enum CsfVersion +{ + Nox = 2, + Cnc = 3, +} + +public enum CsfLanguage +{ + EnglishAmerican = 0, + EnglishBritish = 1, + German = 2, + French = 3, + Spanish = 4, + Italian = 5, + Japanese = 6, + Jabberwockie = 7, // According to ModEnc. + Korean = 8, + Chinese = 9, +} + +/// +/// Represents a CSF file header. Most of the information here is not useful. +/// +struct CsfFileHeader +{ + public const int SizeOf = 24; + private const uint CsfPrefix = 0x43534620; // " FSC" as a LE-uint32 + + public CsfFileHeader(byte[] buffer) + { + if (buffer.Length < SizeOf) + throw new CsfLoadException(nameof(CsfFileHeader) + ": buffer is not long enough"); + + if (BitConverter.ToUInt32(buffer, 0) != CsfPrefix) + throw new CsfLoadException(nameof(CsfFileHeader) + ": FSC prefix not found"); + + Version = (CsfVersion)BitConverter.ToUInt32(buffer, 4); + NumberOfLabels = BitConverter.ToUInt32(buffer, 8); + NumberOfStrings = BitConverter.ToUInt32(buffer, 12); + Unused = BitConverter.ToUInt32(buffer, 16); + Language = (CsfLanguage)BitConverter.ToUInt32(buffer, 20); + } + + public CsfVersion Version; + public uint NumberOfLabels; + public uint NumberOfStrings; + public uint Unused; + public CsfLanguage Language; +} + +/// +/// Represents a CSF (stringtable) file. Contains several string labels: text key-value pairs. +/// +public class CsfFile +{ + private const uint LblPrefix = 0x4C424C20; // " LBL" as a LE-uint32 + private const uint StrPrefix = 0x53545220; // " RTS" as a LE-uint32 + private const uint StrwPrefix = 0x53545257; // "WRTS" as a LE-uint32 + + public CsfFile() { } + + public CsfFile(string fileName) + { + this.fileName = fileName; + } + + private readonly string fileName; + private CsfFileHeader csfFileHeader; + + public CsfString[] Strings { get; private set; } + + /// + /// Creates a CSf file from a directory + path or from MIX file system. + /// + /// Path to file or file name inside MIX file system. + /// The path to the game directory. + /// File manager object holding MIXes. + /// Loaded CSF file, or empty CSF file object if the file was not found. + public static CsfFile FromPathOrMix(string filePath, string gameDirectory, CCFileManager ccFileManager) + { + if (filePath.Length == 0) + return new(); + + string path = Path.Combine(gameDirectory, filePath); + if (File.Exists(path)) + { + var file = new CsfFile(path); + file.ParseFromFile(path); + + return file; + } + + var bytes = ccFileManager.LoadFile(filePath); + if (bytes != null) + { + var file = new CsfFile(filePath); + file.ParseFromBuffer(bytes); + } + + return new(); + } + + public void ParseFromFile(string filePath) + { + using (FileStream stream = File.OpenRead(filePath)) + { + Parse(stream); + } + } + + public void Parse(Stream stream) + { + byte[] buffer = new byte[stream.Length]; + stream.Position = 0; + stream.Read(buffer, 0, buffer.Length); + ParseFromBuffer(buffer); + } + + public void ParseFromBuffer(byte[] buffer) + { + try + { + csfFileHeader = new CsfFileHeader(buffer); + var strings = new List((int)csfFileHeader.NumberOfLabels); + + using (var memoryStream = new MemoryStream(buffer)) + { + memoryStream.Position = CsfFileHeader.SizeOf; + for (int i = 0; i < csfFileHeader.NumberOfLabels; i++) + strings.Add(ParseLabel(memoryStream)); + } + + Strings = strings.ToArray(); + } + catch (CsfLoadException ex) + { + throw new CsfLoadException("Failed to load CSF file. Make sure that the file is not corrupted. Filename: " + fileName + ", original exception: " + ex.Message); + } + } + + /// + /// Parse a CsfString from a stream. + /// + /// Input stream. + /// + public CsfString ParseLabel(MemoryStream memoryStream) + { + var buffer = new byte[4]; + memoryStream.Read(buffer, 0, 4); + + if (BitConverter.ToUInt32(buffer) != LblPrefix) + throw new CsfLoadException(nameof(CsfFile) + ": LBL prefix not found"); + + memoryStream.Read(buffer, 0, 4); + uint numberOfPairs = BitConverter.ToUInt32(buffer, 0); + memoryStream.Read(buffer, 0, 4); + uint labelLength = BitConverter.ToUInt32(buffer, 0); + + var labelBuffer = new byte[labelLength]; + memoryStream.Read(labelBuffer, 0, labelBuffer.Length); + var csfLabel = System.Text.Encoding.ASCII.GetString(labelBuffer); + + var csfString = ParseString(memoryStream, buffer); + for (uint i = 1; i < numberOfPairs; i++) + ParseString(memoryStream, buffer, skip: true); + + return new CsfString(csfLabel, csfString); + } + + /// + /// Parse a single string from a stream. + /// + /// Input stream. + /// 4 byte long temporary buffer. + /// Only advance the stream and return null. + /// Read string or null if `skip` was true. + /// + public string ParseString(MemoryStream memoryStream, byte[] buffer, bool skip = false) + { + memoryStream.Read(buffer, 0, 4); + uint prefix = BitConverter.ToUInt32(buffer); + bool hasExtra = prefix == StrwPrefix; + + if ((prefix != StrPrefix) && !hasExtra) + throw new CsfLoadException(nameof(CsfFile) + ": STR/STRW prefix not found"); + + memoryStream.Read(buffer, 0, 4); + uint stringLength = BitConverter.ToUInt32(buffer, 0); + string csfString = null; + + if (!skip) + { + var stringBuffer = new byte[stringLength * 2]; + memoryStream.Read(stringBuffer, 0, stringBuffer.Length); + // Westwood's "encoding". + for (uint i = 0; i < stringBuffer.Length; i++) + stringBuffer[i] = (byte)~stringBuffer[i]; + + csfString = System.Text.Encoding.Unicode.GetString(stringBuffer); + } + else + { + memoryStream.Position += stringLength; + } + + if (hasExtra) + { + memoryStream.Read(buffer, 0, 4); + uint extraLength = BitConverter.ToUInt32(buffer, 0); + // Skip the extra data, as it's unused by the game. + memoryStream.Position += extraLength; + } + + return csfString; + } +} diff --git a/src/MapEditorLibrary/CCEngine/GameConfigINIFiles.cs b/src/MapEditorLibrary/CCEngine/GameConfigINIFiles.cs new file mode 100644 index 000000000..4745ecc16 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/GameConfigINIFiles.cs @@ -0,0 +1,37 @@ +using MapEditorLibrary.Extensions; +using Rampastring.Tools; + +namespace MapEditorLibrary.CCEngine; + +public class GameConfigINIFiles +{ + public GameConfigINIFiles(string gameDirectory, CCFileManager fileManager) + { + RulesIni = IniFileEx.FromPathOrMix(Constants.RulesIniPath, gameDirectory, fileManager, true); + FirestormIni = IniFileEx.FromPathOrMix(Constants.FirestormIniPath, gameDirectory, fileManager, true); + + if (RulesIni == null && FirestormIni == null) + throw new FileNotFoundException("No Rules.ini found! (including derivates like Firestorm.ini / Rulesmd.ini)"); + + if (RulesIni == null) + RulesIni = new IniFileEx(); + + if (FirestormIni == null) + FirestormIni = new IniFileEx(); + + ArtIni = IniFileEx.FromPathOrMix(Constants.ArtIniPath, gameDirectory, fileManager); + ArtFSIni = IniFileEx.FromPathOrMix(Constants.FirestormArtIniPath, gameDirectory, fileManager); + AIIni = IniFileEx.FromPathOrMix(Constants.AIIniPath, gameDirectory, fileManager); + AIFSIni = IniFileEx.FromPathOrMix(Constants.FirestormAIIniPath, gameDirectory, fileManager); + + IniFile artOverridesIni = Helpers.ReadConfigINI("ArtOverrides.ini"); + IniFile.ConsolidateIniFiles(ArtFSIni, artOverridesIni); + } + + public IniFileEx RulesIni { get; } + public IniFileEx FirestormIni { get; } + public IniFileEx ArtIni { get; } + public IniFileEx ArtFSIni { get; } + public IniFileEx AIIni { get; } + public IniFileEx AIFSIni { get; } +} diff --git a/src/MapEditorLibrary/CCEngine/HvaFile.cs b/src/MapEditorLibrary/CCEngine/HvaFile.cs new file mode 100644 index 000000000..d12a28cf0 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/HvaFile.cs @@ -0,0 +1,100 @@ +using CNCMaps.FileFormats.VirtualFileSystem; +using Microsoft.Xna.Framework; + +namespace MapEditorLibrary.CCEngine; + +public class HvaLoadException : Exception +{ + public HvaLoadException(string message) : base(message) { } +} + +/// +/// .hva file format +/// Based on the CNCMaps Renderer code +/// https://github.com/zzattack/ccmaps-net +/// + +public class HvaFile : VirtualFile +{ + public int NumFrames { get; set; } + public List
Sections { get; set; } + + public class Section + { + public string Name; + public List Matrices; + public Section(int numMatrices) + { + Matrices = new List(numMatrices); + } + } + + public HvaFile(Stream baseStream, string filename, int baseOffset, int fileSize, bool isBuffered = true) + : base(baseStream, filename, baseOffset, fileSize, isBuffered) + { + Initialize(); + } + + public HvaFile(Stream baseStream, string filename = "", bool isBuffered = true) + : base(baseStream, filename, isBuffered) + { + Initialize(); + } + + public HvaFile(byte[] buffer, string filename = "") : base(new MemoryStream(buffer), filename, true) + { + Initialize(); + } + + private void Initialize() + { + Seek(0, SeekOrigin.Begin); + ReadCString(16); // filename + NumFrames = ReadInt32(); + int numSections = ReadInt32(); + Sections = new List
(numSections); + + for (int i = 0; i < numSections; i++) + { + Sections.Add(new Section(NumFrames) + { + Name = ReadCString(16) + }); + } + + for (int frame = 0; frame < NumFrames; frame++) + { + for (int section = 0; section < Sections.Count; section++) + Sections[section].Matrices.Add(ReadMatrix()); + } + } + + private float[] ReadMatrix() + { + var ret = new float[12]; + for (int i = 0; i < 12; i++) + { + ret[i] = ReadFloat(); + } + return ret; + } + + public Matrix LoadMatrix(string section, int frame = 0) + { + return ToMatrix(Sections.Find(s => s.Name == section).Matrices[frame]); + } + + public Matrix LoadMatrix(int section, int frame = 0) + { + return ToMatrix(Sections[section].Matrices[frame]); + } + + private static Matrix ToMatrix(float[] hvaMatrix) + { + return new Matrix( + hvaMatrix[0], hvaMatrix[4], hvaMatrix[8], 0, + hvaMatrix[1], hvaMatrix[5], hvaMatrix[9], 0, + hvaMatrix[2], hvaMatrix[6], hvaMatrix[10], 0, + hvaMatrix[3], hvaMatrix[7], hvaMatrix[11], 1); + } +} \ No newline at end of file diff --git a/src/MapEditorLibrary/CCEngine/LayerType.cs b/src/MapEditorLibrary/CCEngine/LayerType.cs new file mode 100644 index 000000000..c40046629 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/LayerType.cs @@ -0,0 +1,10 @@ +namespace MapEditorLibrary.CCEngine; + +public enum LayerType +{ + Underground = -2, + Surface = -1, + Ground = 0, + Air = 1, + Top = 2 +} diff --git a/src/MapEditorLibrary/CCEngine/MixCRC.cs b/src/MapEditorLibrary/CCEngine/MixCRC.cs new file mode 100644 index 000000000..db0218293 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/MixCRC.cs @@ -0,0 +1,56 @@ +namespace MapEditorLibrary.CCEngine; + +internal static class MixCRC +{ + // See Ccrc class in http://xhp.xwis.net/documents/MIX_Format.html + + private static readonly uint[] crc_table = { + 0x00000000, 0x77073096, 0xee0e612c, 0x990951ba, 0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3, + 0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988, 0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91, + 0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de, 0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7, + 0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec, 0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5, + 0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172, 0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b, + 0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940, 0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59, + 0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116, 0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f, + 0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924, 0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d, + 0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a, 0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433, + 0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818, 0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01, + 0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e, 0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457, + 0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c, 0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65, + 0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2, 0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb, + 0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0, 0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9, + 0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086, 0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f, + 0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4, 0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad, + 0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a, 0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683, + 0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8, 0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1, + 0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe, 0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7, + 0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc, 0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5, + 0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252, 0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b, + 0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60, 0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79, + 0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236, 0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f, + 0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04, 0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d, + 0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a, 0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713, + 0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38, 0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21, + 0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e, 0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777, + 0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c, 0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45, + 0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2, 0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db, + 0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0, 0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9, + 0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6, 0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf, + 0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94, 0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d}; + + public static uint GetCRC(byte[] data) + { + int index = 0; + uint crc = 0xFFFFFFFF; + + do + { + byte tableIndex = (byte)(data[index] ^ (crc & 255)); + crc = (crc >> 8) ^ crc_table[tableIndex]; + index++; + } + while (index < data.Length); + + return ~crc; + } +} diff --git a/src/MapEditorLibrary/CCEngine/MixFile.cs b/src/MapEditorLibrary/CCEngine/MixFile.cs new file mode 100644 index 000000000..e698ad221 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/MixFile.cs @@ -0,0 +1,317 @@ +using System.Text; + +namespace MapEditorLibrary.CCEngine; + +/// +/// A Tiberian Sun / Red Alert 2 type MIX file. +/// +class MixFile +{ + + private const int INDEX_POSITION = 10; + + + public MixFile() { } + + public MixFile(MixFile masterMix, int startOffset) + { + this.masterMix = masterMix; + mixStartOffset = startOffset; + + if (masterMix != null) + { + mixStartOffset += masterMix.bodyOffset; + } + } + + + public string FilePath { get; private set; } + + private List entries; + + private int bodyOffset; + + private Stream stream; + + /// + /// The MIX file that this MIX file resides in, if any. + /// + private MixFile masterMix; + + /// + /// The start offset for this MIX file when it's inside another MIX file. + /// Should be zero if this MIX file is not inside another MIX file. + /// + private int mixStartOffset = 0; + + + private readonly object locker = new object(); + + /// + /// Reads MIX file information from a MIX file in the given file system path. + /// + /// The path to the MIX file. + public void Parse(string path) + { + if (masterMix != null) + throw new InvalidOperationException("Can't parse from a file when the MIX file is inside another MIX file."); + + FilePath = path; + + using (FileStream fileStream = File.OpenRead(path)) + { + Parse(fileStream); + } + } + + /// + /// Reads MIX file entry information from a stream. + /// + /// The stream. Can be null for MIX files that + /// reside inside another MIX file. + public void Parse(Stream stream = null) + { + if (masterMix != null) + { + masterMix.OpenFile(); + stream = this.stream = masterMix.stream; + stream.Position = mixStartOffset; + } + + if (stream.Length < INDEX_POSITION) + return; + + entries = new List(); + + byte[] buffer = new byte[256]; + stream.ReadExactly(buffer, 0, 4); + MixType mixType = (MixType)BitConverter.ToInt32(buffer, 0); + + bool isEncrypted = (mixType & MixType.ENCRYPTED) != 0; + + if (isEncrypted) + { + // Read and decrypt the Blowfish associated with this MIX. + stream.ReadExactly(buffer, 0, KeyDecryptor.SIZE_OF_ENCRYPTED_KEY); + stream = new BlowfishStream(stream, KeyDecryptor.DecryptBlowfishKey(buffer)); + } + + stream.ReadExactly(buffer, 0, MixFileHeader.SIZE_OF_HEADER); + + MixFileHeader header = new MixFileHeader(buffer); + + bodyOffset = INDEX_POSITION + MixFileEntry.SIZE_OF_FILE_ENTRY * header.FileCount; + + if (isEncrypted) + { + // Account for Blowfish key and padding. + bodyOffset += KeyDecryptor.SIZE_OF_ENCRYPTED_KEY; + bodyOffset += (header.FileCount % 2) == 0 ? 2 : 6; + } + + for (int i = 0; i < header.FileCount; i++) + { + if (stream.Position + MixFileEntry.SIZE_OF_FILE_ENTRY >= stream.Length) + throw new MixParseException("Invalid MIX file."); + + stream.ReadExactly(buffer, 0, MixFileEntry.SIZE_OF_FILE_ENTRY); + entries.Add(new MixFileEntry(buffer)); + } + + if (masterMix != null) + masterMix.CloseFile(); + } + + /// + /// Returns a list of file entries in this MIX file. + /// + public List GetEntries() + { + // Return a copy of the list so the callee can't modify our original list + return new List(entries); + } + + /// + /// Opens the MIX file for performing one or more read operations. + /// + public void OpenFile() + { + if (masterMix != null) + { + masterMix.OpenFile(); + this.stream = masterMix.stream; + return; + } + + if (FilePath == null) + throw new MixParseException("No MIX file path defined!"); + + if (stream == null || !stream.CanRead) + stream = File.OpenRead(FilePath); + } + + public MixFileEntry? GetEntry(uint id) + { + int index = entries.FindIndex(e => e.Identifier == id); + if (index < 0) + return null; + + return entries[index]; + } + + /// + /// Gets file data from the MIX file. + /// + /// The start offset from the MIX body. + /// The number of bytes to read. + /// A byte array. + public byte[] GetFileData(int offset, int count) + { + byte[] buffer = new byte[count]; + + stream.Position = mixStartOffset + bodyOffset + offset; + stream.ReadExactly(buffer, 0, count); + + return buffer; + } + + /// + /// Closes the MIX file. + /// + public void CloseFile() + { + stream.Close(); + } + + /// + /// Gets data for a single file from the MIX file. + /// + /// The start offset from the MIX body. + /// The number of bytes to read. + /// A byte array. + public byte[] GetSingleFileData(int offset, int count) + { + var lockObject = GetLockObject(); + + lock (lockObject) + { + OpenFile(); + byte[] buffer = GetFileData(offset, count); + CloseFile(); + return buffer; + } + } + + private object GetLockObject() + { + if (masterMix != null) + return masterMix.GetLockObject(); + + return locker; + } + + /// + /// Calculates and returns the internal ID of a file based on its name. + /// The ID is needed when finding files from inside MIX files. + /// + /// The filename. + /// + public static uint GetFileID(string fileName) + { + fileName = fileName.ToUpperInvariant(); + int a = fileName.Length >> 2; + if ((fileName.Length & 3) > 0) + { + fileName += (char)(fileName.Length - (a << 2)); + int i = 3 - ((fileName.Length - 1) & 3); + while (i-- > 0) + fileName = fileName + fileName[a << 2]; + } + + return MixCRC.GetCRC(Encoding.ASCII.GetBytes(fileName)); + } +} + +/// +/// The type of a MIX file. +/// +[Flags] +public enum MixType +{ + DEFAULT = 0, + CHECKSUMMED = 0x00010000, + ENCRYPTED = 0x00020000 +} + +/// +/// A MIX file header. Contains information on the number of files and the size +/// of the body of the MIX file. +/// +public struct MixFileHeader +{ + public const int SIZE_OF_HEADER = 6; + + public MixFileHeader(byte[] buffer) + { + if (buffer.Length < SIZE_OF_HEADER) + throw new ArgumentException("buffer is not long enough"); + + FileCount = BitConverter.ToInt16(buffer, 0); + BodySize = BitConverter.ToInt32(buffer, 2); + } + + /// + /// The number of files in the MIX file. + /// + public short FileCount { get; private set; } + + /// + /// The size of the MIX file, excluding the header and index. + /// + public int BodySize { get; private set; } +} + +/// +/// Contains information on a file stored inside a MIX file. +/// +public struct MixFileEntry +{ + public const int SIZE_OF_FILE_ENTRY = 12; + + public MixFileEntry(byte[] buffer) + { + if (buffer.Length < SIZE_OF_FILE_ENTRY) + throw new ArgumentException("buffer is not long enough"); + + Identifier = BitConverter.ToUInt32(buffer, 0); + Offset = BitConverter.ToInt32(buffer, 4); + Size = BitConverter.ToInt32(buffer, 8); + } + + /// + /// The identifier used to identify the file instead of a normal name. + /// + public uint Identifier { get; private set; } + + /// + /// The offset of the file, from the start of the body. + /// + public int Offset { get; private set; } + + /// + /// The size of the file. + /// + public int Size { get; private set; } +} + +public struct FileOffsetInfo +{ + +} + +public class MixParseException : Exception +{ + public MixParseException(string message) : base(message) + { + } +} diff --git a/src/MapEditorLibrary/CCEngine/Palette.cs b/src/MapEditorLibrary/CCEngine/Palette.cs new file mode 100644 index 000000000..323de1422 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/Palette.cs @@ -0,0 +1,119 @@ +using MapEditorLibrary.Models; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace MapEditorLibrary.CCEngine; + +/// +/// Layer on top of the game palette to enable rendering paletted textures with it. +/// +public class XNAPalette : Palette +{ + public XNAPalette(string name, byte[] buffer, GraphicsDevice graphicsDevice, bool hasFullyBrightColors) : base(name, buffer) + { + PaletteWithLight = new(name, buffer); + Texture = CreateTexture(graphicsDevice, this); + TextureWithLight = CreateTexture(graphicsDevice, PaletteWithLight); + HasFullyBrightColors = hasFullyBrightColors; + } + + public XNAPalette(string name, RGBColor[] data, GraphicsDevice graphicsDevice, bool hasFullyBrightColors) : base(name, data) + { + PaletteWithLight = new(name, data); + Texture = CreateTexture(graphicsDevice, this); + TextureWithLight = CreateTexture(graphicsDevice, PaletteWithLight); + HasFullyBrightColors = hasFullyBrightColors; + } + + private Texture2D Texture; + private Texture2D TextureWithLight; + private Palette PaletteWithLight; + private bool HasFullyBrightColors; + + public void Dispose() + { + Texture?.Dispose(); + TextureWithLight?.Dispose(); + + Texture = null; + TextureWithLight = null; + } + + public Texture2D GetTexture() + { + return Texture; + } + + public Palette GetPalette() + { + return this; + } + + private Texture2D CreateTexture(GraphicsDevice graphicsDevice, Palette palette) + { + Texture2D texture = new(graphicsDevice, LENGTH, 1, false, SurfaceFormat.Color); + + Color[] colorData = new Color[LENGTH]; + colorData[0] = Color.Transparent; + for (int i = 1; i < colorData.Length; i++) + { + colorData[i] = palette.Data[i].ToXnaColor(); + } + texture.SetData(colorData); + + return texture; + } + + private void AdjustColor(int i, Color[] colorData, MapColor color) + { + RGBColor newColor = Data[i] * color; + PaletteWithLight.Data[i] = newColor; + colorData[i] = newColor.ToXnaColor(); + } + + public void ApplyLighting(MapColor color) + { + Color[] colorData = new Color[LENGTH]; + int last = HasFullyBrightColors ? LENGTH - 16 : 255; + + for (int i = 1; i < last; i++) + AdjustColor(i, colorData, color); + + AdjustColor(255, colorData, color); + + TextureWithLight.SetData(colorData); + } +} + +/// +/// A C&C Tiberian Sun or Red Alert 2 palette. +/// +public class Palette +{ + public const int LENGTH = 256; + + public Palette(string name, byte[] buffer) + { + Name = name; + Data = new RGBColor[LENGTH]; + Parse(buffer); + } + + public Palette(string name, RGBColor[] data) + { + Name = name; + Data = data; + } + + public readonly string Name; + + public RGBColor[] Data; + + public void Parse(byte[] buffer) + { + for (int i = 0; i < Data.Length; i++) + { + Data[i] = new RGBColor(buffer, i * 3, 2); + } + } +} diff --git a/src/MapEditorLibrary/CCEngine/RGBColor.cs b/src/MapEditorLibrary/CCEngine/RGBColor.cs new file mode 100644 index 000000000..778fac7d8 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/RGBColor.cs @@ -0,0 +1,26 @@ +using Microsoft.Xna.Framework; + +namespace MapEditorLibrary.CCEngine; + +public struct RGBColor +{ + public RGBColor(byte[] buffer, int offset, int shift) + { + R = (byte)(buffer[offset] << shift); + G = (byte)(buffer[offset + 1] << shift); + B = (byte)(buffer[offset + 2] << shift); + } + + public RGBColor(byte r, byte g, byte b) + { + R = r; + G = g; + B = b; + } + + public byte R; + public byte G; + public byte B; + + public Color ToXnaColor() => new Color(R, G, B); +} diff --git a/src/MapEditorLibrary/CCEngine/RampType.cs b/src/MapEditorLibrary/CCEngine/RampType.cs new file mode 100644 index 000000000..0ddb76144 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/RampType.cs @@ -0,0 +1,42 @@ +namespace MapEditorLibrary.CCEngine; + +/// +/// Defines the types of terrain ramps in the game. +/// +/// Taken from TS++, TIBSUN_DEFINES.H +/// https://github.com/Vinifera-Developers/TSpp +/// Originally by CCHyper, adjusted for C# by Rampastring +public enum RampType +{ + None = 0, + + // Basic, two adjacent corners raised + West = 1, + North = 2, + East = 3, + South = 4, + + // Tile outside corners (one corner raised by half a cell) + CornerNW = 5, + CornerNE = 6, + CornerSE = 7, + CornerSW = 8, + + // Tile inside corners (three corners raised by half a cell) + MidNW = 9, + MidNE = 10, + MidSE = 11, + MidSW = 12, + + // Full tile sloped (mid corners raised by half cell, far corner by full cell) + SteepSE = 13, + SteepSW = 14, + SteepNW = 15, + SteepNE = 16, + + // Double ramps (two corners raised, alternating) + DoubleUpSWNE = 17, + DoubleDownSWNE = 18, + DoubleUpNWSE = 19, + DoubleDownNWSE = 20 +} diff --git a/src/MapEditorLibrary/CCEngine/ScriptAction.cs b/src/MapEditorLibrary/CCEngine/ScriptAction.cs new file mode 100644 index 000000000..ad592c1d3 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/ScriptAction.cs @@ -0,0 +1,97 @@ +using MapEditorLibrary.Models.Enums; +using Rampastring.Tools; +using System.Globalization; + +namespace MapEditorLibrary.CCEngine; + +public class ScriptActionPresetOption +{ + public int Value; + public string Text; + + public ScriptActionPresetOption(int value, string text) + { + Value = value; + Text = text; + } + + public string GetOptionText() + { + if (string.IsNullOrEmpty(Text)) + return Value.ToString(); + else + return Value + " - " + Text; + } +} + +public class ScriptAction +{ + public ScriptAction(int id) + { + ID = id; + } + + public int ID { get; set; } + public string Name { get; set; } = Translate("ScriptAction.UnknownAction", "Unknown action"); + public string Description { get; set; } = Translate("ScriptAction.NoDescription", "No description"); + public string ParamDescription { get; set; } = Translate("ScriptAction.Use0", "Use 0"); + public string OptionsSectionName { get; set; } = string.Empty; + public TriggerParamType ParamType { get; set; } = TriggerParamType.Unknown; + public List PresetOptions { get; } = new List(0); + public bool UseWindowSelection { get; set; } = false; + + public void ReadIniSection(IniFile iniFile, string sectionName) + { + var iniSection = iniFile.GetSection(sectionName); + ID = iniSection.GetIntValue("IDOverride", ID); + string untranslatedName = iniSection.GetStringValue(nameof(Name), Name); + Name = Translate(this, untranslatedName + ".Name", untranslatedName); + Description = Translate(this, untranslatedName + ".Description", iniSection.GetStringValue(nameof(Description), Description)); + OptionsSectionName = iniSection.GetStringValue(nameof(OptionsSectionName), OptionsSectionName); + ParamDescription = iniSection.GetStringValue(nameof(ParamDescription), ParamDescription); + UseWindowSelection = iniSection.GetBooleanValue(nameof(UseWindowSelection), UseWindowSelection); + if (Enum.TryParse(iniSection.GetStringValue(nameof(ParamType), "Unknown"), out TriggerParamType result)) + { + ParamType = result; + } + + var optionsSection = iniSection; + string extraDesc = string.Empty; + if (!string.IsNullOrEmpty(OptionsSectionName) && iniFile.SectionExists(OptionsSectionName)) + { + optionsSection = iniFile.GetSection(OptionsSectionName); + extraDesc = $"(Options section: {OptionsSectionName}) "; + } + + int i = 0; + while (true) + { + string key = "Option" + i; + + if (!optionsSection.KeyExists(key)) + break; + + string value = optionsSection.GetStringValue(key, null); + if (string.IsNullOrWhiteSpace(value)) + { + Logger.Log($"Invalid {key}= in ScriptAction {extraDesc}" + iniSection.SectionName); + break; + } + + int commaIndex = value.IndexOf(','); + if (commaIndex < 0) + { + Logger.Log($"Invalid {key}= in ScriptAction {extraDesc}" + iniSection.SectionName); + break; + } + + int presetValue = Conversions.IntFromString(value.Substring(0, commaIndex), 0); + string presetText = value.Substring(commaIndex + 1); + presetText = Translate(this, untranslatedName + ".Option" + i.ToString(CultureInfo.InvariantCulture) + ".PresetText", presetText); + + PresetOptions.Add(new ScriptActionPresetOption(presetValue, presetText)); + + i++; + } + } +} diff --git a/src/MapEditorLibrary/CCEngine/ShpFile.cs b/src/MapEditorLibrary/CCEngine/ShpFile.cs new file mode 100644 index 000000000..bb4b3cb9a --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/ShpFile.cs @@ -0,0 +1,267 @@ +namespace MapEditorLibrary.CCEngine; + +public class ShpLoadException : Exception +{ + public ShpLoadException(string message) : base(message) + { + } +} + +[Flags] +public enum ShpCompression +{ + None = 0, + HasTransparency = 1, + UsesRle = 2 +} + +/// +/// Represents the header of a SHP file. +/// +struct ShpFileHeader +{ + public const int SizeOf = 8; + + public ShpFileHeader(byte[] buffer) + { + if (buffer.Length < SizeOf) + throw new ShpLoadException(nameof(ShpFileHeader) + ": buffer is not long enough"); + + Unknown = BitConverter.ToUInt16(buffer, 0); + if (Unknown != 0) + throw new ShpLoadException("Unexpected field value in SHP header"); + + SpriteWidth = BitConverter.ToUInt16(buffer, 2); + SpriteHeight = BitConverter.ToUInt16(buffer, 4); + FrameCount = BitConverter.ToUInt16(buffer, 6); + } + + public ushort Unknown; + public ushort SpriteWidth; + public ushort SpriteHeight; + public ushort FrameCount; +} + +/// +/// Represents the information of a single frame in a SHP file. +/// +public class ShpFrameInfo +{ + private const int SizeOf = 24; + + public ShpFrameInfo(Stream stream) + { + if (stream.Length < stream.Position + SizeOf) + throw new ShpLoadException(nameof(ShpFrameInfo) + ": buffer is not long enough"); + + XOffset = ReadUShortFromStream(stream); + YOffset = ReadUShortFromStream(stream); + Width = ReadUShortFromStream(stream); + Height = ReadUShortFromStream(stream); + Flags = (ShpCompression)ReadUIntFromStream(stream); + byte r = (byte)stream.ReadByte(); + byte g = (byte)stream.ReadByte(); + byte b = (byte)stream.ReadByte(); + AverageColor = new RGBColor(r, g, b); + Unknown1 = (byte)stream.ReadByte(); + Unknown2 = ReadUIntFromStream(stream); + DataOffset = ReadUIntFromStream(stream); + } + + private ushort ReadUShortFromStream(Stream stream) + { + stream.Read(buffer, 0, 2); + return BitConverter.ToUInt16(buffer, 0); + } + + private uint ReadUIntFromStream(Stream stream) + { + stream.Read(buffer, 0, 4); + return BitConverter.ToUInt32(buffer, 0); + } + + byte[] buffer = new byte[4]; + + public ushort XOffset; + public ushort YOffset; + public ushort Width; + public ushort Height; + public ShpCompression Flags; + public RGBColor AverageColor; + public byte Unknown1; + public uint Unknown2; + public uint DataOffset; +} + +/// +/// Represents a SHP file. Combines the header and frame information +/// and makes it possible to parse the actual graphical data. +/// +public class ShpFile +{ + public ShpFile() { } + + public ShpFile(string fileName) + { + this.fileName = fileName; + } + + private readonly string fileName; + + private ShpFileHeader shpFileHeader; + private List shpFrameInfos; + + + + public int FrameCount => shpFrameInfos.Count; + + public int Width => shpFileHeader.SpriteWidth; + public int Height => shpFileHeader.SpriteHeight; + + public void ParseFromFile(string filePath) + { + using (FileStream stream = File.OpenRead(filePath)) + { + Parse(stream); + } + } + + public void Parse(Stream stream) + { + byte[] buffer = new byte[stream.Length]; + stream.Position = 0; + stream.Read(buffer, 0, buffer.Length); + ParseFromBuffer(buffer); + } + + public void ParseFromBuffer(byte[] buffer) + { + try + { + shpFileHeader = new ShpFileHeader(buffer); + shpFrameInfos = new List(shpFileHeader.FrameCount); + + using (var memoryStream = new MemoryStream(buffer)) + { + memoryStream.Position = ShpFileHeader.SizeOf; + + for (int i = 0; i < shpFileHeader.FrameCount; i++) + { + var shpFrameInfo = new ShpFrameInfo(memoryStream); + shpFrameInfos.Add(shpFrameInfo); + } + } + } + catch (ShpLoadException ex) + { + throw new ShpLoadException("Failed to load SHP file. Make sure that the file is not corrupted. Filename: " + fileName + ", original exception: " + ex.Message); + } + } + + public ShpFrameInfo GetShpFrameInfo(int frameIndex) => shpFrameInfos[frameIndex]; + + public byte[] GetUncompressedFrameData(int frameIndex, byte[] fileData) + { + ShpFrameInfo frameInfo = shpFrameInfos[frameIndex]; + + if (frameInfo.DataOffset == 0) + return null; + + byte[] frameData = new byte[frameInfo.Width * frameInfo.Height]; + + if ((frameInfo.Flags & ShpCompression.UsesRle) == ShpCompression.None) + { + for (int i = 0; i < frameData.Length; i++) + { + frameData[i] = fileData[frameInfo.DataOffset + i]; + } + } + else + { + DecompressRLEZero(frameData, frameIndex, frameInfo, fileData); + } + + return frameData; + } + + private void DecompressRLEZero(byte[] frameData, int frameIndex, ShpFrameInfo frameInfo, byte[] fileData) + { + // https://moddingwiki.shikadi.net/wiki/Westwood_RLE-Zero + + int dataOffset = 0; + + // Read SHP line-by-line. RLE-zero only compresses the transparent parts (zero bytes) of each line, individually. + for (int lineIndex = 0; lineIndex < frameInfo.Height; lineIndex++) + { + int lineDataStartOffset = (int)frameInfo.DataOffset + dataOffset; + + // Compose little-endian UInt16 from 2 bytes + int lineDataLength = fileData[lineDataStartOffset] | (fileData[lineDataStartOffset + 1] << 8); + + if (lineDataStartOffset + lineDataLength > fileData.Length || lineDataLength < 2) + { + throw new ShpLoadException($"Line data length out-of-bounds in SHP RLE-Zero frame. " + + $"File name: {fileName}, frame index: {frameIndex}, line data length: {lineDataLength}"); + } + + // Line length includes the two-byte line data length value, skip it + int currentByteIndex = 2; + + // Define variable for current pixel # on the current line + int pixelPositionOnLine = 0; + + // Read image damage of current line + while (currentByteIndex < lineDataLength) + { + byte value = fileData[lineDataStartOffset + currentByteIndex]; + + if (value == 0) + { + // If we are at the end of a line when we encounter a zero, something is wrong. + if (currentByteIndex == lineDataLength - 1) + { + throw new ShpLoadException($"Zero-byte encountered at end of line data in SHP RLE-Zero frame. " + + $"File name: {fileName}, line index: {lineIndex}, file offset: {lineDataStartOffset + currentByteIndex}"); + } + + // A zero value means transparent pixels. The following byte + // defines how many pixels are transparent. + byte transparentPixelCount = fileData[lineDataStartOffset + currentByteIndex + 1]; + + // -1 prevents us from counting the current pixel "twice" + if (pixelPositionOnLine + transparentPixelCount - 1 > frameInfo.Width) + { + throw new ShpLoadException($"Out-of-bounds pixel position on transparent data in SHP RLE-Zero frame. " + + $"File name: {fileName}, frame index: {frameIndex}, line index: {lineIndex}, file offset: {lineDataStartOffset + currentByteIndex}"); + } + + // Assign transparent pixel data + while (transparentPixelCount > 0) + { + frameData[lineIndex * frameInfo.Width + pixelPositionOnLine] = 0; + pixelPositionOnLine++; + transparentPixelCount--; + } + + // Advance buffer position + currentByteIndex += 2; + } + else + { + if (pixelPositionOnLine >= frameInfo.Width) + { + throw new ShpLoadException($"Out-of-bounds pixel position on color data in SHP RLE-Zero frame. " + + $"File name: {fileName}, frame index: {frameIndex}, line index: {lineIndex}, file offset: {lineDataStartOffset + currentByteIndex}"); + } + + // A non-zero value is color data that should be just applied directly. + frameData[lineIndex * frameInfo.Width + pixelPositionOnLine] = value; + pixelPositionOnLine++; + currentByteIndex++; + } + } + + dataOffset += lineDataLength; + } + } +} diff --git a/src/MapEditorLibrary/CCEngine/Theater.cs b/src/MapEditorLibrary/CCEngine/Theater.cs new file mode 100644 index 000000000..6cad19e3a --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/Theater.cs @@ -0,0 +1,210 @@ +using Rampastring.Tools; +using MapEditorLibrary.Models; +using MapEditorLibrary.Extensions; +using MapEditorLibrary.Misc; + +namespace MapEditorLibrary.CCEngine; + +public class LATGround +{ + public LATGround(string displayName, TileSet groundTileSet, TileSet transitionTileSet, TileSet baseTileSet, IEnumerable connectToTileSetIndices) + { + DisplayName = displayName; + GroundTileSet = groundTileSet; + TransitionTileSet = transitionTileSet; + BaseTileSet = baseTileSet; + + if (connectToTileSetIndices != null) + ConnectToTileSetIndices.AddRange(connectToTileSetIndices); + } + + public string DisplayName { get; } + public TileSet GroundTileSet { get; } + public TileSet TransitionTileSet { get; } + public TileSet BaseTileSet { get; } + public List ConnectToTileSetIndices = new List(); +} + +public class TheaterIceTileSets +{ + public TheaterIceTileSets(Theater theater, IniFile theaterIni) + { + int ice1SetId = theaterIni.GetIntValue("General", "Ice1Set", -1); + int ice2SetId = theaterIni.GetIntValue("General", "Ice2Set", -1); + int ice3SetId = theaterIni.GetIntValue("General", "Ice3Set", -1); + int iceShoreSetId = theaterIni.GetIntValue("General", "IceShoreSet", -1); + + Ice1Set = theater.TryGetTileSetById(ice1SetId); + Ice2Set = theater.TryGetTileSetById(ice2SetId); + Ice3Set = theater.TryGetTileSetById(ice3SetId); + IceShoreSet = theater.TryGetTileSetById(iceShoreSetId); + } + + public TileSet Ice1Set { get; private set; } + public TileSet Ice2Set { get; private set; } + public TileSet Ice3Set { get; private set; } + public TileSet IceShoreSet { get; private set; } +} + +public class Theater : INIDefineable +{ + public Theater(string name) + { + UIName = name; + } + + public string UIName { get; } + public string ConfigINIPath { get; set; } + public List ContentMIXName { get; set; } + public List OptionalContentMIXName { get; set; } + public string TerrainPaletteName { get; set; } + public string UnitPaletteName { get; set; } + public string TiberiumPaletteName { get; set; } + public string FileExtension { get; set; } + public string FallbackTileFileExtension { get; set; } + public char NewTheaterBuildingLetter { get; set; } + + public List RoughConnectToTileSets { get; set; } + public List SandConnectToTileSets { get; set; } + public List PaveConnectToTileSets { get; set; } + public List GreenConnectToTileSets { get; set; } + + public TheaterIceTileSets IceTileSetInfo { get; private set; } + + public List TileSets = new List(); + public List LATGrounds = new List(); + public TileSet RampTileSet { get; set; } + public TileSet BridgeTileSet { get; set; } + public TileSet TrainBridgeTileSet { get; set; } + public TileSet WoodBridgeTileSet { get; set; } + + private const string REQUIRED_SECTION = "General"; + + public TileSet FindTileSet(string tileSetName) => TileSets.Find(ts => ts.SetName == tileSetName); + + public void ReadConfigINI(string baseDirectoryPath, CCFileManager ccFileManager) + { + TileSets.Clear(); + + IniFileEx theaterIni = IniFileEx.FromPathOrMix(ConfigINIPath, baseDirectoryPath, ccFileManager); + + if (!theaterIni.SectionExists(REQUIRED_SECTION)) + { + throw new FileNotFoundException("Theater config INI not found or invalid: " + ConfigINIPath); + } + + int i; + + for (i = 0; i < 10000; i++) + { + IniSection tileSetSection = theaterIni.GetSection($"TileSet{i:D4}"); + + if (tileSetSection == null) + break; + + TileSet tileSet = new TileSet(i); + tileSet.Read(tileSetSection); + TileSets.Add(tileSet); + } + + IceTileSetInfo = new TheaterIceTileSets(this, theaterIni); + + i = 1; + while (true) + { + if (!InitLATGround(theaterIni, $"Ground{i}Tile", $"Ground{i}Lat", $"Ground{i}Base", $"Ground{i}Name", $"Ground{i}ConnectTo", null)) + break; + + i++; + } + + // DTA + InitLATGround(theaterIni, "PvmntTile", "ClearToPvmntLat", null, null, null, "Pavement"); + + // TS terrain + InitLATGround(theaterIni, "RoughTile", "ClearToRoughLat", null, null, "RoughConnectTo", "Rough", RoughConnectToTileSets); + InitLATGround(theaterIni, "SandTile", "ClearToSandLat", null, null, "SandConnectTo", "Sand", SandConnectToTileSets); + InitLATGround(theaterIni, "PaveTile", "ClearToPaveLat", null, null, "PaveConnectTo", "Pavement", PaveConnectToTileSets); + InitLATGround(theaterIni, "GreenTile", "ClearToGreenLat", null, null, "GreenConnectTo", "Green", GreenConnectToTileSets); + InitLATGround(theaterIni, "CrystalTile", "ClearToCrystalLat", null, null, null, "Crystal"); + InitLATGround(theaterIni, "BlueMoldTile", "ClearToBlueMoldLat", null, null, null, "Blue Mold"); + + RampTileSet = GetTileSetFromKey(theaterIni, "RampBase", false); + BridgeTileSet = GetTileSetFromKey(theaterIni, "BridgeSet", false); + TrainBridgeTileSet = GetTileSetFromKey(theaterIni, "TrainBridgeSet", true); // Unfortunately, YR terrain expansion was dumb and removed this key + WoodBridgeTileSet = GetTileSetFromKey(theaterIni, "WoodBridgeSet", true); // Wood bridges are optional as they do not exist in TS + } + + private TileSet GetTileSetFromKey(IniFile theaterIni, string key, bool optional) + { + int index = theaterIni.GetIntValue("General", key, -1); + + if (index < 0 || index >= TileSets.Count) + { + if (optional) + return null; + + throw new INIConfigException($"Invalid value specified for {key}= in the theater configuration file!"); + } + + return TileSets[index]; + } + + public TileSet TryGetTileSetById(int id) + { + if (id < 0 || id >= TileSets.Count) + return null; + + return TileSets[id]; + } + + private bool InitLATGround(IniFile theaterIni, string tileSetKey, string transitionTileSetKey, string baseTileSetKey, string nameKey, string connectToKey, string defaultName, IEnumerable connectedTileSetIndices = null) + { + int groundTileSetIndex = theaterIni.GetIntValue("General", tileSetKey, -1); + int transitionTileSetIndex = theaterIni.GetIntValue("General", transitionTileSetKey, -1); + + int baseTileSetIndex = -1; + if (!string.IsNullOrEmpty(baseTileSetKey)) + baseTileSetIndex = theaterIni.GetIntValue("General", baseTileSetKey, -1); + + if (groundTileSetIndex < 0 || transitionTileSetIndex < 0) + return false; + + if (groundTileSetIndex >= TileSets.Count || transitionTileSetIndex >= TileSets.Count) + return false; + + string displayName = defaultName; + if (!string.IsNullOrEmpty(nameKey)) + displayName = theaterIni.GetStringValue("General", nameKey, displayName); + + if (displayName == null) + { + string groundTileSetName = TileSets[groundTileSetIndex].SetName; + displayName = groundTileSetName.Substring(0, Math.Min(groundTileSetName.Length, 4)); + } + + List indices = new List(); + + if ((connectedTileSetIndices == null || !connectedTileSetIndices.Any()) && !string.IsNullOrEmpty(connectToKey)) + connectedTileSetIndices = theaterIni.GetStringValue("General", connectToKey, string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries); + + if (connectedTileSetIndices != null) + { + foreach (string indexStr in connectedTileSetIndices) + { + int index = Conversions.IntFromString(indexStr, -1); + + if (index != -1) + indices.Add(index); + } + } + + LATGrounds.Add(new LATGround( + displayName, + TileSets[groundTileSetIndex], + TileSets[transitionTileSetIndex], + baseTileSetIndex > -1 ? TileSets[baseTileSetIndex] : TileSets[0], indices)); + + return true; + } +} diff --git a/src/MapEditorLibrary/CCEngine/TileData/ISubTileImage.cs b/src/MapEditorLibrary/CCEngine/TileData/ISubTileImage.cs new file mode 100644 index 000000000..991439e97 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/TileData/ISubTileImage.cs @@ -0,0 +1,9 @@ +namespace MapEditorLibrary.CCEngine.TileData; + +/// +/// Interface for a single cell of a tile; sub-tile of a full TMP. +/// +public interface ISubTileImage +{ + TmpImage TmpImage { get; } +} diff --git a/src/MapEditorLibrary/CCEngine/TileData/ITileImage.cs b/src/MapEditorLibrary/CCEngine/TileData/ITileImage.cs new file mode 100644 index 000000000..16cf2d75a --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/TileData/ITileImage.cs @@ -0,0 +1,40 @@ +using MapEditorLibrary.GameMath; + +namespace MapEditorLibrary.CCEngine.TileData; + +/// +/// Interface for a full tile image (containing all sub-tiles). +/// +public interface ITileImage +{ + /// + /// Width of the tile in cells. + /// + int Width { get; } + + /// + /// Height of the tile in cells. + /// + int Height { get; } + + /// + /// The index of the tile's tileset. + /// + int TileSetId { get; } + + /// + /// The index of the tile within its tileset. + /// + int TileIndexInTileSet { get; } + + /// + /// The unique ID of this tile within all tiles in the game. + /// + int TileID { get; } + + int SubTileCount { get; } + + ISubTileImage GetSubTile(int index); + + Point2D? GetSubTileCoordOffset(int index); +} diff --git a/src/MapEditorLibrary/CCEngine/TileData/TheaterTileData.cs b/src/MapEditorLibrary/CCEngine/TileData/TheaterTileData.cs new file mode 100644 index 000000000..d80442113 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/TileData/TheaterTileData.cs @@ -0,0 +1,239 @@ +using MapEditorLibrary.Models; +using Rampastring.Tools; +using System.Globalization; + +namespace MapEditorLibrary.CCEngine.TileData; + +class OverlayFrameInformation +{ + private const string SHP_FILE_EXTENSION = ".SHP"; + private const string PNG_FILE_EXTENSION = ".PNG"; + + public OverlayFrameInformation(Theater theater, CCFileManager fileManager, IReadOnlyList overlayTypes) + { + this.theater = theater; + this.fileManager = fileManager; + + ReadOverlayFrameCounts(overlayTypes); + } + + private readonly Theater theater; + private readonly CCFileManager fileManager; + private readonly Dictionary overlayFrameCounts = new Dictionary(); + + public int GetOverlayFrameCount(OverlayType overlayType) + { + if (overlayType == null) + throw new ArgumentNullException(nameof(overlayType)); + + if (!overlayFrameCounts.TryGetValue(overlayType.Index, out int frameCount)) + throw new KeyNotFoundException($"Overlay frame count for {overlayType.ININame} has not been loaded."); + + return frameCount; + } + + public void ReadOverlayFrameCounts(IReadOnlyList overlayTypes) + { + if (overlayTypes == null) + throw new ArgumentNullException(nameof(overlayTypes)); + + Logger.Log("Loading overlay frame counts."); + + overlayFrameCounts.Clear(); + + for (int i = 0; i < overlayTypes.Count; i++) + { + OverlayType overlayType = overlayTypes[i]; + + string imageName = GetOverlayImageName(overlayType); + + byte[] pngData = fileManager.LoadFile(imageName + PNG_FILE_EXTENSION); + if (pngData != null) + { + overlayFrameCounts[overlayType.Index] = GetLogicalOverlayFrameCount(1, 0); + continue; + } + + (string shpFileName, byte[] shpData) = LoadOverlayShpData(overlayType, imageName); + if (shpData == null) + { + overlayFrameCounts[overlayType.Index] = 0; + continue; + } + + var shpFile = new ShpFile(shpFileName); + shpFile.ParseFromBuffer(shpData); + + overlayFrameCounts[overlayType.Index] = GetLogicalOverlayFrameCount(shpFile); + } + + Logger.Log("Finished loading overlay frame counts."); + } + + private static string GetOverlayImageName(OverlayType overlayType) + { + string imageName = overlayType.ININame; + + if (overlayType.ArtConfig.Image != null) + imageName = overlayType.ArtConfig.Image; + else if (overlayType.Image != null) + imageName = overlayType.Image; + + return imageName; + } + + private (string FileName, byte[] Data) LoadOverlayShpData(OverlayType overlayType, string imageName) + { + if (overlayType.ArtConfig.NewTheater) + { + string shpFileName = imageName + SHP_FILE_EXTENSION; + string newTheaterImageName = shpFileName.Substring(0, 1) + theater.NewTheaterBuildingLetter + shpFileName.Substring(2); + byte[] shpData = fileManager.LoadFile(newTheaterImageName); + + if (shpData != null) + return (newTheaterImageName, shpData); + + newTheaterImageName = shpFileName.Substring(0, 1) + Constants.NewTheaterGenericLetter + shpFileName.Substring(2); + shpData = fileManager.LoadFile(newTheaterImageName); + return (newTheaterImageName, shpData); + } + + string fileExtension = overlayType.ArtConfig.Theater ? theater.FileExtension : SHP_FILE_EXTENSION; + string finalShpName = imageName + fileExtension; + return (finalShpName, fileManager.LoadFile(finalShpName)); + } + + private static int GetLogicalOverlayFrameCount(ShpFile shpFile) + { + int frameCount = shpFile.FrameCount; + int lastValidFrame = -1; + + for (int i = 0; i < frameCount; i++) + { + ShpFrameInfo frameInfo = shpFile.GetShpFrameInfo(i); + if (frameInfo != null && frameInfo.DataOffset != 0) + lastValidFrame = i; + } + + return GetLogicalOverlayFrameCount(frameCount, lastValidFrame); + } + + private static int GetLogicalOverlayFrameCount(int frameCount, int lastValidFrame) + { + if (lastValidFrame == frameCount - 1) + return frameCount / 2; + + return lastValidFrame + 1; + } +} + +public interface ITheaterTileData +{ + TileImage GetTileImage(int id); +} + +/// +/// Non-graphical equivalent of the TMP tile-loading portion of . +/// +public class TheaterTileData : ITheater, ITheaterTileData +{ + private readonly CCFileManager fileManager; + private readonly List terrainTileDataList = new List(); + + public TheaterTileData(Theater theater, CCFileManager fileManager, Rules rules) + { + Theater = theater ?? throw new ArgumentNullException(nameof(theater)); + this.fileManager = fileManager ?? throw new ArgumentNullException(nameof(fileManager)); + + ReadTileData(); + overlayFrameInformation = new OverlayFrameInformation(theater, fileManager, rules.OverlayTypes); + } + + public Theater Theater { get; } + + public int TileCount => terrainTileDataList.Count; + + public TileImage GetTileImage(int id) => terrainTileDataList[id][0]; + + public ITileImage GetTile(int id) => GetTileImage(id); + + public int GetTileSetId(int uniqueTileIndex) => GetTileImage(uniqueTileIndex).TileSetId; + + public int GetOverlayFrameCount(OverlayType overlayType) => overlayFrameInformation.GetOverlayFrameCount(overlayType); + + + private readonly OverlayFrameInformation overlayFrameInformation; + + + private void ReadTileData() + { + Logger.Log("Loading tile data."); + + int currentTileIndex = 0; // Used for setting the starting tile ID of a tileset + + for (int tsId = 0; tsId < Theater.TileSets.Count; tsId++) + { + TileSet tileSet = Theater.TileSets[tsId]; + tileSet.StartTileIndex = currentTileIndex; + tileSet.LoadedTileCount = 0; + + for (int i = 0; i < tileSet.TilesInSet; i++) + { + var tileImages = new List(); + + // Handle graphics variation (clear00.tem, clear00a.tem, clear00b.tem etc.). + // Even though this class does not create textures, variations can still carry + // distinct tile metadata, so we parse them just like TheaterGraphics does. + for (int v = 0; v < 'g' - 'a'; v++) + { + string baseName = tileSet.FileName + (i + 1).ToString("D2", CultureInfo.InvariantCulture); + + if (v > 0) + baseName += (char)('a' + (v - 1)); + + string fileName = baseName + Theater.FileExtension; + byte[] data = fileManager.LoadFile(fileName); + + if (data == null && !string.IsNullOrWhiteSpace(Theater.FallbackTileFileExtension)) + { + // Support for the FA2 NEWURBAN hack. FA2 Marble.mix does not contain Marble + // Madness graphics for NEWURBAN, only URBAN. To allow Marble Madness to work + // in NEWURBAN, FA2 also loads .urb files for NEWURBAN. + fileName = baseName + Theater.FallbackTileFileExtension; + data = fileManager.LoadFile(fileName); + } + + if (data == null) + { + if (v == 0) + { + tileImages.Add(new UntexturedTileImage(0, 0, tsId, i, currentTileIndex, Array.Empty())); + break; + } + + break; + } + + var tmpFile = new TmpFile(fileName); + tmpFile.ParseFromBuffer(data); + + var tmpImages = new List(); + for (int img = 0; img < tmpFile.ImageCount; img++) + { + TmpImage tmpImage = tmpFile.GetImage(img); + tmpImage?.FreeImageData(); + tmpImages.Add(tmpImage); + } + + tileImages.Add(new UntexturedTileImage(tmpFile.CellsX, tmpFile.CellsY, tsId, i, currentTileIndex, tmpImages.ToArray())); + } + + tileSet.LoadedTileCount++; + currentTileIndex++; + terrainTileDataList.Add(tileImages.ToArray()); + } + } + + Logger.Log("Finished loading tile data."); + } +} diff --git a/src/MapEditorLibrary/CCEngine/TileData/TileImage.cs b/src/MapEditorLibrary/CCEngine/TileData/TileImage.cs new file mode 100644 index 000000000..2ee51570b --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/TileData/TileImage.cs @@ -0,0 +1,244 @@ +using MapEditorLibrary.GameMath; + +namespace MapEditorLibrary.CCEngine.TileData; + +/// +/// Base class for a full TMP tile, composed of one or more sub-tiles. +/// +public abstract class TileImage : ITileImage +{ + protected TileImage(int width, int height, int tileSetId, int tileIndex, int tileId) + { + Width = width; + Height = height; + TileSetId = tileSetId; + TileIndexInTileSet = tileIndex; + TileID = tileId; + } + + /// + /// Width of the tile in cells. + /// + public int Width { get; } + + /// + /// Height of the tile in cells. + /// + public int Height { get; } + + /// + /// The index of the tile set. + /// + public int TileSetId { get; set; } + + /// + /// The index of the tile within its tileset. + /// + public int TileIndexInTileSet { get; set; } + + /// + /// The unique ID of this tile within all tiles in the game. + /// + public int TileID { get; set; } + + public abstract ISubTileImage GetSubTile(int index); + + public Point2D? GetSubTileCoordOffset(int index) + { + if (GetSubTile(index) == null) + return null; + + int x = index % Width; + int y = index / Width; + return new Point2D(x, y); + } + + public abstract int SubTileCount { get; } + + /// + /// Checks if a condition is true for any valid sub-tile. + /// + public bool CheckForAnyValidSubTile(Func condition) + { + for (int i = 0; i < SubTileCount; i++) + { + ISubTileImage image = GetSubTile(i); + + if (image?.TmpImage == null) + continue; + + if (condition(image)) + return true; + } + + return false; + } + + /// + /// Performs an action for all valid sub-tiles of the tile image. + /// + /// The action to perform. First parameter is the sub-tile, second parameter its offset within the tile, and the third parameter is the sub-tile's index. + public void DoForValidSubTiles(Action action) + { + for (int i = 0; i < SubTileCount; i++) + { + ISubTileImage image = GetSubTile(i); + + if (image == null) + continue; + + int cx = i % Width; + int cy = i / Width; + + action(image, new Point2D(cx, cy), i); + } + } + + public bool Flat => !CheckForAnyValidSubTile(subTile => subTile.TmpImage.Height > 0); + + /// + /// Calculates and returns the width of this full tile image. + /// + public int GetWidth(out int outMinX) + { + outMinX = 0; + + if (SubTileCount == 0) + return 0; + + int maxX = int.MinValue; + int minX = int.MaxValue; + + for (int i = 0; i < SubTileCount; i++) + { + var tmpData = GetSubTile(i)?.TmpImage; + if (tmpData == null) + continue; + + if (tmpData.X < minX) + minX = tmpData.X; + + int cellRightXCoordinate = tmpData.X + Constants.CellSizeX; + if (cellRightXCoordinate > maxX) + maxX = cellRightXCoordinate; + + if (tmpData.HasExtraData()) + { + int extraRightXCoordinate = tmpData.X + tmpData.XExtra + (int)tmpData.ExtraWidth; + if (extraRightXCoordinate > maxX) + maxX = extraRightXCoordinate; + } + } + + if (minX == int.MaxValue) + return 0; + + outMinX = minX; + return maxX - minX; + } + + /// + /// Calculates and returns the height of this full tile image. + /// + public int GetHeight() + { + if (SubTileCount == 0) + return 0; + + int top = int.MaxValue; + int bottom = int.MinValue; + + for (int i = 0; i < SubTileCount; i++) + { + var tmpData = GetSubTile(i)?.TmpImage; + if (tmpData == null) + continue; + + int heightOffset = Constants.CellHeight * tmpData.Height; + + int cellTop = tmpData.Y - heightOffset; + int cellBottom = cellTop + Constants.CellSizeY; + + if (cellTop < top) + top = cellTop; + + if (cellBottom > bottom) + bottom = cellBottom; + + if (tmpData.HasExtraData()) + { + int extraCellTop = tmpData.YExtra - heightOffset; + int extraCellBottom = extraCellTop + (int)tmpData.ExtraHeight; + + if (extraCellTop < top) + top = extraCellTop; + + if (extraCellBottom > bottom) + bottom = extraCellBottom; + } + } + + if (top == int.MaxValue) + return 0; + + return bottom - top; + } + + public int GetYOffset() + { + int height = GetHeight(); + + int yOffset = 0; + + int maxTopCoord = int.MaxValue; + int maxBottomCoord = int.MinValue; + + for (int i = 0; i < SubTileCount; i++) + { + var tmpData = GetSubTile(i)?.TmpImage; + if (tmpData == null) + continue; + + int heightOffset = Constants.CellHeight * tmpData.Height; + int cellTopCoord = tmpData.Y - heightOffset; + int cellBottomCoord = tmpData.Y + Constants.CellSizeY - heightOffset; + + if (cellTopCoord < maxTopCoord) + maxTopCoord = cellTopCoord; + + if (cellBottomCoord > maxBottomCoord) + maxBottomCoord = cellBottomCoord; + } + + for (int i = 0; i < SubTileCount; i++) + { + var tmpData = GetSubTile(i)?.TmpImage; + if (tmpData == null) + continue; + + if (tmpData.HasExtraData()) + { + int heightOffset = Constants.CellHeight * tmpData.Height; + + int extraTopCoord = tmpData.YExtra - heightOffset; + int extraBottomCoord = tmpData.YExtra + (int)tmpData.ExtraHeight - heightOffset; + + if (extraTopCoord < maxTopCoord) + maxTopCoord = extraTopCoord; + + if (extraBottomCoord > maxBottomCoord) + maxBottomCoord = extraBottomCoord; + } + } + + if (maxTopCoord == int.MaxValue) + return 0; + + if (maxTopCoord < 0) + yOffset = -maxTopCoord; + else if (maxBottomCoord > height) + yOffset = -(maxBottomCoord - height); + + return yOffset; + } +} diff --git a/src/MapEditorLibrary/CCEngine/TileData/UntexturedSubTileImage.cs b/src/MapEditorLibrary/CCEngine/TileData/UntexturedSubTileImage.cs new file mode 100644 index 000000000..d3716d7af --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/TileData/UntexturedSubTileImage.cs @@ -0,0 +1,14 @@ +namespace MapEditorLibrary.CCEngine.TileData; + +/// +/// A TMP sub-tile that carries metadata without a MonoGame texture. +/// +public class UntexturedSubTileImage : ISubTileImage +{ + public UntexturedSubTileImage(TmpImage tmpImage) + { + TmpImage = tmpImage; + } + + public TmpImage TmpImage { get; } +} diff --git a/src/MapEditorLibrary/CCEngine/TileData/UntexturedTileImage.cs b/src/MapEditorLibrary/CCEngine/TileData/UntexturedTileImage.cs new file mode 100644 index 000000000..dae7d4b96 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/TileData/UntexturedTileImage.cs @@ -0,0 +1,20 @@ +namespace MapEditorLibrary.CCEngine.TileData; + +/// +/// Contains metadata for a single full TMP (all sub-tiles / all cells) without texture data. +/// +public class UntexturedTileImage : TileImage +{ + public UntexturedTileImage(int width, int height, int tileSetId, int tileIndex, int tileId, TmpImage[] tmpImages) + : base(width, height, tileSetId, tileIndex, tileId) + { + tmpImages ??= Array.Empty(); + TMPImages = Array.ConvertAll(tmpImages, tmpImage => tmpImage == null ? null : new UntexturedSubTileImage(tmpImage)); + } + + public override ISubTileImage GetSubTile(int index) => TMPImages[index]; + + public override int SubTileCount => TMPImages.Length; + + public UntexturedSubTileImage[] TMPImages { get; set; } +} diff --git a/src/MapEditorLibrary/CCEngine/TileSet.cs b/src/MapEditorLibrary/CCEngine/TileSet.cs new file mode 100644 index 000000000..493192dad --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/TileSet.cs @@ -0,0 +1,95 @@ +using MapEditorLibrary.Misc; +using MapEditorLibrary.Models; +using Microsoft.Xna.Framework; +using Rampastring.Tools; +using Rampastring.XNAUI; + +namespace MapEditorLibrary.CCEngine; + +public class TileSet : INIDefineable +{ + public TileSet(int index) + { + Index = index; + SortID = index.ToString(); + } + + public int Index { get; } + public string SortID { get; set; } + public string SetName { get; set; } + + [INI(false)] + public string TranslatedName { get; set; } + public string FileName { get; set; } + public int TilesInSet { get; set; } + public bool Morphable { get; set; } + public int MarbleMadness { get; set; } = -1; + public int NonMarbleMadness { get; set; } = -1; + public bool AllowTiberium { get; set; } + public bool AllowToPlace { get; set; } = true; + public bool Only1x1 { get; set; } + public Color? Color { get; set; } + + /// + /// The unique tile ID of the first tile of this tileset. + /// + public int StartTileIndex { get; set; } + + /// + /// The actual amount of tiles successfully loaded for this tile set. + /// + public int LoadedTileCount { get; set; } + + /// + /// Checks and returns a value that determines whether a tile with a specific + /// index exists within this tile set. + /// + /// The index of the tile. + public bool ContainsTile(int tileIndex) => tileIndex >= StartTileIndex && tileIndex < StartTileIndex + LoadedTileCount; + + public Dictionary TiberiumGraphicsOverrides { get; set; } + public List<(string tiberiumTypeName, string graphicalOverlayName)> ParsedTiberiumGraphicsOverrides { get; set; } + + private static string[] only1x1TileSets = new string[] { "cliffs", "rivers", "shores", "dirt road" }; + + public void Read(IniSection iniSection) + { + ReadPropertiesFromIniSection(iniSection); + TranslatedName = Translate(this, SetName, SetName); + + foreach (string namepart in only1x1TileSets) + { + if (SetName != null && SetName.ToLowerInvariant().Contains(namepart)) + Only1x1 = true; + } + + const string colorKeyName = "EditorColor"; + if (iniSection.KeyExists(colorKeyName)) + Color = iniSection.GetColorValue(colorKeyName, UISettings.ActiveSettings.AltColor); + + // Support for DTA TiberiumOverlays graphics override feature + // TiberiumOverlays=Riparius:TIB1S_01,Vinifera:TIB2S_01,Ore:ORESNO01,Gems:GEMSRA01,GreenGems:GEMSRA01,BlueGems:GEMSRA01 + const string tiberiumOverlaysKeyName = "TiberiumOverlays"; + if (iniSection.KeyExists(tiberiumOverlaysKeyName)) + { + ParsedTiberiumGraphicsOverrides = new List<(string tiberiumTypeName, string graphicalOverlayName)>(); + string value = iniSection.GetStringValue(tiberiumOverlaysKeyName, null); + if (!string.IsNullOrWhiteSpace(value)) + { + string[] values = value.Split(',', StringSplitOptions.RemoveEmptyEntries); + + foreach (string overrideValue in values) + { + string[] parts = overrideValue.Split(':'); + + if (parts.Length != 2) + { + throw new INIConfigException($"Failed to parse TiberiumOverlays= of TileSet {SetName} (#{Index})."); + } + + ParsedTiberiumGraphicsOverrides.Add((parts[0], parts[1])); + } + } + } + } +} diff --git a/src/MapEditorLibrary/CCEngine/TmpFile.cs b/src/MapEditorLibrary/CCEngine/TmpFile.cs new file mode 100644 index 000000000..72d4a9b28 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/TmpFile.cs @@ -0,0 +1,215 @@ +namespace MapEditorLibrary.CCEngine; + +/// +/// A Tiberian Sun TMP file. +/// Represents a collection of sub-tiles that make up a full tile. +/// +public class TmpFile +{ + public TmpFile(string fileName) + { + this.fileName = fileName; + } + + private readonly string fileName; + + private TmpFileHeader tmpFileHeader; + private List tmpImages = new List(); + + public int ImageCount => tmpImages.Count; + public TmpImage GetImage(int id) => tmpImages[id]; + + public int CellsX => tmpFileHeader.Width; + + public int CellsY => tmpFileHeader.Height; + + public void ParseFromFile(string filePath) + { + using (FileStream stream = File.OpenRead(filePath)) + { + Parse(stream); + } + } + + public void Parse(Stream stream) + { + byte[] buffer = new byte[stream.Length]; + stream.Position = 0; + stream.Read(buffer, 0, buffer.Length); + ParseFromBuffer(buffer); + } + + public void ParseFromBuffer(byte[] buffer) + { + tmpFileHeader = new TmpFileHeader(buffer); + int tileCount = tmpFileHeader.Width * tmpFileHeader.Height; + + List tmpHeaderOffsets = new List(); + + for (int i = 0; i < tileCount; i++) + { + int offset = BitConverter.ToInt32(buffer, TmpFileHeader.SIZE + i * 4); + tmpHeaderOffsets.Add(offset); + } + + using (var memoryStream = new MemoryStream(buffer)) + { + for (int i = 0; i < tileCount; i++) + { + if (tmpHeaderOffsets[i] == 0) + { + tmpImages.Add(null); + } + else + { + memoryStream.Position = tmpHeaderOffsets[i]; + TmpImage tmpImage = new TmpImage(memoryStream, fileName); + tmpImages.Add(tmpImage); + } + } + } + } +} + +/// +/// A TMP file header. +/// +struct TmpFileHeader +{ + public const int SIZE = 16; + + public TmpFileHeader(byte[] buffer) + { + if (buffer.Length < SIZE) + throw new ArgumentException("buffer is not long enough"); + + Width = BitConverter.ToInt32(buffer, 0); + Height = BitConverter.ToInt32(buffer, 4); + TileWidth = BitConverter.ToInt32(buffer, 8); + TileHeight = BitConverter.ToInt32(buffer, 12); + } + + public int Width { get; private set; } + public int Height { get; private set; } + public int TileWidth { get; private set; } + public int TileHeight { get; private set; } +} + +/// +/// A single TMP image (representing a single cell). +/// +public class TmpImage +{ + public const int IMAGE_HEADER_SIZE = 48; + + public TmpImage(Stream stream, string fileName) + { + long expectedLength = stream.Position + IMAGE_HEADER_SIZE + Constants.TileColorBufferSize; + if (stream.Length < expectedLength) + { + throw new ArgumentException($"TMP file buffer ran out unexpectedly while reading ${fileName}: " + + $"expected length of at least {expectedLength}, actual length: {stream.Length}"); + } + + X = ReadIntFromStream(stream); + Y = ReadIntFromStream(stream); + ExtraDataOffset = ReadUIntFromStream(stream); + ZDataOffset = ReadUIntFromStream(stream); + ExtraZDataOffset = ReadUIntFromStream(stream); + XExtra = ReadIntFromStream(stream); + YExtra = ReadIntFromStream(stream); + ExtraWidth = ReadUIntFromStream(stream); + ExtraHeight = ReadUIntFromStream(stream); + stream.Read(buffer, 0, 4); + // The image flags of WW tiles contain + // trash / uninitialized memory which we have to clear + ImageFlags = (TmpImageFlags)(BitConverter.ToUInt32(buffer, 0)); + stream.Read(buffer, 0, 3); + Height = buffer[0]; + TerrainType = buffer[1]; + RampType = (RampType)buffer[2]; + RadarLeftColor = ReadRGBColorFromStream(stream); + RadarRightColor = ReadRGBColorFromStream(stream); + stream.Read(buffer, 0, 3); // Discard 3 more bytes of WW trash data / uninitialized memory + stream.Read(ColorData, 0, Constants.TileColorBufferSize); + + if ((ImageFlags & TmpImageFlags.HasZData) == TmpImageFlags.HasZData) + { + ZData = new byte[Constants.TileColorBufferSize]; + stream.Read(ZData, 0, ZData.Length); + } + + if ((ImageFlags & TmpImageFlags.HasExtraData) == TmpImageFlags.HasExtraData) + { + ExtraGraphicsColorData = new byte[ExtraWidth * ExtraHeight]; + stream.Read(ExtraGraphicsColorData, 0, ExtraGraphicsColorData.Length); + + if ((ImageFlags & TmpImageFlags.HasZData) == TmpImageFlags.HasZData && ExtraZDataOffset > 0) + { + ExtraGraphicsZData = new byte[ExtraWidth * ExtraHeight]; + stream.Read(ExtraGraphicsZData, 0, ExtraGraphicsZData.Length); + } + } + } + + public void FreeImageData() + { + ColorData = null; + ZData = null; + ExtraGraphicsColorData = null; + ExtraGraphicsZData = null; + } + + public bool HasExtraData() => (ImageFlags & TmpImageFlags.HasExtraData) == TmpImageFlags.HasExtraData; + + private int ReadIntFromStream(Stream stream) + { + stream.Read(buffer, 0, 4); + return BitConverter.ToInt32(buffer, 0); + } + + private uint ReadUIntFromStream(Stream stream) + { + stream.Read(buffer, 0, 4); + return BitConverter.ToUInt32(buffer, 0); + } + + private RGBColor ReadRGBColorFromStream(Stream stream) + { + stream.Read(buffer, 0, 3); + return new RGBColor(buffer, 0, 0); + } + + byte[] buffer = new byte[4]; + + public int X { get; private set; } + public int Y { get; private set; } + public uint ExtraDataOffset { get; private set; } + public uint ZDataOffset { get; private set; } + public uint ExtraZDataOffset { get; private set; } + public int XExtra { get; private set; } + public int YExtra { get; private set; } + public uint ExtraWidth { get; private set; } + public uint ExtraHeight { get; private set; } + public TmpImageFlags ImageFlags { get; private set; } + public byte Height { get; private set; } + public byte TerrainType { get; private set; } + public RampType RampType { get; private set; } + + public RGBColor RadarLeftColor { get; set; } + public RGBColor RadarRightColor { get; set; } + + public byte[] ColorData = new byte[Constants.TileColorBufferSize]; + public byte[] ZData = Array.Empty(); + public byte[] ExtraGraphicsColorData = Array.Empty(); + public byte[] ExtraGraphicsZData = Array.Empty(); +} + +[Flags] +public enum TmpImageFlags : uint +{ + None = 0, + HasExtraData = 0x01, + HasZData = 0x02, + HasDamagedData = 0x04 +} diff --git a/src/MapEditorLibrary/CCEngine/TriggerActionType.cs b/src/MapEditorLibrary/CCEngine/TriggerActionType.cs new file mode 100644 index 000000000..7dc7d5be8 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/TriggerActionType.cs @@ -0,0 +1,78 @@ +using MapEditorLibrary.Models.Enums; +using Rampastring.Tools; +using System.Globalization; + +namespace MapEditorLibrary.CCEngine; + +public class TriggerActionParam +{ + public TriggerActionParam(TriggerParamType triggerParamType, string nameOverride, List presetOptions = null) + { + TriggerParamType = triggerParamType; + NameOverride = nameOverride; + PresetOptions = presetOptions; + } + + public TriggerParamType TriggerParamType { get; } + public string NameOverride { get; } + public List PresetOptions { get; } + + public bool HasPresetOptions() => PresetOptions != null && PresetOptions.Count > 0; +} + +public class TriggerActionType +{ + public const int MAX_PARAM_COUNT = 7; + + public TriggerActionType(int id) + { + ID = id; + } + + + public int ID { get; set; } + + public string Name { get; set; } + public string Description { get; set; } + public TriggerActionParam[] Parameters { get; } = new TriggerActionParam[MAX_PARAM_COUNT]; + + public void ReadPropertiesFromIniSection(IniSection iniSection) + { + ID = iniSection.GetIntValue("IDOverride", ID); + string untranslatedName = iniSection.GetStringValue(nameof(Name), string.Empty); + Name = Translate(this, untranslatedName + ".Name", untranslatedName); + Description = Translate(this, untranslatedName + ".Description", iniSection.GetStringValue(nameof(Description), string.Empty)); + + for (int i = 0; i < Parameters.Length; i++) + { + string key = $"P{i + 1}Type"; + string nameOverrideKey = $"P{i + 1}Name"; + string presetOptionsKey = $"P{i + 1}PresetOptions"; + + if (!iniSection.KeyExists(key)) + { + Parameters[i] = new TriggerActionParam(TriggerParamType.Unused, null); + continue; + } + + var triggerParamType = (TriggerParamType)Enum.Parse(typeof(TriggerParamType), iniSection.GetStringValue(key, string.Empty)); + string nameOverride = Translate(this, untranslatedName + ".Parameter" + i.ToString(CultureInfo.InvariantCulture) + ".NameOverride", iniSection.GetStringValue(nameOverrideKey, null)); + if (triggerParamType == TriggerParamType.WaypointZZ && string.IsNullOrWhiteSpace(nameOverride)) + nameOverride = Translate(nameof(TriggerParamType) + "." + nameof(TriggerParamType.Waypoint), "Waypoint"); + + List presetOptions = null; + string presetOptionsString = iniSection.GetStringValue(presetOptionsKey, null); + if (!string.IsNullOrWhiteSpace(presetOptionsString)) + { + presetOptions = new List(presetOptionsString.Split(new char[] {','}, StringSplitOptions.RemoveEmptyEntries)); + + for (int j = 0; j < presetOptions.Count; j++) + { + presetOptions[j] = Translate(this, untranslatedName + ".Parameter" + i.ToString(CultureInfo.InvariantCulture) + ".PresentOption" + j.ToString(CultureInfo.InvariantCulture), presetOptions[j]); + } + } + + Parameters[i] = new TriggerActionParam(triggerParamType, nameOverride, presetOptions); + } + } +} diff --git a/src/MapEditorLibrary/CCEngine/TriggerEventType.cs b/src/MapEditorLibrary/CCEngine/TriggerEventType.cs new file mode 100644 index 000000000..87d84cd46 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/TriggerEventType.cs @@ -0,0 +1,95 @@ +using MapEditorLibrary.Models.Enums; +using Rampastring.Tools; +using System.Globalization; + +namespace MapEditorLibrary.CCEngine; + +public class TriggerEventParam +{ + public TriggerEventParam(TriggerParamType triggerParamType, string nameOverride, List presetOptions = null) + { + TriggerParamType = triggerParamType; + NameOverride = nameOverride; + PresetOptions = presetOptions; + } + + public TriggerParamType TriggerParamType { get; } + public string NameOverride { get; } + public List PresetOptions { get; } +} + +public class TriggerEventType +{ + public const int DEF_PARAM_COUNT = 2; + public const int MAX_PARAM_COUNT = 4; + + public TriggerEventType(int id) + { + ID = id; + } + + public int ID { get; set; } + + public string Name { get; set; } + public string Description { get; set; } + public TriggerEventParam[] Parameters { get; } = new TriggerEventParam[MAX_PARAM_COUNT]; + public bool Available { get; set; } = true; + + public int AdditionalParams + { + get + { + int additionalParams = 0; + + for (int i = DEF_PARAM_COUNT; i < MAX_PARAM_COUNT; i++) + { + var param = Parameters[i]; + if (param.TriggerParamType != TriggerParamType.Unused) + additionalParams++; + } + + return additionalParams; + } + } + + public void ReadPropertiesFromIniSection(IniSection iniSection) + { + ID = iniSection.GetIntValue("IDOverride", ID); + string untranslatedName = iniSection.GetStringValue(nameof(Name), string.Empty); + Name = Translate(this, untranslatedName + ".Name", untranslatedName); + Description = Translate(this, untranslatedName + ".Description", iniSection.GetStringValue(nameof(Description), string.Empty)); + Available = iniSection.GetBooleanValue(nameof(Available), true); + + for (int i = 0; i < Parameters.Length; i++) + { + string key = $"P{i + 1}Type"; + string nameOverrideKey = $"P{i + 1}Name"; + string presetOptionsKey = $"P{i + 1}PresetOptions"; + + if (!iniSection.KeyExists(key)) + { + Parameters[i] = new TriggerEventParam(TriggerParamType.Unused, null); + continue; + } + + var triggerParamType = (TriggerParamType)Enum.Parse(typeof(TriggerParamType), iniSection.GetStringValue(key, string.Empty)); + string nameOverride = Translate(this, untranslatedName + ".Parameter" + i.ToString(CultureInfo.InvariantCulture) + ".NameOverride", iniSection.GetStringValue(nameOverrideKey, null)); + if (triggerParamType == TriggerParamType.WaypointZZ && string.IsNullOrWhiteSpace(nameOverride)) + nameOverride = Translate(nameof(TriggerParamType) + "." + nameof(TriggerParamType.Waypoint), "Waypoint"); + + List presetOptions = null; + string presetOptionsString = iniSection.GetStringValue(presetOptionsKey, null); + if (!string.IsNullOrWhiteSpace(presetOptionsString)) + { + presetOptions = new List(presetOptionsString.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries)); + + for (int j = 0; j < presetOptions.Count; j++) + { + presetOptions[j] = Translate(this, untranslatedName + ".Parameter" + i.ToString(CultureInfo.InvariantCulture) + ".PresentOption" + j.ToString(CultureInfo.InvariantCulture), presetOptions[j]); + } + } + + Parameters[i] = new TriggerEventParam(triggerParamType, nameOverride, presetOptions); + } + } +} diff --git a/src/MapEditorLibrary/CCEngine/VplFile.cs b/src/MapEditorLibrary/CCEngine/VplFile.cs new file mode 100644 index 000000000..3a482d625 --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/VplFile.cs @@ -0,0 +1,54 @@ +using CNCMaps.FileFormats.VirtualFileSystem; + +namespace MapEditorLibrary.CCEngine; + +/// +/// .vpl file format +/// Based on the CNCMaps Renderer code +/// https://github.com/zzattack/ccmaps-net +/// +public class VplFile : VirtualFile +{ + public VplFile(Stream baseStream, string filename, int baseOffset, int fileSize, bool isBuffered = false) + : base(baseStream, filename, baseOffset, fileSize, isBuffered) + { + Parse(); + } + + public VplFile(Stream baseStream, string filename = "", bool isBuffered = true) + : base(baseStream, filename, isBuffered) + { + Parse(); + } + + + public VplFile(byte[] buffer, string filename = "") : base(new MemoryStream(buffer), filename, true) + { + Parse(); + } + + + private uint firstRemap; + private uint lastRemap; + private uint numSections; + private uint unknown; + // private Palette _palette; // unused + private List lookupSections = new(); + + private void Parse() + { + firstRemap = ReadUInt32(); + lastRemap = ReadUInt32(); + numSections = ReadUInt32(); + unknown = ReadUInt32(); + var pal = Read(768); + // palette = new Palette(pal, "voxels.vpl"); + for (uint i = 0; i < numSections; i++) + lookupSections.Add(Read(256)); + } + + public byte GetPaletteIndex(byte page, byte color) + { + return lookupSections[page][color]; + } +} \ No newline at end of file diff --git a/src/MapEditorLibrary/CCEngine/VxlFile.cs b/src/MapEditorLibrary/CCEngine/VxlFile.cs new file mode 100644 index 000000000..a4d0807cc --- /dev/null +++ b/src/MapEditorLibrary/CCEngine/VxlFile.cs @@ -0,0 +1,661 @@ +using CNCMaps.FileFormats.VirtualFileSystem; +using Microsoft.Xna.Framework; +using System.Diagnostics; + +namespace MapEditorLibrary.CCEngine; + +public class VxlLoadException : Exception +{ + public VxlLoadException(string message) : base(message) { } +} + +/// +/// .vxl file format +/// Based on the CNCMaps Renderer code +/// https://github.com/zzattack/ccmaps-net +/// + +public class VxlFile : VirtualFile +{ + public FileHeader Header = new(); + public List
Sections = new(); + + public VxlFile(Stream baseStream, string filename, int baseOffset, int fileSize, bool isBuffered = false) + : base(baseStream, filename, baseOffset, fileSize, isBuffered) + { + Initialize(); + } + + public VxlFile(Stream baseStream, string filename = "", bool isBuffered = true) + : base(baseStream, filename, isBuffered) + { + Initialize(); + } + + public VxlFile(byte[] buffer, string filename = "") : base(new MemoryStream(buffer), filename, true) + { + Initialize(); + } + + + private void Initialize() + { + if (Length < FileHeader.Size) + throw new VxlLoadException(nameof(VxlFile) + " .vxl is shorter than specified in its header!"); + + Header.Read(this); + if (Header.HeaderCount == 0 || Header.TailerCount == 0 || Header.TailerCount != Header.HeaderCount) + throw new VxlLoadException(nameof(VxlFile) + " .vxl has no header or tailer sections, or their count doesn't match!"); + + // start with headers + for (int i = 0; i < Header.HeaderCount; ++i) + { + Sections.Add(new Section(i)); + Sections[i].ReadHeader(this); + } + + // then we need tailers before before bodies can be constructed + long bodyStart = Position; + Seek(Header.BodySize, SeekOrigin.Current); + for (int i = 0; i < Header.TailerCount; ++i) + Sections[i].ReadTailer(this); + + for (int i = 0; i < Header.HeaderCount; ++i) + { + Seek(bodyStart, SeekOrigin.Begin); + Sections[i].ReadBodySpans(this); + } + } + + public class FileHeader + { + public const int Size = 32; + + public string FileName; + public uint PaletteCount; + public uint HeaderCount; + public uint TailerCount; + public uint BodySize; + public byte PaletteRemapStart; + public byte PaletteRemapEnd; + // public Palette Palette; // not actually used + + public void Read(VxlFile vxlFile) + { + FileName = vxlFile.ReadCString(16); + PaletteCount = vxlFile.ReadUInt32(); + HeaderCount = vxlFile.ReadUInt32(); + TailerCount = vxlFile.ReadUInt32(); + Debug.Assert(HeaderCount == TailerCount); + BodySize = vxlFile.ReadUInt32(); + PaletteRemapStart = vxlFile.ReadByte(); + PaletteRemapEnd = vxlFile.ReadByte(); + var pal = vxlFile.Read(768); + // Palette = new Palette(pal, "voxel palette"); + } + + }; + + public class Voxel + { + public byte X; + public byte Y; + public byte Z; + public byte ColorIndex; + public byte NormalIndex; + }; + + public class SectionSpan + { + public byte X, Y; + public int StartIndex; + public int EndIndex; + + public byte Height; + + public List Voxels = new(); + + public int SpanLength => (EndIndex - StartIndex) + 1; + public void Read(VirtualFile file) + { + if (StartIndex == -1 || EndIndex == -1) + return; + + for (byte z = 0; z < Height;) + { + z += file.ReadByte(); // skip + byte c = file.ReadByte(); // numvoxels + for (var i = 0; i < c; ++i) + Voxels.Add(new Voxel { X = X, Y = Y, Z = z++, ColorIndex = file.ReadByte(), NormalIndex = file.ReadByte() }); + byte c2 = file.ReadByte(); // numvoxels, repeated + } + } + }; + + public class TransfMatrix + { + public Vector4[] V = new Vector4[3]; + + public void Read(VxlFile vxlFile) + { + for (var i = 0; i < 3; ++i) + { + V[i].X = vxlFile.ReadFloat(); + V[i].Y = vxlFile.ReadFloat(); + V[i].Z = vxlFile.ReadFloat(); + V[i].W = vxlFile.ReadFloat(); + } + } + }; + + public class Section + { + public Section(int index) + { + Index = index; + } + + public int Index; + // header + public string Name; + public uint LimbNumber; + private uint unknown1; + private uint unknown2; + + // body + public SectionSpan[,] Spans; + + // tailer + public uint StartingSpanOffset; + public uint EndingSpanOffset; + public uint DataSpanOffset; + public float HvaMatrixScale; + public TransfMatrix TransfMatrix = new(); + public Vector3 MinBounds; + public Vector3 MaxBounds; + public byte SizeX; + public byte SizeY; + public byte SizeZ; + public byte NormalsMode; + + public float SpanX => MaxBounds.X - MinBounds.X; + public float SpanY => MaxBounds.Y - MinBounds.Y; + public float SpanZ => MaxBounds.Z - MinBounds.Z; + public float ScaleX => SpanX * 1.0f / SizeX; + public float ScaleY => SpanY * 1.0f / SizeY; + public float ScaleZ => SpanZ * 1.0f / SizeZ; + public Vector3 Scale => new(ScaleX, ScaleY, ScaleZ); + + public void ReadHeader(VxlFile vxlFile) + { + Name = vxlFile.ReadCString(16); + LimbNumber = vxlFile.ReadUInt32(); + unknown1 = vxlFile.ReadUInt32(); + unknown2 = vxlFile.ReadUInt32(); + } + + public void ReadBodySpans(VxlFile vxlFile) + { + // need to have position at start of bodies + vxlFile.Seek(StartingSpanOffset, SeekOrigin.Current); + Spans = new SectionSpan[SizeX, SizeY]; + + for (byte y = 0; y < SizeY; ++y) + { + for (byte x = 0; x < SizeX; ++x) + { + var s = new SectionSpan(); + s.StartIndex = vxlFile.ReadInt32(); + s.Height = SizeZ; + s.X = x; + s.Y = y; + Spans[x, y] = s; + } + } + + for (byte y = 0; y < SizeY; ++y) + { + for (byte x = 0; x < SizeX; ++x) + { + Spans[x, y].EndIndex = vxlFile.ReadInt32(); + } + } + + for (byte y = 0; y < SizeY; ++y) + { + for (byte x = 0; x < SizeX; ++x) + { + Spans[x, y].Read(vxlFile); + } + } + } + + public void ReadTailer(VxlFile vxlFile) + { + StartingSpanOffset = vxlFile.ReadUInt32(); + EndingSpanOffset = vxlFile.ReadUInt32(); + DataSpanOffset = vxlFile.ReadUInt32(); + HvaMatrixScale = vxlFile.ReadFloat(); + TransfMatrix.Read(vxlFile); + MinBounds.X = vxlFile.ReadFloat(); + MinBounds.Y = vxlFile.ReadFloat(); + MinBounds.Z = vxlFile.ReadFloat(); + MaxBounds.X = vxlFile.ReadFloat(); + MaxBounds.Y = vxlFile.ReadFloat(); + MaxBounds.Z = vxlFile.ReadFloat(); + + SizeX = vxlFile.ReadByte(); + SizeY = vxlFile.ReadByte(); + SizeZ = vxlFile.ReadByte(); + NormalsMode = vxlFile.ReadByte(); + } + + public Vector3[] GetNormals() + { + switch (NormalsMode) + { + case 1: + return Normals1; + case 2: + return Normals2; + case 3: + return Normals3; + case 4: + return Normals4; + default: + throw new ArgumentException("Voxel normals index ranges from 1 to 4"); + } + } + + public Voxel GetVoxel(uint x, uint y, uint z) + { + if (x < Spans.GetLength(0) && y < Spans.GetLength(1) && z < Spans[x, y].Voxels.Count) + return Spans[x, y].Voxels[(int)z]; + return null; + } + + public Vector3 GetNormal(byte p) + { + var normals = GetNormals(); + return normals[p < normals.Length ? p : normals.Length - 1]; + } + } + + #region Normal Tables + + public static readonly Vector3[] Normals1 = new Vector3[] { + new Vector3(0.54946297f, -0.000183f, -0.835518f), + new Vector3(0.00014400001f, 0.54940403f, -0.83555698f), + new Vector3(-0.54940403f, -0.000068000001f, -0.83555698f), + new Vector3(0.000106f, -0.54946297f, -0.835518f), + new Vector3(0.94900799f, 0.00031599999f, -0.31525001f), + new Vector3(-0.000186f, 0.94899702f, -0.31528401f), + new Vector3(-0.94899702f, 0.00031800001f, -0.31528401f), + new Vector3(-0.000447f, -0.94900799f, -0.31525001f), + new Vector3(0.95084399f, -0.000279f, 0.30967101f), + new Vector3(0.000202f, 0.95084798f, 0.30965701f), + new Vector3(-0.95084798f, -0.000070000002f, 0.30965701f), + new Vector3(0.000147f, -0.95084399f, 0.30967101f), + new Vector3(0.55237001f, -0.000011f, 0.83359897f), + new Vector3(0.000019999999f, 0.55238003f, 0.833592f), + new Vector3(-0.55238003f, 0.000057000001f, 0.83359301f), + new Vector3(-0.000066000001f, -0.55237001f, 0.83359897f), + }; + + public static readonly Vector3[] Normals2 = new Vector3[] { + new Vector3(0.67121398f, 0.19849201f, -0.714194f), + new Vector3(0.26964301f, 0.58439398f, -0.76536f), + new Vector3(-0.040546f, 0.096988f, -0.99445897f), + new Vector3(-0.57242799f, -0.091913998f, -0.81478697f), + new Vector3(-0.17140099f, -0.57270998f, -0.80163902f), + new Vector3(0.36255699f, -0.30299899f, -0.88133103f), + new Vector3(0.81034702f, -0.34897199f, -0.470698f), + new Vector3(0.103962f, 0.93867201f, -0.328767f), + new Vector3(-0.324047f, 0.58766901f, -0.74137598f), + new Vector3(-0.80086499f, 0.34046099f, -0.49264699f), + new Vector3(-0.66549802f, -0.59014702f, -0.45698899f), + new Vector3(0.314767f, -0.803002f, -0.506073f), + new Vector3(0.97262901f, 0.151076f, -0.17655f), + new Vector3(0.680291f, 0.68423599f, -0.26272699f), + new Vector3(-0.52007902f, 0.82777703f, -0.210483f), + new Vector3(-0.96164399f, -0.179001f, -0.207847f), + new Vector3(-0.262714f, -0.937451f, -0.22840101f), + new Vector3(0.219707f, -0.97130102f, 0.091124997f), + new Vector3(0.92380798f, -0.229975f, 0.30608699f), + new Vector3(-0.082488999f, 0.97065997f, 0.225866f), + new Vector3(-0.59179801f, 0.69678998f, 0.40528899f), + new Vector3(-0.92529601f, 0.36660099f, 0.097111002f), + new Vector3(-0.705051f, -0.68777502f, 0.172828f), + new Vector3(0.7324f, -0.68036699f, -0.026304999f), + new Vector3(0.85516202f, 0.37458199f, 0.358311f), + new Vector3(0.47300601f, 0.83648002f, 0.276705f), + new Vector3(-0.097617f, 0.65411198f, 0.750072f), + new Vector3(-0.90412402f, -0.153725f, 0.39865801f), + new Vector3(-0.211916f, -0.85808998f, 0.46773201f), + new Vector3(0.50022697f, -0.67440802f, 0.543091f), + new Vector3(0.584539f, -0.110249f, 0.80384099f), + new Vector3(0.43737301f, 0.45464399f, 0.77588898f), + new Vector3(-0.042440999f, 0.083318003f, 0.995619f), + new Vector3(-0.59625101f, 0.22013199f, 0.77202803f), + new Vector3(-0.506455f, -0.39697701f, 0.76544899f), + new Vector3(0.070569001f, -0.47847399f, 0.87526202f), + }; + + public static readonly Vector3[] Normals3 = new Vector3[] { + new Vector3(0.45651099f, -0.073968001f, -0.88663799f), + new Vector3(0.50769401f, 0.38511699f, -0.77067f), + new Vector3(0.095431998f, 0.22666401f, -0.96928602f), + new Vector3(-0.35876599f, 0.54318798f, -0.75910097f), + new Vector3(-0.361276f, 0.13299499f, -0.92292601f), + new Vector3(-0.48311701f, -0.32406601f, -0.813375f), + new Vector3(-0.018073f, -0.197559f, -0.980124f), + new Vector3(0.3211f, -0.501477f, -0.80337799f), + new Vector3(0.79949099f, 0.069615997f, -0.59662998f), + new Vector3(0.390971f, 0.77130598f, -0.50222403f), + new Vector3(0.080782004f, 0.61448997f, -0.784778f), + new Vector3(-0.73275f, 0.41143101f, -0.54203498f), + new Vector3(-0.73525399f, 0.0091019999f, -0.67773098f), + new Vector3(-0.80249399f, -0.39490801f, -0.44727099f), + new Vector3(-0.13413f, -0.58915502f, -0.79680902f), + new Vector3(0.71955299f, -0.37622699f, -0.58369303f), + new Vector3(0.96687502f, 0.173593f, -0.187132f), + new Vector3(0.760831f, 0.51910597f, -0.38944301f), + new Vector3(-0.114642f, 0.87551898f, -0.46938601f), + new Vector3(-0.53236699f, 0.76885903f, -0.354177f), + new Vector3(-0.96226698f, 0.024977f, -0.27095801f), + new Vector3(-0.46738699f, -0.721986f, -0.51018202f), + new Vector3(0.058449998f, -0.85235399f, -0.51968902f), + new Vector3(0.49823299f, -0.74374002f, -0.44566301f), + new Vector3(0.93915099f, -0.27024499f, -0.212044f), + new Vector3(0.58393198f, 0.80944198f, -0.061857f), + new Vector3(0.183797f, 0.97322798f, -0.138007f), + new Vector3(-0.88435501f, 0.45221901f, -0.115822f), + new Vector3(-0.943178f, -0.33206701f, 0.012138f), + new Vector3(-0.69844002f, -0.70656699f, -0.113772f), + new Vector3(-0.228411f, -0.95470601f, -0.190694f), + new Vector3(0.73156399f, -0.675861f, -0.089588001f), + new Vector3(0.96925098f, 0.046804f, 0.24158201f), + new Vector3(0.85564703f, 0.50347698f, 0.119916f), + new Vector3(-0.25115299f, 0.96794701f, -0.000080999998f), + new Vector3(-0.64779502f, 0.75674897f, 0.087711997f), + new Vector3(-0.96916401f, 0.14519399f, 0.1991f), + new Vector3(-0.41479301f, -0.88896698f, 0.194126f), + new Vector3(0.25077501f, -0.961178f, -0.115109f), + new Vector3(0.47862899f, -0.84259301f, 0.246883f), + new Vector3(0.89004397f, -0.39614201f, 0.225595f), + new Vector3(0.52405101f, 0.76235998f, 0.37970701f), + new Vector3(0.11962f, 0.94548202f, 0.30291f), + new Vector3(-0.76085001f, 0.49007499f, 0.42536199f), + new Vector3(-0.86978501f, -0.20215f, 0.450122f), + new Vector3(-0.70946699f, -0.60242403f, 0.36570701f), + new Vector3(0.019308999f, -0.95887101f, 0.28318599f), + new Vector3(0.626113f, -0.564677f, 0.53770101f), + new Vector3(0.769943f, -0.126663f, 0.62541503f), + new Vector3(0.76419097f, 0.35070199f, 0.54131401f), + new Vector3(-0.001878f, 0.74136698f, 0.67109799f), + new Vector3(-0.37088001f, 0.81836802f, 0.43900099f), + new Vector3(-0.71390897f, 0.12865201f, 0.68831801f), + new Vector3(-0.295165f, -0.73866397f, 0.60601401f), + new Vector3(0.186195f, -0.73836899f, 0.648184f), + new Vector3(0.387523f, -0.35878301f, 0.84917599f), + new Vector3(0.481022f, 0.124846f, 0.86777401f), + new Vector3(0.391808f, 0.54505599f, 0.741216f), + new Vector3(-0.0035359999f, 0.36559799f, 0.93076599f), + new Vector3(-0.42049801f, 0.484961f, 0.76680797f), + new Vector3(-0.35490301f, 0.019470001f, 0.93470001f), + new Vector3(-0.54783702f, -0.35920799f, 0.75554299f), + new Vector3(-0.106662f, -0.445115f, 0.88909799f), + new Vector3(0.086796001f, -0.059307002f, 0.99445897f), + }; + + public static readonly Vector3[] Normals4 = new Vector3[] { + new Vector3(0.52657801f, -0.35962099f, -0.77031702f), + new Vector3(0.150482f, 0.43598399f, 0.88728398f), + new Vector3(0.414195f, 0.73825502f, -0.53237402f), + new Vector3(0.075152002f, 0.91624898f, -0.393498f), + new Vector3(-0.316149f, 0.93073601f, -0.18379299f), + new Vector3(-0.77381903f, 0.62333399f, -0.11251f), + new Vector3(-0.90084201f, 0.42853701f, -0.069568001f), + new Vector3(-0.99894202f, -0.010971f, 0.044665001f), + new Vector3(-0.979761f, -0.15767001f, -0.123324f), + new Vector3(-0.91127402f, -0.362371f, -0.19562f), + new Vector3(-0.62406898f, -0.72094101f, -0.301301f), + new Vector3(-0.310173f, -0.80934501f, -0.498752f), + new Vector3(0.146613f, -0.81581903f, -0.55941403f), + new Vector3(-0.71651602f, -0.69435602f, -0.066887997f), + new Vector3(0.50397199f, -0.114202f, -0.85613698f), + new Vector3(0.45549101f, 0.87262702f, -0.176211f), + new Vector3(-0.00501f, -0.114373f, -0.99342501f), + new Vector3(-0.104675f, -0.327701f, -0.93896502f), + new Vector3(0.56041199f, 0.75258899f, -0.34575599f), + new Vector3(-0.060575999f, 0.82162797f, -0.566796f), + new Vector3(-0.30234101f, 0.79700702f, -0.522847f), + new Vector3(-0.671543f, 0.67074001f, -0.314863f), + new Vector3(-0.77840102f, -0.12835699f, 0.61450499f), + new Vector3(-0.92404997f, 0.278382f, -0.261985f), + new Vector3(-0.69977301f, -0.55049098f, -0.45527801f), + new Vector3(-0.56824797f, -0.51718903f, -0.64000797f), + new Vector3(0.054097999f, -0.93286401f, -0.356143f), + new Vector3(0.75838202f, 0.57289302f, -0.31088799f), + new Vector3(0.0036200001f, 0.30502599f, -0.95233703f), + new Vector3(-0.060849998f, -0.98688602f, -0.14951099f), + new Vector3(0.63523f, 0.045478001f, -0.77098298f), + new Vector3(0.52170497f, 0.241309f, -0.81828701f), + new Vector3(0.26940399f, 0.63542497f, -0.72364098f), + new Vector3(0.045676f, 0.67275399f, -0.738455f), + new Vector3(-0.180511f, 0.67465699f, -0.71571898f), + new Vector3(-0.397131f, 0.63664001f, -0.66104198f), + new Vector3(-0.55200398f, 0.47251499f, -0.687038f), + new Vector3(-0.77217001f, 0.08309f, -0.62996f), + new Vector3(-0.669819f, -0.119533f, -0.73284f), + new Vector3(-0.54045498f, -0.31844401f, -0.77878201f), + new Vector3(-0.38613501f, -0.522789f, -0.75999397f), + new Vector3(-0.261466f, -0.68856698f, -0.676395f), + new Vector3(-0.019412f, -0.69610298f, -0.71767998f), + new Vector3(0.30356899f, -0.48184401f, -0.82199299f), + new Vector3(0.68193901f, -0.19512901f, -0.70490003f), + new Vector3(-0.24488901f, -0.116562f, -0.96251899f), + new Vector3(0.80075902f, -0.022979001f, -0.59854603f), + new Vector3(-0.37027499f, 0.095583998f, -0.92399102f), + new Vector3(-0.33067101f, -0.32657799f, -0.88543999f), + new Vector3(-0.16322f, -0.52757901f, -0.83367902f), + new Vector3(0.12639f, -0.313146f, -0.941257f), + new Vector3(0.34954801f, -0.27222601f, -0.89649802f), + new Vector3(0.23991799f, -0.085825004f, -0.96699202f), + new Vector3(0.390845f, 0.081537001f, -0.91683799f), + new Vector3(0.25526699f, 0.26869699f, -0.92878503f), + new Vector3(0.146245f, 0.48043799f, -0.86474901f), + new Vector3(-0.32601601f, 0.47845599f, -0.81534898f), + new Vector3(-0.46968201f, -0.112519f, -0.87563598f), + new Vector3(0.81844002f, -0.25852001f, -0.51315099f), + new Vector3(-0.474318f, 0.292238f, -0.83043301f), + new Vector3(0.778943f, 0.39584199f, -0.48637101f), + new Vector3(0.62409401f, 0.39377299f, -0.67487001f), + new Vector3(0.74088597f, 0.203834f, -0.63995302f), + new Vector3(0.48021701f, 0.565768f, -0.67029703f), + new Vector3(0.38093001f, 0.42453501f, -0.82137799f), + new Vector3(-0.093422003f, 0.50112402f, -0.86031801f), + new Vector3(-0.236485f, 0.29619801f, -0.92538702f), + new Vector3(-0.131531f, 0.093959004f, -0.98684901f), + new Vector3(-0.82356203f, 0.29577699f, -0.48400599f), + new Vector3(0.61106598f, -0.624304f, -0.486664f), + new Vector3(0.069495998f, -0.52033001f, -0.85113299f), + new Vector3(0.226522f, -0.66487902f, -0.711775f), + new Vector3(0.47130799f, -0.56890398f, -0.67395699f), + new Vector3(0.38842499f, -0.74262398f, -0.54556f), + new Vector3(0.78367501f, -0.48072901f, -0.39338499f), + new Vector3(0.962394f, 0.135676f, -0.235349f), + new Vector3(0.876607f, 0.172034f, -0.449406f), + new Vector3(0.63340503f, 0.58979303f, -0.50094098f), + new Vector3(0.182276f, 0.80065799f, -0.57072097f), + new Vector3(0.177003f, 0.76413399f, 0.62029701f), + new Vector3(-0.544016f, 0.675515f, -0.49772099f), + new Vector3(-0.67929697f, 0.28646699f, -0.67564201f), + new Vector3(-0.59039098f, 0.091369003f, -0.801929f), + new Vector3(-0.82436001f, -0.13312399f, -0.55018902f), + new Vector3(-0.71579403f, -0.33454201f, -0.61296099f), + new Vector3(0.17428599f, -0.89248401f, 0.416049f), + new Vector3(-0.082528003f, -0.83712298f, -0.54075301f), + new Vector3(0.28333101f, -0.88087398f, -0.37918901f), + new Vector3(0.675134f, -0.42662701f, -0.60181701f), + new Vector3(0.84372002f, -0.512335f, -0.160156f), + new Vector3(0.97730398f, -0.098555997f, -0.18752f), + new Vector3(0.846295f, 0.522672f, -0.102947f), + new Vector3(0.67714101f, 0.72132498f, -0.145501f), + new Vector3(0.32096499f, 0.87089199f, -0.37219399f), + new Vector3(-0.178978f, 0.911533f, -0.37023601f), + new Vector3(-0.44716901f, 0.82670099f, -0.341474f), + new Vector3(-0.70320302f, 0.496328f, -0.50908101f), + new Vector3(-0.97718102f, 0.063562997f, -0.202674f), + new Vector3(-0.87817001f, -0.412938f, 0.241455f), + new Vector3(-0.83583099f, -0.35855001f, -0.415728f), + new Vector3(-0.499174f, -0.69343299f, -0.51959199f), + new Vector3(-0.188789f, -0.92375302f, -0.33322501f), + new Vector3(0.19225401f, -0.96936101f, -0.152896f), + new Vector3(0.51594001f, -0.783907f, -0.34539199f), + new Vector3(0.90592498f, -0.30095199f, -0.29787099f), + new Vector3(0.99111199f, -0.127746f, 0.037106998f), + new Vector3(0.99513501f, 0.098424003f, -0.0043830001f), + new Vector3(0.76012301f, 0.64627701f, 0.067367002f), + new Vector3(0.205221f, 0.95958f, -0.192591f), + new Vector3(-0.042750001f, 0.97951299f, -0.19679099f), + new Vector3(-0.43801701f, 0.89892697f, 0.0084920004f), + new Vector3(-0.82199401f, 0.48078501f, -0.30523899f), + new Vector3(-0.89991701f, 0.081710003f, -0.42833701f), + new Vector3(-0.92661202f, -0.144618f, -0.347096f), + new Vector3(-0.79365999f, -0.55779201f, -0.24283899f), + new Vector3(-0.43134999f, -0.84777898f, -0.30855799f), + new Vector3(-0.0054919999f, -0.96499997f, 0.26219299f), + new Vector3(0.58790499f, -0.80402601f, -0.088940002f), + new Vector3(0.69949299f, -0.66768599f, -0.254765f), + new Vector3(0.88930303f, 0.359795f, -0.282291f), + new Vector3(0.780972f, 0.197037f, 0.59267199f), + new Vector3(0.52012098f, 0.50669599f, 0.68755698f), + new Vector3(0.40389499f, 0.69396102f, 0.59605998f), + new Vector3(-0.154983f, 0.89923602f, 0.40909001f), + new Vector3(-0.65733802f, 0.53716803f, 0.528543f), + new Vector3(-0.74619502f, 0.33409101f, 0.575827f), + new Vector3(-0.62495202f, -0.049144f, 0.77911502f), + new Vector3(0.31814101f, -0.254715f, 0.913185f), + new Vector3(-0.555897f, 0.405294f, 0.725752f), + new Vector3(-0.79443401f, 0.099405997f, 0.59916002f), + new Vector3(-0.64036101f, -0.68946302f, 0.33849499f), + new Vector3(-0.12671299f, -0.73409498f, 0.66711998f), + new Vector3(0.105457f, -0.78081697f, 0.61579502f), + new Vector3(0.40799299f, -0.48091599f, 0.77605498f), + new Vector3(0.69513601f, -0.54512f, 0.468647f), + new Vector3(0.97319102f, -0.0064889998f, 0.229908f), + new Vector3(0.94689399f, 0.317509f, -0.050799001f), + new Vector3(0.56358302f, 0.82561201f, 0.027183f), + new Vector3(0.325773f, 0.94542301f, 0.0069490001f), + new Vector3(-0.171821f, 0.98509699f, -0.0078149997f), + new Vector3(-0.67044097f, 0.73993897f, 0.054768998f), + new Vector3(-0.822981f, 0.55496198f, 0.121322f), + new Vector3(-0.96619302f, 0.117857f, 0.229307f), + new Vector3(-0.95376903f, -0.29470399f, 0.058945f), + new Vector3(-0.86438698f, -0.50272799f, -0.010015f), + new Vector3(-0.53060901f, -0.84200603f, -0.097365998f), + new Vector3(-0.162618f, -0.98407501f, 0.071772002f), + new Vector3(0.081446998f, -0.99601102f, 0.036439002f), + new Vector3(0.74598402f, -0.66596299f, 0.00076199998f), + new Vector3(0.94205701f, -0.32926899f, -0.064106002f), + new Vector3(0.93970197f, -0.28108999f, 0.194803f), + new Vector3(0.77121401f, 0.55067003f, 0.319363f), + new Vector3(0.641348f, 0.73069f, 0.23402099f), + new Vector3(0.080682002f, 0.99669099f, 0.0098789996f), + new Vector3(-0.046725001f, 0.97664303f, 0.20972501f), + new Vector3(-0.53107601f, 0.82100099f, 0.209562f), + new Vector3(-0.69581503f, 0.65599f, 0.29243499f), + new Vector3(-0.97612202f, 0.216709f, -0.014913f), + new Vector3(-0.96166098f, -0.14412899f, 0.23331399f), + new Vector3(-0.772084f, -0.61364698f, 0.165299f), + new Vector3(-0.44960001f, -0.83605999f, 0.314426f), + new Vector3(-0.39269999f, -0.91461599f, 0.096247002f), + new Vector3(0.390589f, -0.91947001f, 0.044890001f), + new Vector3(0.58252901f, -0.79919797f, 0.148127f), + new Vector3(0.866431f, -0.48981199f, 0.096864f), + new Vector3(0.90458697f, 0.111498f, 0.41145f), + new Vector3(0.95353699f, 0.23232999f, 0.191806f), + new Vector3(0.497311f, 0.77080297f, 0.398177f), + new Vector3(0.194066f, 0.95631999f, 0.218611f), + new Vector3(0.422876f, 0.882276f, 0.206797f), + new Vector3(-0.373797f, 0.84956598f, 0.37217399f), + new Vector3(-0.53449702f, 0.71402299f, 0.4522f), + new Vector3(-0.881827f, 0.23716f, 0.40759799f), + new Vector3(-0.904948f, -0.014069f, 0.42528901f), + new Vector3(-0.751827f, -0.51281703f, 0.41445801f), + new Vector3(-0.50101501f, -0.69791698f, 0.51175803f), + new Vector3(-0.23519f, -0.92592299f, 0.295555f), + new Vector3(0.228983f, -0.95393997f, 0.193819f), + new Vector3(0.734025f, -0.63489801f, 0.241062f), + new Vector3(0.91375297f, -0.063253f, -0.40131599f), + new Vector3(0.90573502f, -0.161487f, 0.391875f), + new Vector3(0.85892999f, 0.342446f, 0.38074899f), + new Vector3(0.62448603f, 0.60758102f, 0.49077699f), + new Vector3(0.28926399f, 0.85747898f, 0.42550799f), + new Vector3(0.069968f, 0.90216899f, 0.42567101f), + new Vector3(-0.28617999f, 0.94069999f, 0.182165f), + new Vector3(-0.57401299f, 0.80511898f, -0.14930899f), + new Vector3(0.111258f, 0.099717997f, -0.98877603f), + new Vector3(-0.30539301f, -0.94422799f, -0.12316f), + new Vector3(-0.60116601f, -0.78957599f, 0.123163f), + new Vector3(-0.290645f, -0.81213999f, 0.50591898f), + new Vector3(-0.064920001f, -0.87716299f, 0.47578499f), + new Vector3(0.408301f, -0.862216f, 0.29978901f), + new Vector3(0.56609702f, -0.72556603f, 0.39126399f), + new Vector3(0.83936399f, -0.427387f, 0.33586901f), + new Vector3(0.81889999f, -0.041305002f, 0.57244802f), + new Vector3(0.71978402f, 0.41499701f, 0.55649698f), + new Vector3(0.88174403f, 0.45027f, 0.140659f), + new Vector3(0.40182301f, -0.89822f, -0.17815199f), + new Vector3(-0.054019999f, 0.79134399f, 0.60898f), + new Vector3(-0.29377401f, 0.76399398f, 0.57446498f), + new Vector3(-0.450798f, 0.61034697f, 0.65135098f), + new Vector3(-0.63822103f, 0.186694f, 0.74687302f), + new Vector3(-0.87287003f, -0.25712699f, 0.41470799f), + new Vector3(-0.58725703f, -0.52170998f, 0.618828f), + new Vector3(-0.35365799f, -0.64197397f, 0.680291f), + new Vector3(0.041648999f, -0.61127299f, 0.79032302f), + new Vector3(0.348342f, -0.77918297f, 0.52108699f), + new Vector3(0.499167f, -0.62244099f, 0.602826f), + new Vector3(0.79001898f, -0.30383101f, 0.53250003f), + new Vector3(0.66011798f, 0.060733002f, 0.74870199f), + new Vector3(0.60492098f, 0.29416099f, 0.73996001f), + new Vector3(0.38569701f, 0.37934601f, 0.84103203f), + new Vector3(0.239693f, 0.207876f, 0.94833201f), + new Vector3(0.012623f, 0.25853199f, 0.96591997f), + new Vector3(-0.100557f, 0.457147f, 0.88368797f), + new Vector3(0.046967f, 0.62858802f, 0.77631903f), + new Vector3(-0.43039101f, -0.44540501f, 0.785097f), + new Vector3(-0.43429101f, -0.196228f, 0.87913901f), + new Vector3(-0.25663701f, -0.336867f, 0.90590203f), + new Vector3(-0.131372f, -0.15891001f, 0.97851402f), + new Vector3(0.102379f, -0.208767f, 0.972592f), + new Vector3(0.195687f, -0.450129f, 0.87125802f), + new Vector3(0.62731898f, -0.42314801f, 0.65377098f), + new Vector3(0.68743902f, -0.171583f, 0.70568198f), + new Vector3(0.27592f, -0.021255f, 0.96094602f), + new Vector3(0.45936701f, 0.15746599f, 0.87417799f), + new Vector3(0.285395f, 0.583184f, 0.76055598f), + new Vector3(-0.81217402f, 0.46030301f, 0.35846099f), + new Vector3(-0.189068f, 0.64122301f, 0.743698f), + new Vector3(-0.338875f, 0.47648001f, 0.811252f), + new Vector3(-0.92099398f, 0.347186f, 0.176727f), + new Vector3(0.040638998f, 0.024465f, 0.99887401f), + new Vector3(-0.73913199f, -0.35374701f, 0.57318997f), + new Vector3(-0.60351199f, -0.28661501f, 0.74405998f), + new Vector3(-0.188676f, -0.547059f, 0.81555402f), + new Vector3(-0.026045f, -0.39782f, 0.91709399f), + new Vector3(0.26789701f, -0.649041f, 0.71202302f), + new Vector3(0.518246f, -0.28489101f, 0.80638599f), + new Vector3(0.493451f, -0.066532999f, 0.86722499f), + new Vector3(-0.328188f, 0.140251f, 0.93414301f), + new Vector3(0.328188f, 0.140251f, 0.93414301f), + new Vector3(-0.328188f, 0.140251f, 0.93414301f), + new Vector3(-0.328188f, 0.140251f, 0.93414301f), + new Vector3(-0.328188f, 0.140251f, 0.93414301f), + }; + + #endregion + +} \ No newline at end of file diff --git a/src/MapEditorLibrary/Configuration/BrushSize.cs b/src/MapEditorLibrary/Configuration/BrushSize.cs new file mode 100644 index 000000000..a2844f6bd --- /dev/null +++ b/src/MapEditorLibrary/Configuration/BrushSize.cs @@ -0,0 +1,63 @@ +using MapEditorLibrary.GameMath; + +namespace MapEditorLibrary.Configuration; + +public class BrushSize +{ + public BrushSize(int width, int height) + { + Width = width; + Height = height; + } + + public int Width { get; } + public int Height { get; } + public int Max => Math.Max(Width, Height); + + public Point2D CenterWithinBrush(Point2D point) => new Point2D(point.X - (Width / 2), point.Y - (Height / 2)); + + public void DoForBrushSize(Action action) + { + DoForArea(0, 0, Height, Width, action); + } + + public bool CheckForAnyCellInBrushArea(Func checker) + { + return CheckForAnyCellInArea(0, 0, Height, Width, checker); + } + + public void DoForBrushSizeAndSurroundings(Action action) + { + DoForArea(-1, -1, Height + 1, Width + 1, action); + } + + private void DoForArea(int initY, int initX, int height, int width, Action action) + { + for (int y = initY; y < height; y++) + { + for (int x = initX; x < width; x++) + { + action(new Point2D(x, y)); + } + } + } + + private bool CheckForAnyCellInArea(int initY, int initX, int height, int width, Func checker) + { + for (int y = initY; y < height; y++) + { + for (int x = initX; x < width; x++) + { + if (checker(new Point2D(x, y))) + return true; + } + } + + return false; + } + + public override string ToString() + { + return Width + "x" + Height; + } +} diff --git a/src/MapEditorLibrary/Configuration/ObjectTypeCollection.cs b/src/MapEditorLibrary/Configuration/ObjectTypeCollection.cs new file mode 100644 index 000000000..9dad051e8 --- /dev/null +++ b/src/MapEditorLibrary/Configuration/ObjectTypeCollection.cs @@ -0,0 +1,17 @@ +namespace MapEditorLibrary.Configuration; + +public abstract class ObjectTypeCollection +{ + public string Name { get; set; } + public string UIName { get; set; } + + public List AllowedTheaters { get; set; } + + public bool IsValidForTheater(string theaterName) + { + if (AllowedTheaters == null || AllowedTheaters.Count == 0) + return true; + + return AllowedTheaters.Exists(t => t.Equals(theaterName, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/MapEditorLibrary/Configuration/OverlayCollection.cs b/src/MapEditorLibrary/Configuration/OverlayCollection.cs new file mode 100644 index 000000000..ed3cb27e8 --- /dev/null +++ b/src/MapEditorLibrary/Configuration/OverlayCollection.cs @@ -0,0 +1,74 @@ +using MapEditorLibrary.Misc; +using MapEditorLibrary.Models; +using Rampastring.Tools; + +namespace MapEditorLibrary.Configuration; + +/// +/// Combines many overlays into a single entry. +/// +public class OverlayCollection : ObjectTypeCollection +{ + public struct OverlayCollectionEntry + { + public OverlayType OverlayType; + public int Frame; + + public OverlayCollectionEntry(OverlayType overlayType, int frame) + { + OverlayType = overlayType; + Frame = frame; + } + } + + public OverlayCollectionEntry[] Entries; + + public static OverlayCollection InitFromIniSection(IniSection iniSection, List overlayTypes) + { + var overlayCollection = new OverlayCollection(); + overlayCollection.Name = iniSection.GetStringValue("Name", "Unnamed Collection"); + overlayCollection.UIName = Translate(overlayCollection, overlayCollection.Name, overlayCollection.Name); + overlayCollection.AllowedTheaters = iniSection.GetListValue("AllowedTheaters", ',', s => s); + + var entryList = new List(); + + int i = 0; + while (true) + { + string value = iniSection.GetStringValue("OverlayType" + i, null); + if (string.IsNullOrWhiteSpace(value)) + break; + + string[] parts = value.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + string overlayTypeName; + int frame = 0; + if (parts.Length == 1) + { + overlayTypeName = value; + } + else + { + overlayTypeName = parts[0]; + frame = Conversions.IntFromString(parts[1], -1); + } + + var overlayType = overlayTypes.Find(o => o.ININame == overlayTypeName); + if (overlayType == null) + { + throw new INIConfigException($"Overlay type \"{overlayTypeName}\" not found while initializing overlay collection \"{overlayCollection.Name}\"!"); + } + + if (frame < 0) + { + throw new INIConfigException($"Frame below zero defined in entry #{i} in overlay collection \"{overlayCollection.Name}\"!"); + } + + entryList.Add(new OverlayCollectionEntry(overlayType, frame)); + + i++; + } + + overlayCollection.Entries = entryList.ToArray(); + return overlayCollection; + } +} diff --git a/src/MapEditorLibrary/Configuration/SmudgeCollection.cs b/src/MapEditorLibrary/Configuration/SmudgeCollection.cs new file mode 100644 index 000000000..43253314c --- /dev/null +++ b/src/MapEditorLibrary/Configuration/SmudgeCollection.cs @@ -0,0 +1,54 @@ +using MapEditorLibrary.Misc; +using MapEditorLibrary.Models; +using Rampastring.Tools; + +namespace MapEditorLibrary.Configuration; + +/// +/// Combines many smudges into a single entry. +/// +public class SmudgeCollection : ObjectTypeCollection +{ + public struct SmudgeCollectionEntry + { + public SmudgeType SmudgeType; + + public SmudgeCollectionEntry(SmudgeType smudgeType) + { + SmudgeType = smudgeType; + } + } + + public SmudgeCollectionEntry[] Entries; + + public static SmudgeCollection InitFromIniSection(IniSection iniSection, List smudgeTypes) + { + var smudgeCollection = new SmudgeCollection(); + smudgeCollection.Name = iniSection.GetStringValue("Name", "Unnamed Collection"); + smudgeCollection.UIName = Translate(smudgeCollection, smudgeCollection.Name, smudgeCollection.Name); + smudgeCollection.AllowedTheaters = iniSection.GetListValue("AllowedTheaters", ',', s => s); + + var entryList = new List(); + + int i = 0; + while (true) + { + string smudgeTypeName = iniSection.GetStringValue("SmudgeType" + i, null); + if (string.IsNullOrWhiteSpace(smudgeTypeName)) + break; + + var smudgeType = smudgeTypes.Find(o => o.ININame == smudgeTypeName); + if (smudgeType == null) + { + throw new INIConfigException($"Smudge type \"{smudgeTypeName}\" not found while initializing smudge collection \"{smudgeCollection.Name}\"!"); + } + + entryList.Add(new SmudgeCollectionEntry(smudgeType)); + + i++; + } + + smudgeCollection.Entries = entryList.ToArray(); + return smudgeCollection; + } +} diff --git a/src/MapEditorLibrary/Configuration/TerrainGeneratorUserPresets.cs b/src/MapEditorLibrary/Configuration/TerrainGeneratorUserPresets.cs new file mode 100644 index 000000000..11115befb --- /dev/null +++ b/src/MapEditorLibrary/Configuration/TerrainGeneratorUserPresets.cs @@ -0,0 +1,117 @@ +using MapEditorLibrary.Models; +using MapEditorLibrary.Mutations.Classes; +using Rampastring.Tools; +using System.Globalization; + +namespace MapEditorLibrary.Configuration; + +public class TerrainGeneratorUserPresets +{ + public TerrainGeneratorUserPresets(Map map) + { + this.map = map; + } + + private readonly Map map; + + private const string ConfigFileName = "TerrainGeneratorUserPresets.ini"; + + private List configurations = new List(); + + public List GetConfigurationsForCurrentTheater() + => configurations.FindAll(c => c.Theater.Equals(map.LoadedTheaterName, StringComparison.OrdinalIgnoreCase)); + + private bool isDirty; + + public void Load() + { + string path = GetConfigFilePath(); + if (!File.Exists(path)) + return; + + var iniFile = new IniFile(path); + int i = 0; + while (true) + { + string sectionName = "Preset" + i.ToString(CultureInfo.InvariantCulture); + var iniSection = iniFile.GetSection(sectionName); + if (iniSection == null) + break; + + var config = TerrainGeneratorConfiguration.FromConfigSection(iniSection, map.Rules, map.TheaterInstance.Theater, true); + + if (config != null) + configurations.Add(config); + else + Logger.Log($"Failed to load terrain generator config from user preset #{i}!"); + + i++; + } + } + + public bool SaveIfDirty() + { + if (!isDirty) + return true; + + return ForceSave(); + } + + public bool ForceSave() + { + string path = GetConfigFilePath(); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + } + catch (IOException ex) + { + Logger.Log("IOException while trying to create directories for terrain generator user presets file! Returned error: " + ex.Message); + return false; + } + + IniFile iniFile = new IniFile(); + + int i = 0; + configurations.ForEach(c => + { + iniFile.AddSection(c.GetIniConfigSection("Preset" + i.ToString(CultureInfo.InvariantCulture))); + i++; + }); + + try + { + iniFile.WriteIniFile(path); + } + catch (IOException ex) + { + Logger.Log("IOException while saving terrain generator presets! Returned error: " + ex.Message); + return false; + } + + isDirty = false; + return true; + } + + public void DeleteConfig(string name) + { + int index = configurations.FindIndex(c => c.Name == name && c.Theater.Equals(map.LoadedTheaterName, StringComparison.OrdinalIgnoreCase)); + if (index > -1) + { + configurations.RemoveAt(index); + isDirty = true; + } + } + + public void AddConfig(TerrainGeneratorConfiguration configuration) + { + if (configurations.Exists(c => c.Name == configuration.Name && c.Theater.Equals(map.LoadedTheaterName, StringComparison.OrdinalIgnoreCase))) + throw new ArgumentException($"A configuration with the name {configuration.Name} already exists!"); + + configurations.Add(configuration); + isDirty = true; + } + + private string GetConfigFilePath() => Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), Constants.UserDataFolder, ConfigFileName); +} diff --git a/src/MapEditorLibrary/Configuration/TerrainObjectCollection.cs b/src/MapEditorLibrary/Configuration/TerrainObjectCollection.cs new file mode 100644 index 000000000..c9b46d1e5 --- /dev/null +++ b/src/MapEditorLibrary/Configuration/TerrainObjectCollection.cs @@ -0,0 +1,54 @@ +using MapEditorLibrary.Misc; +using MapEditorLibrary.Models; +using Rampastring.Tools; + +namespace MapEditorLibrary.Configuration; + +/// +/// Combines many terrain objects into a single entry. +/// +public class TerrainObjectCollection : ObjectTypeCollection +{ + public struct TerrainObjectCollectionEntry + { + public TerrainType TerrainType; + + public TerrainObjectCollectionEntry(TerrainType terrainType) + { + TerrainType = terrainType; + } + } + + public TerrainObjectCollectionEntry[] Entries; + + public static TerrainObjectCollection InitFromIniSection(IniSection iniSection, List terrainTypes) + { + var terrainObjectCollection = new TerrainObjectCollection(); + terrainObjectCollection.Name = iniSection.GetStringValue("Name", "Unnamed Collection"); + terrainObjectCollection.UIName = Translate(terrainObjectCollection, terrainObjectCollection.Name, terrainObjectCollection.Name); + terrainObjectCollection.AllowedTheaters = iniSection.GetListValue("AllowedTheaters", ',', s => s); + + var entryList = new List(); + + int i = 0; + while (true) + { + string terrainTypeName = iniSection.GetStringValue("TerrainObjectType" + i, null); + if (string.IsNullOrWhiteSpace(terrainTypeName)) + break; + + var terrainType = terrainTypes.Find(o => o.ININame == terrainTypeName); + if (terrainType == null) + { + throw new INIConfigException($"Terrain object type \"{terrainTypeName}\" not found while initializing terrain object collection \"{terrainObjectCollection.Name}\"!"); + } + + entryList.Add(new TerrainObjectCollectionEntry(terrainType)); + + i++; + } + + terrainObjectCollection.Entries = entryList.ToArray(); + return terrainObjectCollection; + } +} diff --git a/src/MapEditorLibrary/Constants.cs b/src/MapEditorLibrary/Constants.cs new file mode 100644 index 000000000..cbb29e713 --- /dev/null +++ b/src/MapEditorLibrary/Constants.cs @@ -0,0 +1,202 @@ +using Rampastring.Tools; +using System.Reflection; + +namespace MapEditorLibrary; + +public static class Constants +{ + public static string Version => Assembly.GetExecutingAssembly().GetName().Version.ToString(3); + + public static int CellSizeX = 48; + public static int CellSizeY = 24; + public const int CellSizeInLeptons = 256; + public static int CellHeight => CellSizeY / 2; + public static int HighBridgeHeight = 4; + public static int TileColorBufferSize = 576; + + public static int RenderPixelPadding = 50; + + public static bool IsFlatWorld = false; + public static bool TheaterPaletteForTiberium = false; + public static bool TheaterPaletteForVeins = false; + public static bool TiberiumAffectedByLighting = false; + public static bool TiberiumTreesAffectedByLighting = false; + public static bool TerrainPaletteBuildingsAffectedByLighting = false; + public static bool VoxelsAffectedByLighting = false; + public static bool NewTheaterGenericBuilding = false; + public static bool DrawBuildingAnimationShadows = false; + public static bool IsRA2YR = false; + public static bool WarnOfTooManyTriggerActions = true; + public static bool DefaultPreview = false; + + public static string[] ExpectedClientExecutableNames = new string[] { "DTA.exe" }; + public static string GameRegistryInstallPath = "SOFTWARE\\DawnOfTheTiberiumAge"; + public static string OpenFileDialogFilter = "TS maps|*.map|All files|*.*"; + + public static bool EnableIniInclude = false; + public static bool EnableIniInheritance = false; + + public static bool IntegerVariables = false; + + public static string RulesIniPath; + public static string FirestormIniPath; + public static string ArtIniPath; + public static string FirestormArtIniPath; + public static string AIIniPath; + public static string FirestormAIIniPath; + public static string TutorialIniPath; + public static string ThemeIniPath; + public static string EvaIniPath; + public static string SoundIniPath; + + public const int TextureSizeLimit = 16384; + + public static int MaxMapWidth; + public static int MaxMapHeight; + + public const byte MaxMapHeightLevel = 12; + public static int MapYBaseline => MaxMapHeightLevel * CellHeight; + + public static int MaxWaypoint = 100; + + public const int ObjectHealthMax = 256; + public const int FacingMax = 255; + + public const int TurretFrameCount = 32; + + // TODO parse from Rules.ini + public const int ConditionYellowHP = 128; + + public const int VeterancyElite = 200; + public const int VeterancyVeteran = 100; + + public const int UIEmptySideSpace = 10; + public const int UIEmptyTopSpace = 10; + public const int UIEmptyBottomSpace = 10; + + public const int UIHorizontalSpacing = 6; + public const int UIVerticalSpacing = 6; + + public const int UIDefaultFont = 0; + public const int UIBoldFont = 1; + + public const int UITextBoxHeight = 21; + public const int UIButtonHeight = 23; + + public const int UITopBarMenuHeight = 23; + + public static int UITreeViewLineHeight = 20; + + public static double UIAccidentalClickPreventionTime = 0.2; + + public static int MapPreviewMaxWidth = 800; + public static int MapPreviewMaxHeight = 400; + + public static int MaxHouseTechLevel = 10; + + public const int MAX_MAP_LENGTH_IN_DIMENSION = 512; + public const int NO_OVERLAY = -1; + public const int OverlayPackFormat = 80; + + public const string NoneValue1 = ""; + public const string NoneValue2 = "None"; + + public const float RemapBrightenFactor = 1.25f; + + // The resolution of depth rendering. In other words, the minimum depth difference that is significant enough to have an impact on rendering order. + public const float DepthEpsilon = 1e-5f; + + // Depth is between 0.0 and 1.0. How much of the scale is reserved for depth increasing as we go southwards on the map. + public const float DownwardsDepthRenderSpace = 0.90f; + + // How much of the depth scale (0.0 to 1.0) is reserved for depth increasing as we go up the map height levels. + // Calculated dynamically. + public static float DepthRenderStep = 0; + + public const string ClipboardMapDataFormatValue = "ScenarioEditorCopiedMapData"; + public const string ClipboardTriggerActionEventFormatValue = "ScenarioEditorCopiedTriggerData"; + public const string ClipboardTriggerFormatValue = "ScenarioEditorCopiedTrigger"; + public const string UserDataFolder = "UserData"; + + public const char NewTheaterGenericLetter = 'G'; + + public const string VeinholeMonsterTypeName = "VEINHOLE"; + public const string VeinholeDummyTypeName = "VEINHOLEDUMMY"; + + public const int MultiplayerMaxPlayers = 8; + + public const int TS_WAYPT_SPECIAL = 100; + + public const string DefaultHouseTypeName = "Neutral"; + + public static void Init() + { + const string ConstantsSectionName = "Constants"; + const string FilePathsSectionName = "FilePaths"; + + IniFile constantsIni = Helpers.ReadConfigINI("Constants.ini"); + + CellSizeX = constantsIni.GetIntValue(ConstantsSectionName, nameof(CellSizeX), CellSizeX); + MaxMapWidth = TextureSizeLimit / CellSizeX; + CellSizeY = constantsIni.GetIntValue(ConstantsSectionName, nameof(CellSizeY), CellSizeY); + MaxMapHeight = TextureSizeLimit / CellSizeY; + + TileColorBufferSize = constantsIni.GetIntValue(ConstantsSectionName, nameof(TileColorBufferSize), TileColorBufferSize); + + RenderPixelPadding = constantsIni.GetIntValue(ConstantsSectionName, nameof(RenderPixelPadding), RenderPixelPadding); + + IsFlatWorld = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(IsFlatWorld), IsFlatWorld); + TheaterPaletteForTiberium = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(TheaterPaletteForTiberium), TheaterPaletteForTiberium); + TheaterPaletteForVeins = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(TheaterPaletteForVeins), TheaterPaletteForVeins); + TiberiumAffectedByLighting = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(TiberiumAffectedByLighting), TiberiumAffectedByLighting); + TiberiumTreesAffectedByLighting = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(TiberiumTreesAffectedByLighting), TiberiumTreesAffectedByLighting); + TerrainPaletteBuildingsAffectedByLighting = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(TerrainPaletteBuildingsAffectedByLighting), TerrainPaletteBuildingsAffectedByLighting); + VoxelsAffectedByLighting = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(VoxelsAffectedByLighting), VoxelsAffectedByLighting); + NewTheaterGenericBuilding = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(NewTheaterGenericBuilding), NewTheaterGenericBuilding); + DrawBuildingAnimationShadows = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(DrawBuildingAnimationShadows), DrawBuildingAnimationShadows); + IsRA2YR = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(IsRA2YR), IsRA2YR); + WarnOfTooManyTriggerActions = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(WarnOfTooManyTriggerActions), WarnOfTooManyTriggerActions); + DefaultPreview = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(DefaultPreview), DefaultPreview); + + // Check two keys for backwards compatibility + if (constantsIni.KeyExists(ConstantsSectionName, "ExpectedClientExecutableName")) + ExpectedClientExecutableNames = constantsIni.GetSection(ConstantsSectionName).GetListValue("ExpectedClientExecutableName", ',', s => s).ToArray(); + else + ExpectedClientExecutableNames = constantsIni.GetSection(ConstantsSectionName).GetListValue(nameof(ExpectedClientExecutableNames), ',', s => s).ToArray(); + + GameRegistryInstallPath = constantsIni.GetStringValue(ConstantsSectionName, nameof(GameRegistryInstallPath), GameRegistryInstallPath); + OpenFileDialogFilter = constantsIni.GetStringValue(ConstantsSectionName, nameof(OpenFileDialogFilter), OpenFileDialogFilter); + + EnableIniInclude = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(EnableIniInclude), EnableIniInclude); + EnableIniInheritance = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(EnableIniInheritance), EnableIniInheritance); + + IntegerVariables = constantsIni.GetBooleanValue(ConstantsSectionName, nameof(IntegerVariables), IntegerVariables); + + MaxWaypoint = constantsIni.GetIntValue(ConstantsSectionName, nameof(MaxWaypoint), MaxWaypoint); + + MapPreviewMaxWidth = constantsIni.GetIntValue(ConstantsSectionName, nameof(MapPreviewMaxWidth), MapPreviewMaxWidth); + MapPreviewMaxHeight = constantsIni.GetIntValue(ConstantsSectionName, nameof(MapPreviewMaxHeight), MapPreviewMaxHeight); + + MaxHouseTechLevel = constantsIni.GetIntValue(ConstantsSectionName, nameof(MaxHouseTechLevel), MaxHouseTechLevel); + + RulesIniPath = constantsIni.GetStringValue(FilePathsSectionName, "Rules", "INI/Rules.ini"); + FirestormIniPath = constantsIni.GetStringValue(FilePathsSectionName, "Firestorm", "INI/Enhance.ini"); + ArtIniPath = constantsIni.GetStringValue(FilePathsSectionName, "Art", "INI/Art.ini"); + FirestormArtIniPath = constantsIni.GetStringValue(FilePathsSectionName, "ArtFS", "INI/ArtE.ini"); + AIIniPath = constantsIni.GetStringValue(FilePathsSectionName, "AI", "INI/AI.ini"); + FirestormAIIniPath = constantsIni.GetStringValue(FilePathsSectionName, "AIFS", "INI/AIE.ini"); + TutorialIniPath = constantsIni.GetStringValue(FilePathsSectionName, "Tutorial", "INI/Tutorial.ini"); + ThemeIniPath = constantsIni.GetStringValue(FilePathsSectionName, "Theme", "INI/Theme.ini"); + EvaIniPath = constantsIni.GetStringValue(FilePathsSectionName, "EVA", "INI/Eva.ini"); + SoundIniPath = constantsIni.GetStringValue(FilePathsSectionName, "Sound", "INI/Sound01.ini"); + + InitUIConstants(); + } + + public static void InitUIConstants() + { + IniFile uiConstantsIni = Helpers.ReadConfigINI("UI/UIConstants.ini"); + + UITreeViewLineHeight = uiConstantsIni.GetIntValue("UI", nameof(UITreeViewLineHeight), UITreeViewLineHeight); + } +} diff --git a/src/TSMapEditor/Extensions/IniFileEx.cs b/src/MapEditorLibrary/Extensions/IniFileEx.cs similarity index 97% rename from src/TSMapEditor/Extensions/IniFileEx.cs rename to src/MapEditorLibrary/Extensions/IniFileEx.cs index db579863f..b23542a34 100644 --- a/src/TSMapEditor/Extensions/IniFileEx.cs +++ b/src/MapEditorLibrary/Extensions/IniFileEx.cs @@ -1,10 +1,8 @@ -using System.IO; -using System.Text; -using System.Collections.Generic; +using System.Text; using Rampastring.Tools; -using TSMapEditor.CCEngine; +using MapEditorLibrary.CCEngine; -namespace TSMapEditor.Extensions; +namespace MapEditorLibrary.Extensions; /// /// IniFile with support for Ares #include and Phobos $Include and $Inherits. diff --git a/src/MapEditorLibrary/Extensions/ListExtensions.cs b/src/MapEditorLibrary/Extensions/ListExtensions.cs new file mode 100644 index 000000000..8b9154a30 --- /dev/null +++ b/src/MapEditorLibrary/Extensions/ListExtensions.cs @@ -0,0 +1,161 @@ +using MapEditorLibrary.Models; +using Rampastring.Tools; + +namespace MapEditorLibrary.Extensions; + +public static class ListExtensions +{ + public static void ReadTaskForces(this List taskForceList, IniFile iniFile, Rules rules, Action errorLogger) + { + var section = iniFile.GetSection("TaskForces"); + if (section == null) + return; + + foreach (var kvp in section.Keys) + { + if (string.IsNullOrWhiteSpace(kvp.Value)) + continue; + + var taskForce = TaskForce.ParseTaskForce(rules, iniFile.GetSection(kvp.Value), errorLogger); + if (taskForce == null) + { + errorLogger(string.Format(Translate("ListExtensions.TaskForceParseError", + "Failed to load TaskForce {0}. It might be missing a section or be otherwise invalid."), kvp.Value)); + + continue; + } + + int existingIndex = taskForceList.FindIndex(tf => tf.ININame == kvp.Value); + if (existingIndex > -1) + { + taskForceList[existingIndex] = taskForce; + } + else + { + taskForceList.Add(taskForce); + } + } + } + + public static void ReadScripts(this List