From d704ee9ea7f3753fb896bf0ba396dc7eeaf24432 Mon Sep 17 00:00:00 2001 From: Blackgamerz <105124395+BlackgamerzVN@users.noreply.github.com> Date: Thu, 9 Jul 2026 06:56:23 +0700 Subject: [PATCH 1/4] Add headless random map generation (RMG) mode - New --generate-map CLI flag (Program.cs) parses theater/width/height/players/seed/output args. - GameClass runs headless generation instead of the interactive UI when requested, hiding the window (still needed for GraphicsDevice/TheaterGraphics) and exiting with a status code. - New HeadlessMapGenerator builds a Map via Map.InitNew, loads TheaterGraphics, runs the existing TerrainGenerationMutation engine over the whole playable area, scatters basic Tiberium/ore resource fields, places N-way symmetric player-start waypoints, and writes the result via Map.AutoSave. - New HeadlessMutationTarget: minimal non-UI IMutationTarget implementation so TerrainGenerationMutation can be driven headlessly. - TerrainGenerationMutation now accepts an optional explicit seed (was previously always DateTime.Now-based) so headless generation can be deterministic. Proof-of-concept scope per plan.md: produces a structurally valid, loadable, symmetric map with resources and player start points. Not build-verified in this environment (no network access for NuGet restore + missing sibling INI Parser checkout); verified via careful manual signature matching against existing call sites (MapSetup.cs, TerrainGeneratorConfigWindow.cs). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Initialization/HeadlessMapGenerator.cs | 369 ++++++++++++++++++ .../Initialization/HeadlessMutationTarget.cs | 47 +++ .../Classes/TerrainGenerationMutation.cs | 4 +- src/TSMapEditor/Program.cs | 21 + src/TSMapEditor/Rendering/GameClass.cs | 29 ++ 5 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 src/TSMapEditor/Initialization/HeadlessMapGenerator.cs create mode 100644 src/TSMapEditor/Initialization/HeadlessMutationTarget.cs diff --git a/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs b/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs new file mode 100644 index 000000000..8b5424973 --- /dev/null +++ b/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Microsoft.Xna.Framework.Graphics; +using Rampastring.Tools; +using TSMapEditor.CCEngine; +using TSMapEditor.GameMath; +using TSMapEditor.Models; +using TSMapEditor.Mutations.Classes; +using TSMapEditor.Rendering; + +namespace TSMapEditor.Initialization +{ + /// + /// Parameters for headless, non-interactive random map generation. + /// + public class HeadlessRmgOptions + { + /// Path to the game's installation directory (contains Rules.ini / mix files). + public string GameDirectory { get; set; } + + /// UI name of the theater to generate the map in (e.g. "Temperate", "Urban", "Snow"). + public string Theater { get; set; } + + /// Map width, in cells. + public int Width { get; set; } + + /// Map height, in cells. + public int Height { get; set; } + + /// Number of players the map should support (and place fair start locations for). + public int PlayerCount { get; set; } + + /// Seed for deterministic random generation. If null, a random seed is used. + public int? Seed { get; set; } + + /// Path (including file name) that the generated .map file should be written to. + public string OutputPath { get; set; } + + public static HeadlessRmgOptions ParseArgs(string[] args) + { + var options = new HeadlessRmgOptions(); + + foreach (string arg in args) + { + if (!arg.StartsWith("--")) + continue; + + int separatorIndex = arg.IndexOf('='); + if (separatorIndex < 0) + continue; + + string key = arg.Substring(2, separatorIndex - 2).Trim().ToLowerInvariant(); + string value = arg.Substring(separatorIndex + 1).Trim(); + + switch (key) + { + case "game-directory": + options.GameDirectory = value; + break; + case "theater": + options.Theater = value; + break; + case "width": + options.Width = int.Parse(value, CultureInfo.InvariantCulture); + break; + case "height": + options.Height = int.Parse(value, CultureInfo.InvariantCulture); + break; + case "players": + options.PlayerCount = int.Parse(value, CultureInfo.InvariantCulture); + break; + case "seed": + options.Seed = int.Parse(value, CultureInfo.InvariantCulture); + break; + case "output": + options.OutputPath = value; + break; + } + } + + return options; + } + + /// + /// Validates that all required options were provided. Returns null if valid, + /// otherwise an error message describing the first problem found. + /// + public string Validate() + { + if (string.IsNullOrWhiteSpace(GameDirectory)) + return "Missing required argument: --game-directory"; + + if (!Directory.Exists(GameDirectory)) + return $"Game directory does not exist: {GameDirectory}"; + + if (string.IsNullOrWhiteSpace(Theater)) + return "Missing required argument: --theater"; + + if (Width <= 0 || Height <= 0) + return "Map width and height must both be positive (--width, --height)"; + + if (PlayerCount < 2 || PlayerCount > Constants.MultiplayerMaxPlayers) + return $"Player count must be between 2 and {Constants.MultiplayerMaxPlayers} (--players)"; + + if (string.IsNullOrWhiteSpace(OutputPath)) + return "Missing required argument: --output"; + + return null; + } + } + + /// + /// Drives headless (non-interactive) random map generation: builds an in-memory + /// from scratch, procedurally generates terrain, resources, and + /// symmetric player start locations, then writes the result to a .map file. + /// + /// This class does not touch any WinForms/interactive-UI types. It still requires a + /// live MonoGame to be supplied by the caller, because + /// (theater tile/overlay/smudge graphics metadata) uploads + /// GPU textures as part of loading - see Phase 1 investigation notes in plan.md for + /// details on why this can't currently be avoided without a deeper engine refactor. + /// + public static class HeadlessMapGenerator + { + /// + /// Generates a random map according to and writes it to disk. + /// Returns null on success, or an error message describing the failure. + /// + public static string Generate(HeadlessRmgOptions options, GraphicsDevice graphicsDevice) + { + string validationError = options.Validate(); + if (validationError != null) + return validationError; + + try + { + var ccFileManager = new CCFileManager() { GameDirectory = options.GameDirectory }; + ccFileManager.ReadConfig(); + + var gameConfigIniFiles = new GameConfigINIFiles(options.GameDirectory, ccFileManager); + + var map = new Map(ccFileManager); + map.InitNew(gameConfigIniFiles, options.Theater, new Point2D(options.Width, options.Height), 0); + + Theater theater = map.EditorConfig.Theaters.Find(t => t.UIName.Equals(options.Theater, StringComparison.InvariantCultureIgnoreCase)); + if (theater == null) + return $"Unknown theater: {options.Theater}"; + + theater.ReadConfigINI(options.GameDirectory, ccFileManager); + + foreach (string theaterMIXName in theater.ContentMIXName) + ccFileManager.LoadRequiredMixFile(theaterMIXName); + + foreach (string theaterMIXName in theater.OptionalContentMIXName) + ccFileManager.LoadOptionalMixFile(theaterMIXName); + + var theaterGraphics = new TheaterGraphics(graphicsDevice, theater, ccFileManager, map.Rules); + map.TheaterInstance = theaterGraphics; + + MapLoader.PostCheckMap(map, theaterGraphics); + map.EditorConfig.PostTheaterInit(map.Rules); + + int seed = options.Seed ?? Environment.TickCount; + var random = new Random(seed); + + var mutationTarget = new HeadlessMutationTarget(map); + + GenerateTerrain(map, mutationTarget, theater, random); + PlaceResources(map, random); + PlacePlayerStarts(map, options.PlayerCount); + + map.AutoSave(options.OutputPath); + + Logger.Log($"Headless RMG: generated {options.Width}x{options.Height} {options.Theater} map " + + $"with {options.PlayerCount} player starts (seed {seed}) -> {options.OutputPath}"); + + return null; + } + catch (Exception ex) + { + Logger.Log("Headless RMG failed: " + ex); + return "Map generation failed: " + ex.Message; + } + } + + /// + /// Returns all cells within the map's local (playable) size. + /// + private static List GetPlayableCells(Map map) + { + var cells = new List(); + + for (int y = map.LocalSize.Y; y < map.LocalSize.Y + map.LocalSize.Height; y++) + { + for (int x = map.LocalSize.X; x < map.LocalSize.X + map.LocalSize.Width; x++) + { + if (map.GetTile(x, y) != null) + cells.Add(new Point2D(x, y)); + } + } + + return cells; + } + + /// + /// Runs the existing brush-based terrain generator engine () + /// over the entire playable area of the map, using the first terrain generator preset + /// configured for the map's theater in Config/TerrainGeneratorPresets.ini. + /// + private static void GenerateTerrain(Map map, HeadlessMutationTarget mutationTarget, Theater theater, Random random) + { + var presetsIni = Helpers.ReadConfigINI("TerrainGeneratorPresets.ini"); + + TerrainGeneratorConfiguration configuration = null; + + foreach (string sectionName in presetsIni.GetSections()) + { + string sectionTheater = presetsIni.GetStringValue(sectionName, "Theater", string.Empty); + if (!string.IsNullOrWhiteSpace(sectionTheater) && !sectionTheater.Equals(map.TheaterName, StringComparison.InvariantCultureIgnoreCase)) + continue; + + configuration = TerrainGeneratorConfiguration.FromConfigSection( + presetsIni.GetSection(sectionName), + false, + map.Rules.TerrainTypes, + theater.TileSets, + map.Rules.OverlayTypes, + map.Rules.SmudgeTypes); + + if (configuration != null) + break; + } + + if (configuration == null) + { + Logger.Log("Headless RMG: no terrain generator preset found for theater " + map.TheaterName + "; skipping terrain variety generation."); + return; + } + + var cells = GetPlayableCells(map); + + // Shuffle deterministically using our own seeded Random so that repeated + // generations with the same seed produce the same layout, independent of + // TerrainGenerationMutation's internal (currently DateTime-seeded) fallback. + for (int i = cells.Count - 1; i > 0; i--) + { + int j = random.Next(i + 1); + (cells[i], cells[j]) = (cells[j], cells[i]); + } + + var mutation = new TerrainGenerationMutation(mutationTarget, cells, configuration, random.Next()); + mutation.Generate(); + } + + /// + /// Scatters basic Tiberium/ore/gem resource fields across the playable area. + /// This is a simple proof-of-concept distribution (random patches), not a + /// balance-tuned resource layout. + /// + private static void PlaceResources(Map map, Random random) + { + var tiberiumOverlays = map.Rules.OverlayTypes.Where(o => o.Tiberium).ToList(); + if (tiberiumOverlays.Count == 0) + { + Logger.Log("Headless RMG: no Tiberium/ore overlay types found in Rules; skipping resource placement."); + return; + } + + var cells = GetPlayableCells(map); + + const double patchChance = 0.02; // chance per cell to seed a resource patch + const int patchRadius = 3; + + foreach (var cellCoords in cells) + { + if (random.NextDouble() > patchChance) + continue; + + var overlayType = tiberiumOverlays[random.Next(tiberiumOverlays.Count)]; + + for (int y = -patchRadius; y <= patchRadius; y++) + { + for (int x = -patchRadius; x <= patchRadius; x++) + { + if (x * x + y * y > patchRadius * patchRadius) + continue; + + if (random.NextDouble() > 0.6) + continue; + + var targetCoords = cellCoords + new Point2D(x, y); + var tile = map.GetTile(targetCoords); + if (tile == null || tile.Overlay != null) + continue; + + if (tile.MatchesLandType(Models.Enums.LandType.Water) || tile.MatchesLandType(Models.Enums.LandType.Road)) + continue; + + tile.Overlay = new Overlay() + { + OverlayType = overlayType, + FrameIndex = 0, + Position = targetCoords + }; + } + } + } + } + + /// + /// Places player-start waypoints (identifiers 0..N-1) + /// arranged with rotational symmetry around the map center, so every player has an + /// equally fair starting position. + /// + private static void PlacePlayerStarts(Map map, int playerCount) + { + double centerX = map.LocalSize.X + map.LocalSize.Width / 2.0; + double centerY = map.LocalSize.Y + map.LocalSize.Height / 2.0; + + // Use a radius that comfortably fits inside the playable area for all player counts. + double radiusX = map.LocalSize.Width / 2.0 * 0.75; + double radiusY = map.LocalSize.Height / 2.0 * 0.75; + + for (int i = 0; i < playerCount; i++) + { + double angle = (2 * Math.PI * i / playerCount) - (Math.PI / 2); // start at top, go clockwise + int x = (int)Math.Round(centerX + radiusX * Math.Cos(angle)); + int y = (int)Math.Round(centerY + radiusY * Math.Sin(angle)); + + x = Math.Clamp(x, map.LocalSize.X, map.LocalSize.X + map.LocalSize.Width - 1); + y = Math.Clamp(y, map.LocalSize.Y, map.LocalSize.Y + map.LocalSize.Height - 1); + + var position = new Point2D(x, y); + + // Make sure we're not placing the start waypoint on top of an existing one + // (can happen on very small maps with many players); nudge outward if so. + var existingTile = map.GetTile(position); + if (existingTile != null && existingTile.Waypoints.Count > 0) + { + position = FindNearestFreeCell(map, position); + } + + map.AddWaypoint(new Waypoint() { Identifier = i, Position = position }); + } + } + + private static Point2D FindNearestFreeCell(Map map, Point2D origin) + { + for (int radius = 1; radius < Math.Max(map.Size.X, map.Size.Y); radius++) + { + for (int y = -radius; y <= radius; y++) + { + for (int x = -radius; x <= radius; x++) + { + var candidate = origin + new Point2D(x, y); + var tile = map.GetTile(candidate); + if (tile != null && tile.Waypoints.Count == 0) + return candidate; + } + } + } + + return origin; + } + } +} diff --git a/src/TSMapEditor/Initialization/HeadlessMutationTarget.cs b/src/TSMapEditor/Initialization/HeadlessMutationTarget.cs new file mode 100644 index 000000000..7519adb38 --- /dev/null +++ b/src/TSMapEditor/Initialization/HeadlessMutationTarget.cs @@ -0,0 +1,47 @@ +using TSMapEditor.GameMath; +using TSMapEditor.Models; +using TSMapEditor.Models.Enums; +using TSMapEditor.Rendering; +using TSMapEditor.UI; + +namespace TSMapEditor.Initialization +{ + /// + /// A minimal, non-UI implementation of . + /// Allows mutation classes (e.g. ) + /// that were originally designed to be driven by the interactive map editor UI to instead + /// be driven programmatically, without any WinForms/MonoGame-rendering objects. + /// + public class HeadlessMutationTarget : IMutationTarget + { + public HeadlessMutationTarget(Map map) + { + Map = map; + TheaterGraphics = map.TheaterInstance as TheaterGraphics; + Randomizer = new Randomizer(); + } + + public Map Map { get; } + + public TheaterGraphics TheaterGraphics { get; } + + public House ObjectOwner { get; set; } + + public BrushSize BrushSize { get; set; } = new BrushSize(1, 1); + + public Randomizer Randomizer { get; } + + public bool AutoLATEnabled { get; set; } = true; + + public LightingPreviewMode LightingPreviewState => LightingPreviewMode.NoLighting; + + public bool LightDisabledLightSources => false; + + public bool OnlyPaintOnClearGround => false; + + // No-ops: there is no UI to refresh or invalidate in headless mode. + public void AddRefreshPoint(Point2D point, int size = 10) { } + + public void InvalidateMap() { } + } +} diff --git a/src/TSMapEditor/Mutations/Classes/TerrainGenerationMutation.cs b/src/TSMapEditor/Mutations/Classes/TerrainGenerationMutation.cs index ef6152f62..f50df4da4 100644 --- a/src/TSMapEditor/Mutations/Classes/TerrainGenerationMutation.cs +++ b/src/TSMapEditor/Mutations/Classes/TerrainGenerationMutation.cs @@ -380,9 +380,9 @@ 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, int? seed = null) : base(mutationTarget) { - seed = DateTime.Now.Millisecond; + this.seed = seed ?? DateTime.Now.Millisecond; random = new Random(); this.cells = cells; this.terrainGeneratorConfiguration = configuration; diff --git a/src/TSMapEditor/Program.cs b/src/TSMapEditor/Program.cs index 9fbf64056..a44af39b7 100644 --- a/src/TSMapEditor/Program.cs +++ b/src/TSMapEditor/Program.cs @@ -1,5 +1,7 @@ using System; +using System.Linq; using System.Windows.Forms; +using TSMapEditor.Initialization; using TSMapEditor.Rendering; namespace TSMapEditor @@ -8,6 +10,12 @@ static class Program { public static string[] args; + /// + /// When set, the editor was launched in headless random-map-generation mode + /// (via the --generate-map command line flag) instead of the interactive GUI. + /// + public static HeadlessRmgOptions HeadlessRmgOptions { get; private set; } + /// /// The main entry point for the application. /// @@ -16,6 +24,19 @@ static void Main(string[] args) { Program.args = args; + if (args.Any(a => a.Equals("--generate-map", StringComparison.OrdinalIgnoreCase))) + { + HeadlessRmgOptions = HeadlessRmgOptions.ParseArgs(args); + + string validationError = HeadlessRmgOptions.Validate(); + if (validationError != null) + { + Console.Error.WriteLine("Invalid --generate-map arguments: " + validationError); + Environment.Exit(1); + return; + } + } + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException); diff --git a/src/TSMapEditor/Rendering/GameClass.cs b/src/TSMapEditor/Rendering/GameClass.cs index 3515f6077..f9fe0596e 100644 --- a/src/TSMapEditor/Rendering/GameClass.cs +++ b/src/TSMapEditor/Rendering/GameClass.cs @@ -200,9 +200,38 @@ protected override void Initialize() windowManager.IMEHandler = imeHandler; #endif + // Headless random map generation: skip the interactive UI entirely, hide the + // window (it only exists because we need a live GraphicsDevice), generate the + // map, print the result, and exit with a status code - no MainMenu is shown. + if (Program.HeadlessRmgOptions != null) + { + RunHeadlessMapGeneration(); + return; + } + InitMainMenu(); } + private void RunHeadlessMapGeneration() + { +#if WINDOWS + var form = System.Windows.Forms.Control.FromHandle(Window.Handle) as System.Windows.Forms.Form; + form?.Hide(); +#endif + string error = TSMapEditor.Initialization.HeadlessMapGenerator.Generate(Program.HeadlessRmgOptions, GraphicsDevice); + + if (error == null) + { + Console.WriteLine("Map generation succeeded. Output written to: " + Program.HeadlessRmgOptions.OutputPath); + Environment.Exit(0); + } + else + { + Console.Error.WriteLine("Map generation failed: " + error); + Environment.Exit(1); + } + } + private void InitMainMenu() { var mainMenu = new MainMenu(windowManager); From ebce6671171b10d8dc9595d6c19244111d3df324 Mon Sep 17 00:00:00 2001 From: Blackgamerz <105124395+BlackgamerzVN@users.noreply.github.com> Date: Thu, 9 Jul 2026 06:59:09 +0700 Subject: [PATCH 2/4] Align headless RMG CLI args with client-side contract - HeadlessRmgOptions.ParseArgs now accepts space-separated '--flag value' args (matching the xna-cncnet-client ExternalProcessRandomMapGenerator contract), in addition to the previously-only-supported '--flag=value' form. - Added optional --gamemode flag (accepted and logged; does not yet alter generation - see plan.md open question). - Logged gamemode alongside seed/output on successful generation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Initialization/HeadlessMapGenerator.cs | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs b/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs index 8b5424973..488086611 100644 --- a/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs +++ b/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs @@ -36,24 +36,54 @@ public class HeadlessRmgOptions /// Seed for deterministic random generation. If null, a random seed is used. public int? Seed { get; set; } + /// + /// Optional game mode name (e.g. "Standard", "MegaWealth"). Currently accepted and + /// logged for forward-compatibility with the calling client, but does not yet alter + /// generation - see plan.md / cross-session notes for the open question on whether + /// this should drive resource density or a specific terrain generator preset. + /// + public string GameMode { get; set; } + /// Path (including file name) that the generated .map file should be written to. public string OutputPath { get; set; } + /// + /// Parses CLI arguments in either "--flag value" (space-separated) or "--flag=value" + /// form. The "--generate-map" flag itself takes no value and is ignored here. + /// public static HeadlessRmgOptions ParseArgs(string[] args) { var options = new HeadlessRmgOptions(); - foreach (string arg in args) + for (int i = 0; i < args.Length; i++) { + string arg = args[i]; if (!arg.StartsWith("--")) continue; + string key; + string value = null; + int separatorIndex = arg.IndexOf('='); - if (separatorIndex < 0) - continue; + if (separatorIndex >= 0) + { + // --flag=value form + key = arg.Substring(2, separatorIndex - 2).Trim().ToLowerInvariant(); + value = arg.Substring(separatorIndex + 1).Trim(); + } + else + { + // --flag value (space-separated) form + key = arg.Substring(2).Trim().ToLowerInvariant(); + if (i + 1 < args.Length && !args[i + 1].StartsWith("--")) + { + value = args[i + 1].Trim(); + i++; + } + } - string key = arg.Substring(2, separatorIndex - 2).Trim().ToLowerInvariant(); - string value = arg.Substring(separatorIndex + 1).Trim(); + if (value == null) + continue; switch (key) { @@ -75,6 +105,9 @@ public static HeadlessRmgOptions ParseArgs(string[] args) case "seed": options.Seed = int.Parse(value, CultureInfo.InvariantCulture); break; + case "gamemode": + options.GameMode = value; + break; case "output": options.OutputPath = value; break; @@ -175,7 +208,7 @@ public static string Generate(HeadlessRmgOptions options, GraphicsDevice graphic map.AutoSave(options.OutputPath); Logger.Log($"Headless RMG: generated {options.Width}x{options.Height} {options.Theater} map " + - $"with {options.PlayerCount} player starts (seed {seed}) -> {options.OutputPath}"); + $"with {options.PlayerCount} player starts (seed {seed}, gamemode {options.GameMode ?? "(none)"}) -> {options.OutputPath}"); return null; } From 801d10538e24c2a7aae3c03b0124e36395a9efb8 Mon Sep 17 00:00:00 2001 From: Blackgamerz <105124395+BlackgamerzVN@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:05:08 +0700 Subject: [PATCH 3/4] Add mirror symmetry option and wedge-balanced resource placement to headless RMG - Introduce SymmetryMode enum (Rotational, Mirror) and --symmetry CLI flag. - Refactor PlacePlayerStarts to support both N-fold rotational symmetry and left/right axis-mirror symmetry (with odd-player-count axis placement). - Rewrite PlaceResources to generate patch centers within a single fundamental domain (one rotational wedge, or one mirror half) and replicate them via the same symmetry transform used for player starts, so resource patches are balanced per-player instead of purely random. Patch budget now scales with playable map area. - Extract shared PlacePatch/GetSymmetryEllipse/ClampToLocalSize helpers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Initialization/HeadlessMapGenerator.cs | 219 ++++++++++++++---- 1 file changed, 172 insertions(+), 47 deletions(-) diff --git a/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs b/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs index 488086611..f11526137 100644 --- a/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs +++ b/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs @@ -13,6 +13,19 @@ namespace TSMapEditor.Initialization { + /// + /// The symmetry strategy used when placing player start locations and distributing + /// resource fields, so that every player has a comparably fair position. + /// + public enum SymmetryMode + { + /// N-fold rotational symmetry around the map center (points evenly spaced on a circle/ellipse). + Rotational, + + /// Left/right axis-mirror symmetry across a vertical line through the map center. + Mirror + } + /// /// Parameters for headless, non-interactive random map generation. /// @@ -44,6 +57,12 @@ public class HeadlessRmgOptions /// public string GameMode { get; set; } + /// + /// Symmetry mode used for player start placement and resource distribution. + /// Defaults to . + /// + public SymmetryMode Symmetry { get; set; } = SymmetryMode.Rotational; + /// Path (including file name) that the generated .map file should be written to. public string OutputPath { get; set; } @@ -108,6 +127,12 @@ public static HeadlessRmgOptions ParseArgs(string[] args) case "gamemode": options.GameMode = value; break; + case "symmetry": + if (value.Equals("mirror", StringComparison.OrdinalIgnoreCase)) + options.Symmetry = SymmetryMode.Mirror; + else if (value.Equals("rotational", StringComparison.OrdinalIgnoreCase)) + options.Symmetry = SymmetryMode.Rotational; + break; case "output": options.OutputPath = value; break; @@ -202,13 +227,13 @@ public static string Generate(HeadlessRmgOptions options, GraphicsDevice graphic var mutationTarget = new HeadlessMutationTarget(map); GenerateTerrain(map, mutationTarget, theater, random); - PlaceResources(map, random); - PlacePlayerStarts(map, options.PlayerCount); + PlaceResources(map, random, options.PlayerCount, options.Symmetry); + PlacePlayerStarts(map, options.PlayerCount, options.Symmetry); map.AutoSave(options.OutputPath); Logger.Log($"Headless RMG: generated {options.Width}x{options.Height} {options.Theater} map " + - $"with {options.PlayerCount} player starts (seed {seed}, gamemode {options.GameMode ?? "(none)"}) -> {options.OutputPath}"); + $"with {options.PlayerCount} player starts ({options.Symmetry} symmetry, seed {seed}, gamemode {options.GameMode ?? "(none)"}) -> {options.OutputPath}"); return null; } @@ -289,11 +314,14 @@ private static void GenerateTerrain(Map map, HeadlessMutationTarget mutationTarg } /// - /// Scatters basic Tiberium/ore/gem resource fields across the playable area. - /// This is a simple proof-of-concept distribution (random patches), not a - /// balance-tuned resource layout. + /// Scatters Tiberium/ore/gem resource fields across the playable area with + /// per-player-wedge balance: patch centers are generated only within one + /// "fundamental domain" (a single rotational wedge, or one side of the mirror axis) + /// and then replicated via the same symmetry transform used for player start + /// placement, so every player's fair share of the map gets an equal number of + /// resource patches of the same size/type. Patch count scales with playable area. /// - private static void PlaceResources(Map map, Random random) + private static void PlaceResources(Map map, Random random, int playerCount, SymmetryMode symmetry) { var tiberiumOverlays = map.Rules.OverlayTypes.Where(o => o.Tiberium).ToList(); if (tiberiumOverlays.Count == 0) @@ -302,71 +330,161 @@ private static void PlaceResources(Map map, Random random) return; } - var cells = GetPlayableCells(map); + var (centerX, centerY, radiusX, radiusY) = GetSymmetryEllipse(map); + + // Replica count per generated patch: one per player under rotational symmetry + // (one patch per wedge = exactly one per player), or two under mirror symmetry + // (one patch and its mirror image, independent of player count). + int replicas = symmetry == SymmetryMode.Mirror ? 2 : playerCount; - const double patchChance = 0.02; // chance per cell to seed a resource patch - const int patchRadius = 3; + int playableArea = map.LocalSize.Width * map.LocalSize.Height; + const int cellsPerPatchAttemptPerWedge = 900; // arbitrary proof-of-concept density + int patchesPerWedge = Math.Max(1, (playableArea / replicas) / cellsPerPatchAttemptPerWedge); - foreach (var cellCoords in cells) + for (int p = 0; p < patchesPerWedge; p++) { - if (random.NextDouble() > patchChance) - continue; + double angleDeg; + if (symmetry == SymmetryMode.Rotational) + { + // Sample within the first wedge only; replicas below rotate it to the rest. + double wedgeDeg = 360.0 / playerCount; + angleDeg = -90 + random.NextDouble() * wedgeDeg; + } + else + { + // Sample within the right half only; the single mirror replica covers the left half. + angleDeg = -90 + random.NextDouble() * 180; + } + + double radiusFraction = 0.15 + random.NextDouble() * 0.7; // avoid the very center and the map edge + double baseAngle = angleDeg * Math.PI / 180.0; var overlayType = tiberiumOverlays[random.Next(tiberiumOverlays.Count)]; + int patchRadius = 2 + random.Next(3); // 2..4 - for (int y = -patchRadius; y <= patchRadius; y++) + for (int r = 0; r < replicas; r++) { - for (int x = -patchRadius; x <= patchRadius; x++) + double px, py; + + if (symmetry == SymmetryMode.Rotational) + { + double rotatedAngle = baseAngle + r * (2 * Math.PI / playerCount); + px = centerX + radiusX * radiusFraction * Math.Cos(rotatedAngle); + py = centerY + radiusY * radiusFraction * Math.Sin(rotatedAngle); + } + else { - if (x * x + y * y > patchRadius * patchRadius) - continue; - - if (random.NextDouble() > 0.6) - continue; - - var targetCoords = cellCoords + new Point2D(x, y); - var tile = map.GetTile(targetCoords); - if (tile == null || tile.Overlay != null) - continue; - - if (tile.MatchesLandType(Models.Enums.LandType.Water) || tile.MatchesLandType(Models.Enums.LandType.Road)) - continue; - - tile.Overlay = new Overlay() - { - OverlayType = overlayType, - FrameIndex = 0, - Position = targetCoords - }; + double bx = centerX + radiusX * radiusFraction * Math.Cos(baseAngle); + double by = centerY + radiusY * radiusFraction * Math.Sin(baseAngle); + px = r == 0 ? bx : centerX - (bx - centerX); // r == 1: mirror across the vertical axis + py = by; } + + var patchCenter = new Point2D((int)Math.Round(px), (int)Math.Round(py)); + PlacePatch(map, random, patchCenter, patchRadius, overlayType); + } + } + } + + private static void PlacePatch(Map map, Random random, Point2D center, int patchRadius, OverlayType overlayType) + { + for (int y = -patchRadius; y <= patchRadius; y++) + { + for (int x = -patchRadius; x <= patchRadius; x++) + { + if (x * x + y * y > patchRadius * patchRadius) + continue; + + if (random.NextDouble() > 0.6) + continue; + + var targetCoords = center + new Point2D(x, y); + var tile = map.GetTile(targetCoords); + if (tile == null || tile.Overlay != null) + continue; + + if (tile.MatchesLandType(Models.Enums.LandType.Water) || tile.MatchesLandType(Models.Enums.LandType.Road)) + continue; + + tile.Overlay = new Overlay() + { + OverlayType = overlayType, + FrameIndex = 0, + Position = targetCoords + }; } } } /// - /// Places player-start waypoints (identifiers 0..N-1) - /// arranged with rotational symmetry around the map center, so every player has an - /// equally fair starting position. + /// Returns the center point and ellipse radii (as a fraction of the map's local/playable + /// size) used as the basis for both player start placement and resource-patch replication, + /// so both follow the exact same geometric symmetry. /// - private static void PlacePlayerStarts(Map map, int playerCount) + private static (double centerX, double centerY, double radiusX, double radiusY) GetSymmetryEllipse(Map map) { double centerX = map.LocalSize.X + map.LocalSize.Width / 2.0; double centerY = map.LocalSize.Y + map.LocalSize.Height / 2.0; - // Use a radius that comfortably fits inside the playable area for all player counts. + // Use a radius that comfortably fits inside the playable area. double radiusX = map.LocalSize.Width / 2.0 * 0.75; double radiusY = map.LocalSize.Height / 2.0 * 0.75; - for (int i = 0; i < playerCount; i++) + return (centerX, centerY, radiusX, radiusY); + } + + /// + /// Places player-start waypoints (identifiers 0..N-1), + /// arranged according to so every player has an equally + /// fair starting position: either N-fold rotational symmetry around the map center, or + /// left/right mirror symmetry across a vertical axis through the map center. + /// + private static void PlacePlayerStarts(Map map, int playerCount, SymmetryMode symmetry) + { + var (centerX, centerY, radiusX, radiusY) = GetSymmetryEllipse(map); + + var positions = new List(); + + if (symmetry == SymmetryMode.Rotational) { - double angle = (2 * Math.PI * i / playerCount) - (Math.PI / 2); // start at top, go clockwise - int x = (int)Math.Round(centerX + radiusX * Math.Cos(angle)); - int y = (int)Math.Round(centerY + radiusY * Math.Sin(angle)); + for (int i = 0; i < playerCount; i++) + { + double angle = (2 * Math.PI * i / playerCount) - (Math.PI / 2); // start at top, go clockwise + positions.Add(ClampToLocalSize(map, + (int)Math.Round(centerX + radiusX * Math.Cos(angle)), + (int)Math.Round(centerY + radiusY * Math.Sin(angle)))); + } + } + else + { + // Pair players up across a vertical mirror axis through the map center. + // For an odd player count, the final player is placed on the axis itself. + int pairCount = playerCount / 2; + bool hasAxisPlayer = playerCount % 2 == 1; + + for (int i = 0; i < pairCount; i++) + { + double t = pairCount == 1 ? 0.5 : (double)i / (pairCount - 1); + double angleDeg = -70 + t * 140; // spread across the right half, avoiding the axis extremes + double angle = angleDeg * Math.PI / 180.0; + + double rightX = centerX + radiusX * Math.Cos(angle); + double y = centerY + radiusY * Math.Sin(angle); + double leftX = centerX - (rightX - centerX); + + positions.Add(ClampToLocalSize(map, (int)Math.Round(rightX), (int)Math.Round(y))); + positions.Add(ClampToLocalSize(map, (int)Math.Round(leftX), (int)Math.Round(y))); + } - x = Math.Clamp(x, map.LocalSize.X, map.LocalSize.X + map.LocalSize.Width - 1); - y = Math.Clamp(y, map.LocalSize.Y, map.LocalSize.Y + map.LocalSize.Height - 1); + if (hasAxisPlayer) + { + positions.Add(ClampToLocalSize(map, (int)Math.Round(centerX), (int)Math.Round(centerY + radiusY))); + } + } - var position = new Point2D(x, y); + for (int i = 0; i < positions.Count; i++) + { + var position = positions[i]; // Make sure we're not placing the start waypoint on top of an existing one // (can happen on very small maps with many players); nudge outward if so. @@ -380,6 +498,13 @@ private static void PlacePlayerStarts(Map map, int playerCount) } } + private static Point2D ClampToLocalSize(Map map, int x, int y) + { + x = Math.Clamp(x, map.LocalSize.X, map.LocalSize.X + map.LocalSize.Width - 1); + y = Math.Clamp(y, map.LocalSize.Y, map.LocalSize.Y + map.LocalSize.Height - 1); + return new Point2D(x, y); + } + private static Point2D FindNearestFreeCell(Map map, Point2D origin) { for (int radius = 1; radius < Math.Max(map.Size.X, map.Size.Y); radius++) From f2b4581f74b02f3f566727ac5f4c62f10f3a0388 Mon Sep 17 00:00:00 2001 From: Blackgamerz <105124395+BlackgamerzVN@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:33:41 +0700 Subject: [PATCH 4/4] Add opt-in Wave Function Collapse terrain layout algorithm - New WaveFunctionCollapseSolver: a from-scratch implementation of the 'simple tiled model' WFC algorithm (observe lowest-entropy cell, collapse, propagate adjacency constraints via BFS, detect contradictions). Based on the publicly documented algorithm from mxgmn/WaveFunctionCollapse (MIT licensed); no code from that repo is reused, only the algorithm description. - New --algorithm scatter|wfc CLI flag (default: scatter, preserving prior behavior exactly). wfc mode divides the map into 6x6-cell blocks and runs the solver to decide which single TerrainGeneratorTileGroup governs each block, using the theater's real LATGrounds ground/base adjacency data (plus tileset index 0, the theater's default base/clear tileset) to build the compatibility matrix - so neighboring blocks only ever get tile groups the game's own tileset rules say can actually sit next to each other. - Each block is painted using the existing, already-vetted TerrainGenerationMutation engine restricted to just that block's tile group; auto-LAT blending (already enabled on HeadlessMutationTarget) then smooths the boundaries between adjacent blocks using the same mechanism as manual GUI tile painting. - Falls back to the scatter algorithm if the solver hits a contradiction, if a block's generation throws, or if the active preset has fewer than 2 tile groups (WFC needs at least 2 domains to be meaningful). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Initialization/HeadlessMapGenerator.cs | 167 +++++++++++- .../WaveFunctionCollapseSolver.cs | 240 ++++++++++++++++++ 2 files changed, 403 insertions(+), 4 deletions(-) create mode 100644 src/TSMapEditor/Initialization/WaveFunctionCollapseSolver.cs diff --git a/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs b/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs index f11526137..dba3d5a92 100644 --- a/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs +++ b/src/TSMapEditor/Initialization/HeadlessMapGenerator.cs @@ -26,6 +26,25 @@ public enum SymmetryMode Mirror } + /// + /// Terrain generation strategy used by the headless RMG pipeline. + /// + public enum TerrainAlgorithm + { + /// The original approach: run + /// once over the whole map with all configured tile groups competing independently per cell. + Scatter, + + /// + /// Divide the map into blocks and use a Wave Function Collapse solver (see + /// ) to decide which tile group governs each + /// block, respecting the theater's real LAT ground/base adjacency rules so neighboring + /// blocks are only assigned tile groups that are actually allowed to sit next to each + /// other. Produces more spatially coherent terrain clusters than . + /// + WaveFunctionCollapse + } + /// /// Parameters for headless, non-interactive random map generation. /// @@ -63,6 +82,13 @@ public class HeadlessRmgOptions /// public SymmetryMode Symmetry { get; set; } = SymmetryMode.Rotational; + /// + /// Terrain generation strategy. Defaults to + /// (the original, already-shipped behavior) for backwards compatibility; pass + /// "--algorithm wfc" to opt into the newer Wave-Function-Collapse-based layout. + /// + public TerrainAlgorithm Algorithm { get; set; } = TerrainAlgorithm.Scatter; + /// Path (including file name) that the generated .map file should be written to. public string OutputPath { get; set; } @@ -133,6 +159,13 @@ public static HeadlessRmgOptions ParseArgs(string[] args) else if (value.Equals("rotational", StringComparison.OrdinalIgnoreCase)) options.Symmetry = SymmetryMode.Rotational; break; + case "algorithm": + if (value.Equals("wfc", StringComparison.OrdinalIgnoreCase) || + value.Equals("wavefunctioncollapse", StringComparison.OrdinalIgnoreCase)) + options.Algorithm = TerrainAlgorithm.WaveFunctionCollapse; + else if (value.Equals("scatter", StringComparison.OrdinalIgnoreCase)) + options.Algorithm = TerrainAlgorithm.Scatter; + break; case "output": options.OutputPath = value; break; @@ -226,14 +259,14 @@ public static string Generate(HeadlessRmgOptions options, GraphicsDevice graphic var mutationTarget = new HeadlessMutationTarget(map); - GenerateTerrain(map, mutationTarget, theater, random); + GenerateTerrain(map, mutationTarget, theater, random, options.Algorithm); PlaceResources(map, random, options.PlayerCount, options.Symmetry); PlacePlayerStarts(map, options.PlayerCount, options.Symmetry); map.AutoSave(options.OutputPath); Logger.Log($"Headless RMG: generated {options.Width}x{options.Height} {options.Theater} map " + - $"with {options.PlayerCount} player starts ({options.Symmetry} symmetry, seed {seed}, gamemode {options.GameMode ?? "(none)"}) -> {options.OutputPath}"); + $"with {options.PlayerCount} player starts ({options.Symmetry} symmetry, {options.Algorithm} terrain algorithm, seed {seed}, gamemode {options.GameMode ?? "(none)"}) -> {options.OutputPath}"); return null; } @@ -266,9 +299,11 @@ private static List GetPlayableCells(Map map) /// /// Runs the existing brush-based terrain generator engine () /// over the entire playable area of the map, using the first terrain generator preset - /// configured for the map's theater in Config/TerrainGeneratorPresets.ini. + /// configured for the map's theater in Config/TerrainGeneratorPresets.ini. Dispatches to + /// either the original whole-map "scatter" strategy or the newer Wave-Function-Collapse + /// block layout strategy, depending on . /// - private static void GenerateTerrain(Map map, HeadlessMutationTarget mutationTarget, Theater theater, Random random) + private static void GenerateTerrain(Map map, HeadlessMutationTarget mutationTarget, Theater theater, Random random, TerrainAlgorithm algorithm) { var presetsIni = Helpers.ReadConfigINI("TerrainGeneratorPresets.ini"); @@ -309,10 +344,134 @@ private static void GenerateTerrain(Map map, HeadlessMutationTarget mutationTarg (cells[i], cells[j]) = (cells[j], cells[i]); } + if (algorithm == TerrainAlgorithm.WaveFunctionCollapse && configuration.TileGroups.Count >= 2) + { + if (GenerateTerrainWithWfc(map, mutationTarget, theater, configuration, random)) + return; + + Logger.Log("Headless RMG: Wave Function Collapse terrain layout failed (contradiction or setup issue); falling back to the scatter algorithm."); + } + var mutation = new TerrainGenerationMutation(mutationTarget, cells, configuration, random.Next()); mutation.Generate(); } + /// + /// Lays out terrain by dividing the map into fixed-size blocks and using a Wave + /// Function Collapse solver to decide which single + /// governs each block, so neighboring blocks are only ever assigned tile groups that the + /// theater's actual LAT ground/base data says are allowed to be adjacent (falling back to + /// "always compatible with the theater's default base tileset", index 0, which every LAT + /// transition in the theater is ultimately defined relative to - see Theater.InitLATGround). + /// Each block is then painted using the existing, already-vetted + /// engine, restricted to that one tile group. + /// + /// Returns false (without modifying the map further) if the solver hits a contradiction + /// or the configuration doesn't have enough tile groups to make WFC meaningful, so the + /// caller can fall back to the scatter algorithm instead. + /// + private static bool GenerateTerrainWithWfc(Map map, HeadlessMutationTarget mutationTarget, Theater theater, TerrainGeneratorConfiguration configuration, Random random) + { + const int blockSize = 6; // cells per block edge; arbitrary proof-of-concept granularity + + var domains = configuration.TileGroups; + int domainCount = domains.Count; + + var weights = new double[domainCount]; + for (int i = 0; i < domainCount; i++) + weights[i] = Math.Max(domains[i].OpenChance, 0.01); + + var compatibility = new bool[domainCount, domainCount]; + for (int i = 0; i < domainCount; i++) + { + for (int j = 0; j < domainCount; j++) + { + if (i == j) + { + compatibility[i, j] = true; + continue; + } + + var tileSetA = domains[i].TileSet; + var tileSetB = domains[j].TileSet; + + bool compatible = + tileSetA == tileSetB || + tileSetA.Index == 0 || tileSetB.Index == 0 || // the theater's default base/clear tileset is always a safe neighbor + theater.LATGrounds.Exists(lg => + (lg.GroundTileSet == tileSetA && lg.BaseTileSet == tileSetB) || + (lg.GroundTileSet == tileSetB && lg.BaseTileSet == tileSetA)); + + compatibility[i, j] = compatible; + } + } + + int blocksX = Math.Max(1, (int)Math.Ceiling(map.LocalSize.Width / (double)blockSize)); + int blocksY = Math.Max(1, (int)Math.Ceiling(map.LocalSize.Height / (double)blockSize)); + + var solver = new WaveFunctionCollapseSolver(blocksX, blocksY, weights, compatibility); + int[] assignment = solver.Solve(random); + + if (assignment == null) + return false; + + for (int by = 0; by < blocksY; by++) + { + for (int bx = 0; bx < blocksX; bx++) + { + int domainIndex = assignment[by * blocksX + bx]; + var tileGroup = domains[domainIndex]; + + var blockCells = new List(); + int startX = map.LocalSize.X + bx * blockSize; + int startY = map.LocalSize.Y + by * blockSize; + int endX = Math.Min(startX + blockSize, map.LocalSize.X + map.LocalSize.Width); + int endY = Math.Min(startY + blockSize, map.LocalSize.Y + map.LocalSize.Height); + + for (int y = startY; y < endY; y++) + { + for (int x = startX; x < endX; x++) + { + if (map.GetTile(x, y) != null) + blockCells.Add(new Point2D(x, y)); + } + } + + if (blockCells.Count == 0) + continue; + + for (int i = blockCells.Count - 1; i > 0; i--) + { + int j = random.Next(i + 1); + (blockCells[i], blockCells[j]) = (blockCells[j], blockCells[i]); + } + + var blockConfiguration = new TerrainGeneratorConfiguration( + configuration.Name + " (WFC block)", + configuration.Theater, + configuration.IsUserConfiguration, + configuration.TerrainTypeGroups, + new List { tileGroup }, + new List(), // resource/overlay placement is handled separately by PlaceResources + configuration.SmudgeGroups); + + try + { + var mutation = new TerrainGenerationMutation(mutationTarget, blockCells, blockConfiguration, random.Next()); + mutation.Generate(); + } + catch (Exception ex) + { + // Don't let one bad block abort the whole map; log and keep going so the + // rest of the map still gets a usable layout. + Logger.Log($"Headless RMG: WFC block ({bx},{by}) terrain generation failed, leaving it unpainted: {ex.Message}"); + } + } + } + + return true; + } + /// /// Scatters Tiberium/ore/gem resource fields across the playable area with /// per-player-wedge balance: patch centers are generated only within one diff --git a/src/TSMapEditor/Initialization/WaveFunctionCollapseSolver.cs b/src/TSMapEditor/Initialization/WaveFunctionCollapseSolver.cs new file mode 100644 index 000000000..a2cc2e1d4 --- /dev/null +++ b/src/TSMapEditor/Initialization/WaveFunctionCollapseSolver.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; + +namespace TSMapEditor.Initialization +{ + /// + /// A from-scratch, minimal implementation of the "simple tiled model" variant of the + /// Wave Function Collapse algorithm (see https://github.com/mxgmn/WaveFunctionCollapse, + /// MIT licensed - no code from that repository is reused here, only the published + /// algorithm description: observe the lowest-entropy cell, collapse it to a single + /// domain value, then propagate the resulting adjacency constraints outward until + /// either every cell is collapsed or a contradiction (an empty possibility set) is hit). + /// + /// This operates over an abstract rectangular grid of "cells", each of which can take on + /// one of domainCount possible integer domain values, constrained by a symmetric + /// pairwise compatibility matrix and 4-directional (N/S/E/W) adjacency. It knows nothing + /// about tiles, maps, or WAE-specific types - see for + /// how it's used to decide which TerrainGeneratorTileGroup governs each block of + /// map cells. + /// + internal sealed class WaveFunctionCollapseSolver + { + private readonly int width; + private readonly int height; + private readonly int domainCount; + private readonly double[] weights; + private readonly bool[,] compatibility; + private readonly bool[][] possibilities; + private readonly int[] remainingCount; + + public WaveFunctionCollapseSolver(int width, int height, double[] weights, bool[,] compatibility) + { + this.width = width; + this.height = height; + this.weights = weights; + this.compatibility = compatibility; + domainCount = weights.Length; + + int cellCount = width * height; + possibilities = new bool[cellCount][]; + remainingCount = new int[cellCount]; + + for (int i = 0; i < cellCount; i++) + { + possibilities[i] = new bool[domainCount]; + for (int d = 0; d < domainCount; d++) + possibilities[i][d] = true; + + remainingCount[i] = domainCount; + } + } + + /// + /// Runs the observation/propagation loop to completion. + /// Returns an array of domain indices (one per cell, row-major) on success, + /// or null if the solver ran into a contradiction (no valid assignment found + /// for some cell given the constraints). + /// + public int[] Solve(Random random) + { + while (true) + { + int cellIndex = FindLowestEntropyCell(random); + if (cellIndex < 0) + break; // every cell is fully collapsed - success + + if (!CollapseCell(cellIndex, random)) + return null; // a cell had zero remaining possibilities - contradiction + + if (!Propagate(cellIndex)) + return null; // propagation emptied some cell's possibility set - contradiction + } + + var result = new int[width * height]; + for (int i = 0; i < result.Length; i++) + { + result[i] = Array.IndexOf(possibilities[i], true); + } + + return result; + } + + private int FindLowestEntropyCell(Random random) + { + int bestIndex = -1; + double bestEntropy = double.MaxValue; + + for (int i = 0; i < possibilities.Length; i++) + { + if (remainingCount[i] <= 1) + continue; // already collapsed (or contradictory - handled by caller) + + double sumWeights = 0; + double sumWeightLogWeight = 0; + for (int d = 0; d < domainCount; d++) + { + if (!possibilities[i][d]) + continue; + + double w = weights[d]; + sumWeights += w; + sumWeightLogWeight += w * Math.Log(w); + } + + if (sumWeights <= 0) + continue; + + // Shannon entropy of the weighted distribution, plus a small amount of + // random jitter so ties between equal-entropy cells are broken pseudo-randomly + // rather than always picking the same (e.g. top-left-most) cell. + double entropy = Math.Log(sumWeights) - (sumWeightLogWeight / sumWeights); + entropy += random.NextDouble() * 1e-6; + + if (entropy < bestEntropy) + { + bestEntropy = entropy; + bestIndex = i; + } + } + + return bestIndex; + } + + private bool CollapseCell(int cellIndex, Random random) + { + double totalWeight = 0; + for (int d = 0; d < domainCount; d++) + { + if (possibilities[cellIndex][d]) + totalWeight += weights[d]; + } + + if (totalWeight <= 0) + return false; + + double roll = random.NextDouble() * totalWeight; + int chosen = -1; + for (int d = 0; d < domainCount; d++) + { + if (!possibilities[cellIndex][d]) + continue; + + roll -= weights[d]; + if (roll <= 0) + { + chosen = d; + break; + } + } + + if (chosen < 0) + { + // Floating point edge case - fall back to the last possible domain. + for (int d = domainCount - 1; d >= 0; d--) + { + if (possibilities[cellIndex][d]) + { + chosen = d; + break; + } + } + } + + if (chosen < 0) + return false; + + for (int d = 0; d < domainCount; d++) + possibilities[cellIndex][d] = d == chosen; + + remainingCount[cellIndex] = 1; + return true; + } + + /// + /// Breadth-first constraint propagation: whenever a cell's possibility set shrinks, + /// each of its 4-directional neighbors has any domain removed that isn't compatible + /// with at least one of the remaining possibilities of the cell that just changed. + /// + private bool Propagate(int startCellIndex) + { + var queue = new Queue(); + queue.Enqueue(startCellIndex); + + while (queue.Count > 0) + { + int cellIndex = queue.Dequeue(); + int x = cellIndex % width; + int y = cellIndex / width; + + foreach (var (nx, ny) in GetNeighbors(x, y)) + { + int neighborIndex = ny * width + nx; + if (remainingCount[neighborIndex] <= 1) + continue; // already collapsed - nothing to constrain further + + bool changed = false; + + for (int nd = 0; nd < domainCount; nd++) + { + if (!possibilities[neighborIndex][nd]) + continue; + + bool stillAllowed = false; + for (int d = 0; d < domainCount; d++) + { + if (possibilities[cellIndex][d] && compatibility[d, nd]) + { + stillAllowed = true; + break; + } + } + + if (!stillAllowed) + { + possibilities[neighborIndex][nd] = false; + remainingCount[neighborIndex]--; + changed = true; + } + } + + if (remainingCount[neighborIndex] <= 0) + return false; // contradiction + + if (changed) + queue.Enqueue(neighborIndex); + } + } + + return true; + } + + private IEnumerable<(int x, int y)> GetNeighbors(int x, int y) + { + if (x > 0) yield return (x - 1, y); + if (x < width - 1) yield return (x + 1, y); + if (y > 0) yield return (x, y - 1); + if (y < height - 1) yield return (x, y + 1); + } + } +}