From 3f2054191883254e72f8a5c94b440022c02e8c12 Mon Sep 17 00:00:00 2001 From: Terkala Date: Mon, 16 Mar 2026 13:21:37 -0500 Subject: [PATCH 01/10] Base Implementation --- .../_Macro/Salvage/SalvageRuinGenerator.cs | 1355 +++++++++++++++++ .../Salvage/SharedSalvageSystem.Magnet.cs | 39 +- .../_Macro/Salvage/Magnet/RuinOffering.cs | 21 + .../_Macro/Salvage/RuinMapPrototype.cs | 16 + .../SalvageMagnetRuinConfigPrototype.cs | 116 ++ .../Salvage/SalvageRuinDebrisPrototype.cs | 42 + .../Procedural/salvage_magnet_offerings.yml | 7 + .../_Macro/Procedural/ruin_maps.yml | 20 + .../Procedural/salvage_magnet_ruin_config.yml | 24 + 9 files changed, 1626 insertions(+), 14 deletions(-) create mode 100644 Content.Server/_Macro/Salvage/SalvageRuinGenerator.cs create mode 100644 Content.Shared/_Macro/Salvage/Magnet/RuinOffering.cs create mode 100644 Content.Shared/_Macro/Salvage/RuinMapPrototype.cs create mode 100644 Content.Shared/_Macro/Salvage/SalvageMagnetRuinConfigPrototype.cs create mode 100644 Content.Shared/_Macro/Salvage/SalvageRuinDebrisPrototype.cs create mode 100644 Resources/Prototypes/Procedural/salvage_magnet_offerings.yml create mode 100644 Resources/Prototypes/_Macro/Procedural/ruin_maps.yml create mode 100644 Resources/Prototypes/_Macro/Procedural/salvage_magnet_ruin_config.yml diff --git a/Content.Server/_Macro/Salvage/SalvageRuinGenerator.cs b/Content.Server/_Macro/Salvage/SalvageRuinGenerator.cs new file mode 100644 index 00000000000..add77c6b1d8 --- /dev/null +++ b/Content.Server/_Macro/Salvage/SalvageRuinGenerator.cs @@ -0,0 +1,1355 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +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; + +[RegisterSystem] +/// +/// 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 IPrototypeManager _prototypeManager = default!; + [Dependency] private readonly ITileDefinitionManager _tileDefinitionManager = default!; + [Dependency] private readonly MapLoaderSystem _mapLoader = default!; + [Dependency] private readonly ILogManager _logManager = default!; + + private ISawmill _sawmill = default!; + private bool _attemptedPrebuild = false; + + /// + /// 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. + /// + 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); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + // Try to prebuild cost maps on first tick when all systems are guaranteed ready + if (!_attemptedPrebuild) + { + _attemptedPrebuild = true; + + var ruinMaps = _prototypeManager.EnumeratePrototypes().ToList(); + if (ruinMaps.Count > 0) + { + _sawmill.Info("[SalvageRuinGenerator] Building cost maps for ruin maps on first tick..."); + PrebuildCostMaps(); + } + } + } + + private void OnPrototypesReloaded(PrototypesReloadedEventArgs args) + { + // Build cost maps when prototypes are first loaded, or rebuild if RuinMapPrototype was modified + 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() + { + _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; + } + + // Parse wall entities + var wallEntities = ParseWallEntities(entitiesNode, firstGridUid); + + // Parse window entities + var windowEntities = ParseWindowEntities(entitiesNode, firstGridUid); + + // Get default config for cost map building (use "Default" if available, otherwise null for defaults) + var defaultConfigId = new ProtoId("Default"); + SalvageMagnetRuinConfigPrototype? defaultConfig = null; + _prototypeManager.TryIndex(defaultConfigId, out 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; + } + + // 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; + } + + /// + /// 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) + { + var wallEntities = new List<(Vector2i, string)>(); + + var wallPrototypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "WallSolid", + "WallReinforced", + "WallReinforcedRust", + "WallSolidRust", + // Diagonal walls + "WallSolidDiagonal", + "WallReinforcedDiagonal", + "WallShuttleDiagonal", + "WallPlastitaniumDiagonal", + "WallMiningDiagonal", + }; + + 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; + } + + /// + /// 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) + { + var windowEntities = new List<(Vector2i, string, Angle)>(); + + foreach (var protoGroupNode in entitiesNode.Sequence.Cast()) + { + if (!protoGroupNode.TryGet("proto", out ValueDataNode? protoNode)) + continue; + + var protoId = protoNode.Value; + + // Check if this is a window entity (contains "Window" in the ID) + if (!IsWindowEntity(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; + } + + /// + /// Checks if a prototype ID represents a window entity. + /// + private bool IsWindowEntity(string prototypeId) + { + // Check for common window patterns + var id = prototypeId.ToLowerInvariant(); + + // Include windows, windoors, and firelocks + return (id.Contains("window", StringComparison.OrdinalIgnoreCase) || + id.Contains("windoor", StringComparison.OrdinalIgnoreCase) || + id.Contains("firelock", StringComparison.OrdinalIgnoreCase)) && + !id.Contains("frame", StringComparison.OrdinalIgnoreCase) && // Exclude frames/assemblies + !id.Contains("assembly", StringComparison.OrdinalIgnoreCase); + } + + /// + /// 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. + /// + private int GetWindowCost(string prototypeId, SalvageMagnetRuinConfigPrototype? config = null) + { + var id = prototypeId.ToLowerInvariant(); + + // Firelocks (treated as reinforced barriers) + if (id.Contains("firelock", StringComparison.OrdinalIgnoreCase)) + { + return config?.ReinforcedWindowCost ?? 4; + } + + // Windoors (secure windoors are reinforced, regular windoors are not) + if (id.Contains("windoor", StringComparison.OrdinalIgnoreCase)) + { + if (id.Contains("secure", StringComparison.OrdinalIgnoreCase)) + return config?.ReinforcedWindowCost ?? 4; + else + return config?.RegularWindowCost ?? 2; + } + + // Directional Reinforced windows (most expensive) + if (id.Contains("directional", StringComparison.OrdinalIgnoreCase) && + id.Contains("reinforced", StringComparison.OrdinalIgnoreCase)) + { + return config?.ReinforcedWindowCost ?? 4; + } + + // Directional windows (regular) + if (id.Contains("directional", StringComparison.OrdinalIgnoreCase)) + { + return config?.DirectionalWindowCost ?? 2; + } + + // Diagonal windows (treated similar to directional) + if (id.Contains("diagonal", StringComparison.OrdinalIgnoreCase)) + { + if (id.Contains("reinforced", StringComparison.OrdinalIgnoreCase)) + return config?.ReinforcedWindowCost ?? 4; + else + return config?.DirectionalWindowCost ?? 2; + } + + // Reinforced windows (non-directional) + if (id.Contains("reinforced", StringComparison.OrdinalIgnoreCase)) + { + return config?.ReinforcedWindowCost ?? 4; + } + + // Regular windows + return config?.RegularWindowCost ?? 2; + } + + /// + /// Checks if a tile is a wall based on its prototype ID. + /// + private bool IsWallTile(string tileId) + { + return tileId.Equals("WallSolid", StringComparison.OrdinalIgnoreCase) || + tileId.Equals("WallReinforced", StringComparison.OrdinalIgnoreCase) || + tileId.Equals("WallReinforcedRust", StringComparison.OrdinalIgnoreCase) || + tileId.Equals("WallSolidRust", StringComparison.OrdinalIgnoreCase) || + tileId.Equals("WallSolidDiagonal", StringComparison.OrdinalIgnoreCase) || + tileId.Equals("WallReinforcedDiagonal", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Gets the cost for a tile based on its prototype ID. + /// + private int GetTileCost(string tileId, SalvageMagnetRuinConfigPrototype? config = null) + { + if (IsWallTile(tileId)) + return config?.WallCost ?? 6; + + if (tileId.Contains("DirectionalGlass", StringComparison.OrdinalIgnoreCase)) + return config?.DirectionalGlassCost ?? 2; + + if (tileId.Contains("ReinforcedGlass", StringComparison.OrdinalIgnoreCase)) + return config?.ReinforcedGlassCost ?? 4; + + if (tileId.Contains("Glass", StringComparison.OrdinalIgnoreCase) && + !tileId.Contains("Directional", StringComparison.OrdinalIgnoreCase)) + return config?.RegularGlassCost ?? 4; + + if (tileId.Contains("Grille", StringComparison.OrdinalIgnoreCase)) + return config?.GrilleCost ?? 2; + + return config?.DefaultTileCost ?? 1; + } + + /// + /// 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/Salvage/SharedSalvageSystem.Magnet.cs b/Content.Shared/Salvage/SharedSalvageSystem.Magnet.cs index 3950b1b72bf..8d5a7a2da35 100644 --- a/Content.Shared/Salvage/SharedSalvageSystem.Magnet.cs +++ b/Content.Shared/Salvage/SharedSalvageSystem.Magnet.cs @@ -13,14 +13,10 @@ namespace Content.Shared.Salvage; public abstract partial class SharedSalvageSystem { private readonly List _salvageMaps = new(); - - private readonly Dictionary _offeringWeights = new() - { - { new AsteroidOffering(), 4.5f }, - { new DebrisOffering(), 3.5f }, - { new SalvageOffering(), 2.0f }, - }; - + private readonly List _ruinMaps = new(); +// Macro start + private readonly ProtoId _magnetOfferingWeights = "SalvageMagnetOfferings"; +// Macro end private readonly List> _asteroidConfigs = new() { "BlobAsteroid", @@ -41,11 +37,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 +76,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 +94,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..356590b1d01 --- /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("ruinMap")] +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..9e848603051 --- /dev/null +++ b/Content.Shared/_Macro/Salvage/SalvageMagnetRuinConfigPrototype.cs @@ -0,0 +1,116 @@ +using Robust.Shared.Prototypes; + +namespace Content.Shared.Salvage; + +/// +/// Configuration for salvage magnet ruin generation, including damage simulation parameters. +/// These are all overwritten by salvage_magnet_ruin_config.yml in the Resources/Prototypes/Procedural folder. +/// +[Prototype("salvageMagnetRuinConfig")] +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. + /// + [DataField(required: true)] + public int FloodFillPoints = 50; + + /// + /// Chance (0.0 to 1.0) that a wall entity will be destroyed and not spawned. + /// + [DataField] + public float WallDestroyChance = 0.0f; + + /// + /// Chance (0.0 to 1.0) that a window entity will be spawned in a damaged state. + /// + [DataField] + public float WindowDamageChance = 0.0f; + + /// + /// 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.0f; + + /// + /// Path cost for walls. Higher values make flood-fill avoid walls more. + /// + [DataField] + public int WallCost = 6; + + /// + /// Path cost for directional windows. Lower values make flood-fill prefer directional windows. + /// + [DataField] + public int DirectionalWindowCost = 2; + + /// + /// Path cost for reinforced windows. Higher values make flood-fill avoid reinforced windows. + /// + [DataField] + public int ReinforcedWindowCost = 4; + + /// + /// Path cost for regular (non-directional, non-reinforced) windows. + /// + [DataField] + public int RegularWindowCost = 2; + + /// + /// Path cost for directional glass tiles. + /// + [DataField] + public int DirectionalGlassCost = 2; + + /// + /// Path cost for reinforced glass tiles. + /// + [DataField] + public int ReinforcedGlassCost = 4; + + /// + /// Path cost for regular glass tiles (non-directional, non-reinforced). + /// + [DataField] + public int RegularGlassCost = 4; + + /// + /// Path cost for grille tiles. + /// + [DataField] + public int GrilleCost = 2; + + /// + /// Default path cost for all other tiles (floors, etc.). + /// + [DataField] + public int DefaultTileCost = 1; + + /// + /// Path cost for space tiles (tiles not in the map). + /// Set to a very high value (99) 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 = 5; + + /// + /// 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 = 64f; +} + diff --git a/Content.Shared/_Macro/Salvage/SalvageRuinDebrisPrototype.cs b/Content.Shared/_Macro/Salvage/SalvageRuinDebrisPrototype.cs new file mode 100644 index 00000000000..7041e79db1a --- /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("salvageRuinDebris")] +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/Prototypes/Procedural/salvage_magnet_offerings.yml b/Resources/Prototypes/Procedural/salvage_magnet_offerings.yml new file mode 100644 index 00000000000..73a98166119 --- /dev/null +++ b/Resources/Prototypes/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/ruin_maps.yml b/Resources/Prototypes/_Macro/Procedural/ruin_maps.yml new file mode 100644 index 00000000000..861d4420424 --- /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 under 4. 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: Core + mapPath: /Maps/core.yml + +- type: ruinMap + id: Fland + mapPath: /Maps/fland.yml \ No newline at end of file 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..7d3e823d20b --- /dev/null +++ b/Resources/Prototypes/_Macro/Procedural/salvage_magnet_ruin_config.yml @@ -0,0 +1,24 @@ +# Configuration for salvage magnet ruin generation. +# This controls flood-fill parameters and damage simulation. + +- type: salvageMagnetRuinConfig + id: Default + floodFillPoints: 25 #if you increase this, also increase the ruinSpawnDistance, otherwise the ruins will spawn ontop of the magnet + ruinSpawnDistance: 82 # Distance in tiles from magnet where ruins spawn + wallDestroyChance: 0.30 #chance of a wall being destroyed and not spawned + windowDamageChance: 0.10 #chance of a window being damaged + floorToLatticeChance: 0.15 #chance of a floor tile being replaced with lattice, makes it look more damaged + # Path costs for flood-fill algorithm (higher = more expensive to traverse) + # the way floodfill works, it costs this much to traverse each tile + wallCost: 20 + directionalWindowCost: 10 + reinforcedWindowCost: 10 + regularWindowCost: 4 + directionalGlassCost: 8 + reinforcedGlassCost: 12 + regularGlassCost: 8 + grilleCost: 4 + defaultTileCost: 1 + spaceCost: 9999 # Space tiles (not in map) - very high cost makes them impassable + floodFillStages: 7 # Number of flood-fill stages to create irregular branching shapes. It does the flood fill this many times on the edges to construct the ruin. + From a997fdcb225764ca0283a0db0e9239717020b0cd Mon Sep 17 00:00:00 2001 From: Terkala Date: Mon, 16 Mar 2026 14:55:36 -0500 Subject: [PATCH 02/10] Basic functionality. Bugfixing starts here --- .../UI/SalvageMagnetBoundUserInterface.cs | 5 ++ .../Salvage/SalvageSystem.Magnet.cs | 68 +++++++++++++++++-- .../_Macro/Salvage/SalvageRuinGenerator.cs | 27 +++++++- .../Salvage/SharedSalvageSystem.Magnet.cs | 2 +- .../Locale/en-US/salvage/salvage-magnet.ftl | 3 + .../_Macro/Procedural/ruin_maps.yml | 6 +- .../Procedural/salvage_magnet_offerings.yml | 0 7 files changed, 102 insertions(+), 9 deletions(-) rename Resources/Prototypes/{ => _Macro}/Procedural/salvage_magnet_offerings.yml (100%) 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/Salvage/SalvageSystem.Magnet.cs b/Content.Server/Salvage/SalvageSystem.Magnet.cs index ee5cd792c69..f8b9078360a 100644 --- a/Content.Server/Salvage/SalvageSystem.Magnet.cs +++ b/Content.Server/Salvage/SalvageSystem.Magnet.cs @@ -2,9 +2,16 @@ using System.Numerics; using System.Threading.Tasks; using Content.Server.Salvage.Magnet; +using Content.Shared.Damage; // Macro +using Content.Shared.Damage.Components; // Macro +using Content.Shared.Damage.Prototypes; // Macro +using Content.Shared.Damage.Systems; // Macro +using Content.Shared.FixedPoint; // Macro using Content.Shared.Mobs.Components; +using Content.Shared.Parallax.Biomes; // Macro using Content.Shared.Procedural; using Content.Shared.Radio; +using Content.Shared.Salvage; // Macro using Content.Shared.Salvage.Magnet; using Robust.Shared.Exceptions; using Robust.Shared.Map; @@ -15,6 +22,8 @@ namespace Content.Server.Salvage; public sealed partial class SalvageSystem { [Dependency] private readonly IRuntimeLog _runtimeLog = default!; + [Dependency] private readonly DamageableSystem _damageable = default!; // Macro + [Dependency] private readonly SalvageRuinGeneratorSystem _ruinGenerator = default!; // Macro private static readonly ProtoId MagnetChannel = "Supply"; @@ -305,14 +314,65 @@ private async Task TakeMagnetOffer(Entity data, int break; case SalvageOffering wreck: var salvageProto = wreck.SalvageMap; - +// Macro Start - Adding RuinOffering to the salvage system 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"); _mapSystem.DeleteMap(salvMapXform.MapID); return; } + 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); + } + + 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(ruinGrid.Owner, ruinGrid.Comp, windowPos); + if (tileRef.Tile.IsEmpty) + continue; + + var windowEntity = SpawnAttachedTo(windowProto, new EntityCoordinates(ruinGrid.Owner, windowPos), rotation: windowRotation); + var windowXform = Transform(windowEntity); + if (!windowXform.Anchored) + _transform.AnchorEntity((windowEntity, windowXform), (ruinGrid.Owner, ruinGrid.Comp), windowPos); + + if (windowDamageChance > 0.0f && windowRand.NextSingle() < windowDamageChance && + TryComp(windowEntity, out _)) + { + var damage = new DamageSpecifier( + _prototypeManager.Index("Structural"), + FixedPoint2.New(25)); + _damageable.TryChangeDamage(windowEntity, damage); + } + } + + var biome = EnsureComp(ruinGrid.Owner); + _biome.SetSeed(ruinGrid.Owner, biome, seed); + _biome.SetTemplate(ruinGrid.Owner, biome, _prototypeManager.Index("SpaceDebris")); + break; default: throw new ArgumentOutOfRangeException(); @@ -338,13 +398,13 @@ 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) { _metaData.SetEntityName(mapChild, Loc.GetString("salvage-asteroid-name")); _gravity.EnableGravity(mapChild); } } - +// Macro end var magnetXform = _xformQuery.GetComponent(magnet.Owner); var magnetGridUid = magnetXform.GridUid; var attachedBounds = new Box2Rotated(); diff --git a/Content.Server/_Macro/Salvage/SalvageRuinGenerator.cs b/Content.Server/_Macro/Salvage/SalvageRuinGenerator.cs index add77c6b1d8..6f08bc5a2ba 100644 --- a/Content.Server/_Macro/Salvage/SalvageRuinGenerator.cs +++ b/Content.Server/_Macro/Salvage/SalvageRuinGenerator.cs @@ -20,7 +20,6 @@ namespace Content.Server.Salvage; -[RegisterSystem] /// /// Generates ruins from station maps using cost-based flood-fill. /// Pre-builds cost maps at server startup for performance. @@ -512,6 +511,32 @@ private bool BuildCostMapForMap(ResPath mapPath) 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) diff --git a/Content.Shared/Salvage/SharedSalvageSystem.Magnet.cs b/Content.Shared/Salvage/SharedSalvageSystem.Magnet.cs index 8d5a7a2da35..59b3d81d27a 100644 --- a/Content.Shared/Salvage/SharedSalvageSystem.Magnet.cs +++ b/Content.Shared/Salvage/SharedSalvageSystem.Magnet.cs @@ -13,8 +13,8 @@ namespace Content.Shared.Salvage; public abstract partial class SharedSalvageSystem { private readonly List _salvageMaps = new(); +// Macro start - Making it so the magnet offering is defined in yml private readonly List _ruinMaps = new(); -// Macro start private readonly ProtoId _magnetOfferingWeights = "SalvageMagnetOfferings"; // Macro end private readonly List> _asteroidConfigs = new() 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/ruin_maps.yml b/Resources/Prototypes/_Macro/Procedural/ruin_maps.yml index 861d4420424..cbad6753a6f 100644 --- a/Resources/Prototypes/_Macro/Procedural/ruin_maps.yml +++ b/Resources/Prototypes/_Macro/Procedural/ruin_maps.yml @@ -1,6 +1,6 @@ # 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 under 4. Make sure the maps selected are mostly station-like +# 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 @@ -12,8 +12,8 @@ mapPath: /Maps/box.yml - type: ruinMap - id: Core - mapPath: /Maps/core.yml + id: Packed + mapPath: /Maps/packed.yml - type: ruinMap id: Fland diff --git a/Resources/Prototypes/Procedural/salvage_magnet_offerings.yml b/Resources/Prototypes/_Macro/Procedural/salvage_magnet_offerings.yml similarity index 100% rename from Resources/Prototypes/Procedural/salvage_magnet_offerings.yml rename to Resources/Prototypes/_Macro/Procedural/salvage_magnet_offerings.yml From d109b88deed435c29b70d86317417ec47d6aea09 Mon Sep 17 00:00:00 2001 From: Terkala Date: Mon, 16 Mar 2026 15:23:42 -0500 Subject: [PATCH 03/10] Seems to be working well --- .../Salvage/SalvageSystem.Magnet.cs | 50 ++++++++++++- .../Parallax/Biomes/SharedBiomeSystem.cs | 9 ++- .../Magnet/space_ruin_templates.yml | 73 +++++++++++++++++++ 3 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 Resources/Prototypes/_Macro/Procedural/Magnet/space_ruin_templates.yml diff --git a/Content.Server/Salvage/SalvageSystem.Magnet.cs b/Content.Server/Salvage/SalvageSystem.Magnet.cs index f8b9078360a..772a6d65693 100644 --- a/Content.Server/Salvage/SalvageSystem.Magnet.cs +++ b/Content.Server/Salvage/SalvageSystem.Magnet.cs @@ -1,26 +1,31 @@ using System.Linq; using System.Numerics; using System.Threading.Tasks; +using Content.Server.Decals; using Content.Server.Salvage.Magnet; using Content.Shared.Damage; // Macro using Content.Shared.Damage.Components; // Macro using Content.Shared.Damage.Prototypes; // Macro using Content.Shared.Damage.Systems; // Macro using Content.Shared.FixedPoint; // Macro +using Content.Shared.Maps; using Content.Shared.Mobs.Components; using Content.Shared.Parallax.Biomes; // Macro +using Content.Shared.Physics; // Macro using Content.Shared.Procedural; using Content.Shared.Radio; using Content.Shared.Salvage; // Macro using Content.Shared.Salvage.Magnet; using Robust.Shared.Exceptions; using Robust.Shared.Map; +using Robust.Shared.Map.Components; //Macro using Robust.Shared.Prototypes; namespace Content.Server.Salvage; public sealed partial class SalvageSystem { + [Dependency] private readonly DecalSystem _decals = default!; [Dependency] private readonly IRuntimeLog _runtimeLog = default!; [Dependency] private readonly DamageableSystem _damageable = default!; // Macro [Dependency] private readonly SalvageRuinGeneratorSystem _ruinGenerator = default!; // Macro @@ -369,9 +374,7 @@ private async Task TakeMagnetOffer(Entity data, int } } - var biome = EnsureComp(ruinGrid.Owner); - _biome.SetSeed(ruinGrid.Owner, biome, seed); - _biome.SetTemplate(ruinGrid.Owner, biome, _prototypeManager.Index("SpaceDebris")); + SpawnRuinBiomeEntities(ruinGrid.Owner, ruinGrid.Comp, ruinResult, seed); break; default: @@ -478,7 +481,48 @@ private async Task TakeMagnetOffer(Entity data, int RaiseLocalEvent(ref active); } +// Macro start - Adding mobs and loot to the salvage system via SpaceRuin biome template + 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("SpaceRuin", out var ruinTemplate)) + return; + var layers = ruinTemplate.Layers; + + 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)) + { + 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)) + { + 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) { var attachedAABB = attachedBounds.CalcBoundingBox(); diff --git a/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs b/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs index a5238e8c6ec..07e01476a5d 100644 --- a/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs +++ b/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs @@ -21,6 +21,11 @@ public abstract class SharedBiomeSystem : EntitySystem protected const byte ChunkSize = 8; + /// + /// When present in AllowedTiles, allows spawning on any floor tile. + /// + private static readonly ProtoId AllTiles = new("all"); + private T Pick(List collection, float value) { // Listen I don't need this exact and I'm too lazy to finetune just for random ent picking. @@ -225,7 +230,7 @@ public bool TryGetEntity(Vector2i indices, List layers, Tile tileRe case BiomeDummyLayer: continue; case IBiomeWorldLayer worldLayer: - if (!worldLayer.AllowedTiles.Contains(tileId)) + if (!worldLayer.AllowedTiles.Contains(AllTiles) && !worldLayer.AllowedTiles.Contains(tileId)) continue; break; @@ -301,7 +306,7 @@ public bool TryGetDecals(Vector2i indices, List layers, int seed, E case BiomeDummyLayer: continue; case IBiomeWorldLayer worldLayer: - if (!worldLayer.AllowedTiles.Contains(tileId)) + if (!worldLayer.AllowedTiles.Contains(AllTiles) && !worldLayer.AllowedTiles.Contains(tileId)) continue; break; 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..3819fd86c32 --- /dev/null +++ b/Resources/Prototypes/_Macro/Procedural/Magnet/space_ruin_templates.yml @@ -0,0 +1,73 @@ +# Ruin-specific biome template for salvage magnet ruins. +# Mirrors SpaceDebris but only spawns loot/mob/decals (no walls/grilles). +# Uses "all" to allow spawning 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 + allowedTiles: + - all + 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 + threshold: 0.45 + noise: + seed: 1 + noiseType: OpenSimplex2 + fractalType: Ridged + octaves: 4 + frequency: 0.065 + gain: 2 + lacunarity: 1.5 + allowedTiles: + - all + entities: + - SalvageSpawnerStructuresVarious + # Scrap - threshold 0.2, same ratios as SpaceDebris + - !type:BiomeEntityLayer + allowedTiles: + - all + threshold: 0.2 + noise: + seed: 1 + frequency: 1 + entities: + - SalvageSpawnerScrapValuable + - SalvageSpawnerScrapCommon + - SalvageSpawnerScrapCommon + - SalvageSpawnerScrapCommon + - SalvageSpawnerScrapCommon75 + # Treasure - threshold 0.7, same ratios as SpaceDebris + - !type:BiomeEntityLayer + allowedTiles: + - all + threshold: 0.7 + noise: + seed: 1 + frequency: 1 + entities: + - SalvageSpawnerTreasure + - SalvageSpawnerTreasure + - SalvageSpawnerTreasureValuable + # Mobs - threshold 0.925, same as SpaceDebris + - !type:BiomeEntityLayer + allowedTiles: + - all + threshold: 0.925 + noise: + seed: 1 + frequency: 1 + entities: + - SalvageSpawnerMobMagnet75 From 15e546153d9cec2cb05ccf6046e77f6d4a79e68c Mon Sep 17 00:00:00 2001 From: Terkala Date: Mon, 16 Mar 2026 15:38:56 -0500 Subject: [PATCH 04/10] adding comments --- Content.Server/Salvage/SalvageSystem.Magnet.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Content.Server/Salvage/SalvageSystem.Magnet.cs b/Content.Server/Salvage/SalvageSystem.Magnet.cs index 772a6d65693..50d4a8988c3 100644 --- a/Content.Server/Salvage/SalvageSystem.Magnet.cs +++ b/Content.Server/Salvage/SalvageSystem.Magnet.cs @@ -1,14 +1,14 @@ using System.Linq; using System.Numerics; using System.Threading.Tasks; -using Content.Server.Decals; +using Content.Server.Decals; // Macro using Content.Server.Salvage.Magnet; using Content.Shared.Damage; // Macro using Content.Shared.Damage.Components; // Macro using Content.Shared.Damage.Prototypes; // Macro using Content.Shared.Damage.Systems; // Macro using Content.Shared.FixedPoint; // Macro -using Content.Shared.Maps; +using Content.Shared.Maps; // Macro using Content.Shared.Mobs.Components; using Content.Shared.Parallax.Biomes; // Macro using Content.Shared.Physics; // Macro From fb53bcbc5cefe5420d14a196af07b0d871f0d5c4 Mon Sep 17 00:00:00 2001 From: Terkala Date: Mon, 16 Mar 2026 16:30:43 -0500 Subject: [PATCH 05/10] Making yaml linter happy --- Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs | 10 +++------- Content.Shared/_Macro/Salvage/RuinMapPrototype.cs | 2 +- .../_Macro/Salvage/SalvageMagnetRuinConfigPrototype.cs | 2 +- .../_Macro/Salvage/SalvageRuinDebrisPrototype.cs | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs b/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs index 07e01476a5d..32c97e0d6e4 100644 --- a/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs +++ b/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs @@ -21,11 +21,7 @@ public abstract class SharedBiomeSystem : EntitySystem protected const byte ChunkSize = 8; - /// - /// When present in AllowedTiles, allows spawning on any floor tile. - /// - private static readonly ProtoId AllTiles = new("all"); - + private static readonly ProtoId AllTiles = new("all"); // Macro - All as an option to allow all floor tile types private T Pick(List collection, float value) { // Listen I don't need this exact and I'm too lazy to finetune just for random ent picking. @@ -230,7 +226,7 @@ public bool TryGetEntity(Vector2i indices, List layers, Tile tileRe case BiomeDummyLayer: continue; case IBiomeWorldLayer worldLayer: - if (!worldLayer.AllowedTiles.Contains(AllTiles) && !worldLayer.AllowedTiles.Contains(tileId)) + if (!worldLayer.AllowedTiles.Contains(AllTiles) && !worldLayer.AllowedTiles.Contains(tileId)) //Macro - Allow "all" as an option for allowed tiles continue; break; @@ -306,7 +302,7 @@ public bool TryGetDecals(Vector2i indices, List layers, int seed, E case BiomeDummyLayer: continue; case IBiomeWorldLayer worldLayer: - if (!worldLayer.AllowedTiles.Contains(AllTiles) && !worldLayer.AllowedTiles.Contains(tileId)) + if (!worldLayer.AllowedTiles.Contains(AllTiles) && !worldLayer.AllowedTiles.Contains(tileId)) //Macro - Allow "all" as an option for allowed tiles continue; break; diff --git a/Content.Shared/_Macro/Salvage/RuinMapPrototype.cs b/Content.Shared/_Macro/Salvage/RuinMapPrototype.cs index 356590b1d01..b136bcaec37 100644 --- a/Content.Shared/_Macro/Salvage/RuinMapPrototype.cs +++ b/Content.Shared/_Macro/Salvage/RuinMapPrototype.cs @@ -3,7 +3,7 @@ namespace Content.Shared.Salvage; -[Prototype("ruinMap")] +[Prototype] public sealed partial class RuinMapPrototype : IPrototype { [ViewVariables] [IdDataField] public string ID { get; private set; } = default!; diff --git a/Content.Shared/_Macro/Salvage/SalvageMagnetRuinConfigPrototype.cs b/Content.Shared/_Macro/Salvage/SalvageMagnetRuinConfigPrototype.cs index 9e848603051..c704ede60b5 100644 --- a/Content.Shared/_Macro/Salvage/SalvageMagnetRuinConfigPrototype.cs +++ b/Content.Shared/_Macro/Salvage/SalvageMagnetRuinConfigPrototype.cs @@ -6,7 +6,7 @@ namespace Content.Shared.Salvage; /// Configuration for salvage magnet ruin generation, including damage simulation parameters. /// These are all overwritten by salvage_magnet_ruin_config.yml in the Resources/Prototypes/Procedural folder. /// -[Prototype("salvageMagnetRuinConfig")] +[Prototype] public sealed partial class SalvageMagnetRuinConfigPrototype : IPrototype { [ViewVariables] [IdDataField] public string ID { get; private set; } = default!; diff --git a/Content.Shared/_Macro/Salvage/SalvageRuinDebrisPrototype.cs b/Content.Shared/_Macro/Salvage/SalvageRuinDebrisPrototype.cs index 7041e79db1a..9790ae9068a 100644 --- a/Content.Shared/_Macro/Salvage/SalvageRuinDebrisPrototype.cs +++ b/Content.Shared/_Macro/Salvage/SalvageRuinDebrisPrototype.cs @@ -6,7 +6,7 @@ namespace Content.Shared.Salvage; /// /// Prototype for configuring debris entities that spawn on salvage ruins. /// -[Prototype("salvageRuinDebris")] +[Prototype] public sealed partial class SalvageRuinDebrisPrototype : IPrototype { [ViewVariables] From 92a3a3d71c8033d65d77745c766d7de0095e8be1 Mon Sep 17 00:00:00 2001 From: Terkala Date: Mon, 16 Mar 2026 16:36:23 -0500 Subject: [PATCH 06/10] YAML linter, my most hated foe --- Content.Server/Salvage/SalvageSystem.Magnet.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Content.Server/Salvage/SalvageSystem.Magnet.cs b/Content.Server/Salvage/SalvageSystem.Magnet.cs index 50d4a8988c3..5652957dd51 100644 --- a/Content.Server/Salvage/SalvageSystem.Magnet.cs +++ b/Content.Server/Salvage/SalvageSystem.Magnet.cs @@ -31,6 +31,8 @@ public sealed partial class SalvageSystem [Dependency] private readonly SalvageRuinGeneratorSystem _ruinGenerator = default!; // Macro private static readonly ProtoId MagnetChannel = "Supply"; + private static readonly ProtoId StructuralDamageType = "Structural"; + private static readonly ProtoId SpaceRuinBiome = "SpaceRuin"; private EntityQuery _salvMobQuery; private EntityQuery _mobStateQuery; @@ -368,7 +370,7 @@ private async Task TakeMagnetOffer(Entity data, int TryComp(windowEntity, out _)) { var damage = new DamageSpecifier( - _prototypeManager.Index("Structural"), + _prototypeManager.Index(StructuralDamageType), FixedPoint2.New(25)); _damageable.TryChangeDamage(windowEntity, damage); } @@ -488,7 +490,7 @@ private void SpawnRuinBiomeEntities(EntityUid gridUid, MapGridComponent grid, Sa ruinResult.WallEntities.Select(w => w.Position) .Concat(ruinResult.WindowEntities.Select(w => w.Position))); - if (!_prototypeManager.TryIndex("SpaceRuin", out var ruinTemplate)) + if (!_prototypeManager.TryIndex(SpaceRuinBiome, out var ruinTemplate)) return; var layers = ruinTemplate.Layers; From 9a62cb0b83baa77ba44aae929736bba754c53ec3 Mon Sep 17 00:00:00 2001 From: Terkala Date: Mon, 16 Mar 2026 16:49:14 -0500 Subject: [PATCH 07/10] Changing the All tag --- .../Parallax/Biomes/Layers/BiomeDecalLayer.cs | 4 ++++ .../Parallax/Biomes/Layers/BiomeEntityLayer.cs | 4 ++++ .../Parallax/Biomes/Layers/IBiomeWorldLayer.cs | 5 +++++ .../Parallax/Biomes/SharedBiomeSystem.cs | 5 ++--- .../Procedural/Magnet/space_ruin_templates.yml | 17 ++++++----------- 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/Content.Shared/Parallax/Biomes/Layers/BiomeDecalLayer.cs b/Content.Shared/Parallax/Biomes/Layers/BiomeDecalLayer.cs index f95848e42b0..5cc8f222686 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(); + /// + [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..b94114cb591 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(); + /// + [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..c0c97c51551 100644 --- a/Content.Shared/Parallax/Biomes/Layers/IBiomeWorldLayer.cs +++ b/Content.Shared/Parallax/Biomes/Layers/IBiomeWorldLayer.cs @@ -12,4 +12,9 @@ public partial interface IBiomeWorldLayer : IBiomeLayer /// What tiles we're allowed to spawn on, real or biome. /// List> AllowedTiles { get; } + + /// + /// 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 32c97e0d6e4..182884f9c75 100644 --- a/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs +++ b/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs @@ -21,7 +21,6 @@ public abstract class SharedBiomeSystem : EntitySystem protected const byte ChunkSize = 8; - private static readonly ProtoId AllTiles = new("all"); // Macro - All as an option to allow all floor tile types private T Pick(List collection, float value) { // Listen I don't need this exact and I'm too lazy to finetune just for random ent picking. @@ -226,7 +225,7 @@ public bool TryGetEntity(Vector2i indices, List layers, Tile tileRe case BiomeDummyLayer: continue; case IBiomeWorldLayer worldLayer: - if (!worldLayer.AllowedTiles.Contains(AllTiles) && !worldLayer.AllowedTiles.Contains(tileId)) //Macro - Allow "all" as an option for allowed tiles + if (!worldLayer.AllowAllTiles && !worldLayer.AllowedTiles.Contains(tileId)) // Macro - allow all tiles continue; break; @@ -302,7 +301,7 @@ public bool TryGetDecals(Vector2i indices, List layers, int seed, E case BiomeDummyLayer: continue; case IBiomeWorldLayer worldLayer: - if (!worldLayer.AllowedTiles.Contains(AllTiles) && !worldLayer.AllowedTiles.Contains(tileId)) //Macro - Allow "all" as an option for allowed tiles + if (!worldLayer.AllowAllTiles && !worldLayer.AllowedTiles.Contains(tileId)) // Macro - allow all tiles continue; break; diff --git a/Resources/Prototypes/_Macro/Procedural/Magnet/space_ruin_templates.yml b/Resources/Prototypes/_Macro/Procedural/Magnet/space_ruin_templates.yml index 3819fd86c32..85512f70620 100644 --- a/Resources/Prototypes/_Macro/Procedural/Magnet/space_ruin_templates.yml +++ b/Resources/Prototypes/_Macro/Procedural/Magnet/space_ruin_templates.yml @@ -1,13 +1,12 @@ # Ruin-specific biome template for salvage magnet ruins. # Mirrors SpaceDebris but only spawns loot/mob/decals (no walls/grilles). -# Uses "all" to allow spawning on any floor tile (girders, plating, etc.). +# 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 - allowedTiles: - - all + allowAllTiles: true threshold: -0.5 divisions: 1 noise: @@ -22,6 +21,7 @@ - DirtLight # Structures - threshold 0.45, same as SpaceDebris - !type:BiomeEntityLayer + allowAllTiles: true threshold: 0.45 noise: seed: 1 @@ -31,14 +31,11 @@ frequency: 0.065 gain: 2 lacunarity: 1.5 - allowedTiles: - - all entities: - SalvageSpawnerStructuresVarious # Scrap - threshold 0.2, same ratios as SpaceDebris - !type:BiomeEntityLayer - allowedTiles: - - all + allowAllTiles: true threshold: 0.2 noise: seed: 1 @@ -51,8 +48,7 @@ - SalvageSpawnerScrapCommon75 # Treasure - threshold 0.7, same ratios as SpaceDebris - !type:BiomeEntityLayer - allowedTiles: - - all + allowAllTiles: true threshold: 0.7 noise: seed: 1 @@ -63,8 +59,7 @@ - SalvageSpawnerTreasureValuable # Mobs - threshold 0.925, same as SpaceDebris - !type:BiomeEntityLayer - allowedTiles: - - all + allowAllTiles: true threshold: 0.925 noise: seed: 1 From 8adf7dd243399d1b2e555b095fc766275883d828 Mon Sep 17 00:00:00 2001 From: Terkala Date: Mon, 16 Mar 2026 17:00:31 -0500 Subject: [PATCH 08/10] Cache Noise, reduce hitching --- .../Procedural/DungeonJob/DungeonJob.Biome.cs | 7 +++- .../Salvage/SalvageSystem.Magnet.cs | 11 +++-- .../Parallax/Biomes/SharedBiomeSystem.cs | 40 +++++++++++++------ 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/Content.Server/Procedural/DungeonJob/DungeonJob.Biome.cs b/Content.Server/Procedural/DungeonJob/DungeonJob.Biome.cs index 10f11bb8541..3976cc4f736 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; 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; 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(); 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"; - private static readonly ProtoId SpaceRuinBiome = "SpaceRuin"; + private static readonly ProtoId StructuralDamageType = "Structural"; // Macro + private static readonly ProtoId SpaceRuinBiome = "SpaceRuin"; // Macro private EntityQuery _salvMobQuery; private EntityQuery _mobStateQuery; @@ -494,6 +496,7 @@ private void SpawnRuinBiomeEntities(EntityUid gridUid, MapGridComponent grid, Sa return; var layers = ruinTemplate.Layers; + var noiseCache = new Dictionary(); foreach (var (pos, tile) in ruinResult.FloorTiles) { @@ -504,7 +507,7 @@ private void SpawnRuinBiomeEntities(EntityUid gridUid, MapGridComponent grid, Sa if (tileRef.Tile.IsEmpty) continue; - if (_biome.TryGetDecals(pos, layers, seed, (gridUid, grid), out var decals)) + if (_biome.TryGetDecals(pos, layers, seed, (gridUid, grid), out var decals, noiseCache)) { foreach (var decal in decals) { @@ -512,7 +515,7 @@ private void SpawnRuinBiomeEntities(EntityUid gridUid, MapGridComponent grid, Sa } } - if (_biome.TryGetEntity(pos, layers, tileRef.Tile, seed, (gridUid, grid), out var entityProto)) + 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; diff --git a/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs b/Content.Shared/Parallax/Biomes/SharedBiomeSystem.cs index 182884f9c75..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.AllowAllTiles && !worldLayer.AllowedTiles.Contains(tileId)) // Macro - allow all tiles + 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.AllowAllTiles && !worldLayer.AllowedTiles.Contains(tileId)) // Macro - allow all tiles + 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 From 7fd0c5fb88af50915d684dcb152aeda062c097b3 Mon Sep 17 00:00:00 2001 From: Terkala Date: Mon, 16 Mar 2026 18:16:57 -0500 Subject: [PATCH 09/10] The stupid things you have to do to rename folder capitalization --- .../{_Macro => _MACRO}/Salvage/SalvageRuinGenerator.cs | 0 .../RuinOffering.cs => _MACRO/Salvage/Magnet/RuinOffering2.cs} | 0 .../RuinMapPrototype.cs => _MACRO/Salvage/RuinMapPrototype2.cs} | 0 .../Salvage/SalvageMagnetRuinConfigPrototype2.cs} | 0 .../Salvage/SalvageRuinDebrisPrototype2.cs} | 0 .../Procedural/Magnet/space_ruin_templates2.yml} | 0 .../ruin_maps.yml => _MACRO/Procedural/ruin_maps2.yml} | 2 +- .../Procedural/salvage_magnet_offerings2.yml} | 0 .../Procedural/salvage_magnet_ruin_config2.yml} | 0 9 files changed, 1 insertion(+), 1 deletion(-) rename Content.Server/{_Macro => _MACRO}/Salvage/SalvageRuinGenerator.cs (100%) rename Content.Shared/{_Macro/Salvage/Magnet/RuinOffering.cs => _MACRO/Salvage/Magnet/RuinOffering2.cs} (100%) rename Content.Shared/{_Macro/Salvage/RuinMapPrototype.cs => _MACRO/Salvage/RuinMapPrototype2.cs} (100%) rename Content.Shared/{_Macro/Salvage/SalvageMagnetRuinConfigPrototype.cs => _MACRO/Salvage/SalvageMagnetRuinConfigPrototype2.cs} (100%) rename Content.Shared/{_Macro/Salvage/SalvageRuinDebrisPrototype.cs => _MACRO/Salvage/SalvageRuinDebrisPrototype2.cs} (100%) rename Resources/Prototypes/{_Macro/Procedural/Magnet/space_ruin_templates.yml => _MACRO/Procedural/Magnet/space_ruin_templates2.yml} (100%) rename Resources/Prototypes/{_Macro/Procedural/ruin_maps.yml => _MACRO/Procedural/ruin_maps2.yml} (97%) rename Resources/Prototypes/{_Macro/Procedural/salvage_magnet_offerings.yml => _MACRO/Procedural/salvage_magnet_offerings2.yml} (100%) rename Resources/Prototypes/{_Macro/Procedural/salvage_magnet_ruin_config.yml => _MACRO/Procedural/salvage_magnet_ruin_config2.yml} (100%) diff --git a/Content.Server/_Macro/Salvage/SalvageRuinGenerator.cs b/Content.Server/_MACRO/Salvage/SalvageRuinGenerator.cs similarity index 100% rename from Content.Server/_Macro/Salvage/SalvageRuinGenerator.cs rename to Content.Server/_MACRO/Salvage/SalvageRuinGenerator.cs diff --git a/Content.Shared/_Macro/Salvage/Magnet/RuinOffering.cs b/Content.Shared/_MACRO/Salvage/Magnet/RuinOffering2.cs similarity index 100% rename from Content.Shared/_Macro/Salvage/Magnet/RuinOffering.cs rename to Content.Shared/_MACRO/Salvage/Magnet/RuinOffering2.cs diff --git a/Content.Shared/_Macro/Salvage/RuinMapPrototype.cs b/Content.Shared/_MACRO/Salvage/RuinMapPrototype2.cs similarity index 100% rename from Content.Shared/_Macro/Salvage/RuinMapPrototype.cs rename to Content.Shared/_MACRO/Salvage/RuinMapPrototype2.cs diff --git a/Content.Shared/_Macro/Salvage/SalvageMagnetRuinConfigPrototype.cs b/Content.Shared/_MACRO/Salvage/SalvageMagnetRuinConfigPrototype2.cs similarity index 100% rename from Content.Shared/_Macro/Salvage/SalvageMagnetRuinConfigPrototype.cs rename to Content.Shared/_MACRO/Salvage/SalvageMagnetRuinConfigPrototype2.cs diff --git a/Content.Shared/_Macro/Salvage/SalvageRuinDebrisPrototype.cs b/Content.Shared/_MACRO/Salvage/SalvageRuinDebrisPrototype2.cs similarity index 100% rename from Content.Shared/_Macro/Salvage/SalvageRuinDebrisPrototype.cs rename to Content.Shared/_MACRO/Salvage/SalvageRuinDebrisPrototype2.cs diff --git a/Resources/Prototypes/_Macro/Procedural/Magnet/space_ruin_templates.yml b/Resources/Prototypes/_MACRO/Procedural/Magnet/space_ruin_templates2.yml similarity index 100% rename from Resources/Prototypes/_Macro/Procedural/Magnet/space_ruin_templates.yml rename to Resources/Prototypes/_MACRO/Procedural/Magnet/space_ruin_templates2.yml diff --git a/Resources/Prototypes/_Macro/Procedural/ruin_maps.yml b/Resources/Prototypes/_MACRO/Procedural/ruin_maps2.yml similarity index 97% rename from Resources/Prototypes/_Macro/Procedural/ruin_maps.yml rename to Resources/Prototypes/_MACRO/Procedural/ruin_maps2.yml index cbad6753a6f..47c59116ea9 100644 --- a/Resources/Prototypes/_Macro/Procedural/ruin_maps.yml +++ b/Resources/Prototypes/_MACRO/Procedural/ruin_maps2.yml @@ -1,7 +1,7 @@ # 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. +# 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 diff --git a/Resources/Prototypes/_Macro/Procedural/salvage_magnet_offerings.yml b/Resources/Prototypes/_MACRO/Procedural/salvage_magnet_offerings2.yml similarity index 100% rename from Resources/Prototypes/_Macro/Procedural/salvage_magnet_offerings.yml rename to Resources/Prototypes/_MACRO/Procedural/salvage_magnet_offerings2.yml diff --git a/Resources/Prototypes/_Macro/Procedural/salvage_magnet_ruin_config.yml b/Resources/Prototypes/_MACRO/Procedural/salvage_magnet_ruin_config2.yml similarity index 100% rename from Resources/Prototypes/_Macro/Procedural/salvage_magnet_ruin_config.yml rename to Resources/Prototypes/_MACRO/Procedural/salvage_magnet_ruin_config2.yml From 8b301db25a7e683313d6be83dc3b634b026311fc Mon Sep 17 00:00:00 2001 From: Terkala Date: Mon, 16 Mar 2026 18:17:53 -0500 Subject: [PATCH 10/10] folder names fixed --- .../_MACRO/Salvage/Magnet/{RuinOffering2.cs => RuinOffering.cs} | 0 .../_MACRO/Salvage/{RuinMapPrototype2.cs => RuinMapPrototype.cs} | 0 ...uinConfigPrototype2.cs => SalvageMagnetRuinConfigPrototype.cs} | 0 ...lvageRuinDebrisPrototype2.cs => SalvageRuinDebrisPrototype.cs} | 0 .../{space_ruin_templates2.yml => space_ruin_templates.yml} | 0 .../_MACRO/Procedural/{ruin_maps2.yml => ruin_maps.yml} | 0 ...salvage_magnet_offerings2.yml => salvage_magnet_offerings.yml} | 0 ...age_magnet_ruin_config2.yml => salvage_magnet_ruin_config.yml} | 0 8 files changed, 0 insertions(+), 0 deletions(-) rename Content.Shared/_MACRO/Salvage/Magnet/{RuinOffering2.cs => RuinOffering.cs} (100%) rename Content.Shared/_MACRO/Salvage/{RuinMapPrototype2.cs => RuinMapPrototype.cs} (100%) rename Content.Shared/_MACRO/Salvage/{SalvageMagnetRuinConfigPrototype2.cs => SalvageMagnetRuinConfigPrototype.cs} (100%) rename Content.Shared/_MACRO/Salvage/{SalvageRuinDebrisPrototype2.cs => SalvageRuinDebrisPrototype.cs} (100%) rename Resources/Prototypes/_MACRO/Procedural/Magnet/{space_ruin_templates2.yml => space_ruin_templates.yml} (100%) rename Resources/Prototypes/_MACRO/Procedural/{ruin_maps2.yml => ruin_maps.yml} (100%) rename Resources/Prototypes/_MACRO/Procedural/{salvage_magnet_offerings2.yml => salvage_magnet_offerings.yml} (100%) rename Resources/Prototypes/_MACRO/Procedural/{salvage_magnet_ruin_config2.yml => salvage_magnet_ruin_config.yml} (100%) diff --git a/Content.Shared/_MACRO/Salvage/Magnet/RuinOffering2.cs b/Content.Shared/_MACRO/Salvage/Magnet/RuinOffering.cs similarity index 100% rename from Content.Shared/_MACRO/Salvage/Magnet/RuinOffering2.cs rename to Content.Shared/_MACRO/Salvage/Magnet/RuinOffering.cs diff --git a/Content.Shared/_MACRO/Salvage/RuinMapPrototype2.cs b/Content.Shared/_MACRO/Salvage/RuinMapPrototype.cs similarity index 100% rename from Content.Shared/_MACRO/Salvage/RuinMapPrototype2.cs rename to Content.Shared/_MACRO/Salvage/RuinMapPrototype.cs diff --git a/Content.Shared/_MACRO/Salvage/SalvageMagnetRuinConfigPrototype2.cs b/Content.Shared/_MACRO/Salvage/SalvageMagnetRuinConfigPrototype.cs similarity index 100% rename from Content.Shared/_MACRO/Salvage/SalvageMagnetRuinConfigPrototype2.cs rename to Content.Shared/_MACRO/Salvage/SalvageMagnetRuinConfigPrototype.cs diff --git a/Content.Shared/_MACRO/Salvage/SalvageRuinDebrisPrototype2.cs b/Content.Shared/_MACRO/Salvage/SalvageRuinDebrisPrototype.cs similarity index 100% rename from Content.Shared/_MACRO/Salvage/SalvageRuinDebrisPrototype2.cs rename to Content.Shared/_MACRO/Salvage/SalvageRuinDebrisPrototype.cs diff --git a/Resources/Prototypes/_MACRO/Procedural/Magnet/space_ruin_templates2.yml b/Resources/Prototypes/_MACRO/Procedural/Magnet/space_ruin_templates.yml similarity index 100% rename from Resources/Prototypes/_MACRO/Procedural/Magnet/space_ruin_templates2.yml rename to Resources/Prototypes/_MACRO/Procedural/Magnet/space_ruin_templates.yml diff --git a/Resources/Prototypes/_MACRO/Procedural/ruin_maps2.yml b/Resources/Prototypes/_MACRO/Procedural/ruin_maps.yml similarity index 100% rename from Resources/Prototypes/_MACRO/Procedural/ruin_maps2.yml rename to Resources/Prototypes/_MACRO/Procedural/ruin_maps.yml diff --git a/Resources/Prototypes/_MACRO/Procedural/salvage_magnet_offerings2.yml b/Resources/Prototypes/_MACRO/Procedural/salvage_magnet_offerings.yml similarity index 100% rename from Resources/Prototypes/_MACRO/Procedural/salvage_magnet_offerings2.yml rename to Resources/Prototypes/_MACRO/Procedural/salvage_magnet_offerings.yml diff --git a/Resources/Prototypes/_MACRO/Procedural/salvage_magnet_ruin_config2.yml b/Resources/Prototypes/_MACRO/Procedural/salvage_magnet_ruin_config.yml similarity index 100% rename from Resources/Prototypes/_MACRO/Procedural/salvage_magnet_ruin_config2.yml rename to Resources/Prototypes/_MACRO/Procedural/salvage_magnet_ruin_config.yml