diff --git a/OpenRA.Mods.Common/Pathfinder/HierarchicalPathFinder.cs b/OpenRA.Mods.Common/Pathfinder/HierarchicalPathFinder.cs index ddfe4f5451..2cef1b7a9f 100644 --- a/OpenRA.Mods.Common/Pathfinder/HierarchicalPathFinder.cs +++ b/OpenRA.Mods.Common/Pathfinder/HierarchicalPathFinder.cs @@ -112,6 +112,14 @@ namespace OpenRA.Mods.Common.Pathfinder /// Dictionary> abstractGraph; + /// + /// The abstract domains are represented here. + /// An abstract node is the key, and a domain index is given. + /// If the domain index of two nodes is equal, a path exists between them (ignoring all blocking actors). + /// If unequal, no path is possible. + /// + readonly Dictionary abstractDomains; + /// /// Knows about the abstract nodes within a grid. Can map a local cell to its abstract node. /// @@ -227,20 +235,27 @@ namespace OpenRA.Mods.Common.Pathfinder BuildGrids(); BuildCostTable(); + abstractDomains = new Dictionary(gridXs * gridYs); + RebuildDomains(); // When we build the cost table, it depends on the movement costs of the cells at that time. // When this changes, we must update the cost table. locomotor.CellCostChanged += RequireCostRefreshInCell; } - public IReadOnlyDictionary> GetOverlayData() + public ( + IReadOnlyDictionary> AbstractGraph, + IReadOnlyDictionary AbstractDomains) GetOverlayData() { if (costEstimator == null) return default; - // Ensure the abstract graph is up to date when using the overlay. + // Ensure the abstract graph and domains are up to date when using the overlay. RebuildDirtyGrids(); - return new ReadOnlyDictionary>(abstractGraph); + RebuildDomains(); + return ( + new ReadOnlyDictionary>(abstractGraph), + new ReadOnlyDictionary(abstractDomains)); } /// @@ -704,6 +719,31 @@ namespace OpenRA.Mods.Common.Pathfinder } } + /// + /// Determines if a path exists between source and target. + /// Only terrain is taken into account, i.e. as if was given. + /// This would apply for any actor using the same as this . + /// + public bool PathExists(CPos source, CPos target) + { + if (costEstimator == null) + return false; + + RebuildDomains(); + + var sourceGridInfo = gridInfos[GridIndex(source)]; + var targetGridInfo = gridInfos[GridIndex(target)]; + var abstractSource = sourceGridInfo.AbstractCellForLocalCell(source); + if (abstractSource == null) + return false; + var abstractTarget = targetGridInfo.AbstractCellForLocalCell(target); + if (abstractTarget == null) + return false; + var sourceDomain = abstractDomains[abstractSource.Value]; + var targetDomain = abstractDomains[abstractTarget.Value]; + return sourceDomain == targetDomain; + } + /// /// The abstract graph can become out of date when reachability costs for terrain change. /// When this occurs, we must rebuild any affected parts of the abstract graph so it remains correct. @@ -713,6 +753,9 @@ namespace OpenRA.Mods.Common.Pathfinder if (dirtyGridIndexes.Count == 0) return; + // An empty domain indicates it is out of date and will require rebuilding when next accessed. + abstractDomains.Clear(); + var customMovementLayers = world.GetCustomMovementLayers(); foreach (var gridIndex in dirtyGridIndexes) { @@ -767,6 +810,48 @@ namespace OpenRA.Mods.Common.Pathfinder } } + /// + /// The abstract domains can become out of date when the abstract graph changes. + /// When this occurs, we must rebuild the domain cache. + /// + void RebuildDomains() + { + // First, rebuild the abstract graph if it is out of date. + RebuildDirtyGrids(); + + // Check if our domain cache is empty, if so this indicates it is out-of-date and needs rebuilding. + if (abstractDomains.Count != 0) + return; + + List AbstractEdge(CPos abstractCell) + { + if (abstractGraph.TryGetValue(abstractCell, out var abstractEdge)) + return abstractEdge; + return null; + } + + // As in BuildGrid, flood fill the search graph until all disjoint domains are discovered. + var domain = 0u; + var abstractCells = new HashSet(abstractGraph.Count); + foreach (var grid in gridInfos) + grid.CopyAbstractCellsInto(abstractCells); + while (abstractCells.Count > 0) + { + var searchCell = abstractCells.First(); + var search = PathSearch.ToTargetCellOverGraph( + AbstractEdge, + locomotor, + searchCell, + searchCell, + abstractGraph.Count / 8); + var searched = search.ExpandAll(); + foreach (var abstractCell in searched) + abstractDomains.Add(abstractCell, domain); + abstractCells.ExceptWith(searched); + domain++; + } + } + /// /// Maps a local cell to a abstract node in the graph. /// Returns null when the local cell is unreachable. diff --git a/OpenRA.Mods.Common/Traits/BotModules/Squads/States/NavyStates.cs b/OpenRA.Mods.Common/Traits/BotModules/Squads/States/NavyStates.cs index 8b9e599778..0a2719fe31 100644 --- a/OpenRA.Mods.Common/Traits/BotModules/Squads/States/NavyStates.cs +++ b/OpenRA.Mods.Common/Traits/BotModules/Squads/States/NavyStates.cs @@ -28,12 +28,11 @@ namespace OpenRA.Mods.Common.Traits.BotModules.Squads // Navy squad AI can exploit enemy naval production to find path, if any. // (Way better than finding a nearest target which is likely to be on Ground) // You might be tempted to move these lookups into Activate() but that causes null reference exception. - var domainIndex = first.World.WorldActor.Trait(); - var locomotor = first.Trait().Locomotor; + var mobile = first.Trait(); var navalProductions = owner.World.ActorsHavingTrait().Where(a => owner.SquadManager.Info.NavalProductionTypes.Contains(a.Info.Name) - && domainIndex.IsPassable(first.Location, a.Location, locomotor) + && mobile.PathFinder.PathExistsForLocomotor(mobile.Locomotor, first.Location, a.Location) && a.AppearsHostileTo(first)); if (navalProductions.Any()) diff --git a/OpenRA.Mods.Common/Traits/Buildings/Bridge.cs b/OpenRA.Mods.Common/Traits/Buildings/Bridge.cs index 4bb1a38645..97c447a7bf 100644 --- a/OpenRA.Mods.Common/Traits/Buildings/Bridge.cs +++ b/OpenRA.Mods.Common/Traits/Buildings/Bridge.cs @@ -323,11 +323,6 @@ namespace OpenRA.Mods.Common.Traits radarSignature[i++] = (c, tileInfo.GetColor(self.World.LocalRandom)); } - // If this bridge repair operation connects two pathfinding domains, - // update the domain index. - var domainIndex = self.World.WorldActor.Trait(); - domainIndex.UpdateCells(self.World, footprint.Keys); - if (LongBridgeSegmentIsDead() && !killedUnits) { killedUnits = true; diff --git a/OpenRA.Mods.Common/Traits/Buildings/GroundLevelBridge.cs b/OpenRA.Mods.Common/Traits/Buildings/GroundLevelBridge.cs index 11d37839df..af60e69a81 100644 --- a/OpenRA.Mods.Common/Traits/Buildings/GroundLevelBridge.cs +++ b/OpenRA.Mods.Common/Traits/Buildings/GroundLevelBridge.cs @@ -70,8 +70,6 @@ namespace OpenRA.Mods.Common.Traits { foreach (var cell in cells) self.World.Map.CustomTerrain[cell] = terrainIndex; - - self.World.WorldActor.Trait().UpdateCells(self.World, cells); } void INotifyAddedToWorld.AddedToWorld(Actor self) diff --git a/OpenRA.Mods.Common/Traits/ProductionFromMapEdge.cs b/OpenRA.Mods.Common/Traits/ProductionFromMapEdge.cs index 351b0faeaf..3b8ed8f930 100644 --- a/OpenRA.Mods.Common/Traits/ProductionFromMapEdge.cs +++ b/OpenRA.Mods.Common/Traits/ProductionFromMapEdge.cs @@ -25,13 +25,13 @@ namespace OpenRA.Mods.Common.Traits class ProductionFromMapEdge : Production { readonly CPos? spawnLocation; - readonly DomainIndex domainIndex; + readonly IPathFinder pathFinder; RallyPoint rp; public ProductionFromMapEdge(ActorInitializer init, ProductionInfo info) : base(init, info) { - domainIndex = init.Self.World.WorldActor.Trait(); + pathFinder = init.Self.World.WorldActor.Trait(); var spawnLocationInit = init.GetOrDefault(info); if (spawnLocationInit != null) @@ -65,7 +65,7 @@ namespace OpenRA.Mods.Common.Traits { var locomotor = self.World.WorldActor.TraitsImplementing().First(l => l.Info.Name == mobileInfo.Locomotor); location = self.World.Map.ChooseClosestMatchingEdgeCell(self.Location, - c => mobileInfo.CanEnterCell(self.World, null, c) && domainIndex.IsPassable(c, destinations[0], locomotor)); + c => mobileInfo.CanEnterCell(self.World, null, c) && pathFinder.PathExistsForLocomotor(locomotor, c, destinations[0])); } } diff --git a/OpenRA.Mods.Common/Traits/World/DomainIndex.cs b/OpenRA.Mods.Common/Traits/World/DomainIndex.cs deleted file mode 100644 index a13fb365cb..0000000000 --- a/OpenRA.Mods.Common/Traits/World/DomainIndex.cs +++ /dev/null @@ -1,254 +0,0 @@ -#region Copyright & License Information -/* - * Copyright 2007-2022 The OpenRA Developers (see AUTHORS) - * 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.Collections.Generic; -using System.Linq; -using OpenRA.Graphics; -using OpenRA.Support; -using OpenRA.Traits; - -namespace OpenRA.Mods.Common.Traits -{ - [TraitLocation(SystemActors.World)] - [Desc("Identify untraversable regions of the map for faster pathfinding, especially with AI.", - "This trait is required. Every mod needs it attached to the world actor.")] - class DomainIndexInfo : TraitInfo { } - - public class DomainIndex : IWorldLoaded - { - Dictionary domainIndexes; - - public void WorldLoaded(World world, WorldRenderer wr) - { - domainIndexes = new Dictionary(); - var locomotors = world.WorldActor.TraitsImplementing().Where(l => !string.IsNullOrEmpty(l.Info.Name)); - var movementClasses = locomotors.Select(t => t.MovementClass).Distinct(); - - foreach (var mc in movementClasses) - domainIndexes[mc] = new MovementClassDomainIndex(world, mc); - } - - public bool IsPassable(CPos p1, CPos p2, Locomotor locomotor) - { - // HACK: Work around units in other movement layers from being blocked - // when the point in the main layer is not pathable - if (p1.Layer != 0 || p2.Layer != 0) - return true; - - if (locomotor.Info.DisableDomainPassabilityCheck) - return true; - - return domainIndexes[locomotor.MovementClass].IsPassable(p1, p2); - } - - /// Regenerate the domain index for a group of cells. - public void UpdateCells(World world, IEnumerable cells) - { - var dirty = cells.ToHashSet(); - foreach (var index in domainIndexes) - index.Value.UpdateCells(world, dirty); - } - - public void AddFixedConnection(IEnumerable cells) - { - foreach (var index in domainIndexes) - index.Value.AddFixedConnection(cells); - } - } - - class MovementClassDomainIndex - { - readonly Map map; - readonly uint movementClass; - readonly CellLayer domains; - readonly Dictionary> transientConnections; - - public MovementClassDomainIndex(World world, uint movementClass) - { - map = world.Map; - this.movementClass = movementClass; - domains = new CellLayer(world.Map); - transientConnections = new Dictionary>(); - - using (new PerfTimer($"BuildDomains: {world.Map.Title} for movement class {movementClass}")) - BuildDomains(world); - } - - public bool IsPassable(CPos p1, CPos p2) - { - if (!domains.Contains(p1) || !domains.Contains(p2)) - return false; - - if (domains[p1] == domains[p2]) - return true; - - // Even though p1 and p2 are in different domains, it's possible - // that some dynamic terrain (i.e. bridges) may connect them. - return HasConnection(domains[p1], domains[p2]); - } - - public void UpdateCells(World world, HashSet dirtyCells) - { - var neighborDomains = new List(); - - foreach (var cell in dirtyCells) - { - // Select all neighbors inside the map boundaries - var thisCell = cell; // benign closure hazard - var neighbors = CVec.Directions.Select(d => d + thisCell) - .Where(c => map.Contains(c)); - - var found = false; - foreach (var n in neighbors) - { - if (!dirtyCells.Contains(n)) - { - var neighborDomain = domains[n]; - if (CanTraverseTile(world, n)) - { - neighborDomains.Add(neighborDomain); - - // Set ourselves to the first non-dirty neighbor we find. - if (!found) - { - domains[cell] = neighborDomain; - found = true; - } - } - } - } - } - - foreach (var c1 in neighborDomains) - foreach (var c2 in neighborDomains) - CreateConnection(c1, c2); - } - - public void AddFixedConnection(IEnumerable cells) - { - // HACK: this is a temporary workaround to add a permanent connection between the domains of the listed cells. - // This is sufficient for fixed point-to-point tunnels, but not for dynamically updating custom layers - // such as destroyable elevated bridges. - // To support those the domain index will need to learn about custom movement layers, but that then requires - // a complete refactor of the domain code to deal with MobileInfo or better a shared pathfinder class type. - var cellDomains = cells.Select(c => domains[c]).ToHashSet(); - foreach (var c1 in cellDomains) - foreach (var c2 in cellDomains.Where(c => c != c1)) - CreateConnection(c1, c2); - } - - bool HasConnection(ushort d1, ushort d2) - { - // Search our connections graph for a possible route - var visited = new HashSet(); - var toProcess = new Stack(); - toProcess.Push(d1); - - while (toProcess.Count > 0) - { - var current = toProcess.Pop(); - if (!transientConnections.ContainsKey(current)) - continue; - - foreach (var neighbor in transientConnections[current]) - { - if (neighbor == d2) - return true; - - if (!visited.Contains(neighbor)) - toProcess.Push(neighbor); - } - - visited.Add(current); - } - - return false; - } - - void CreateConnection(ushort d1, ushort d2) - { - if (!transientConnections.ContainsKey(d1)) - transientConnections[d1] = new HashSet(); - if (!transientConnections.ContainsKey(d2)) - transientConnections[d2] = new HashSet(); - - transientConnections[d1].Add(d2); - transientConnections[d2].Add(d1); - } - - bool CanTraverseTile(World world, CPos p) - { - if (!map.Contains(p)) - return false; - - var terrainOffset = world.Map.GetTerrainIndex(p); - return (movementClass & (1 << terrainOffset)) > 0; - } - - void BuildDomains(World world) - { - ushort domain = 1; - - var visited = new CellLayer(map); - - var toProcess = new Queue(); - toProcess.Enqueue(MPos.Zero.ToCPos(map)); - - // Flood-fill over each domain. - while (toProcess.Count != 0) - { - var start = toProcess.Dequeue(); - - // Technically redundant with the check in the inner loop, but prevents - // ballooning the domain counter. - if (visited[start]) - continue; - - var domainQueue = new Queue(); - domainQueue.Enqueue(start); - - var currentPassable = CanTraverseTile(world, start); - - // Add all contiguous cells to our domain, and make a note of - // any non-contiguous cells for future domains. - while (domainQueue.Count != 0) - { - var n = domainQueue.Dequeue(); - if (visited[n]) - continue; - - var candidatePassable = CanTraverseTile(world, n); - if (candidatePassable != currentPassable) - { - toProcess.Enqueue(n); - continue; - } - - visited[n] = true; - domains[n] = domain; - - // PERF: Avoid LINQ. - foreach (var direction in CVec.Directions) - { - // Don't crawl off the map, or add already-visited cells. - var neighbor = direction + n; - if (visited.Contains(neighbor) && !visited[neighbor]) - domainQueue.Enqueue(neighbor); - } - } - - domain += 1; - } - - Log.Write("debug", "Found {0} domains for movement class {1} on map {2}.", domain - 1, movementClass, map.Title); - } - } -} diff --git a/OpenRA.Mods.Common/Traits/World/ElevatedBridgeLayer.cs b/OpenRA.Mods.Common/Traits/World/ElevatedBridgeLayer.cs index 5d2aaa520d..db5e4b581b 100644 --- a/OpenRA.Mods.Common/Traits/World/ElevatedBridgeLayer.cs +++ b/OpenRA.Mods.Common/Traits/World/ElevatedBridgeLayer.cs @@ -17,7 +17,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits { [TraitLocation(SystemActors.World)] - public class ElevatedBridgeLayerInfo : TraitInfo, Requires, ILobbyCustomRulesIgnore, ICustomMovementLayerInfo + public class ElevatedBridgeLayerInfo : TraitInfo, ILobbyCustomRulesIgnore, ICustomMovementLayerInfo { [Desc("Terrain type used by cells outside any elevated bridge footprint.")] public readonly string ImpassableTerrainType = "Impassable"; @@ -44,7 +44,6 @@ namespace OpenRA.Mods.Common.Traits public void WorldLoaded(World world, WorldRenderer wr) { - var domainIndex = world.WorldActor.Trait(); var cellHeight = world.Map.CellHeightStep.Length; foreach (var tti in world.WorldActor.Info.TraitInfos()) { @@ -61,7 +60,6 @@ namespace OpenRA.Mods.Common.Traits } var end = tti.EndCells(); - domainIndex.AddFixedConnection(end); foreach (var c in end) { // Need to explicitly set both default and tunnel layers, otherwise the .Contains check will fail diff --git a/OpenRA.Mods.Common/Traits/World/HierarchicalPathFinderOverlay.cs b/OpenRA.Mods.Common/Traits/World/HierarchicalPathFinderOverlay.cs index d7cf89014e..ded78433ba 100644 --- a/OpenRA.Mods.Common/Traits/World/HierarchicalPathFinderOverlay.cs +++ b/OpenRA.Mods.Common/Traits/World/HierarchicalPathFinderOverlay.cs @@ -88,10 +88,10 @@ namespace OpenRA.Mods.Common.Traits : new[] { Locomotor }; foreach (var locomotor in locomotors) { - var abstractGraph = pathFinder.GetOverlayDataForLocomotor(locomotor); + var (abstractGraph, abstractDomains) = pathFinder.GetOverlayDataForLocomotor(locomotor); // Locomotor doesn't allow movement, nothing to display. - if (abstractGraph == null) + if (abstractGraph == null || abstractDomains == null) continue; foreach (var connectionsFromOneNode in abstractGraph) @@ -129,6 +129,19 @@ namespace OpenRA.Mods.Common.Traits yield return new TextAnnotationRenderable(font, centerPos, 0, lineColor, cost.Cost.ToString()); } } + + foreach (var domainForCell in abstractDomains) + { + var nodeCell = domainForCell.Key; + var srcUv = (PPos)nodeCell.ToMPos(self.World.Map); + if (!visibleRegion.Contains(srcUv)) + continue; + + // Show the abstract cell and its domain index. + var nodePos = self.World.Map.CenterOfSubCell(nodeCell, SubCell.FullCell); + yield return new TextAnnotationRenderable( + font, nodePos, 0, info.AbstractNodeColor, $"{domainForCell.Value}: {nodeCell}"); + } } } diff --git a/OpenRA.Mods.Common/Traits/World/PathFinder.cs b/OpenRA.Mods.Common/Traits/World/PathFinder.cs index cdff4f46fd..d32ca0cb87 100644 --- a/OpenRA.Mods.Common/Traits/World/PathFinder.cs +++ b/OpenRA.Mods.Common/Traits/World/PathFinder.cs @@ -46,7 +46,9 @@ namespace OpenRA.Mods.Common.Traits world = self.World; } - public IReadOnlyDictionary> GetOverlayDataForLocomotor(Locomotor locomotor) + public ( + IReadOnlyDictionary> AbstractGraph, + IReadOnlyDictionary AbstractDomains) GetOverlayDataForLocomotor(Locomotor locomotor) { return hierarchicalPathFindersByLocomotor[locomotor].GetOverlayData(); } @@ -133,6 +135,16 @@ namespace OpenRA.Mods.Common.Traits return search.FindPath(); } + /// + /// Determines if a path exists between source and target. + /// Only terrain is taken into account, i.e. as if was given. + /// This would apply for any actor using the given . + /// + public bool PathExistsForLocomotor(Locomotor locomotor, CPos source, CPos target) + { + return hierarchicalPathFindersByLocomotor[locomotor].PathExists(source, target); + } + static Locomotor GetActorLocomotor(Actor self) { // PERF: This PathFinder trait requires the use of Mobile, so we can be sure that is in use. diff --git a/OpenRA.Mods.Common/Traits/World/TerrainTunnelLayer.cs b/OpenRA.Mods.Common/Traits/World/TerrainTunnelLayer.cs index b1f69ef351..490727be26 100644 --- a/OpenRA.Mods.Common/Traits/World/TerrainTunnelLayer.cs +++ b/OpenRA.Mods.Common/Traits/World/TerrainTunnelLayer.cs @@ -17,7 +17,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits { [TraitLocation(SystemActors.World)] - public class TerrainTunnelLayerInfo : TraitInfo, Requires, ILobbyCustomRulesIgnore, ICustomMovementLayerInfo + public class TerrainTunnelLayerInfo : TraitInfo, ILobbyCustomRulesIgnore, ICustomMovementLayerInfo { [Desc("Terrain type used by cells outside any tunnel footprint.")] public readonly string ImpassableTerrainType = "Impassable"; @@ -43,7 +43,6 @@ namespace OpenRA.Mods.Common.Traits public void WorldLoaded(World world, WorldRenderer wr) { - var domainIndex = world.WorldActor.Trait(); var cellHeight = world.Map.CellHeightStep.Length; foreach (var tti in world.WorldActor.Info.TraitInfos()) { @@ -60,7 +59,6 @@ namespace OpenRA.Mods.Common.Traits } var portal = tti.PortalCells(); - domainIndex.AddFixedConnection(portal); foreach (var c in portal) { // Need to explicitly set both default and tunnel layers, otherwise the .Contains check will fail diff --git a/OpenRA.Mods.Common/TraitsInterfaces.cs b/OpenRA.Mods.Common/TraitsInterfaces.cs index 785789e258..8be26d4280 100644 --- a/OpenRA.Mods.Common/TraitsInterfaces.cs +++ b/OpenRA.Mods.Common/TraitsInterfaces.cs @@ -815,5 +815,12 @@ namespace OpenRA.Mods.Common.Traits Func customCost = null, Actor ignoreActor = null, bool laneBias = true); + + /// + /// Determines if a path exists between source and target. + /// Only terrain is taken into account, i.e. as if was given. + /// This would apply for any actor using the given . + /// + bool PathExistsForLocomotor(Locomotor locomotor, CPos source, CPos target); } } diff --git a/mods/cnc/rules/world.yaml b/mods/cnc/rules/world.yaml index f65c682107..3b00d74c3e 100644 --- a/mods/cnc/rules/world.yaml +++ b/mods/cnc/rules/world.yaml @@ -162,7 +162,6 @@ World: Bridges: bridge1, bridge2, bridge3, bridge4 ProductionQueueFromSelection: ProductionTabsWidget: PRODUCTION_TABS - DomainIndex: SmudgeLayer@SCORCH: Type: Scorch Sequence: scorches diff --git a/mods/d2k/rules/world.yaml b/mods/d2k/rules/world.yaml index a68fc70ba5..ae58232430 100644 --- a/mods/d2k/rules/world.yaml +++ b/mods/d2k/rules/world.yaml @@ -137,7 +137,6 @@ World: ValidGround: Sand, Rock, Transition, Spice, SpiceSand, Dune, Concrete InitialSpawnDelay: 1500 CheckboxDisplayOrder: 1 - DomainIndex: WarheadDebugOverlay: BuildableTerrainLayer: ResourceLayer: diff --git a/mods/ra/rules/world.yaml b/mods/ra/rules/world.yaml index 3f1f03e732..73edffbada 100644 --- a/mods/ra/rules/world.yaml +++ b/mods/ra/rules/world.yaml @@ -184,7 +184,6 @@ World: WaterChance: 20 InitialSpawnDelay: 1500 CheckboxDisplayOrder: 1 - DomainIndex: SmudgeLayer@SCORCH: Type: Scorch Sequence: scorches diff --git a/mods/ts/rules/world.yaml b/mods/ts/rules/world.yaml index 45267b928e..6b5aa7b08e 100644 --- a/mods/ts/rules/world.yaml +++ b/mods/ts/rules/world.yaml @@ -241,7 +241,6 @@ World: BuildingInfluence: ProductionQueueFromSelection: ProductionPaletteWidget: PRODUCTION_PALETTE - DomainIndex: SmudgeLayer@SMALLSCORCH: Type: SmallScorch Sequence: smallscorches