diff --git a/Content.Client/Salvage/UI/SalvageMagnetBoundUserInterface.cs b/Content.Client/Salvage/UI/SalvageMagnetBoundUserInterface.cs index a344ddd1eef..9dc92f6f72e 100644 --- a/Content.Client/Salvage/UI/SalvageMagnetBoundUserInterface.cs +++ b/Content.Client/Salvage/UI/SalvageMagnetBoundUserInterface.cs @@ -128,6 +128,11 @@ protected override void UpdateState(BoundUserInterfaceState state) option.AddContent(salvContainer); break; + // Macro start - Adding RuinOffering to the salvage system + case RuinOffering ruin: + option.Title = Loc.GetString("salvage-magnet-ruin"); + break; + // Macro end default: throw new ArgumentOutOfRangeException(); } diff --git a/Content.Server/Procedural/DungeonJob/DungeonJob.Biome.cs b/Content.Server/Procedural/DungeonJob/DungeonJob.Biome.cs index 10f11bb8541..e097cf58185 100644 --- a/Content.Server/Procedural/DungeonJob/DungeonJob.Biome.cs +++ b/Content.Server/Procedural/DungeonJob/DungeonJob.Biome.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; // Macro using System.Threading.Tasks; using Content.Server.Parallax; using Content.Shared.Maps; @@ -5,6 +6,7 @@ using Content.Shared.Procedural; using Content.Shared.Procedural.PostGeneration; using Robust.Shared.Map; +using Robust.Shared.Noise; // Macro using Robust.Shared.Utility; namespace Content.Server.Procedural.DungeonJob; @@ -23,6 +25,7 @@ private async Task PostGen(BiomeDunGen dunGen, Dungeon dungeon, HashSet(); + var noiseCache = new Dictionary(); // Macro - Cache noise for each layer var tiles = _maps.GetAllTilesEnumerator(_gridUid, _grid); while (tiles.MoveNext(out var tileRef)) @@ -44,7 +47,7 @@ private async Task PostGen(BiomeDunGen dunGen, Dungeon dungeon, HashSet MagnetChannel = "Supply"; + private static readonly ProtoId StructuralDamageType = "Structural"; // Macro + private static readonly ProtoId SpaceRuinBiome = "SpaceRuin"; // Macro private EntityQuery _salvMobQuery; private EntityQuery _mobStateQuery; @@ -305,19 +323,46 @@ private async Task TakeMagnetOffer(Entity data, int break; case SalvageOffering wreck: var salvageProto = wreck.SalvageMap; - if (!_loader.TryLoadGrid(salvMapXform.MapID, salvageProto.MapPath, out _)) { - Report(magnet, MagnetChannel, "salvage-system-announcement-spawn-debris-disintegrated"); + Report(magnet.Owner, MagnetChannel, "salvage-system-announcement-spawn-debris-disintegrated"); // Macro added a .Owner to the magnet + _mapSystem.DeleteMap(salvMapXform.MapID); + return; + } +// Macro Start - Adding RuinOffering to the salvage system + break; + case RuinOffering ruin: + var ruinConfigId = new ProtoId("Default"); + _prototypeManager.TryIndex(ruinConfigId, out var ruinConfig); + + var ruinResult = _ruinGenerator.GenerateRuin(ruin.RuinMap.MapPath, seed, ruinConfig); + if (ruinResult == null) + { + Report(magnet.Owner, MagnetChannel, "salvage-system-announcement-spawn-no-debris-available"); _mapSystem.DeleteMap(salvMapXform.MapID); return; } + var ruinGrid = _mapManager.CreateGridEntity(salvMap); + _mapSystem.SetTiles(ruinGrid.Owner, ruinGrid.Comp, ruinResult.FloorTiles); + + foreach (var (wallPos, wallProto) in ruinResult.WallEntities) + { + var wallEntity = SpawnAtPosition(wallProto, new EntityCoordinates(ruinGrid.Owner, wallPos)); + var wallXform = Transform(wallEntity); + if (!wallXform.Anchored) + _transform.AnchorEntity((wallEntity, wallXform), (ruinGrid.Owner, ruinGrid.Comp), wallPos); + } + + SpawnRuinWindows(ruinGrid.Owner, ruinGrid.Comp, ruinResult, seed); + + SpawnRuinBiomeEntities(ruinGrid.Owner, ruinGrid.Comp, ruinResult, seed); + break; default: throw new ArgumentOutOfRangeException(); } - +// Macro end Box2? bounds = null; if (salvMapXform.ChildCount == 0) @@ -338,7 +383,7 @@ private async Task TakeMagnetOffer(Entity data, int bounds = bounds?.Union(childAABB) ?? childAABB; // Update mass scanner names as relevant. - if (offering is AsteroidOffering or DebrisOffering) + if (offering is AsteroidOffering or DebrisOffering or RuinOffering) // Macro added RuinOffering { _metaData.SetEntityName(mapChild, Loc.GetString("salvage-asteroid-name")); _gravity.EnableGravity(mapChild); @@ -418,6 +463,82 @@ private async Task TakeMagnetOffer(Entity data, int RaiseLocalEvent(ref active); } +// Macro start - Adding mobs and loot to the salvage system via SpaceRuin biome template +/// +/// Spawns windows for a ruin. Damages the windows if they are supposed to be damaged. +/// + private void SpawnRuinWindows(EntityUid gridUid, MapGridComponent grid, SalvageRuinGeneratorSystem.RuinResult ruinResult, int seed) + { + var windowDamageChance = ruinResult.Config?.WindowDamageChance ?? 0.0f; + var windowRand = new System.Random(seed); + foreach (var (windowPos, windowProto, windowRotation) in ruinResult.WindowEntities) + { + var tileRef = _mapSystem.GetTileRef(gridUid, grid, windowPos); + if (tileRef.Tile.IsEmpty) + continue; + + var windowEntity = SpawnAttachedTo(windowProto, new EntityCoordinates(gridUid, windowPos), rotation: windowRotation); + var windowXform = Transform(windowEntity); + if (!windowXform.Anchored) + _transform.AnchorEntity((windowEntity, windowXform), (gridUid, grid), windowPos); + + if (windowDamageChance > 0.0f && windowRand.NextSingle() < windowDamageChance && + TryComp(windowEntity, out _)) + { + var damage = new DamageSpecifier( + _prototypeManager.Index(StructuralDamageType), + FixedPoint2.New(25)); + _damageable.TryChangeDamage(windowEntity, damage); + } + } + } + +/// +/// Spawns biome entities for a ruin. +/// Which are the loot and the mobs that spawn randomly. As well as the dirt decals +/// + private void SpawnRuinBiomeEntities(EntityUid gridUid, MapGridComponent grid, SalvageRuinGeneratorSystem.RuinResult ruinResult, int seed) + { + var blockedPositions = new HashSet( + ruinResult.WallEntities.Select(w => w.Position) + .Concat(ruinResult.WindowEntities.Select(w => w.Position))); + + if (!_prototypeManager.TryIndex(SpaceRuinBiome, out var ruinTemplate)) + return; + + var layers = ruinTemplate.Layers; + var noiseCache = new Dictionary(); + + foreach (var (pos, tile) in ruinResult.FloorTiles) + { + if (blockedPositions.Contains(pos)) + continue; + + var tileRef = _mapSystem.GetTileRef(gridUid, grid, pos); + if (tileRef.Tile.IsEmpty) + continue; + + if (_biome.TryGetDecals(pos, layers, seed, (gridUid, grid), out var decals, noiseCache)) + { + foreach (var decal in decals) + { + _decals.TryAddDecal(decal.ID, new EntityCoordinates(gridUid, decal.Position), out _); + } + } + + if (_biome.TryGetEntity(pos, layers, tileRef.Tile, seed, (gridUid, grid), out var entityProto, noiseCache)) + { + if (!_anchorable.TileFree((gridUid, grid), pos, (int)CollisionGroup.MachineLayer, (int)CollisionGroup.MachineLayer)) + continue; + + var entity = SpawnAtPosition(entityProto, new EntityCoordinates(gridUid, pos + grid.TileSizeHalfVector)); + var xform = Transform(entity); + if (!xform.Anchored) + _transform.AnchorEntity((entity, xform), (gridUid, grid), pos); + } + } + } +// Macro end private bool TryGetSalvagePlacementLocation(Entity magnet, MapId mapId, Box2Rotated attachedBounds, Box2 bounds, Angle worldAngle, out MapCoordinates coords, out Angle angle) { diff --git a/Content.Server/_MACRO/Salvage/SalvageRuinGenerator.cs b/Content.Server/_MACRO/Salvage/SalvageRuinGenerator.cs new file mode 100644 index 00000000000..efde9f264b3 --- /dev/null +++ b/Content.Server/_MACRO/Salvage/SalvageRuinGenerator.cs @@ -0,0 +1,1378 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using Content.Server.GameTicking.Events; +using Content.Shared.Maps; +using Content.Shared.Salvage; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.GameObjects; +using Robust.Shared.Log; +using Robust.Shared.Map; +using Robust.Shared.Maths; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; +using Robust.Shared.Serialization.Markdown; +using Robust.Shared.Serialization.Markdown.Mapping; +using Robust.Shared.Serialization.Markdown.Sequence; +using Robust.Shared.Serialization.Markdown.Value; +using Robust.Shared.Utility; + +namespace Content.Server.Salvage; + +/// +/// Generates ruins from station maps using cost-based flood-fill. +/// Pre-builds cost maps at server startup for performance. +/// +public sealed class SalvageRuinGeneratorSystem : EntitySystem +{ + + + [Dependency] private readonly ILogManager _logManager = default!; + [Dependency] private readonly IPrototypeManager _prototypeManager = default!; + [Dependency] private readonly ITileDefinitionManager _tileDefinitionManager = default!; + [Dependency] private readonly MapLoaderSystem _mapLoader = default!; + + + + private ISawmill _sawmill = default!; + + /// + /// Cached map data for each ruin map. Built at server startup. + /// + private readonly Dictionary _cachedMapData = new(); + + /// + /// Cached data for a map, including cost map, coordinate map, wall entities, and window entities. + /// The reason we're doing this is to avoid having to parse the map file every time we want to generate a ruin. + /// This also allows us to parse the map file once, making the large performance impact only happen at server startup. + /// Parameters: CostMap, CoordinateMap, WallEntities, WindowEntities + /// CostMap: A dictionary of Vector2i and int. The Vector2i is the position of the tile, and the int is the cost of the tile. + /// CoordinateMap: A dictionary of Vector2i and string. The Vector2i is the position of the tile, and the string is the prototype id of the tile. + /// WallEntities: A list of (Vector2i, string PrototypeId). The Vector2i is the position of the wall, and the string PrototypeId is the prototype id of the wall. + /// WindowEntities: A list of (Vector2i, string PrototypeId, Angle Rotation). The Vector2i is the position of the window, the string PrototypeId is the prototype id of the window, and the Angle Rotation is the rotation of the window. + /// + private sealed class CachedMapData + { + public Dictionary CostMap = new(); + public Dictionary CoordinateMap = new(); + public List<(Vector2i Position, string PrototypeId)> WallEntities = new(); + public List<(Vector2i Position, string PrototypeId, Angle Rotation)> WindowEntities = new(); + } + + /// + /// Result of ruin generation containing floor tiles to place, wall entities, and window entities to spawn. + /// + public sealed class RuinResult + { + public List<(Vector2i Position, Tile Tile)> FloorTiles = new(); + public List<(Vector2i Position, string PrototypeId)> WallEntities = new(); + public List<(Vector2i Position, string PrototypeId, Angle Rotation)> WindowEntities = new(); + public Box2 Bounds; + + // Configuration used for this ruin (needed for damage simulation when spawning) + public SalvageMagnetRuinConfigPrototype? Config; + } + + public override void Initialize() + { + base.Initialize(); + _sawmill = _logManager.GetSawmill("system.salvage"); + + // Subscribe to prototype reload events for hot reloading + SubscribeLocalEvent(OnPrototypesReloaded); + SubscribeLocalEvent(OnRoundStart); + } + + private void OnRoundStart(RoundStartingEvent ev) + { + // Prebuild on round start when everything is initialized; guard in PrebuildCostMaps prevents double-build. + PrebuildCostMaps(); + } + + private void OnPrototypesReloaded(PrototypesReloadedEventArgs args) + { + // Prebuild cost maps when prototypes are first loaded, or rebuild if RuinMapPrototype was modified. + // If this event fires before we subscribe (e.g. during content init), GenerateRuin builds on-demand. + var isFirstLoad = _cachedMapData.Count == 0; + var wasModified = args.WasModified(); + + if (isFirstLoad || wasModified) + { + if (isFirstLoad) + { + _sawmill.Info("[SalvageRuinGenerator] Prototypes loaded for first time, building cost maps for ruin maps..."); + } + else + { + _sawmill.Info("[SalvageRuinGenerator] RuinMapPrototype prototypes were reloaded, rebuilding cost maps..."); + _cachedMapData.Clear(); + } + PrebuildCostMaps(); + } + } + + /// + /// Pre-builds cost maps for all configured ruin maps at server startup. + /// + private void PrebuildCostMaps() + { + if (_cachedMapData.Count > 0) + { + _sawmill.Debug("[SalvageRuinGenerator] Cost maps already pre-built, skipping"); + return; + } + + _sawmill.Info("[SalvageRuinGenerator] Pre-building cost maps for ruin maps..."); + + var ruinMaps = _prototypeManager.EnumeratePrototypes().ToList(); + + // If no prototypes are loaded yet, skip pre-building (will build on-demand) + if (ruinMaps.Count == 0) + { + _sawmill.Debug("[SalvageRuinGenerator] No RuinMapPrototype prototypes found yet, will build cost maps on-demand"); + return; + } + + var successCount = 0; + var failCount = 0; + + foreach (var ruinMap in ruinMaps) + { + if (BuildCostMapForMap(ruinMap.MapPath)) + { + successCount++; + } + else + { + failCount++; + } + } + + _sawmill.Info($"[SalvageRuinGenerator] Pre-built cost maps: {successCount} succeeded, {failCount} failed"); + } + + /// + /// Builds and caches cost map data for a specific map path. + /// + private bool BuildCostMapForMap(ResPath mapPath) + { + // Read YAML file directly without loading entities + if (!_mapLoader.TryReadFile(mapPath, out var mapData)) + { + _sawmill.Error($"[SalvageRuinGenerator] Failed to read map file: {mapPath}"); + return false; + } + + // Parse tilemap section + var tileMap = new Dictionary(); + if (!mapData.TryGet("tilemap", out MappingDataNode? tilemapNode)) + { + _sawmill.Error($"[SalvageRuinGenerator] Map file {mapPath} missing tilemap section"); + return false; + } + + foreach (var (key, valueNode) in tilemapNode.Children) + { + var valueValue = valueNode as ValueDataNode; + if (valueValue == null) + continue; + + if (!int.TryParse(key, out var yamlTileId)) + continue; + + tileMap[yamlTileId] = valueValue.Value; + } + + if (tileMap.Count == 0) + { + _sawmill.Error($"[SalvageRuinGenerator] Map file {mapPath} has empty tilemap"); + return false; + } + + // Parse grids section + if (!mapData.TryGet("grids", out SequenceDataNode? gridsNode) || gridsNode.Sequence.Count == 0) + { + _sawmill.Error($"[SalvageRuinGenerator] Map file {mapPath} missing or empty grids section"); + return false; + } + + var firstGridUidNode = gridsNode.Sequence[0] as ValueDataNode; + if (firstGridUidNode == null || !int.TryParse(firstGridUidNode.Value, out var firstGridUid)) + { + _sawmill.Error($"[SalvageRuinGenerator] Map file {mapPath} first grid UID is invalid"); + return false; + } + + // Parse entities section + if (!mapData.TryGet("entities", out SequenceDataNode? entitiesNode)) + { + _sawmill.Error($"[SalvageRuinGenerator] Map file {mapPath} missing entities section"); + return false; + } + + // Find the grid entity + MappingDataNode? gridEntityNode = null; + foreach (var protoGroupNode in entitiesNode.Sequence.Cast()) + { + if (!protoGroupNode.TryGet("entities", out SequenceDataNode? entitiesInGroup)) + continue; + + foreach (var entityNode in entitiesInGroup.Sequence.Cast()) + { + if (!entityNode.TryGet("uid", out ValueDataNode? uidNode)) + continue; + + if (!int.TryParse(uidNode.Value, out var entityUid) || entityUid != firstGridUid) + continue; + + gridEntityNode = entityNode; + break; + } + + if (gridEntityNode != null) + break; + } + + if (gridEntityNode == null) + { + _sawmill.Error($"[SalvageRuinGenerator] Map file {mapPath} grid entity with UID {firstGridUid} not found"); + return false; + } + + // Get MapGridComponent + if (!gridEntityNode.TryGet("components", out SequenceDataNode? componentsNode)) + { + _sawmill.Error($"[SalvageRuinGenerator] Map file {mapPath} grid entity missing components section"); + return false; + } + + MappingDataNode? mapGridComponent = null; + foreach (var componentNode in componentsNode.Sequence.Cast()) + { + if (!componentNode.TryGet("type", out ValueDataNode? typeNode)) + continue; + + if (typeNode.Value == "MapGrid") + { + mapGridComponent = componentNode; + break; + } + } + + if (mapGridComponent == null) + { + _sawmill.Error($"[SalvageRuinGenerator] Map file {mapPath} grid entity missing MapGrid component"); + return false; + } + + // Get chunks + if (!mapGridComponent.TryGet("chunks", out MappingDataNode? chunksNode)) + { + _sawmill.Error($"[SalvageRuinGenerator] Map file {mapPath} MapGrid component missing chunks section"); + return false; + } + + // Get chunk size + ushort chunkSize = 16; + if (mapGridComponent.TryGet("chunksize", out ValueDataNode? chunkSizeNode)) + { + if (ushort.TryParse(chunkSizeNode.Value, out var parsedChunkSize)) + chunkSize = parsedChunkSize; + } + + // Build coordinate map from parsed chunks + var coordinateMap = ParseChunks(chunksNode, tileMap, chunkSize); + + if (coordinateMap.Count == 0) + { + _sawmill.Error($"[SalvageRuinGenerator] Map file {mapPath} produced empty coordinate map"); + return false; + } + + // Get default config for cost map building and entity parsing (use "Default" if available) + var defaultConfigId = new ProtoId("Default"); + SalvageMagnetRuinConfigPrototype? defaultConfig = null; + _prototypeManager.TryIndex(defaultConfigId, out defaultConfig); + + // Parse wall entities (use default config's wall prototypes for identifying walls in map) + var wallEntities = ParseWallEntities(entitiesNode, firstGridUid, defaultConfig); + + // Parse window entities (use default config's window prototypes for identifying windows in map) + var windowEntities = ParseWindowEntities(entitiesNode, firstGridUid, defaultConfig); + + // Build cost map (includes windows and walls in cost calculation) + var costMap = BuildCostMap(coordinateMap, windowEntities, wallEntities, defaultConfig); + + // Cache the data + _cachedMapData[mapPath] = new CachedMapData + { + CostMap = costMap, + CoordinateMap = coordinateMap, + WallEntities = wallEntities, + WindowEntities = windowEntities + }; + + _sawmill.Debug($"[SalvageRuinGenerator] Cached cost map for {mapPath}: {costMap.Count} tiles, {wallEntities.Count} walls, {windowEntities.Count} windows"); + return true; + } + + /// + /// Generates a ruin from the specified map using flood-fill. + /// Uses pre-built cost maps for performance. + /// + public RuinResult? GenerateRuin(ResPath mapPath, int seed, SalvageMagnetRuinConfigPrototype? config = null) + { + // Get cached map data + if (!_cachedMapData.TryGetValue(mapPath, out var cachedData)) + { + _sawmill.Info($"[SalvageRuinGenerator] Building cost map for {mapPath} on-demand..."); + // Build it on-demand (this is normal on first use if cache wasn't built at startup) + if (!BuildCostMapForMap(mapPath) || !_cachedMapData.TryGetValue(mapPath, out cachedData)) + { + _sawmill.Error($"[SalvageRuinGenerator] Failed to build cost map for {mapPath}"); + return null; + } + _sawmill.Info($"[SalvageRuinGenerator] Successfully built cost map for {mapPath} on-demand"); + } + + var costMap = cachedData.CostMap; + var coordinateMap = cachedData.CoordinateMap; + var wallEntities = cachedData.WallEntities; + var windowEntities = cachedData.WindowEntities; + + // Get configuration values (defaults if not provided) + var floodFillPoints = config?.FloodFillPoints ?? 50; + var floodFillStages = config?.FloodFillStages ?? 5; + var wallDestroyChance = config?.WallDestroyChance ?? 0.0f; + var windowDamageChance = config?.WindowDamageChance ?? 0.0f; + var floorToLatticeChance = config?.FloorToLatticeChance ?? 0.0f; + var spaceCost = config?.SpaceCost ?? 99; + var defaultTileCost = config?.DefaultTileCost ?? 1; + + // Find valid start location (retry up to 10 times) + var rand = new System.Random(seed); + var startPos = FindValidStartLocation(costMap, rand, maxRetries: 10, spaceCost); + if (!startPos.HasValue) + { + _sawmill.Error($"[SalvageRuinGenerator] Failed to find valid start location for map {mapPath} with seed {seed}"); + return null; + } + + _sawmill.Debug($"[SalvageRuinGenerator] Starting multi-stage flood-fill at {startPos.Value} with {floodFillStages} stages, {floodFillPoints} budget per stage"); + + // Create sets of wall and window positions for fast lookup + var wallPositions = new HashSet(wallEntities.Select(w => w.Position)); + var windowPositions = new HashSet(windowEntities.Select(w => w.Position)); + var allBlockingPositions = new HashSet(wallPositions); + allBlockingPositions.UnionWith(windowPositions); + + // Perform multi-stage flood-fill + var region = FloodFillMultiStage(costMap, startPos.Value, floodFillStages, floodFillPoints, allBlockingPositions, rand, spaceCost, defaultTileCost); + if (region.Count == 0) + { + _sawmill.Error($"[SalvageRuinGenerator] Flood-fill returned empty region for map {mapPath} with seed {seed}"); + return null; + } + + _sawmill.Debug($"[SalvageRuinGenerator] Flood-fill collected {region.Count} tiles"); + + // Extract floor tiles from region, and walls adjacent to the region + var result = new RuinResult(); + var tilesToPlace = new Dictionary(); + var minX = int.MaxValue; + var minY = int.MaxValue; + var maxX = int.MinValue; + var maxY = int.MinValue; + + // First, find the bounds of the flood-filled region + foreach (var pos in region) + { + minX = Math.Min(minX, pos.X); + minY = Math.Min(minY, pos.Y); + maxX = Math.Max(maxX, pos.X); + maxY = Math.Max(maxY, pos.Y); + } + + // Find wall entities adjacent to the flood-filled region + var adjacentWallEntities = new List<(Vector2i Position, string PrototypeId)>(); + var regionSet = new HashSet(region); + + foreach (var (wallPos, wallProto) in wallEntities) + { + // Check if this wall is adjacent to any tile in the region + var neighbors = new[] + { + new Vector2i(wallPos.X + 1, wallPos.Y), + new Vector2i(wallPos.X - 1, wallPos.Y), + new Vector2i(wallPos.X, wallPos.Y + 1), + new Vector2i(wallPos.X, wallPos.Y - 1) + }; + + foreach (var neighbor in neighbors) + { + if (regionSet.Contains(neighbor)) + { + adjacentWallEntities.Add((wallPos, wallProto)); + // Update bounds to include walls + minX = Math.Min(minX, wallPos.X); + minY = Math.Min(minY, wallPos.Y); + maxX = Math.Max(maxX, wallPos.X); + maxY = Math.Max(maxY, wallPos.Y); + break; // Only add once + } + } + } + + _sawmill.Debug($"[SalvageRuinGenerator] Found {adjacentWallEntities.Count} adjacent wall entities"); + + // Find window entities within or adjacent to the flood-filled region + var windowEntitiesInRegion = new List<(Vector2i Position, string PrototypeId, Angle Rotation)>(); + + foreach (var (windowPos, windowProto, windowRotation) in windowEntities) + { + // Check if window is within the region or adjacent to it + if (regionSet.Contains(windowPos)) + { + // Window is within the flood-filled region + windowEntitiesInRegion.Add((windowPos, windowProto, windowRotation)); + // Update bounds to include windows + minX = Math.Min(minX, windowPos.X); + minY = Math.Min(minY, windowPos.Y); + maxX = Math.Max(maxX, windowPos.X); + maxY = Math.Max(maxY, windowPos.Y); + } + else + { + // Check if window is adjacent to the region + var neighbors = new[] + { + new Vector2i(windowPos.X + 1, windowPos.Y), + new Vector2i(windowPos.X - 1, windowPos.Y), + new Vector2i(windowPos.X, windowPos.Y + 1), + new Vector2i(windowPos.X, windowPos.Y - 1) + }; + + foreach (var neighbor in neighbors) + { + if (regionSet.Contains(neighbor)) + { + windowEntitiesInRegion.Add((windowPos, windowProto, windowRotation)); + // Update bounds to include windows + minX = Math.Min(minX, windowPos.X); + minY = Math.Min(minY, windowPos.Y); + maxX = Math.Max(maxX, windowPos.X); + maxY = Math.Max(maxY, windowPos.Y); + break; // Only add once + } + } + } + } + + _sawmill.Debug($"[SalvageRuinGenerator] Found {windowEntitiesInRegion.Count} window entities in or adjacent to region"); + + // Now normalize all coordinates to start from (0,0) relative to the ruin's origin + var originX = minX; + var originY = minY; + + // Create random instance for damage simulation + var damageRand = new System.Random(seed); + + // Add all tiles from the flood-filled region (floors) with normalized coordinates + // Apply floor-to-lattice damage simulation IN MEMORY before spawning + foreach (var pos in region) + { + if (!coordinateMap.TryGetValue(pos, out var tileId)) + continue; + + // Get tile definition + if (!_prototypeManager.TryIndex(tileId, out var tileDef)) + continue; + + // Check if this tile is already lattice (never damage lattice further) + var isLattice = tileDef.ID.Equals("Lattice", StringComparison.OrdinalIgnoreCase); + + Tile tile; + if (!isLattice && floorToLatticeChance > 0.0f && damageRand.NextSingle() < floorToLatticeChance) + { + // Replace floor tile with lattice + if (!_tileDefinitionManager.TryGetDefinition("Lattice", out var latticeDef)) + { + // Fallback to original tile if lattice not found + tile = new Tile(tileDef.TileId); + } + else + { + tile = new Tile(latticeDef.TileId); + } + } + else + { + // Keep original tile + tile = new Tile(tileDef.TileId); + } + + // Normalize coordinates relative to ruin origin + var normalizedPos = new Vector2i(pos.X - originX, pos.Y - originY); + tilesToPlace[normalizedPos] = tile; + } + + // Ensure floor tiles exist under all wall positions (prevents floating walls). + // Walls adjacent to the region may not be in the region, so they might not have floor tiles. + foreach (var (wallPos, _) in adjacentWallEntities) + { + var normalizedWallPos = new Vector2i(wallPos.X - originX, wallPos.Y - originY); + if (tilesToPlace.ContainsKey(normalizedWallPos)) + continue; + + Tile floorTile; + if (coordinateMap.TryGetValue(wallPos, out var tileId) && + _prototypeManager.TryIndex(tileId, out var tileDef)) + { + floorTile = new Tile(tileDef.TileId); + } + else if (_tileDefinitionManager.TryGetDefinition("Plating", out var platingDef)) + { + floorTile = new Tile(platingDef.TileId); + } + else + { + continue; + } + + tilesToPlace[normalizedWallPos] = floorTile; + } + + // Add adjacent wall entities with normalized coordinates + // Apply wall destruction simulation IN MEMORY before spawning + foreach (var (wallPos, wallProto) in adjacentWallEntities) + { + // Check if wall should be destroyed + if (wallDestroyChance > 0.0f && damageRand.NextSingle() < wallDestroyChance) + { + // Wall is destroyed, skip it (don't add to result) + continue; + } + + var normalizedWallPos = new Vector2i(wallPos.X - originX, wallPos.Y - originY); + result.WallEntities.Add((normalizedWallPos, wallProto)); + } + + // Add window entities with normalized coordinates and preserved rotation + // Window damage will be applied when spawning, but we track which ones should be damaged + foreach (var (windowPos, windowProto, windowRotation) in windowEntitiesInRegion) + { + var normalizedWindowPos = new Vector2i(windowPos.X - originX, windowPos.Y - originY); + result.WindowEntities.Add((normalizedWindowPos, windowProto, windowRotation)); + } + + if (tilesToPlace.Count == 0) + { + _sawmill.Error($"[SalvageRuinGenerator] No tiles to place after processing region for map {mapPath}"); + return null; + } + + // Calculate normalized bounds (should start from 0,0) + var normalizedMinX = 0; + var normalizedMinY = 0; + var normalizedMaxX = maxX - originX; + var normalizedMaxY = maxY - originY; + + _sawmill.Debug($"[SalvageRuinGenerator] Generated ruin with {tilesToPlace.Count} tiles ({region.Count} floors), {result.WallEntities.Count} wall entities (after destruction), and {windowEntitiesInRegion.Count} window entities"); + + // Convert to list + result.FloorTiles = tilesToPlace.Select(kvp => (kvp.Key, kvp.Value)).ToList(); + + // Calculate bounds (add 1 for inclusive bounds) + result.Bounds = new Box2(normalizedMinX, normalizedMinY, normalizedMaxX + 1, normalizedMaxY + 1); + + // Store config for damage simulation when spawning + result.Config = config; + + return result; + } + + /// + /// Parses chunks from YAML data and builds a coordinate map of tile positions to tile IDs. + /// + private Dictionary ParseChunks(MappingDataNode chunksNode, Dictionary tileMap, ushort chunkSize) + { + var coordinateMap = new Dictionary(); + + _sawmill.Debug($"[SalvageRuinGenerator] ParseChunks: Processing {chunksNode.Children.Count} chunks with chunk size {chunkSize}"); + + foreach (var (chunkIndexKey, chunkValueNode) in chunksNode.Children) + { + var chunkIndexStr = chunkIndexKey; + var chunkIndexParts = chunkIndexStr.Split(','); + if (chunkIndexParts.Length != 2 || + !int.TryParse(chunkIndexParts[0], out var chunkX) || + !int.TryParse(chunkIndexParts[1], out var chunkY)) + { + _sawmill.Warning($"[SalvageRuinGenerator] Invalid chunk index format: {chunkIndexStr}"); + continue; + } + + if (chunkValueNode is not MappingDataNode chunkNode) + { + _sawmill.Warning($"[SalvageRuinGenerator] Chunk value is not a mapping node for chunk {chunkIndexStr}"); + continue; + } + + if (!chunkNode.TryGet("tiles", out ValueDataNode? tilesNode)) + { + _sawmill.Warning($"[SalvageRuinGenerator] Chunk {chunkIndexStr} missing 'tiles' data"); + continue; + } + + int version = 7; + if (chunkNode.TryGet("version", out ValueDataNode? versionNode)) + int.TryParse(versionNode.Value, out version); + + byte[] tileBytes; + try + { + tileBytes = Convert.FromBase64String(tilesNode.Value); + } + catch + { + continue; + } + + using var stream = new MemoryStream(tileBytes); + using var reader = new BinaryReader(stream); + + var tilesAddedInChunk = 0; + for (ushort y = 0; y < chunkSize; y++) + { + for (ushort x = 0; x < chunkSize; x++) + { + int yamlTileId; + byte flags; + byte variant; + byte rotationMirroring = 0; + + if (version >= 7) + { + yamlTileId = reader.ReadInt32(); + flags = reader.ReadByte(); + variant = reader.ReadByte(); + rotationMirroring = reader.ReadByte(); + } + else + { + yamlTileId = version < 6 ? reader.ReadUInt16() : reader.ReadInt32(); + flags = reader.ReadByte(); + variant = reader.ReadByte(); + } + + if (!tileMap.TryGetValue(yamlTileId, out var tileDefName)) + continue; + + if (!_tileDefinitionManager.TryGetDefinition(tileDefName, out var tileDef)) + continue; + + var worldX = chunkX * chunkSize + x; + var worldY = chunkY * chunkSize + y; + var worldPos = new Vector2i(worldX, worldY); + + if (tileDef.TileId != 0) + { + coordinateMap[worldPos] = tileDef.ID; + tilesAddedInChunk++; + } + } + } + + if (tilesAddedInChunk == 0) + _sawmill.Warning($"[SalvageRuinGenerator] Chunk {chunkX},{chunkY} produced no valid tiles"); + } + + _sawmill.Debug($"[SalvageRuinGenerator] ParseChunks: Produced {coordinateMap.Count} tiles total"); + return coordinateMap; + } + + /// + /// Gets the set of wall prototype IDs for map parsing. Wall definitions come from the YAML config. + /// + private HashSet GetWallPrototypesSet(SalvageMagnetRuinConfigPrototype? config) + { + if (config == null) + _prototypeManager.TryIndex(new ProtoId("Default"), out config); + + if (config == null || config.WallPrototypes.Count == 0) + return new HashSet(StringComparer.OrdinalIgnoreCase); + + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var proto in config.WallPrototypes) + { + set.Add(proto.Id); + } + return set; + } + + /// + /// Parses wall entities from the map file and returns their positions and prototype IDs. + /// + private List<(Vector2i Position, string PrototypeId)> ParseWallEntities(SequenceDataNode entitiesNode, int gridUid, SalvageMagnetRuinConfigPrototype? config) + { + var wallEntities = new List<(Vector2i, string)>(); + + var wallPrototypes = GetWallPrototypesSet(config); + + foreach (var protoGroupNode in entitiesNode.Sequence.Cast()) + { + if (!protoGroupNode.TryGet("proto", out ValueDataNode? protoNode)) + continue; + + var protoId = protoNode.Value; + + if (!wallPrototypes.Contains(protoId)) + continue; + + if (!protoGroupNode.TryGet("entities", out SequenceDataNode? entitiesInGroup)) + continue; + + foreach (var entityNode in entitiesInGroup.Sequence.Cast()) + { + if (entityNode.TryGet("uid", out ValueDataNode? uidNode) && + int.TryParse(uidNode.Value, out var entityUid) && + entityUid == gridUid) + continue; + + if (!entityNode.TryGet("components", out SequenceDataNode? componentsNode)) + continue; + + Vector2i? entityPos = null; + int? parentUid = null; + + foreach (var componentNode in componentsNode.Sequence.Cast()) + { + if (!componentNode.TryGet("type", out ValueDataNode? typeNode)) + continue; + + if (typeNode.Value == "Transform") + { + if (componentNode.TryGet("pos", out ValueDataNode? posNode)) + { + var posParts = posNode.Value.Split(','); + if (posParts.Length == 2 && + float.TryParse(posParts[0], out var x) && + float.TryParse(posParts[1], out var y)) + { + entityPos = new Vector2i((int)Math.Floor(x), (int)Math.Floor(y)); + } + } + + if (componentNode.TryGet("parent", out ValueDataNode? parentNode) && + int.TryParse(parentNode.Value, out var parent)) + { + parentUid = parent; + } + } + } + + if (entityPos.HasValue && parentUid == gridUid) + { + wallEntities.Add((entityPos.Value, protoId)); + } + } + } + + return wallEntities; + } + + /// + /// Gets the set of window prototype IDs for map parsing. Window definitions come from the YAML config. + /// + private HashSet GetWindowPrototypesSet(SalvageMagnetRuinConfigPrototype? config) + { + if (config == null) + _prototypeManager.TryIndex(new ProtoId("Default"), out config); + + if (config == null || config.WindowPrototypes.Count == 0) + return new HashSet(StringComparer.OrdinalIgnoreCase); + + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var proto in config.WindowPrototypes) + { + set.Add(proto.Id); + } + return set; + } + + /// + /// Parses window entities from the map file and returns their positions, prototype IDs, and rotations. + /// + private List<(Vector2i Position, string PrototypeId, Angle Rotation)> ParseWindowEntities(SequenceDataNode entitiesNode, int gridUid, SalvageMagnetRuinConfigPrototype? config) + { + var windowEntities = new List<(Vector2i, string, Angle)>(); + + var windowPrototypes = GetWindowPrototypesSet(config); + + foreach (var protoGroupNode in entitiesNode.Sequence.Cast()) + { + if (!protoGroupNode.TryGet("proto", out ValueDataNode? protoNode)) + continue; + + var protoId = protoNode.Value; + + if (!windowPrototypes.Contains(protoId)) + continue; + + if (!protoGroupNode.TryGet("entities", out SequenceDataNode? entitiesInGroup)) + continue; + + foreach (var entityNode in entitiesInGroup.Sequence.Cast()) + { + if (entityNode.TryGet("uid", out ValueDataNode? uidNode) && + int.TryParse(uidNode.Value, out var entityUid) && + entityUid == gridUid) + continue; + + if (!entityNode.TryGet("components", out SequenceDataNode? componentsNode)) + continue; + + Vector2i? entityPos = null; + int? parentUid = null; + Angle rotation = Angle.Zero; + + foreach (var componentNode in componentsNode.Sequence.Cast()) + { + if (!componentNode.TryGet("type", out ValueDataNode? typeNode)) + continue; + + if (typeNode.Value == "Transform") + { + if (componentNode.TryGet("pos", out ValueDataNode? posNode)) + { + var posParts = posNode.Value.Split(','); + if (posParts.Length == 2 && + float.TryParse(posParts[0], out var x) && + float.TryParse(posParts[1], out var y)) + { + entityPos = new Vector2i((int)Math.Floor(x), (int)Math.Floor(y)); + } + } + + if (componentNode.TryGet("parent", out ValueDataNode? parentNode) && + int.TryParse(parentNode.Value, out var parent)) + { + parentUid = parent; + } + + // Parse rotation from "rot" field (stored as "X rad" or just "X") + if (componentNode.TryGet("rot", out ValueDataNode? rotNode)) + { + var rotStr = rotNode.Value.Trim(); + // Remove "rad" suffix if present + if (rotStr.EndsWith("rad", StringComparison.OrdinalIgnoreCase)) + { + rotStr = rotStr.Substring(0, rotStr.Length - 3).Trim(); + } + + if (float.TryParse(rotStr, out var rotValue)) + { + rotation = new Angle(rotValue); + } + } + } + } + + if (entityPos.HasValue && parentUid == gridUid) + { + windowEntities.Add((entityPos.Value, protoId, rotation)); + } + } + } + + return windowEntities; + } + + /// + /// Builds a cost map from coordinate map. Space tiles (missing from map) get cost from config (default 99). + /// Window and wall entities at positions get appropriate cost based on type. + /// + private Dictionary BuildCostMap(Dictionary coordinateMap, List<(Vector2i Position, string PrototypeId, Angle Rotation)> windowEntities, List<(Vector2i Position, string PrototypeId)> wallEntities, SalvageMagnetRuinConfigPrototype? config = null) + { + var costMap = new Dictionary(); + + // Create sets of window and wall positions for fast lookup + var windowPositions = new HashSet(windowEntities.Select(w => w.Position)); + var wallPositions = new HashSet(wallEntities.Select(w => w.Position)); + + // Get wall cost from config + var wallCost = config?.WallCost ?? 6; + + foreach (var (pos, tileId) in coordinateMap) + { + // Priority: walls > windows > tiles + // If there's a wall entity at this position, use wall cost + if (wallPositions.Contains(pos)) + { + costMap[pos] = wallCost; + } + // If there's a window entity at this position, use window cost + else if (windowPositions.Contains(pos)) + { + var windowEntity = windowEntities.FirstOrDefault(w => w.Position == pos); + if (windowEntity.PrototypeId != null) + { + var cost = GetWindowCost(windowEntity.PrototypeId, config); + costMap[pos] = cost; + } + else + { + // Fallback to tile cost if window not found + var cost = GetTileCost(tileId, config); + costMap[pos] = cost; + } + } + else + { + var cost = GetTileCost(tileId, config); + costMap[pos] = cost; + } + } + + // Also add wall positions that might not have floor tiles underneath + foreach (var (wallPos, _) in wallEntities) + { + if (!costMap.ContainsKey(wallPos)) + { + costMap[wallPos] = wallCost; + } + } + + // Also add window positions that might not have floor tiles underneath + foreach (var (windowPos, windowProto, _) in windowEntities) + { + if (!costMap.ContainsKey(windowPos)) + { + var cost = GetWindowCost(windowProto, config); + costMap[windowPos] = cost; + } + } + + return costMap; + } + + /// + /// Gets the cost for a window entity based on its prototype ID. + /// Uses WindowCosts from config; longest matching pattern wins. Falls back to DefaultWindowCost. + /// + private int GetWindowCost(string prototypeId, SalvageMagnetRuinConfigPrototype? config = null) + { + if (config == null || config.WindowCosts.Count == 0) + return config?.DefaultWindowCost ?? 4; + + var id = prototypeId.ToLowerInvariant(); + var bestMatch = ""; + foreach (var (pattern, _) in config.WindowCosts) + { + if (pattern.Length > bestMatch.Length && id.Contains(pattern.ToLowerInvariant())) + bestMatch = pattern; + } + + if (bestMatch.Length > 0 && config.WindowCosts.TryGetValue(bestMatch, out var cost)) + return cost; + + return config.DefaultWindowCost; + } + + /// + /// Checks if a tile is a wall based on its tile definition ID. + /// Uses WallTileIds from config; wall tile definitions come from the YAML config. + /// + private bool IsWallTile(string tileId, SalvageMagnetRuinConfigPrototype? config) + { + if (config == null) + _prototypeManager.TryIndex(new ProtoId("Default"), out config); + + if (config == null || config.WallTileIds.Count == 0) + return false; + + foreach (var wallTileId in config.WallTileIds) + { + if (tileId.Equals(wallTileId, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + /// + /// Gets the cost for a tile based on its prototype ID. + /// + private int GetTileCost(string tileId, SalvageMagnetRuinConfigPrototype? config = null) + { + if (IsWallTile(tileId, config)) + return config?.WallCost ?? 20; + + if (config == null) + _prototypeManager.TryIndex(new ProtoId("Default"), out config); + + if (config == null || config.TileCosts.Count == 0) + return config?.DefaultTileCost ?? 1; + + var id = tileId.ToLowerInvariant(); + var bestMatch = ""; + foreach (var (pattern, _) in config.TileCosts) + { + if (pattern.Length > bestMatch.Length && id.Contains(pattern.ToLowerInvariant())) + bestMatch = pattern; + } + + if (bestMatch.Length > 0 && config.TileCosts.TryGetValue(bestMatch, out var cost)) + return cost; + + return config.DefaultTileCost; + } + + /// + /// Finds a valid start location (non-space tile) with retries. + /// + private Vector2i? FindValidStartLocation(Dictionary costMap, System.Random rand, int maxRetries, int spaceCost = 99) + { + if (costMap.Count == 0) + return null; + + var positions = costMap.Keys.ToList(); + + for (var i = 0; i < maxRetries; i++) + { + var pos = positions[rand.Next(positions.Count)]; + var cost = costMap[pos]; + + if (cost < spaceCost) + return pos; + } + + foreach (var (pos, cost) in costMap) + { + if (cost < spaceCost) + return pos; + } + + return null; + } + + /// + /// Performs cost-based flood-fill from start position, collecting tiles until budget cost is exhausted. + /// Uses accumulated cost to determine when to stop, not tile count. + /// When budget is exhausted, extends the result to include adjacent tiles with walls. + /// + private HashSet FloodFillWithCost(Dictionary costMap, Vector2i start, int budget, HashSet wallPositions) + { + var result = new HashSet(); + var visited = new HashSet(); + var queue = new List<(Vector2i Pos, int AccumulatedCost)>(); + var accumulatedCosts = new Dictionary(); + var totalCostSpent = 0; // Track total cost spent, not tile count + + queue.Add((start, 0)); + accumulatedCosts[start] = 0; + + // CRITICAL FIX: Stop when accumulated cost exceeds budget, not when tile count exceeds budget + while (queue.Count > 0) + { + queue.Sort((a, b) => a.AccumulatedCost.CompareTo(b.AccumulatedCost)); + var (current, accumulatedCost) = queue[0]; + queue.RemoveAt(0); + + if (visited.Contains(current)) + continue; + + visited.Add(current); + + if (!costMap.TryGetValue(current, out var tileCost)) + continue; + + // Check if adding this tile would exceed the budget + // accumulatedCost is the cost to reach this tile (path cost), + // but we need to pay the tile's cost to add it to the result + // So total cost = accumulatedCost (path) + tileCost (tile itself) + var totalCostIfAdded = accumulatedCost + tileCost; + if (totalCostIfAdded > budget) + { + // Can't afford this tile, stop the flood-fill + break; + } + + // Add the tile to result and update total cost spent + result.Add(current); + totalCostSpent = totalCostIfAdded; + + var neighbors = new[] + { + current + Vector2i.Up, + current + Vector2i.Down, + current + Vector2i.Left, + current + Vector2i.Right + }; + + foreach (var neighbor in neighbors) + { + if (visited.Contains(neighbor)) + continue; + + if (!costMap.TryGetValue(neighbor, out var neighborCost)) + continue; + + var newAccumulatedCost = accumulatedCost + neighborCost; + + // Only consider neighbors we can afford + if (newAccumulatedCost > budget) + continue; + + if (!accumulatedCosts.TryGetValue(neighbor, out var existingCost) || + newAccumulatedCost < existingCost) + { + accumulatedCosts[neighbor] = newAccumulatedCost; + queue.Add((neighbor, newAccumulatedCost)); + } + } + } + + // If we stopped due to budget exhaustion, check adjacent tiles for walls + // and add them to ensure the ruin has proper boundaries + if (result.Count >= budget) + { + var adjacentWithWalls = new HashSet(); + + // Find all tiles adjacent to the flood-filled region + foreach (var pos in result) + { + var neighbors = new[] + { + pos + Vector2i.Up, + pos + Vector2i.Down, + pos + Vector2i.Left, + pos + Vector2i.Right + }; + + foreach (var neighbor in neighbors) + { + // Skip if already in result + if (result.Contains(neighbor)) + continue; + + // Check if this adjacent tile has a wall entity + if (wallPositions.Contains(neighbor)) + { + adjacentWithWalls.Add(neighbor); + } + } + } + + // Add all adjacent wall tiles to the result + foreach (var wallPos in adjacentWithWalls) + { + result.Add(wallPos); + } + + if (adjacentWithWalls.Count > 0) + { + _sawmill.Debug($"[SalvageRuinGenerator] Extended flood-fill to include {adjacentWithWalls.Count} adjacent wall tiles"); + } + } + + return result; + } + + /// + /// Performs multi-stage cost-based flood-fill, creating irregular branching shapes. + /// Each stage starts from a random tile adjacent to the previous stages' results, + /// ensuring connectivity while creating organic, branching ruin structures. + /// Prioritizes low-cost tiles (floors) for stage starting positions. + /// + private HashSet FloodFillMultiStage(Dictionary costMap, Vector2i start, int stagesCount, int budgetPerStage, HashSet wallPositions, System.Random rand, int spaceCost, int defaultTileCost) + { + var result = new HashSet(); + var visited = new HashSet(); + var currentStart = start; + + for (var stage = 0; stage < stagesCount; stage++) + { + // This is going to look very odd compared to most other ss14 code. This section of code is doing the floodfill entirely in memory without spawning anything, because that's much more performant. + var stageResult = new HashSet(); + var stageVisited = new HashSet(); + var queue = new List<(Vector2i Pos, int AccumulatedCost)>(); + var accumulatedCosts = new Dictionary(); + + queue.Add((currentStart, 0)); + accumulatedCosts[currentStart] = 0; + + // Perform flood-fill for this stage + while (queue.Count > 0) + { + queue.Sort((a, b) => a.AccumulatedCost.CompareTo(b.AccumulatedCost)); + var (current, accumulatedCost) = queue[0]; + queue.RemoveAt(0); + + if (stageVisited.Contains(current)) + continue; + + stageVisited.Add(current); + + if (!costMap.TryGetValue(current, out var tileCost)) + continue; + + // Check if adding this tile would exceed the budget + var totalCostIfAdded = accumulatedCost + tileCost; + if (totalCostIfAdded > budgetPerStage) + { + // Can't afford this tile, skip it + continue; + } + + // Add the tile to stage result + stageResult.Add(current); + result.Add(current); + visited.Add(current); + + var neighbors = new[] + { + current + Vector2i.Up, + current + Vector2i.Down, + current + Vector2i.Left, + current + Vector2i.Right + }; + + foreach (var neighbor in neighbors) + { + if (stageVisited.Contains(neighbor) || visited.Contains(neighbor)) + continue; + + if (!costMap.TryGetValue(neighbor, out var neighborCost)) + continue; + + var newAccumulatedCost = accumulatedCost + neighborCost; + + // Only consider neighbors we can afford + if (newAccumulatedCost > budgetPerStage) + continue; + + if (!accumulatedCosts.TryGetValue(neighbor, out var existingCost) || + newAccumulatedCost < existingCost) + { + accumulatedCosts[neighbor] = newAccumulatedCost; + queue.Add((neighbor, newAccumulatedCost)); + } + } + } + + // Extend stage result to include adjacent wall tiles (same as single-stage) + // In practice this makes it so that anywhere the floodfill visited, if it's next to a wall, brings in that wall + // This makes it so that it generally brings in room shapes better than if it only brought in visited tiles. Because the floodfill often runs out of points when it finds a wall. + var adjacentWithWalls = new HashSet(); + foreach (var pos in stageResult) + { + var neighbors = new[] + { + pos + Vector2i.Up, + pos + Vector2i.Down, + pos + Vector2i.Left, + pos + Vector2i.Right + }; + + foreach (var neighbor in neighbors) + { + if (result.Contains(neighbor)) + continue; + + if (wallPositions.Contains(neighbor)) + { + adjacentWithWalls.Add(neighbor); + } + } + } + + foreach (var wallPos in adjacentWithWalls) + { + result.Add(wallPos); + visited.Add(wallPos); + } + + _sawmill.Debug($"[SalvageRuinGenerator] Stage {stage + 1}/{stagesCount}: Added {stageResult.Count} tiles, {adjacentWithWalls.Count} adjacent walls"); + + // Pick next start position from tiles directly adjacent to current result + // Prioritize low-cost tiles (floors) over high-cost tiles (walls/windows) for better expansion + if (stage < stagesCount - 1) + { + + // Build lists of adjacent unvisited tiles, grouped by cost priority + var lowCostTiles = new List(); // defaultTileCost tiles (floors) + var mediumCostTiles = new List(); // Medium cost tiles (windows, grilles) + var highCostTiles = new List(); // High cost tiles (walls) + + foreach (var pos in result) + { + var neighbors = new[] + { + pos + Vector2i.Up, + pos + Vector2i.Down, + pos + Vector2i.Left, + pos + Vector2i.Right + }; + + foreach (var neighbor in neighbors) + { + // Skip if already visited or in result + if (visited.Contains(neighbor) || result.Contains(neighbor)) + continue; + + // Check if this is a valid tile (not space) + if (!costMap.TryGetValue(neighbor, out var neighborCost)) + continue; + + if (neighborCost >= spaceCost) + continue; + + // Group by cost priority + if (neighborCost <= defaultTileCost) + { + if (!lowCostTiles.Contains(neighbor)) + lowCostTiles.Add(neighbor); + } + else if (neighborCost <= 5) + { + if (!mediumCostTiles.Contains(neighbor)) + mediumCostTiles.Add(neighbor); + } + else + { + if (!highCostTiles.Contains(neighbor)) + highCostTiles.Add(neighbor); + } + } + } + + // Select from priority groups: low cost first, then medium, then high + if (lowCostTiles.Count > 0) + { + currentStart = lowCostTiles[rand.Next(lowCostTiles.Count)]; + _sawmill.Debug($"[SalvageRuinGenerator] Stage {stage + 1} complete, starting stage {stage + 2} from low-cost tile {currentStart} ({lowCostTiles.Count} low-cost candidates)"); + } + else if (mediumCostTiles.Count > 0) + { + currentStart = mediumCostTiles[rand.Next(mediumCostTiles.Count)]; + _sawmill.Debug($"[SalvageRuinGenerator] Stage {stage + 1} complete, starting stage {stage + 2} from medium-cost tile {currentStart} ({mediumCostTiles.Count} medium-cost candidates)"); + } + else if (highCostTiles.Count > 0) + { + currentStart = highCostTiles[rand.Next(highCostTiles.Count)]; + _sawmill.Debug($"[SalvageRuinGenerator] Stage {stage + 1} complete, starting stage {stage + 2} from high-cost tile {currentStart} ({highCostTiles.Count} high-cost candidates)"); + } + else + { + // No adjacent tiles available, stop early (map exhausted) + _sawmill.Debug($"[SalvageRuinGenerator] Stage {stage + 1} complete, no adjacent unvisited tiles available (map exhausted), stopping early after {stage + 1} stages"); + break; + } + } + } + + return result; + } +} diff --git a/Content.Shared/Parallax/Biomes/Layers/BiomeDecalLayer.cs b/Content.Shared/Parallax/Biomes/Layers/BiomeDecalLayer.cs index f95848e42b0..44c1fcb1da2 100644 --- a/Content.Shared/Parallax/Biomes/Layers/BiomeDecalLayer.cs +++ b/Content.Shared/Parallax/Biomes/Layers/BiomeDecalLayer.cs @@ -13,6 +13,10 @@ public sealed partial class BiomeDecalLayer : IBiomeWorldLayer [DataField] public List> AllowedTiles { get; private set; } = new(); + // Macro - A flag to allow all tiles to be used for the decal layer + [DataField] + public bool AllowAllTiles { get; private set; } = false; // Macro - allow all tiles + /// /// Divide each tile up by this amount. /// diff --git a/Content.Shared/Parallax/Biomes/Layers/BiomeEntityLayer.cs b/Content.Shared/Parallax/Biomes/Layers/BiomeEntityLayer.cs index c09980aaadf..c4ce53e54ae 100644 --- a/Content.Shared/Parallax/Biomes/Layers/BiomeEntityLayer.cs +++ b/Content.Shared/Parallax/Biomes/Layers/BiomeEntityLayer.cs @@ -12,6 +12,10 @@ public sealed partial class BiomeEntityLayer : IBiomeWorldLayer [DataField] public List> AllowedTiles { get; private set; } = new(); + // Macro - A flag to allow all tiles to be used for the entity layer + [DataField] + public bool AllowAllTiles { get; private set; } = false; // Macro - allow all tiles + [DataField("noise")] public FastNoiseLite Noise { get; private set; } = new(0); /// diff --git a/Content.Shared/Parallax/Biomes/Layers/IBiomeWorldLayer.cs b/Content.Shared/Parallax/Biomes/Layers/IBiomeWorldLayer.cs index 92779b6f8e8..fe8d45077ab 100644 --- a/Content.Shared/Parallax/Biomes/Layers/IBiomeWorldLayer.cs +++ b/Content.Shared/Parallax/Biomes/Layers/IBiomeWorldLayer.cs @@ -12,4 +12,10 @@ public partial interface IBiomeWorldLayer : IBiomeLayer /// What tiles we're allowed to spawn on, real or biome. /// List> AllowedTiles { get; } + + // Macro - A flag to allow all tiles to be used for the world layer + /// + /// When true, allows spawning on any floor tile regardless of AllowedTiles. + /// + bool AllowAllTiles { get; } // Macro - allow all tiles } diff --git a/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs b/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs index a5238e8c6ec..5c056a76f02 100644 --- a/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs +++ b/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; // Macro using System.Diagnostics.CodeAnalysis; using System.Numerics; using Content.Shared.Maps; @@ -212,7 +213,8 @@ public bool TryGetEntity(Vector2i indices, BiomeComponent component, MapGridComp } public bool TryGetEntity(Vector2i indices, List layers, Tile tileRef, int seed, Entity? grid, - [NotNullWhen(true)] out string? entity) + [NotNullWhen(true)] out string? entity, + Dictionary? noiseCache = null) // Macro - Cache noise for each layer { var tileId = TileDefManager[tileRef.TypeId].ID; @@ -225,7 +227,7 @@ public bool TryGetEntity(Vector2i indices, List layers, Tile tileRe case BiomeDummyLayer: continue; case IBiomeWorldLayer worldLayer: - if (!worldLayer.AllowedTiles.Contains(tileId)) + if (!worldLayer.AllowAllTiles && !worldLayer.AllowedTiles.Contains(tileId)) // Macro - Allow all as an option for allowed tiles continue; break; @@ -235,7 +237,7 @@ public bool TryGetEntity(Vector2i indices, List layers, Tile tileRe continue; } - var noiseCopy = GetNoise(layer.Noise, seed); + var noiseCopy = GetOrCreateNoise(layers, i, seed, noiseCache); // Macro - Cache noise for each layer var invert = layer.Invert; var value = noiseCopy.GetNoise(indices.X, indices.Y); @@ -246,7 +248,7 @@ public bool TryGetEntity(Vector2i indices, List layers, Tile tileRe if (layer is BiomeMetaLayer meta) { - if (TryGetEntity(indices, ProtoManager.Index(meta.Template).Layers, tileRef, seed, grid, out entity)) + if (TryGetEntity(indices, ProtoManager.Index(meta.Template).Layers, tileRef, seed, grid, out entity, null)) // Macro - Cache noise for each layer { return true; } @@ -272,16 +274,18 @@ public bool TryGetEntity(Vector2i indices, List layers, Tile tileRe [Obsolete("Use the Entity? overload")] public bool TryGetEntity(Vector2i indices, List layers, Tile tileRef, int seed, MapGridComponent grid, - [NotNullWhen(true)] out string? entity) + [NotNullWhen(true)] out string? entity, + Dictionary? noiseCache = null) // Macro - Cache noise for each layer { - return TryGetEntity(indices, layers, tileRef, seed, grid == null ? null : (grid.Owner, grid), out entity); + return TryGetEntity(indices, layers, tileRef, seed, grid == null ? null : (grid.Owner, grid), out entity, noiseCache); // Macro - Cache noise for each layer } /// /// Tries to get the relevant decals for this tile. /// public bool TryGetDecals(Vector2i indices, List layers, int seed, Entity? grid, - [NotNullWhen(true)] out List<(string ID, Vector2 Position)>? decals) + [NotNullWhen(true)] out List<(string ID, Vector2 Position)>? decals, + Dictionary? noiseCache = null) { if (!TryGetBiomeTile(indices, layers, seed, grid, out var tileRef)) { @@ -301,7 +305,7 @@ public bool TryGetDecals(Vector2i indices, List layers, int seed, E case BiomeDummyLayer: continue; case IBiomeWorldLayer worldLayer: - if (!worldLayer.AllowedTiles.Contains(tileId)) + if (!worldLayer.AllowAllTiles && !worldLayer.AllowedTiles.Contains(tileId)) // Macro - Allow all as an option for allowed tiles continue; break; @@ -312,7 +316,7 @@ public bool TryGetDecals(Vector2i indices, List layers, int seed, E } var invert = layer.Invert; - var noiseCopy = GetNoise(layer.Noise, seed); + var noiseCopy = GetOrCreateNoise(layers, i, seed, noiseCache); // Macro - Cache noise for each layer var value = noiseCopy.GetNoise(indices.X, indices.Y); value = invert ? value * -1 : value; @@ -321,7 +325,7 @@ public bool TryGetDecals(Vector2i indices, List layers, int seed, E if (layer is BiomeMetaLayer meta) { - if (TryGetDecals(indices, ProtoManager.Index(meta.Template).Layers, seed, grid, out decals)) + if (TryGetDecals(indices, ProtoManager.Index(meta.Template).Layers, seed, grid, out decals, null)) // Macro - no need to cache noise for decals { return true; } @@ -369,9 +373,10 @@ public bool TryGetDecals(Vector2i indices, List layers, int seed, E /// [Obsolete("Use the Entity? overload")] public bool TryGetDecals(Vector2i indices, List layers, int seed, MapGridComponent grid, - [NotNullWhen(true)] out List<(string ID, Vector2 Position)>? decals) + [NotNullWhen(true)] out List<(string ID, Vector2 Position)>? decals, + Dictionary? noiseCache = null) // Macro - Cache noise for each layer { - return TryGetDecals(indices, layers, seed, grid == null ? null : (grid.Owner, grid), out decals); + return TryGetDecals(indices, layers, seed, grid == null ? null : (grid.Owner, grid), out decals, noiseCache); // Macro - Cache noise for each layer } private FastNoiseLite GetNoise(FastNoiseLite seedNoise, int seed) @@ -383,4 +388,15 @@ private FastNoiseLite GetNoise(FastNoiseLite seedNoise, int seed) noiseCopy.SetFractalOctaves(noiseCopy.GetFractalOctaves()); return noiseCopy; } +// Macro start - Cache noise for each layer + private FastNoiseLite GetOrCreateNoise(List layers, int layerIndex, int seed, Dictionary? cache) + { + if (cache != null && cache.TryGetValue(layerIndex, out var cached)) + return cached; + + var noise = GetNoise(layers[layerIndex].Noise, seed); + cache?.Add(layerIndex, noise); + return noise; + } } +// Macro end diff --git a/Content.Shared/Salvage/SharedSalvageSystem.Magnet.cs b/Content.Shared/Salvage/SharedSalvageSystem.Magnet.cs index 3950b1b72bf..91bc46db1a2 100644 --- a/Content.Shared/Salvage/SharedSalvageSystem.Magnet.cs +++ b/Content.Shared/Salvage/SharedSalvageSystem.Magnet.cs @@ -14,13 +14,18 @@ public abstract partial class SharedSalvageSystem { private readonly List _salvageMaps = new(); + +// Macro start - Making it so the magnet offering is defined in yml + /* Legacy magnet offering weights, pre YAML definitions private readonly Dictionary _offeringWeights = new() { { new AsteroidOffering(), 4.5f }, { new DebrisOffering(), 3.5f }, { new SalvageOffering(), 2.0f }, - }; - + };*/ + private readonly List _ruinMaps = new(); + private readonly ProtoId _magnetOfferingWeights = "SalvageMagnetOfferings"; +// Macro end private readonly List> _asteroidConfigs = new() { "BlobAsteroid", @@ -41,11 +46,13 @@ public abstract partial class SharedSalvageSystem public ISalvageMagnetOffering GetSalvageOffering(int seed) { var rand = new System.Random(seed); +// Macro start, making it so the offering is defined in YML + var offeringWeights = _proto.Index(_magnetOfferingWeights); + var typeId = offeringWeights.Pick(rand); - var type = SharedRandomExtensions.Pick(_offeringWeights, rand); - switch (type) + switch (typeId) { - case AsteroidOffering: + case "Asteroid": var configId = _asteroidConfigs[rand.Next(_asteroidConfigs.Count)]; var configProto =_proto.Index(configId); var layers = new Dictionary(); @@ -78,13 +85,13 @@ public ISalvageMagnetOffering GetSalvageOffering(int seed) DungeonConfig = config, MarkerLayers = layers, }; - case DebrisOffering: + case "Debris": var id = rand.Pick(_debrisConfigs); return new DebrisOffering { Id = id }; - case SalvageOffering: + case "Salvage": // Salvage map seed _salvageMaps.Clear(); _salvageMaps.AddRange(_proto.EnumeratePrototypes()); @@ -96,8 +103,21 @@ public ISalvageMagnetOffering GetSalvageOffering(int seed) { SalvageMap = map, }; + case "Ruin": + _ruinMaps.Clear(); + _ruinMaps.AddRange(_proto.EnumeratePrototypes()); + _ruinMaps.Sort((x, y) => string.Compare(x.ID, y.ID, StringComparison.Ordinal)); + var ruinIndex = rand.Next(_ruinMaps.Count); + var ruin = _ruinMaps[ruinIndex]; + + return new RuinOffering + { + RuinMap = ruin, + StationName = string.Empty, + }; default: - throw new NotImplementedException($"Salvage type {type} not implemented!"); + throw new NotImplementedException($"Salvage magnet offering type {typeId} not implemented!"); } } } +// Macro end diff --git a/Content.Shared/_MACRO/Salvage/Magnet/RuinOffering.cs b/Content.Shared/_MACRO/Salvage/Magnet/RuinOffering.cs new file mode 100644 index 00000000000..8500b3e737c --- /dev/null +++ b/Content.Shared/_MACRO/Salvage/Magnet/RuinOffering.cs @@ -0,0 +1,21 @@ +namespace Content.Shared.Salvage.Magnet; + +/// +/// Ruin offered for the magnet, generated from station maps. +/// +public record struct RuinOffering : ISalvageMagnetOffering +{ + public RuinMapPrototype RuinMap; + + /// + /// Generated name for the ruined station + /// + public string StationName; + + public RuinOffering() + { + RuinMap = null!; + StationName = string.Empty; + } +} + diff --git a/Content.Shared/_MACRO/Salvage/RuinMapPrototype.cs b/Content.Shared/_MACRO/Salvage/RuinMapPrototype.cs new file mode 100644 index 00000000000..b136bcaec37 --- /dev/null +++ b/Content.Shared/_MACRO/Salvage/RuinMapPrototype.cs @@ -0,0 +1,16 @@ +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; + +namespace Content.Shared.Salvage; + +[Prototype] +public sealed partial class RuinMapPrototype : IPrototype +{ + [ViewVariables] [IdDataField] public string ID { get; private set; } = default!; + + /// + /// Relative directory path to the given map + /// + [DataField(required: true)] public ResPath MapPath; +} + diff --git a/Content.Shared/_MACRO/Salvage/SalvageMagnetRuinConfigPrototype.cs b/Content.Shared/_MACRO/Salvage/SalvageMagnetRuinConfigPrototype.cs new file mode 100644 index 00000000000..f1a0e9f54dc --- /dev/null +++ b/Content.Shared/_MACRO/Salvage/SalvageMagnetRuinConfigPrototype.cs @@ -0,0 +1,180 @@ +using System.Collections.Immutable; +using System.Collections.Generic; +using Robust.Shared.Prototypes; + +namespace Content.Shared.Salvage; + +/// +/// Configuration for salvage magnet ruin generation, including damage simulation parameters. +/// Defaults are used as fallback when fields are omitted from salvage_magnet_ruin_config.yml. +/// +[Prototype] +public sealed partial class SalvageMagnetRuinConfigPrototype : IPrototype +{ + [ViewVariables] [IdDataField] public string ID { get; private set; } = default!; + + /// + /// Number of cost points to use for flood-fill algorithm. + /// Higher values result in larger ruins. If you increase this, also increase RuinSpawnDistance. + /// + [DataField] + public int FloodFillPoints = 25; + + /// + /// Distance (in tiles) at which ruins spawn from the salvage magnet. + /// Ruins spawn this distance away in the direction the magnet is facing. + /// + [DataField] + public float RuinSpawnDistance = 82f; + + /// + /// Chance (0.0 to 1.0) that a wall entity will be destroyed and not spawned. + /// + [DataField] + public float WallDestroyChance = 0.30f; + + /// + /// Chance (0.0 to 1.0) that a window entity will be spawned in a damaged state. + /// + [DataField] + public float WindowDamageChance = 0.10f; + + /// + /// Chance (0.0 to 1.0) that a floor tile will be replaced with lattice. + /// Lattice tiles are never damaged further (they're already the most damaged state). + /// + [DataField] + public float FloorToLatticeChance = 0.15f; + + /// + /// Path cost for walls. Higher values make flood-fill avoid walls more. + /// + [DataField] + public int WallCost = 20; + + /// + /// Path cost for window entities by pattern. Keys are substrings matched case-insensitively against prototype IDs. + /// Longest matching pattern wins. E.g. "windoorsecure" matches before "windoor". + /// + [DataField] + public Dictionary WindowCosts = new() + { + ["firelock"] = 10, + ["windoorsecure"] = 10, + ["windoor"] = 4, + ["reinforceddirectional"] = 10, + ["directional"] = 10, + ["reinforceddiagonal"] = 10, + ["diagonal"] = 10, + ["reinforced"] = 10, + }; + + /// + /// Default path cost for windows when no pattern in WindowCosts matches. + /// + [DataField] + public int DefaultWindowCost = 4; + + /// + /// Path cost for tile definitions by pattern. Keys are substrings matched case-insensitively against tile IDs. + /// Longest matching pattern wins. Wall tiles use WallCost (via WallTileIds), not this. + /// + [DataField] + public Dictionary TileCosts = new() + { + ["directionalglass"] = 8, + ["reinforcedglass"] = 12, + ["glass"] = 8, + ["grille"] = 4, + }; + + /// + /// Default path cost for tiles when no pattern in TileCosts matches (floors, plating, etc.). + /// + [DataField] + public int DefaultTileCost = 1; + + /// + /// Path cost for space tiles (tiles not in the map). + /// Set to a very high value (9999) to make flood-fill treat them as impassable. + /// Lower values would allow flood-fill to cross small gaps of space. + /// + [DataField] + public int SpaceCost = 9999; + + /// + /// Number of flood-fill stages to perform. Each stage starts from the previous stage's frontier + /// (tiles that were almost added but exceeded budget), creating irregular branching shapes. + /// + [DataField] + public int FloodFillStages = 7; + + /// + /// Tile definition IDs that are considered wall tiles for cost map building. + /// Used by GetTileCost to apply WallCost to wall tiles in the tilemap. + /// + [DataField] + public ImmutableList WallTileIds = ImmutableList.CreateRange(new[] + { + "WallSolid", + "WallReinforced", + "WallReinforcedRust", + "WallSolidRust", + "WallSolidDiagonal", + "WallReinforcedDiagonal" + }); + + /// + /// Entity prototype IDs that are considered walls when parsing ruin maps. + /// Used to identify wall entities in the map file for cost map building. + /// + [DataField] + public ImmutableList WallPrototypes = ImmutableList.CreateRange(new EntProtoId[] + { + "WallSolid", + "WallReinforced", + "WallReinforcedRust", + "WallSolidRust", + "WallSolidDiagonal", + "WallReinforcedDiagonal", + "WallShuttleDiagonal", + "WallPlastitaniumDiagonal", + "WallMiningDiagonal" + }); + + /// + /// Entity prototype IDs that are considered windows when parsing ruin maps. + /// Includes windows, windoors, firelocks, frames, and assemblies. + /// Used to identify window entities in the map file for cost map building. + /// + [DataField] + public ImmutableList WindowPrototypes = ImmutableList.CreateRange(new EntProtoId[] + { + "Window", + "WindowDirectional", + "ReinforcedWindow", + "WindowReinforcedDirectional", + "WindowDiagonal", + "ReinforcedWindowDiagonal", + "TintedWindow", + "WindowFrostedDirectional", + "ShuttleWindow", + "ShuttleWindowDiagonal", + "PlastitaniumWindow", + "PlastitaniumWindowDiagonal", + "Windoor", + "WindoorSecure", + "WindoorPlasma", + "WindoorSecurePlasma", + "WindoorClockwork", + "Firelock", + "FirelockGlass", + "FirelockEdge", + "FirelockFrame", + "WindoorAssembly", + "WindoorAssemblySecure", + "WindoorAssemblyPlasma", + "WindoorAssemblySecurePlasma" + }); +} + diff --git a/Content.Shared/_MACRO/Salvage/SalvageRuinDebrisPrototype.cs b/Content.Shared/_MACRO/Salvage/SalvageRuinDebrisPrototype.cs new file mode 100644 index 00000000000..9790ae9068a --- /dev/null +++ b/Content.Shared/_MACRO/Salvage/SalvageRuinDebrisPrototype.cs @@ -0,0 +1,42 @@ +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; + +namespace Content.Shared.Salvage; + +/// +/// Prototype for configuring debris entities that spawn on salvage ruins. +/// +[Prototype] +public sealed partial class SalvageRuinDebrisPrototype : IPrototype +{ + [ViewVariables] + [IdDataField] + public string ID { get; private set; } = default!; + + /// + /// List of debris entities to spawn with their relative spawn chances. + /// + [DataField("entries", required: true)] + public List Entries = new(); +} + +/// +/// An entry for a debris entity to spawn in salvage ruins. +/// +[DataDefinition] +public sealed partial class SalvageRuinDebrisEntry +{ + /// + /// The prototype ID of the entity to spawn. + /// + [DataField("proto", required: true, customTypeSerializer: typeof(PrototypeIdSerializer))] + public string Proto = string.Empty; + + /// + /// The relative spawn chance for this entity (normalized with other entries). + /// Higher values mean more likely to spawn. + /// + [DataField("chance")] + public float Chance = 1.0f; +} + diff --git a/Resources/Locale/en-US/salvage/salvage-magnet.ftl b/Resources/Locale/en-US/salvage/salvage-magnet.ftl index 14ee6bb8de7..a36d86fad4c 100644 --- a/Resources/Locale/en-US/salvage/salvage-magnet.ftl +++ b/Resources/Locale/en-US/salvage/salvage-magnet.ftl @@ -40,6 +40,9 @@ dungeon-config-proto-ClusterAsteroid = Asteroid cluster dungeon-config-proto-SpindlyAsteroid = Asteroid spiral dungeon-config-proto-SwissCheeseAsteroid = Asteroid fragments +# Ruins +salvage-magnet-ruin = Station ruin + # Wrecks salvage-map-wreck = Salvage wreck salvage-map-wreck-desc-size = Size: diff --git a/Resources/Prototypes/_MACRO/Procedural/Magnet/space_ruin_templates.yml b/Resources/Prototypes/_MACRO/Procedural/Magnet/space_ruin_templates.yml new file mode 100644 index 00000000000..85512f70620 --- /dev/null +++ b/Resources/Prototypes/_MACRO/Procedural/Magnet/space_ruin_templates.yml @@ -0,0 +1,68 @@ +# Ruin-specific biome template for salvage magnet ruins. +# Mirrors SpaceDebris but only spawns loot/mob/decals (no walls/grilles). +# Uses allowAllTiles to spawn on any floor tile (girders, plating, etc.). +- type: biomeTemplate + id: SpaceRuin + layers: + # Decals - same ratios as SpaceDebris (DirtHeavy x3, DirtMedium x2, DirtLight x1) + - !type:BiomeDecalLayer + allowAllTiles: true + threshold: -0.5 + divisions: 1 + noise: + seed: 1 + frequency: 1 + decals: + - DirtHeavy + - DirtHeavy + - DirtHeavy + - DirtMedium + - DirtMedium + - DirtLight + # Structures - threshold 0.45, same as SpaceDebris + - !type:BiomeEntityLayer + allowAllTiles: true + threshold: 0.45 + noise: + seed: 1 + noiseType: OpenSimplex2 + fractalType: Ridged + octaves: 4 + frequency: 0.065 + gain: 2 + lacunarity: 1.5 + entities: + - SalvageSpawnerStructuresVarious + # Scrap - threshold 0.2, same ratios as SpaceDebris + - !type:BiomeEntityLayer + allowAllTiles: true + threshold: 0.2 + noise: + seed: 1 + frequency: 1 + entities: + - SalvageSpawnerScrapValuable + - SalvageSpawnerScrapCommon + - SalvageSpawnerScrapCommon + - SalvageSpawnerScrapCommon + - SalvageSpawnerScrapCommon75 + # Treasure - threshold 0.7, same ratios as SpaceDebris + - !type:BiomeEntityLayer + allowAllTiles: true + threshold: 0.7 + noise: + seed: 1 + frequency: 1 + entities: + - SalvageSpawnerTreasure + - SalvageSpawnerTreasure + - SalvageSpawnerTreasureValuable + # Mobs - threshold 0.925, same as SpaceDebris + - !type:BiomeEntityLayer + allowAllTiles: true + threshold: 0.925 + noise: + seed: 1 + frequency: 1 + entities: + - SalvageSpawnerMobMagnet75 diff --git a/Resources/Prototypes/_MACRO/Procedural/ruin_maps.yml b/Resources/Prototypes/_MACRO/Procedural/ruin_maps.yml new file mode 100644 index 00000000000..47c59116ea9 --- /dev/null +++ b/Resources/Prototypes/_MACRO/Procedural/ruin_maps.yml @@ -0,0 +1,20 @@ +# Ruin maps that can be used for salvage magnet ruin offerings. +# These maps are used to extract floor and wall tiles using flood-fill. +# Recommended to keep the total number of maps 5 or less. More maps will slow server startup. Make sure the maps selected are mostly station-like +# Avoid any stations that have large amounts of asteroid walls or xeno-walls. The ruin system was not designed to parse those types of maps. + +- type: ruinMap + id: Bagel + mapPath: /Maps/bagel.yml + +- type: ruinMap + id: Box + mapPath: /Maps/box.yml + +- type: ruinMap + id: Packed + mapPath: /Maps/packed.yml + +- type: ruinMap + id: Fland + mapPath: /Maps/fland.yml \ No newline at end of file diff --git a/Resources/Prototypes/_MACRO/Procedural/salvage_magnet_offerings.yml b/Resources/Prototypes/_MACRO/Procedural/salvage_magnet_offerings.yml new file mode 100644 index 00000000000..73a98166119 --- /dev/null +++ b/Resources/Prototypes/_MACRO/Procedural/salvage_magnet_offerings.yml @@ -0,0 +1,7 @@ +- type: weightedRandom + id: SalvageMagnetOfferings + weights: + Asteroid: 4.5 + #Debris: 3.5 # Ruins are a replacement for debris, enable one or the other + Ruin: 3.5 + Salvage: 2.0 diff --git a/Resources/Prototypes/_MACRO/Procedural/salvage_magnet_ruin_config.yml b/Resources/Prototypes/_MACRO/Procedural/salvage_magnet_ruin_config.yml new file mode 100644 index 00000000000..0ed8c155d07 --- /dev/null +++ b/Resources/Prototypes/_MACRO/Procedural/salvage_magnet_ruin_config.yml @@ -0,0 +1,73 @@ +# Configuration for salvage magnet ruin generation. +# Defaults in SalvageMagnetRuinConfigPrototype.cs are used when fields are omitted. + +- type: salvageMagnetRuinConfig + id: Default + floodFillPoints: 25 + ruinSpawnDistance: 82 + wallDestroyChance: 0.30 + windowDamageChance: 0.10 + floorToLatticeChance: 0.15 + wallCost: 20 + windowCosts: + firelock: 10 + windoorsecure: 10 + windoor: 4 + reinforceddirectional: 10 + directional: 6 + reinforceddiagonal: 8 + diagonal: 6 + reinforced: 8 + defaultWindowCost: 4 + tileCosts: + directionalglass: 8 + reinforcedglass: 12 + glass: 8 + grille: 4 + defaultTileCost: 1 + spaceCost: 9999 + floodFillStages: 7 + wallTileIds: + - WallSolid + - WallReinforced + - WallReinforcedRust + - WallSolidRust + - WallSolidDiagonal + - WallReinforcedDiagonal + wallPrototypes: + - WallSolid + - WallReinforced + - WallReinforcedRust + - WallSolidRust + - WallSolidDiagonal + - WallReinforcedDiagonal + - WallShuttleDiagonal + - WallPlastitaniumDiagonal + - WallMiningDiagonal + windowPrototypes: + - Window + - WindowDirectional + - ReinforcedWindow + - WindowReinforcedDirectional + - WindowDiagonal + - ReinforcedWindowDiagonal + - TintedWindow + - WindowFrostedDirectional + - ShuttleWindow + - ShuttleWindowDiagonal + - PlastitaniumWindow + - PlastitaniumWindowDiagonal + - Windoor + - WindoorSecure + - WindoorPlasma + - WindoorSecurePlasma + - WindoorClockwork + - Firelock + - FirelockGlass + - FirelockEdge + - FirelockFrame + - WindoorAssembly + - WindoorAssemblySecure + - WindoorAssemblyPlasma + - WindoorAssemblySecurePlasma + diff --git a/Resources/_MACRO/Locale/en-US/salvage/salvage-magnet-ruin.ftl b/Resources/_MACRO/Locale/en-US/salvage/salvage-magnet-ruin.ftl new file mode 100644 index 00000000000..87410e3f7ef --- /dev/null +++ b/Resources/_MACRO/Locale/en-US/salvage/salvage-magnet-ruin.ftl @@ -0,0 +1,2 @@ +# Ruins option that is displayed by the salvage magnet +salvage-magnet-ruin = Station ruin