diff --git a/AUTHORS b/AUTHORS index ba1de28b2a..35bb05e335 100644 --- a/AUTHORS +++ b/AUTHORS @@ -36,6 +36,7 @@ Also thanks to: * Andreas Beck (baxtor) * Ang Soon Li (asl97) * Arik Lirette (Angusm3) + * Ashley Newson * Barnaby Smith (mvi) * Bellator * Bernd Stellwag (burned42) diff --git a/OpenRA.Game/Exts.cs b/OpenRA.Game/Exts.cs index f450b4201f..28e232fd82 100644 --- a/OpenRA.Game/Exts.cs +++ b/OpenRA.Game/Exts.cs @@ -510,6 +510,11 @@ namespace OpenRA return byte.Parse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo); } + public static ushort ParseUshortInvariant(string s) + { + return ushort.Parse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo); + } + public static short ParseInt16Invariant(string s) { return short.Parse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo); @@ -520,6 +525,22 @@ namespace OpenRA return int.Parse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo); } + public static float ParseFloatOrPercentInvariant(string s) + { + var f = float.Parse(s.Replace("%", ""), NumberStyles.Float, NumberFormatInfo.InvariantInfo); + return f * (s.Contains('%') ? 0.01f : 1f); + } + + public static bool TryParseByteInvariant(string s, out byte i) + { + return byte.TryParse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out i); + } + + public static bool TryParseUshortInvariant(string s, out ushort i) + { + return ushort.TryParse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out i); + } + public static bool TryParseInt32Invariant(string s, out int i) { return int.TryParse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out i); @@ -530,6 +551,17 @@ namespace OpenRA return long.TryParse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out i); } + public static bool TryParseFloatOrPercentInvariant(string s, out float f) + { + if (float.TryParse(s.Replace("%", ""), NumberStyles.Float, NumberFormatInfo.InvariantInfo, out f)) + { + f *= s.Contains('%') ? 0.01f : 1f; + return true; + } + + return false; + } + public static string ToStringInvariant(this byte i) { return i.ToString(NumberFormatInfo.InvariantInfo); @@ -545,6 +577,11 @@ namespace OpenRA return i.ToString(NumberFormatInfo.InvariantInfo); } + public static string ToStringInvariant(this float f) + { + return f.ToString(NumberFormatInfo.InvariantInfo); + } + public static string ToStringInvariant(this int i, string format) { return i.ToString(format, NumberFormatInfo.InvariantInfo); diff --git a/OpenRA.Game/Map/CellLayer.cs b/OpenRA.Game/Map/CellLayer.cs index 409d377d6c..6c5d6fae5c 100644 --- a/OpenRA.Game/Map/CellLayer.cs +++ b/OpenRA.Game/Map/CellLayer.cs @@ -63,12 +63,16 @@ namespace OpenRA var u = (x - y) / 2; var v = x + y; + if (!Bounds.Contains(u, v)) + throw new IndexOutOfRangeException(); return v * Size.Width + u; } // Resolve an array index from map coordinates int Index(MPos uv) { + if (!Bounds.Contains(uv.U, uv.V)) + throw new IndexOutOfRangeException(); return uv.V * Size.Width + uv.U; } @@ -145,6 +149,9 @@ namespace OpenRA { return uv.Clamp(new Rectangle(0, 0, Size.Width - 1, Size.Height - 1)); } + + public CellRegion CellRegion => + new(GridType, new MPos(0, 0), new MPos(Size.Width - 1, Size.Height - 1)); } // Helper functions diff --git a/OpenRA.Game/Map/Map.cs b/OpenRA.Game/Map/Map.cs index 2457675980..64f4b6a2c6 100644 --- a/OpenRA.Game/Map/Map.cs +++ b/OpenRA.Game/Map/Map.cs @@ -1145,6 +1145,11 @@ namespace OpenRA } public byte GetTerrainIndex(CPos cell) + { + return GetTerrainIndex(cell.ToMPos(this)); + } + + public byte GetTerrainIndex(MPos uv) { // Lazily initialize a cache for terrain indexes. if (cachedTerrainIndexes == null) @@ -1153,7 +1158,6 @@ namespace OpenRA cachedTerrainIndexes.Clear(InvalidCachedTerrainIndex); } - var uv = cell.ToMPos(this); var terrainIndex = cachedTerrainIndexes[uv]; // PERF: Cache terrain indexes per cell on demand. @@ -1168,7 +1172,12 @@ namespace OpenRA public TerrainTypeInfo GetTerrainInfo(CPos cell) { - return Rules.TerrainInfo.TerrainTypes[GetTerrainIndex(cell)]; + return GetTerrainInfo(cell.ToMPos(this)); + } + + public TerrainTypeInfo GetTerrainInfo(MPos uv) + { + return Rules.TerrainInfo.TerrainTypes[GetTerrainIndex(uv)]; } public CPos Clamp(CPos cell) diff --git a/OpenRA.Game/Map/TileReference.cs b/OpenRA.Game/Map/TileReference.cs index f26978ba47..1d3c44d4a1 100644 --- a/OpenRA.Game/Map/TileReference.cs +++ b/OpenRA.Game/Map/TileReference.cs @@ -25,6 +25,21 @@ namespace OpenRA public override int GetHashCode() { return Type.GetHashCode() ^ Index.GetHashCode(); } public override string ToString() { return Type + "," + Index; } + + public static bool TryParse(string s, out TerrainTile tt) + { + var split = s.Split(','); + if (split.Length == 2 && + Exts.TryParseUshortInvariant(split[0], out var type) && + Exts.TryParseByteInvariant(split[1], out var index)) + { + tt = new TerrainTile(type, index); + return true; + } + + tt = default; + return false; + } } public readonly struct ResourceTile diff --git a/OpenRA.Game/Primitives/PriorityArray.cs b/OpenRA.Game/Primitives/PriorityArray.cs new file mode 100644 index 0000000000..72225435aa --- /dev/null +++ b/OpenRA.Game/Primitives/PriorityArray.cs @@ -0,0 +1,130 @@ +#region Copyright & License Information +/* + * Copyright (c) The OpenRA Developers and Contributors + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +using System; + +namespace OpenRA.Primitives +{ + /// + /// A random-access array which keeps track of the minimum item. + /// + public sealed class PriorityArray where T : IComparable + { + readonly T[] items; + + readonly int[] itemIndexToHeapIndex; + readonly int[] heapOfItemIndices; + + /// + /// Create a new PriorityArray of given size with all values preset to the given init value. + /// + public PriorityArray(int size, T init) + { + items = new T[size]; + itemIndexToHeapIndex = new int[size]; + heapOfItemIndices = new int[size]; + Array.Fill(items, init); + for (var i = 0; i < size; i++) + { + items[i] = init; + itemIndexToHeapIndex[i] = i; + heapOfItemIndices[i] = i; + } + } + + public int Length => items.Length; + + /// Get the index of the minimum element. + public int GetMinIndex() => heapOfItemIndices[0]; + + public T this[int itemIndex] + { + get => items[itemIndex]; + set + { + items[itemIndex] = value; + Bubble(itemIndexToHeapIndex[itemIndex]); + } + } + + void Bubble(int heapIndex) + { + if (!BubbleUp(heapIndex)) + { + BubbleDown(heapIndex); + } + } + + bool BubbleUp(int heapIndex) + { + if (heapIndex == 0) + return false; + var itemIndex = heapOfItemIndices[heapIndex]; + var upHeapIndex = ((heapIndex + 1) >> 1) - 1; + var upItemIndex = heapOfItemIndices[upHeapIndex]; + if (items[itemIndex].CompareTo(items[upItemIndex]) >= 0) + return false; + Swap(heapIndex, upHeapIndex, itemIndex, upItemIndex); + BubbleUp(upHeapIndex); + return true; + } + + bool BubbleDown(int heapIndex) + { + var itemIndex = heapOfItemIndices[heapIndex]; + var leftDownHeapIndex = ((heapIndex + 1) << 1) - 1; + var rightDownHeapIndex = leftDownHeapIndex + 1; + if (leftDownHeapIndex >= Length) + return false; + var item = items[itemIndex]; + if (rightDownHeapIndex < Length) + { + var leftDownItemIndex = heapOfItemIndices[leftDownHeapIndex]; + var rightDownItemIndex = heapOfItemIndices[rightDownHeapIndex]; + var leftDownItem = items[leftDownItemIndex]; + var rightDownItem = items[rightDownItemIndex]; + if (item.CompareTo(leftDownItem) <= 0 && item.CompareTo(rightDownItem) <= 0) + return false; + if (leftDownItem.CompareTo(rightDownItem) <= 0) + { + Swap(heapIndex, leftDownHeapIndex, itemIndex, leftDownItemIndex); + BubbleDown(leftDownHeapIndex); + } + else + { + Swap(heapIndex, rightDownHeapIndex, itemIndex, rightDownItemIndex); + BubbleDown(rightDownHeapIndex); + } + + return true; + } + else + { + // Just the left down one exists. + var leftDownItemIndex = heapOfItemIndices[leftDownHeapIndex]; + var leftDownItem = items[leftDownItemIndex]; + if (item.CompareTo(leftDownItem) <= 0) + return false; + Swap(heapIndex, leftDownHeapIndex, itemIndex, leftDownItemIndex); + BubbleDown(leftDownHeapIndex); + return true; + } + } + + void Swap(int heapIndex1, int heapIndex2, int itemIndex1, int itemIndex2) + { + (itemIndexToHeapIndex[itemIndex1], itemIndexToHeapIndex[itemIndex2]) = + (itemIndexToHeapIndex[itemIndex2], itemIndexToHeapIndex[itemIndex1]); + (heapOfItemIndices[heapIndex1], heapOfItemIndices[heapIndex2]) = + (heapOfItemIndices[heapIndex2], heapOfItemIndices[heapIndex1]); + } + } +} diff --git a/OpenRA.Game/Primitives/Size.cs b/OpenRA.Game/Primitives/Size.cs index a1611c6864..2693d662a2 100644 --- a/OpenRA.Game/Primitives/Size.cs +++ b/OpenRA.Game/Primitives/Size.cs @@ -46,6 +46,16 @@ namespace OpenRA.Primitives public bool IsEmpty => Width == 0 && Height == 0; + public int2 ToInt2() + { + return new(Width, Height); + } + + public static Size FromInt2(int2 xy) + { + return new(xy.X, xy.Y); + } + public bool Equals(Size other) { return this == other; diff --git a/OpenRA.Game/Support/MersenneTwister.cs b/OpenRA.Game/Support/MersenneTwister.cs index 9c217a79a5..e83097c224 100644 --- a/OpenRA.Game/Support/MersenneTwister.cs +++ b/OpenRA.Game/Support/MersenneTwister.cs @@ -10,6 +10,8 @@ #endregion using System; +using System.Collections.Generic; +using System.Linq; namespace OpenRA.Support { @@ -32,7 +34,10 @@ namespace OpenRA.Support mt[i] = 1812433253u * (mt[i - 1] ^ (mt[i - 1] >> 30)) + i; } - public int Next() + /// + /// Produces an unsigned integer between -0x80000000 and 0x7fffffff inclusive. + /// + public uint NextUint() { if (index == 0) Generate(); @@ -45,6 +50,24 @@ namespace OpenRA.Support index = (index + 1) % 624; TotalCount++; Last = (int)(y % int.MaxValue); + return y; + } + + /// + /// Produces an unsigned integer between -0x80000000 and 0x7fffffff inclusive. + /// + public ulong NextUlong() + { + return (ulong)NextUint() << 32 | NextUint(); + } + + /// + /// Produces signed integers between -0x7fffffff and 0x7fffffff inclusive. + /// 0 is twice as likely as any other number. + /// + public int Next() + { + NextUint(); return Last; } @@ -65,11 +88,73 @@ namespace OpenRA.Support return Next(0, high); } + /// + /// Produces random 32-bit floats between 0 inclusive and 1 inclusive. + /// Note that whilst floats are 32-bit (23-bit mantissa), the entropy is not. Lower numbers preserve more entropy. + /// public float NextFloat() { return Math.Abs(Next() / (float)0x7fffffff); } + /// + /// Produces uniformally distributed random floats between 0 inclusive and 1 exclusive. + /// Note that whilst floats are 32-bit (23-bit mantissa), each output contains exactly 23 bits of entropy. + /// + public float NextFloatExclusive() + { + return (NextUint() & 0x7fffff) / (float)0x800000; + } + + /// + /// Produces uniformally distributed random doubles between 0 inclusive and 1 exclusive. + /// Note that whilst doubles are 64-bit (52-bit mantissa), each output contains exactly 52 bits of entropy. + /// + public double NextDoubleExclusive() + { + return (NextUlong() & 0xfffffffffffffL) / (double)0x10000000000000L; + } + + /// + /// Pick a random an index from a list of weights. + /// + public int PickWeighted(IReadOnlyList weights) + { + var total = weights.Sum(); + var spin = NextFloatExclusive() * total; + int i; + float acc = 0; + for (i = 0; i < weights.Count; i++) + { + acc += weights[i]; + if (spin < acc) + return i; + } + + // This might be possible due to floating point precision loss + // (in rare cases). Or we might have been given rubbish + // weights. Return anything > 0. + for (i = 0; i < weights.Count; i++) + if (weights[i] > 0) + return i; + + // All <= 0! + return Next(0, weights.Count); + } + + /// + /// Shuffle a portion of a list list in place. Has minor biases. + /// + public void ShuffleInPlace(IList list, int start, int len) + { + for (var i = len; i > 1; i--) + { + var swap = Next(i); + (list[start + i - 1], list[start + swap]) = + (list[start + swap], list[start + i - 1]); + } + } + void Generate() { unchecked diff --git a/OpenRA.Mods.Common/Lint/CheckMultiBrushes.cs b/OpenRA.Mods.Common/Lint/CheckMultiBrushes.cs new file mode 100644 index 0000000000..e573611411 --- /dev/null +++ b/OpenRA.Mods.Common/Lint/CheckMultiBrushes.cs @@ -0,0 +1,51 @@ +#region Copyright & License Information +/* + * Copyright (c) The OpenRA Developers and Contributors + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +using System; +using OpenRA.Mods.Common.MapGenerator; +using OpenRA.Mods.Common.Terrain; + +namespace OpenRA.Mods.Common.Lint +{ + public class CheckMultiBrushes : ILintPass + { + public void Run(Action emitError, Action emitWarning, ModData modData) + { + foreach (var (terrainInfoName, terrainInfo) in modData.DefaultTerrainInfo) + { + if (terrainInfo is ITemplatedTerrainInfo templatedTerrainInfo && templatedTerrainInfo.MultiBrushCollections.Count > 0) + { + var map = new Map(modData, terrainInfo, 1, 1); + foreach (var (collectionName, collection) in templatedTerrainInfo.MultiBrushCollections) + foreach (var info in collection) + { + try + { + // Includes validation of actor types and template IDs. + var multiBrush = new MultiBrush(map, info); + + // Validates there is at least something in the MultiBrush. + multiBrush.Contract(); + + foreach (var (_, tile) in multiBrush.Tiles) + if (!templatedTerrainInfo.TryGetTerrainInfo(tile, out var _)) + emitError($"Tileset {terrainInfoName} has invalid MultiBrush collection `{collectionName}`: tileset does not have tile {tile.Type},{tile.Index}"); + } + catch (Exception e) when (e is ArgumentException || e is InvalidOperationException) + { + emitError($"Tileset {terrainInfoName} has invalid MultiBrush collection `{collectionName}`: {e.Message}"); + } + } + } + } + } + } +} diff --git a/OpenRA.Mods.Common/MapGenerator/ActorPlan.cs b/OpenRA.Mods.Common/MapGenerator/ActorPlan.cs new file mode 100644 index 0000000000..4d36c42bd9 --- /dev/null +++ b/OpenRA.Mods.Common/MapGenerator/ActorPlan.cs @@ -0,0 +1,149 @@ +#region Copyright & License Information +/* + * Copyright (c) The OpenRA Developers and Contributors + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +using System; +using System.Collections.Generic; +using System.Linq; +using OpenRA.Mods.Common.Traits; +using OpenRA.Traits; + +namespace OpenRA.Mods.Common.MapGenerator +{ + /// Description of an actor to add to a map. + public sealed class ActorPlan + { + public readonly Map Map; + public readonly ActorInfo Info; + public readonly ActorReference Reference; + + public CPos Location + { + get => Reference.Get().Value; + set + { + Reference.RemoveAll(); + Reference.Add(new LocationInit(value)); + } + } + + public WPos WPosLocation + { + get => CellLayerUtils.CPosToWPos(Location, Map.Grid.Type); + set => Location = CellLayerUtils.WPosToCPos(value, Map.Grid.Type); + } + + /// + /// WPos representation of actor's center. + /// For example, A 1x2 actor on a Rectangular grid will have +WVec(0, 512, 0) + /// offset to its WPosLocation. + /// + public WPos WPosCenterLocation + { + get => WPosLocation + WVecCenterOffset(); + set => WPosLocation = value - WVecCenterOffset(); + } + + /// + /// Create an ActorPlan from a reference. The referenced actor becomes owned. + /// + public ActorPlan(Map map, ActorReference reference) + { + Map = map; + Reference = reference; + if (!map.Rules.Actors.TryGetValue(Reference.Type.ToLowerInvariant(), out Info)) + throw new ArgumentException($"MultiBrush Actor of unknown type `{Reference.Type.ToLowerInvariant()}`"); + } + + /// + /// Create an ActorPlan containing a new Neutral-owned actor of the given type. + /// + public ActorPlan(Map map, string type) + : this(map, ActorFromType(type)) + { } + + /// + /// Create a cloned actor plan, cloning the underlying ActorReference. + /// + public ActorPlan Clone() + { + return new ActorPlan(Map, Reference.Clone()); + } + + static ActorReference ActorFromType(string type) + { + return new ActorReference(type) + { + new LocationInit(default), + new OwnerInit("Neutral"), + }; + } + + /// + /// The footprint of the actor (influenced by its location). + /// + public IReadOnlyDictionary Footprint() + { + var location = Location; + var ios = Info.TraitInfoOrDefault(); + var subCellInit = Reference.GetOrDefault(); + var subCell = subCellInit != null ? subCellInit.Value : SubCell.Any; + + var occupiedCells = ios?.OccupiedCells(Info, location, subCell); + if (occupiedCells == null || occupiedCells.Count == 0) + return new Dictionary() { { location, SubCell.FullCell } }; + else + return occupiedCells; + } + + /// + /// Relocates the actor such that the top-most, left-most footprint + /// square is at (0, 0). + /// + public ActorPlan AlignFootprint() + { + var footprint = Footprint(); + var first = footprint.Select(kv => kv.Key).OrderBy(cpos => (cpos.Y, cpos.X)).First(); + Location -= new CVec(first.X, first.Y); + return this; + } + + /// + /// + /// Return a WVec center offset (from its WPosLocation) for the actor. + /// + /// + /// For example, for a 1x2 actor on a Rectangular grid, this would be WVec(0, 512, 0). + /// + /// + WVec WVecCenterOffset() + { + var bi = Info.TraitInfoOrDefault(); + if (bi == null) + return new WVec(0, 0, 0); + + var left = int.MaxValue; + var right = int.MinValue; + var top = int.MaxValue; + var bottom = int.MinValue; + foreach (var (cvec, type) in bi.Footprint) + { + if (type == FootprintCellType.Empty) + continue; + left = Math.Min(left, cvec.X); + top = Math.Min(top, cvec.Y); + right = Math.Max(right, cvec.X); + bottom = Math.Max(bottom, cvec.Y); + } + + return CellLayerUtils.CVecToWVec(new CVec(left + right, top + bottom), Map.Grid.Type) / 2; + } + } +} diff --git a/OpenRA.Mods.Common/MapGenerator/CellLayerUtils.cs b/OpenRA.Mods.Common/MapGenerator/CellLayerUtils.cs new file mode 100644 index 0000000000..00afee5935 --- /dev/null +++ b/OpenRA.Mods.Common/MapGenerator/CellLayerUtils.cs @@ -0,0 +1,500 @@ +#region Copyright & License Information +/* + * Copyright (c) The OpenRA Developers and Contributors + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using OpenRA.Primitives; +using OpenRA.Support; + +namespace OpenRA.Mods.Common.MapGenerator +{ + public static class CellLayerUtils + { + static int FloorDiv(int a, int b) + { + var q = Math.DivRem(a, b, out var r); + if (r < 0) + return q - 1; + else + return q; + } + + /// Return true iff a and b have the same grid type and size. + public static bool AreSameShape(CellLayer a, CellLayer b) + { + return a.Size == b.Size && a.GridType == b.GridType; + } + + /// + /// Returns the half-way point between the centers of the top-left (first) and bottom-right + /// (last) MPos cells. This will either lie in the exact center of a cell, an edge between + /// two cells, or a corner between four cells. Note that this might not fit all reasonable + /// or intuitive definitions of a map center, but has convenient properties. + /// + public static WPos Center(CellLayer cellLayer) + { + switch (cellLayer.GridType) + { + case MapGridType.Rectangular: + return new WPos( + cellLayer.Size.Width * 512, + cellLayer.Size.Height * 512, + 0); + case MapGridType.RectangularIsometric: + return new WPos( + (cellLayer.Size.Width * 2 + (~cellLayer.Size.Height & 1)) * 362, + (cellLayer.Size.Height + 1) * 362, + 0); + default: + throw new NotImplementedException(); + } + } + + /// Get the WPos of the -X-Y corner of a CPos cell. + public static WPos CornerToWPos(CPos xy, MapGridType gridType) + { + switch (gridType) + { + case MapGridType.Rectangular: + return new WPos( + xy.X * 1024, + xy.Y * 1024, + 0); + case MapGridType.RectangularIsometric: + return new WPos( + (xy.X - xy.Y) * 724 + 724, + (xy.X + xy.Y) * 724, + 0); + default: + throw new NotImplementedException(); + } + } + + /// Get the WVec representing the same translation as the given CVec. + public static WVec CVecToWVec(CVec cvec, MapGridType gridType) + { + switch (gridType) + { + case MapGridType.Rectangular: + return new WVec( + cvec.X * 1024, + cvec.Y * 1024, + 0); + case MapGridType.RectangularIsometric: + return new WVec( + (cvec.X - cvec.Y) * 724, + (cvec.X + cvec.Y) * 724, + 0); + default: + throw new NotImplementedException(); + } + } + + /// Get the WPos center of a CPos cell. + public static WPos CPosToWPos(CPos cpos, MapGridType gridType) + { + var wvec = CVecToWVec(new CVec(cpos.X, cpos.Y), gridType); + switch (gridType) + { + case MapGridType.Rectangular: + return new WPos(512, 512, 0) + wvec; + case MapGridType.RectangularIsometric: + return new WPos(724, 724, 0) + wvec; + default: + throw new NotImplementedException(); + } + } + + /// + /// Find the CPos cell in which a WPos position lies. WPos positions on + /// an edge or corner match the CPos with higher X and/or Y positions. + /// + public static CPos WPosToCPos(WPos wpos, MapGridType gridType) + { + switch (gridType) + { + case MapGridType.Rectangular: + return new CPos( + FloorDiv(wpos.X, 1024), + FloorDiv(wpos.Y, 1024), + 0); + case MapGridType.RectangularIsometric: + return new CPos( + FloorDiv(wpos.Y + wpos.X - 724, 1448), + FloorDiv(wpos.Y - wpos.X + 724, 1448), + 0); + default: + throw new NotImplementedException(); + } + } + + /// Get the WPos center of an MPos cell. + public static WPos MPosToWPos(MPos mpos, MapGridType gridType) + { + return CPosToWPos(mpos.ToCPos(gridType), gridType); + } + + /// + /// Find the MPos cell in which a WPos position lies. WPos positions on + /// an edge or corner match the CPos (not necessarily MPos) with higher + /// X and/or Y positions. + /// + public static MPos WPosToMPos(WPos wpos, MapGridType gridType) + { + return WPosToCPos(wpos, gridType).ToMPos(gridType); + } + + /// + /// Translates CPos-like positions to zero-based positions. + /// + public static int2[][] ToMatrixPoints( + IEnumerable pointArrayArray, CellLayer cellLayer) + { + var cellBounds = CellBounds(cellLayer); + return pointArrayArray + .Select(xys => xys + .Select(xy => new int2(xy.X - cellBounds.Left, xy.Y - cellBounds.Top)) + .ToArray()) + .ToArray(); + } + + /// + /// Translates zero-based positions to CPos-like positions. + /// + public static CPos[][] FromMatrixPoints( + IEnumerable pointArrayArray, CellLayer cellLayer) + { + var cellBounds = CellBounds(cellLayer); + return pointArrayArray + .Select(xys => xys + .Select(xy => new CPos(xy.X + cellBounds.Left, xy.Y + cellBounds.Top)) + .ToArray()) + .ToArray(); + } + + /// + /// + /// Run an action over the inside or outside of a circle of given center and radius in + /// world coordinates. The action is called with cells' MPos, CPos, WPos center, and the + /// squared distance to the WPos center from the circle's center. + /// If outside is true, the action is run for cells outside of the circle instead of the + /// inside. + /// + /// + /// A cell is inside the circle if its center is <= wRadius from wCenter. + /// Coordinates outside of the CellLayer are ignored. + /// + /// + public static void OverCircle( + CellLayer cellLayer, + WPos wCenter, + int wRadius, + bool outside, + Action action) + { + var gridType = cellLayer.GridType; + int minU; + int minV; + int maxU; + int maxV; + if (outside) + { + minU = 0; + minV = 0; + maxU = cellLayer.Size.Width - 1; + maxV = cellLayer.Size.Height - 1; + } + else + { + var mCenter = WPosToMPos(wCenter, gridType); + + var mRadiusU = wRadius / 1448 + 2; + var mRadiusV = wRadius / 724 + 1; + + minU = Math.Max(mCenter.U - mRadiusU, 0); + minV = Math.Max(mCenter.V - mRadiusV, 0); + maxU = Math.Min(mCenter.U + mRadiusU, cellLayer.Size.Width - 1); + maxV = Math.Min(mCenter.V + mRadiusV, cellLayer.Size.Height - 1); + } + + var wRadiusSquared = (long)wRadius * wRadius; + for (var v = minV; v <= maxV; v++) + for (var u = minU; u <= maxU; u++) + { + var mpos = new MPos(u, v); + var cpos = mpos.ToCPos(gridType); + var wpos = CPosToWPos(cpos, gridType); + var offset = wCenter - wpos; + var thisRadiusSquared = offset.LengthSquared; + if (thisRadiusSquared <= wRadiusSquared != outside) + action(mpos, cpos, wpos, thisRadiusSquared); + } + } + + /// + /// Return a linear copy of all entries in a CellLayer, ordered v * width + u, similar to + /// MPos(0, 0), MPos(1, 0), MPos(2, 0), ..., MPos(0, 1), MPos(1, 1), MPos(2, 1), ... + /// + public static T[] Entries(CellLayer cellLayer) + { + var i = 0; + var entries = new T[cellLayer.Size.Width * cellLayer.Size.Height]; + foreach (var value in cellLayer) + entries[i++] = value; + return entries; + } + + /// + /// Uniformally add to or subtract from all matrix cells such that the given quantile, + /// fraction, has the given target value. + /// + public static void CalibrateQuantileInPlace(CellLayer cellLayer, float target, float fraction) + { + var sorted = Entries(cellLayer); + Array.Sort(sorted); + var adjustment = target - MatrixUtils.ArrayQuantile(sorted, fraction); + foreach (var mpos in cellLayer.CellRegion.MapCoords) + cellLayer[mpos] += adjustment; + } + + /// + /// Get the smallest CPos rectangle that contains all cells for the specified grid. + /// + public static Rectangle CellBounds(Size size, MapGridType gridType) + { + switch (gridType) + { + case MapGridType.Rectangular: + return new Rectangle(0, 0, size.Width, size.Height); + + case MapGridType.RectangularIsometric: + { + var maxCX = + new MPos(size.Width - 1, size.Height - 1) + .ToCPos(gridType).X; + var minCY = + new MPos(size.Width - 1, 0) + .ToCPos(gridType).Y; + var maxCY = + new MPos(0, size.Height - 1) + .ToCPos(gridType).Y; + return Rectangle.FromLTRB(0, minCY, maxCX + 1, maxCY + 1); + } + + default: + throw new NotImplementedException(); + } + } + + /// + /// Get the smallest CPos rectangle that contains all cells in a CellLayer. + /// + public static Rectangle CellBounds(CellLayer cellLayer) + { + return CellBounds(cellLayer.Size, cellLayer.GridType); + } + + /// + /// Get the smallest CPos rectangle that contains all cells in a map. + /// + public static Rectangle CellBounds(Map map) + { + return CellBounds(new Size(map.MapSize.X, map.MapSize.Y), map.Grid.Type); + } + + /// + /// Copies a CPos-aligned Matrix into a CellLayer. Depending on the grid type, this may + /// discard data for cells that don't exist in the CellLayer. + /// + public static void FromMatrix( + CellLayer cellLayer, + Matrix matrix, + bool allowOversizedMatrix = false) + { + var cellBounds = CellBounds(cellLayer); + var size = cellBounds.Size.ToInt2(); + if (allowOversizedMatrix) + { + if (matrix.Size.X < size.X || matrix.Size.Y < size.Y) + throw new ArgumentException("source Matrix does not cover destination CellLayer"); + } + else if (matrix.Size != size) + { + throw new ArgumentException("destination and source have incompatible sizes"); + } + + foreach (var cpos in cellLayer.CellRegion) + cellLayer[cpos] = matrix[cpos.X - cellBounds.Left, cpos.Y - cellBounds.Top]; + } + + /// + /// Copies a CellLayer into a CPos-aligned Matrix. Depending on the grid type, this may + /// fill the matrix with some default values for cells that don't exist in the CellLayer. + /// + public static Matrix ToMatrix(CellLayer cellLayer, T defaultValue) + { + var cellBounds = CellBounds(cellLayer); + var matrix = new Matrix(cellBounds.Size.ToInt2()).Fill(defaultValue); + foreach (var cpos in cellLayer.CellRegion) + matrix[cpos.X - cellBounds.Left, cpos.Y - cellBounds.Top] = cellLayer[cpos]; + return matrix; + } + + /// Wrapper around MatrixUtils.BordersToPoints in CPos space. + public static CPos[][] BordersToPoints(CellLayer cellLayer, CellLayer mask = null) + { + if (mask != null) + { + if (!AreSameShape(cellLayer, mask)) + throw new ArgumentException("cellLayer and mask must have same shape."); + } + else + { + mask = new CellLayer(cellLayer.GridType, cellLayer.Size); + mask.Clear(true); + } + + var matrix = ToMatrix(cellLayer, false); + var maskMatrix = ToMatrix(mask, false); + var matrixPoints = MatrixUtils.BordersToPoints(matrix, maskMatrix); + return FromMatrixPoints(matrixPoints, cellLayer); + } + + /// Wrapper around MatrixUtils.ChebyshevRoom in CPos space. + public static void ChebyshevRoom( + CellLayer output, + CellLayer input, + bool outsideValue) + { + var matrix = ToMatrix(input, outsideValue); + var roominess = MatrixUtils.ChebyshevRoom(matrix, outsideValue); + FromMatrix(output, roominess); + } + + /// Wrapper around MatrixUtils.WalkingDistance in CPos space. + public static void WalkingDistances( + CellLayer distances, + CellLayer passable, + IEnumerable seeds, + float maxDistance) + { + var passableMatrix = ToMatrix(passable, false); + var cellBounds = CellBounds(passable); + var int2Seeds = seeds + .Select(cpos => new int2(cpos.X - cellBounds.Left, cpos.Y - cellBounds.Top)); + var distancesMatrix = MatrixUtils.WalkingDistances(passableMatrix, int2Seeds, maxDistance); + FromMatrix(distances, distancesMatrix); + } + + /// + /// Rank all cell values and select the best (greatest compared) value. + /// If there are equally good best candidates, choose one at random. + /// + public static (MPos MPos, T Value) FindRandomBest( + CellLayer cellLayer, + MersenneTwister random, + Comparison comparison) + { + var candidates = new List(); + var best = cellLayer[new MPos(0, 0)]; + foreach (var mpos in cellLayer.CellRegion.MapCoords) + { + var rank = comparison(cellLayer[mpos], best); + if (rank > 0) + { + best = cellLayer[mpos]; + candidates.Clear(); + } + + if (rank >= 0) + candidates.Add(mpos); + } + + var choice = candidates[random.Next(candidates.Count)]; + return (choice, best); + } + + /// + /// Pick a random MPos position in a CellLayer where each cell is a + /// selection weight. + /// + public static MPos PickWeighted(CellLayer weights, MersenneTwister random) + { + var entries = Entries(weights); + var choice = random.PickWeighted(entries); + var v = Math.DivRem(choice, weights.Size.Width, out var u); + return new MPos(u, v); + } + + /// + /// + /// Perform a generic flood fill starting at seeds [(cpos, prop), ...]. + /// + /// + /// For each point being considered for fill, filler(cpos, prop) is + /// called with the current position (cpos) and propagation value (prop). + /// filler should return the value to be propagated or null if not to be + /// propagated. Propagation happens to all neighbours (offsets) defined + /// by spread, regardless of whether they have previously been visited, + /// so filler is responsible for terminating propagation by returning + /// nulls. Usually, Direction.Spread4CVec or Direction.Spread8CVec + /// is appropriate as a spread pattern. + /// + /// + /// filler should capture and manipulate any necessary input and output + /// arrays. + /// + /// + /// Each call to filler will have either an equal or greater + /// growth/propagation distance from their seed value than all calls + /// before it. (You can think of this as them being called in ordered + /// growth layers.) + /// + /// + /// Note that filler may be called multiple times for the same spot, + /// perhaps with different propagation values. Within the same + /// growth/propagation distance, filler will be called from values + /// propagated from earlier seeds before values propagated from later + /// seeds. + /// + /// + /// filler is not called for positions outside of cellLayer EXCEPT for + /// points being processed as seed values. + /// + /// + public static void FloodFill( + CellLayer cellLayer, + IEnumerable<(CPos CPos, P Prop)> seeds, + Func filler, + ImmutableArray spread) where P : struct + { + var next = seeds.ToList(); + while (next.Count != 0) + { + var current = next; + next = new List<(CPos, P)>(); + foreach (var (source, prop) in current) + { + var newProp = filler(source, prop); + if (newProp != null) + foreach (var offset in spread) + { + var destination = source + offset; + if (cellLayer.Contains(destination)) + next.Add((destination, (P)newProp)); + } + } + } + } + } +} diff --git a/OpenRA.Mods.Common/MapGenerator/Direction.cs b/OpenRA.Mods.Common/MapGenerator/Direction.cs new file mode 100644 index 0000000000..08398f50ac --- /dev/null +++ b/OpenRA.Mods.Common/MapGenerator/Direction.cs @@ -0,0 +1,296 @@ +#region Copyright & License Information +/* + * Copyright (c) The OpenRA Developers and Contributors + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +using System; +using System.Collections.Immutable; +using System.Linq; + +namespace OpenRA.Mods.Common.MapGenerator +{ + /// + /// Utilities for simple directions and adjacency. Note that coordinate systems might not agree + /// as to which directions are conceptually left/right or up/down. + /// + public static class Direction + { + /// No direction. + public const int None = -1; + + /// +X ("right"). + public const int R = 0; + + /// +X+Y ("right down"). + public const int RD = 1; + + /// +Y ("down"). + public const int D = 2; + + /// -X+Y ("left down"). + public const int LD = 3; + + /// -X ("left"). + public const int L = 4; + + /// -X-Y ("left up"). + public const int LU = 5; + + /// -Y ("up"). + public const int U = 6; + + /// +X-Y ("right up"). + public const int RU = 7; + + /// Bitmask right. + public const int MR = 1 << R; + + /// Bitmask right-down. + public const int MRD = 1 << RD; + + /// Bitmask down. + public const int MD = 1 << D; + + /// Bitmask left-down. + public const int MLD = 1 << LD; + + /// Bitmask left. + public const int ML = 1 << L; + + /// Bitmask left-up. + public const int MLU = 1 << LU; + + /// Bitmask up. + public const int MU = 1 << U; + + /// Bitmask right-up. + public const int MRU = 1 << RU; + + /// Adjacent offsets with directions, excluding diagonals. + public static readonly ImmutableArray<(int2, int)> Spread4D = ImmutableArray.Create(new[] + { + (new int2(1, 0), R), + (new int2(0, 1), D), + (new int2(-1, 0), L), + (new int2(0, -1), U) + }); + + /// Adjacent offsets, excluding diagonals. + public static readonly ImmutableArray Spread4 = + Spread4D.Select(((int2 XY, int _) v) => v.XY).ToImmutableArray(); + + /// + /// Adjacent offsets, excluding diagonals. Assumes that CVec(1, 0) + /// corresponds to Direction.R. + /// + public static readonly ImmutableArray Spread4CVec = + Spread4.Select(xy => new CVec(xy.X, xy.Y)).ToImmutableArray(); + + /// Adjacent offsets with directions, including diagonals. + public static readonly ImmutableArray<(int2, int)> Spread8D = ImmutableArray.Create(new[] + { + (new int2(1, 0), R), + (new int2(1, 1), RD), + (new int2(0, 1), D), + (new int2(-1, 1), LD), + (new int2(-1, 0), L), + (new int2(-1, -1), LU), + (new int2(0, -1), U), + (new int2(1, -1), RU) + }); + + /// Adjacent offsets, including diagonals. + public static readonly ImmutableArray Spread8 = + Spread8D.Select(((int2 XY, int _) v) => v.XY).ToImmutableArray(); + + /// + /// Adjacent offsets, including diagonals. Assumes that CVec(1, 0) + /// corresponds to Direction.R. + /// + public static readonly ImmutableArray Spread8CVec = + Spread8.Select(xy => new CVec(xy.X, xy.Y)).ToImmutableArray(); + + /// Convert a non-none direction to an int2 offset. + public static int2 ToInt2(int d) + { + if (d >= 0 && d < 8) + return Spread8[d]; + else + throw new ArgumentException("bad direction"); + } + + /// + /// Convert a non-none direction to a CVec offset. Assumes that + /// CVec(1, 0) corresponds to Direction.R. + /// + public static CVec ToCVec(int d) + { + if (d >= 0 && d < 8) + return Spread8CVec[d]; + else + throw new ArgumentException("bad direction"); + } + + /// + /// Convert an offset (of arbitrary non-zero magnitude) to a direction. + /// Supplying a zero-offset will throw. + /// + public static int FromOffset(int dx, int dy) + { + if (dx > 0) + { + if (dy > 0) + return RD; + else if (dy < 0) + return RU; + else + return R; + } + else if (dx < 0) + { + if (dy > 0) + return LD; + else if (dy < 0) + return LU; + else + return L; + } + else + { + if (dy > 0) + return D; + else if (dy < 0) + return U; + else + throw new ArgumentException("Bad direction"); + } + } + + /// + /// Convert an offset (of arbitrary non-zero magnitude) to a direction. + /// Supplying a zero-offset will throw. + /// + public static int FromInt2(int2 delta) + => FromOffset(delta.X, delta.Y); + + /// + /// Convert an offset (of arbitrary non-zero magnitude) to a direction. + /// Supplying a zero-offset will throw. Assumes that CVec(1, 0) + /// corresponds to Direction.R. + /// + public static int FromCVec(CVec delta) + => FromOffset(delta.X, delta.Y); + + /// + /// Convert an offset (of arbitrary non-zero magnitude) to a non-diagonal direction. + /// Supplying a zero-offset will throw. + /// + public static int FromOffsetNonDiagonal(int dx, int dy) + { + if (dx - dy > 0 && dx + dy >= 0) + return R; + if (dy + dx > 0 && dy - dx >= 0) + return D; + if (-dx + dy > 0 && -dx - dy >= 0) + return L; + if (-dy - dx > 0 && -dy + dx >= 0) + return U; + throw new ArgumentException("bad direction"); + } + + /// + /// Convert an offset (of arbitrary non-zero magnitude) to a + /// non-diagonal direction. Supplying a zero-offset will throw. + /// + public static int FromInt2NonDiagonal(int2 delta) + => FromOffsetNonDiagonal(delta.X, delta.Y); + + /// + /// Convert an offset (of arbitrary non-zero magnitude) to a + /// non-diagonal direction. Supplying a zero-offset will throw. Assumes + /// that CVec(1, 0) corresponds to Direction.R. + /// + public static int FromCVecNonDiagonal(CVec delta) + => FromOffsetNonDiagonal(delta.X, delta.Y); + + /// Return the opposite direction. + public static int Reverse(int direction) + { + if (direction == None) + return None; + return direction ^ 4; + } + + /// Convert a direction to a short string, like "None", "R", "RD", etc. + public static string ToString(int direction) + { + switch (direction) + { + case None: return "None"; + case R: return "R"; + case RD: return "RD"; + case D: return "D"; + case LD: return "LD"; + case L: return "L"; + case LU: return "LU"; + case U: return "U"; + case RU: return "RU"; + default: throw new ArgumentException("bad direction"); + } + } + + /// Count the number of set bits in a direction mask. + public static int Count(int dm) + { + var count = 0; + for (var m = dm; m != 0; m >>= 1) + if ((m & 1) == 1) + count++; + + return count; + } + + /// Finds the only direction set in a direction mask or returns NONE. + public static int FromMask(int mask) + { + switch (mask) + { + case MR: return R; + case MRD: return RD; + case MD: return D; + case MLD: return LD; + case ML: return L; + case MLU: return LU; + case MU: return U; + case MRU: return RU; + default: return None; + } + } + + /// True if diagonal, false if horizontal/vertical, throws otherwise. + public static bool IsDiagonal(int direction) + { + switch (direction) + { + case R: + case D: + case L: + case U: + return false; + case RD: + case LD: + case LU: + case RU: + return true; + default: + throw new ArgumentException("NONE or bad direction"); + } + } + } +} diff --git a/OpenRA.Mods.Common/MapGenerator/MapGeneratorSettings.cs b/OpenRA.Mods.Common/MapGenerator/MapGeneratorSettings.cs new file mode 100644 index 0000000000..64e7700b5a --- /dev/null +++ b/OpenRA.Mods.Common/MapGenerator/MapGeneratorSettings.cs @@ -0,0 +1,364 @@ +#region Copyright & License Information +/* + * Copyright (c) The OpenRA Developers and Contributors + * This file is part of OpenRA, which is free software. It is made + * available to you under the terms of the GNU General Public License + * as published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. For more + * information, see COPYING. + */ +#endregion + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace OpenRA.Mods.Common.MapGenerator +{ + public sealed class MapGeneratorSettings + { + /// How an option should be treated for UI purposes. + public enum UiType + { + Hidden, + DropDown, + Checkbox, + Integer, + Float, + String, + } + + /// Represents a bunch of settings, along with UI information. + public sealed class Choice + { + /// Uniquely identifies a Choice within an Option. + [FieldLoader.Ignore] + public readonly string Id; + + /// The label to use for UI selection. Post-fluent. + [FieldLoader.Ignore] + public readonly string Label = null; + + /// Only offer the Choice for this tileset. (If null, show for all.) + public readonly string Tileset = null; + + /// (Partial) settings to combine into the final overall settings. + [FieldLoader.LoadUsing(nameof(SettingsLoader))] + public readonly MiniYaml Settings; + + public Choice(string id, MiniYaml my) + { + Id = id; + FieldLoader.Load(this, my); + var label = my.NodeWithKeyOrDefault("Label")?.Value.Value; + if (label != null) + Label = FluentProvider.GetMessage(label); + } + + /// Create a choice that represents a top-level setting with a given value. + public Choice(string setting, string value) + { + Id = value; + Label = value; + Tileset = null; + Settings = new MiniYaml(null, new[] { new MiniYamlNode(setting, value) }); + } + + public static void DumpFluent(MiniYaml my, List references) + { + var label = my.NodeWithKeyOrDefault("Label")?.Value.Value; + if (label != null) + references.Add(label); + } + + static MiniYaml SettingsLoader(MiniYaml my) => my.NodeWithKey("Settings").Value; + + /// Check whether this choice is permitted for this map. + public bool Allowed(Map map) + { + if (Tileset != null && map.Tileset != Tileset) + return false; + + return true; + } + + /// For single-setting choices, creates a new choice for that setting with a given value. + public Choice NewValue(string value) + { + if (Settings.Nodes.Length != 1) + throw new InvalidOperationException("NewValue can only be used on single-setting Choices"); + return new(Settings.Nodes[0].Key, value); + } + } + + public sealed class Option + { + /// Unique identifier for this Option. + [FieldLoader.Ignore] + public readonly string Id; + + /// The label to use for UI selection. Post-fluent. + [FieldLoader.Ignore] + public readonly string Label = null; + + /// + /// For multiple-choice options (dropdowns/checkboxes) holds the allowed Choices. + /// For text entry Options, contains the default value. + /// If this is empty, the map is not compatible with the generator. + /// + [FieldLoader.Ignore] + public readonly IReadOnlyList Choices; + + /// + /// The default Choice for this option. + /// Default will be ignored if it is not valid for the map. + /// + [FieldLoader.Ignore] + public readonly Choice Default; + + public readonly double Min = double.NegativeInfinity; + public readonly double Max = double.PositiveInfinity; + + /// Whether the Option can be randomized. + public readonly bool Random = false; + + /// How an option should be treated for UI purposes. + [FieldLoader.Ignore] + public readonly UiType Ui; + + /// Settings layering priority. Higher overrides lower. + public readonly int Priority = 0; + + public Option(string id, MiniYaml my, Map map) + { + Id = id; + FieldLoader.Load(this, my); + var label = my.NodeWithKeyOrDefault("Label")?.Value.Value; + if (label != null) + Label = FluentProvider.GetMessage(label); + + var choices = new List(); + Choices = choices; + + var integerSetting = my.NodeWithKeyOrDefault("Integer"); + var floatSetting = my.NodeWithKeyOrDefault("Float"); + var stringSetting = my.NodeWithKeyOrDefault("String"); + var checkboxSetting = my.NodeWithKeyOrDefault("Checkbox"); + var simpleChoiceSetting = my.NodeWithKeyOrDefault("SimpleChoice"); + var textSetting = integerSetting ?? floatSetting ?? stringSetting; + if (textSetting != null) + { + var setting = textSetting.Value.Value; + var value = my.NodeWithKeyOrDefault("Default")?.Value.Value ?? ""; + var choice = new Choice(setting, value); + choices.Add(choice); + Default = choice; + Ui = + integerSetting != null ? UiType.Integer : + floatSetting != null ? UiType.Float : + UiType.String; + + if (Ui == UiType.Integer) + { + if (Min == double.NegativeInfinity) + Min = int.MinValue; + if (Max == double.PositiveInfinity) + Max = int.MaxValue; + } + } + else if (checkboxSetting != null) + { + var setting = checkboxSetting.Value.Value; + var falseChoice = new Choice(setting, "False"); + var trueChoice = new Choice(setting, "True"); + choices.Add(falseChoice); + choices.Add(trueChoice); + var enabled = (my.NodeWithKeyOrDefault("Default")?.Value.Value ?? "False") == "True"; + Default = enabled ? trueChoice : falseChoice; + Ui = UiType.Checkbox; + } + else + { + if (simpleChoiceSetting != null) + { + var setting = simpleChoiceSetting.Value.Value; + var values = simpleChoiceSetting.Value + .NodeWithKey("Values").Value.Value + .Split(','); + foreach (var value in values) + choices.Add(new Choice(setting, value)); + } + else + { + foreach (var node in my.Nodes) + { + var split = node.Key.Split('@'); + if (split[0] == "Choice") + { + string choiceId = null; + if (split.Length >= 2) + choiceId = split[1]; + var choice = new Choice(choiceId, node.Value); + if (choice.Allowed(map)) + choices.Add(choice); + } + } + } + + if (Choices.Count > 0) + { + var defaultNode = my.NodeWithKeyOrDefault("Default"); + if (defaultNode != null) + { + Default = Choices.FirstOrDefault(choice => choice.Id == defaultNode.Value.Value); + if (Default == null) + throw new YamlException($"Option `{id}` default choice `{defaultNode.Value.Value}` is not valid"); + } + else + { + Default = Choices[0]; + } + } + + Ui = Label == null ? UiType.Hidden : UiType.DropDown; + } + } + + public static void DumpFluent(MiniYaml my, List references) + { + var label = my.NodeWithKeyOrDefault("Label")?.Value.Value; + if (label != null) + references.Add(label); + foreach (var node in my.Nodes) + if (node.Key.Split('@')[0] == "Choice") + Choice.DumpFluent(node.Value, references); + } + + public bool ValidateChoice(Choice choice) + { + switch (Ui) + { + case UiType.Integer: + { + var valid = long.TryParse(choice.Id, out var value); + if (value < Min || value > Max) + valid = false; + return valid; + } + + case UiType.Float: + { + var valid = double.TryParse(choice.Id, out var value); + if (value < Min || value > Max) + valid = false; + return valid; + } + + case UiType.String: + return true; + + default: + return Choices.Contains(choice); + } + } + + public Choice RandomChoice() + { + if (Random) + { + var random = (long)Guid.NewGuid().GetHashCode() - int.MinValue; + switch (Ui) + { + case UiType.Integer: + var intRandom = (int)(random % (long)(Max - Min + 1) + (long)Min); + return Default.NewValue(intRandom.ToStringInvariant()); + case UiType.Float: + var floatRandom = (float)((double)0xffffffff * random / (Max - Min) + Min); + return Default.NewValue(floatRandom.ToStringInvariant()); + case UiType.Checkbox: + case UiType.DropDown: + if (Choices.Count == 0) + throw new InvalidOperationException($"Map is not compatible with Option `{Id}`"); + return Choices[(int)(random % Choices.Count)]; + default: + throw new InvalidOperationException($"Option `{Id}` does not have a randomizable type"); + } + } + else + { + return Default; + } + } + } + + public readonly IReadOnlyList