From 16b562cf76b624f50064b84eddaed9c05b5a271c Mon Sep 17 00:00:00 2001 From: Livyathan Date: Sun, 29 Mar 2026 14:09:24 -0400 Subject: [PATCH 1/3] add surgerytarget component back to cyborgs --- Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml b/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml index 4294984a34d..0da20365bd8 100644 --- a/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml +++ b/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml @@ -75,6 +75,7 @@ - type: ActivatableUI key: enum.BorgUiKey.Key - type: Targeting # Shitmed + - type: SurgeryTarget # HardLight: Necessary for Cyborgs to perform surgery. - type: SiliconLawBound - type: ActionGrant actions: From 7c4c5bff373dca02b0da05e6c3b79f769e840a30 Mon Sep 17 00:00:00 2001 From: Livyathan Date: Sun, 29 Mar 2026 15:41:16 -0400 Subject: [PATCH 2/3] refactor ship save sanitization system & fix for depreciated prototypes load fail (#1071) --- .../_HL/Shipyard/ShipSaveYamlSanitizer.cs | 727 ++++++++++++++ .../Systems/ShipyardGridSaveSystem.cs | 895 +----------------- .../Systems/ShipyardSystem.Consoles.cs | 15 +- .../_NF/Shipyard/Systems/ShipyardSystem.cs | 740 ++++++++++++++- .../NPC/Systems/NpcFactionSystem.Exception.cs | 6 + 5 files changed, 1465 insertions(+), 918 deletions(-) create mode 100644 Content.Server/_HL/Shipyard/ShipSaveYamlSanitizer.cs diff --git a/Content.Server/_HL/Shipyard/ShipSaveYamlSanitizer.cs b/Content.Server/_HL/Shipyard/ShipSaveYamlSanitizer.cs new file mode 100644 index 00000000000..78b7a4777bf --- /dev/null +++ b/Content.Server/_HL/Shipyard/ShipSaveYamlSanitizer.cs @@ -0,0 +1,727 @@ +using System; +using System.Collections.Generic; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.Markdown.Mapping; +using Robust.Shared.Serialization.Markdown.Sequence; +using Robust.Shared.Serialization.Markdown.Value; + +namespace Content.Server._HL.Shipyard; + +/// +/// Centralized ship-save YAML sanitization logic used by shipyard save/export flows. +/// +public static class ShipSaveYamlSanitizer +{ + // Implants that should not persist when found inside implanters during ship save. + private static readonly HashSet BlockedContainedImplantPrototypes = new(StringComparer.Ordinal) + { + "DeathRattleImplantColcomm", + "RadioImplantColcomm", + "UplinkImplant", + }; + + // Components stripped from all entities during ship-save export. + // Add new always-remove component types here. + private static readonly HashSet FilteredTypes = new(StringComparer.Ordinal) + { + "Joint", + "StationMember", + "NavMap", + "ShuttleDeed", + "IFF", + "LinkedLifecycleGridParent", + "AccessReader", + "DeviceNetwork", + "DeviceNetworkComponent", + "UserInterface", + "Docking", + "ActionGrant", + "Mind", + "MindContainer", + "VendingMachine", + "Forensics", + }; + + // Fill components that are normally removed from ship saves. + private static readonly HashSet FillComponentTypes = new(StringComparer.Ordinal) + { + "StorageFill", + "ContainerFill", + "EntityTableContainerFill", + "SurplusBundle", + }; + + // Prototype-level exceptions that are allowed to keep fill components. + // Add prototype IDs here when forced fill removal breaks a specific entity type. + private static readonly HashSet FillComponentWhitelistPrototypes = new(StringComparer.OrdinalIgnoreCase) + { + "AirAlarm", + }; + + // Components that should be marked as missing after removal so reconstruction follows prototype defaults. + private static readonly HashSet ForcedMissingComponents = new(StringComparer.Ordinal) + { + "StorageFill", + "ContainerFill", + "EntityTableContainerFill", + "SurplusBundle", + }; + + // Prototype IDs that should never be included in ship exports. + // Add non-ship entities here to drop them entirely. + private static readonly HashSet FilteredPrototypes = new(StringComparer.OrdinalIgnoreCase) + { + // Machines & circuitboards + "MachineFlatpacker", + "CommsComputerCircuitboard", + "ComputerDNAScanner", + "ComputerExpeditionDiskPrinter", + "ComputerFundingAllocation", + "ComputerPsionicsRecords", + "ComputerRoboticsControl", + "ComputerShuttleRecords", + "ComputerTabletopShuttleAntag", + "DnaScannerConsoleComputerCircuitboard", + "IDComputerCircuitboard", + "StationAiUploadComputer", + // Vending machines + "DEBUGVendingMachineAmmoBoxes", + "DEBUGVendingMachineMagazines", + "DEBUGVendingMachineRangedWeapons", + "VendingMachineAmmoPOI", + "VendingMachineAstroVendPOI", + "VendingMachineBoozePOI", + "VendingMachineBountyVendPOI", + "VendingMachineCigsPOI", + "VendingMachineEngivendPOI", + "VendingMachineExpeditionaryFlatpackVend", + "VendingMachineFlatpackVend", + "VendingMachineFuelVend", + "VendingMachineGamesPOI", + "LessLethalVendingMachinePOI", + "VendingMachineMediDrobePOI", + "VendingMachineMercVend", + "VendingMachinePickNPackPOI", + "VendingMachinePottedPlantVendPOI", + "VendingMachineSalvagePOI", + "VendingMachineSyndieContraband", + "VendingMachineTankDispenserEVAPOI", + "VendingMachineVendomatPOI", + "VendingMachineYouToolPOI", + // Everything else + "PortalBlue", + "PortalRed", + "ReactorGasPipe", + "ShipShield", + }; + + // Entity-level exclusion by component signature. + // If an entity has any of these components it is removed from export, + // unless allowed by ComponentExclusionExceptions below. + private static readonly HashSet FilteredEntityByComponentTypes = new(StringComparer.Ordinal) + { + "CommunicationsConsole", + "ContrabandPalletConsole", + "CriminalRecordsConsole", + "DnaSequenceInjector", + "DoorRemote", + "EmergencyShuttleConsole", + "GeneralStationRecordConsole", + "GeneticAnalyzer", + "Ghost", + "GhostRole", + "HumanoidAppearance", + "IdCard", + "IdCardConsole", + "MarketConsole", + "NFCargoOrderConsole", + "Pda", + "ShipyardConsole", + "Store", + }; + + // Component exclusion exceptions: keep entity when both component and its paired exception component exist. + private static readonly Dictionary ComponentExclusionExceptions = new(StringComparer.Ordinal) + { + ["ShipyardConsole"] = "ShipyardListing", + }; + + public static void SanitizeShipSaveNode(MappingDataNode root, IPrototypeManager prototypeManager) + { + // Keep serialized nullspace empty so ship exports stay scoped to the grid payload. + try + { + root["nullspace"] = new SequenceDataNode(); + } + catch + { + // Best effort: if nullspace cannot be overwritten, continue with entity-level sanitation. + } + + if (!root.TryGet("entities", out SequenceDataNode? protoSeq) || protoSeq == null) + return; + + // Track entity UIDs removed during sanitation so we can prune stale container/storage references. + var removedEntityUids = new HashSet(StringComparer.Ordinal); + var blockedContainedImplantEntityUids = CollectBlockedContainedImplantEntityUids(protoSeq); + + foreach (var protoNode in protoSeq) + { + if (protoNode is not MappingDataNode protoMap) + continue; + + if (!protoMap.TryGet("entities", out SequenceDataNode? entitiesSeq) || entitiesSeq == null) + continue; + + for (var i = 0; i < entitiesSeq.Count; i++) + { + if (entitiesSeq[i] is not MappingDataNode entMap) + continue; + + // Remove runtime-only map flags from exported entities. + entMap.Remove("mapInit"); + entMap.Remove("paused"); + + var hasProtoGroup = false; + var allowFillComponents = false; + HashSet? protoMissing = null; + var dropByPrototypeComponent = false; + EntityPrototype? entityProto = null; + + if (protoMap.TryGet("proto", out ValueDataNode? protoIdNode) && protoIdNode != null) + { + hasProtoGroup = true; + var protoId = protoIdNode.Value; + allowFillComponents = FillComponentWhitelistPrototypes.Contains(protoId); + + if (FilteredPrototypes.Contains(protoId)) + { + if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) + removedEntityUids.Add(removedUidNode.Value); + + entitiesSeq.RemoveAt(i); + i--; + continue; + } + + if (prototypeManager.TryIndex(protoId, out var proto)) + { + entityProto = proto; + + foreach (var componentName in FilteredEntityByComponentTypes) + { + if (!proto.Components.ContainsKey(componentName)) + continue; + + if (ComponentExclusionExceptions.TryGetValue(componentName, out var exceptionComponent) + && proto.Components.ContainsKey(exceptionComponent)) + continue; + + dropByPrototypeComponent = true; + break; + } + + if (!allowFillComponents && proto.Components.ContainsKey("Door")) + allowFillComponents = true; + + if (!allowFillComponents) + { + foreach (var name in ForcedMissingComponents) + { + if (!proto.Components.ContainsKey(name)) + continue; + + protoMissing ??= new HashSet(StringComparer.Ordinal); + protoMissing.Add(name); + } + } + } + } + + if (!entMap.TryGet("components", out SequenceDataNode? comps) || comps == null) + { + if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) + removedEntityUids.Add(removedUidNode.Value); + + entitiesSeq.RemoveAt(i); + i--; + continue; + } + + // Remove implanters containing blocked implant entities. + var isImplanter = entityProto?.Components.ContainsKey("Implanter") == true || HasComponentNode(comps, "Implanter"); + if (isImplanter && HasBlockedContainedImplant(entMap, blockedContainedImplantEntityUids)) + { + if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) + removedEntityUids.Add(removedUidNode.Value); + + entitiesSeq.RemoveAt(i); + i--; + continue; + } + + var dropByComponent = false; + foreach (var c in comps) + { + if (c is not MappingDataNode cm) + continue; + + if (!cm.TryGet("type", out ValueDataNode? t) || t == null) + continue; + + if (!FilteredEntityByComponentTypes.Contains(t.Value)) + continue; + + if (ComponentExclusionExceptions.TryGetValue(t.Value, out var exceptionComponent) + && (HasComponentNode(comps, exceptionComponent) + || entityProto?.Components.ContainsKey(exceptionComponent) == true)) + continue; + + dropByComponent = true; + break; + } + + if (dropByComponent) + { + if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) + removedEntityUids.Add(removedUidNode.Value); + + entitiesSeq.RemoveAt(i); + i--; + continue; + } + + if (dropByPrototypeComponent) + { + if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) + removedEntityUids.Add(removedUidNode.Value); + + entitiesSeq.RemoveAt(i); + i--; + continue; + } + + // Grid root gets slightly different Transform sanitation. + var hasMapGrid = false; + var compsNotNull = comps; + var paintStylePrototype = GetPaintStylePrototype(compsNotNull); + + foreach (var c in compsNotNull) + { + if (c is MappingDataNode cm && cm.TryGet("type", out ValueDataNode? t) && t != null && t.Value == "MapGrid") + { + hasMapGrid = true; + break; + } + } + + var hasDoorComponent = false; + foreach (var c in compsNotNull) + { + if (c is MappingDataNode cm && cm.TryGet("type", out ValueDataNode? t) && t != null && t.Value == "Door") + { + hasDoorComponent = true; + break; + } + } + + if (hasDoorComponent) + { + allowFillComponents = true; + protoMissing = null; + } + + // Build sanitized component list for this entity. + var newComps = new SequenceDataNode(); + var removedFromPrototype = hasProtoGroup ? new HashSet(StringComparer.Ordinal) : null; + + foreach (var compNode in compsNotNull) + { + if (compNode is not MappingDataNode compMap) + continue; + + if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null) + { + newComps.Add(compMap); + continue; + } + + var typeName = typeNode.Value; + + if (FilteredTypes.Contains(typeName)) + { + if (allowFillComponents && FillComponentTypes.Contains(typeName)) + { + newComps.Add(compMap); + continue; + } + + if (ForcedMissingComponents.Contains(typeName)) + removedFromPrototype?.Add(typeName); + + continue; + } + + if (typeName == "Transform" && hasMapGrid) + compMap.Remove("rot"); + + if (typeName == "Appearance" && paintStylePrototype != null) + ApplyPaintStyleToAppearance(compMap, paintStylePrototype); + + if (typeName == "SpreaderGrid") + { + compMap.Remove("updateAccumulator"); + compMap.Remove("UpdateAccumulator"); + } + + if (typeName == "VendingMachine") + { + compMap.Remove("Inventory"); + compMap.Remove("EmaggedInventory"); + compMap.Remove("ContrabandInventory"); + compMap.Remove("Contraband"); + compMap.Remove("EjectEnd"); + compMap.Remove("DenyEnd"); + compMap.Remove("DispenseOnHitEnd"); + compMap.Remove("NextEmpEject"); + compMap.Remove("EjectRandomCounter"); + } + + if (typeName == "ResearchServer") + { + compMap.Remove("points"); + compMap.Remove("Points"); + compMap.Remove("pointsPerSecond"); + compMap.Remove("PointsPerSecond"); + } + + if (typeName == "TechnologyDatabase") + { + compMap.Remove("unlockedTechnologies"); + compMap.Remove("UnlockedTechnologies"); + compMap.Remove("unlockedRecipes"); + compMap.Remove("UnlockedRecipes"); + compMap.Remove("currentTechnologyCards"); + compMap.Remove("CurrentTechnologyCards"); + compMap.Remove("mainDiscipline"); + compMap.Remove("MainDiscipline"); + } + + if (typeName == "Battery") + { + compMap["currentCharge"] = new ValueDataNode("0"); + compMap["CurrentCharge"] = new ValueDataNode("0"); + } + + if (typeName == "DeviceNetwork") + { + compMap.Remove("devices"); + compMap.Remove("Devices"); + } + + if (typeName == "Solution") + { + if (compMap.TryGetValue("solution", out var solutionNode) + && solutionNode is MappingDataNode solutionMap + && solutionMap.TryGetValue("name", out var nameNode) + && nameNode is ValueDataNode nameValue + && nameValue.Value == "buffer") + { + solutionMap["canReact"] = new ValueDataNode("false"); + } + } + + if (typeName == "ReagentDispenser") + { + compMap.Remove("storageSlots"); + compMap.Remove("storageSlotIds"); + compMap.Remove("autoLabel"); + } + + newComps.Add(compMap); + } + + // If SprayPainted exists without Appearance, synthesize Appearance so paint style persists in saves. + if (paintStylePrototype != null && !HasComponentNode(newComps, "Appearance")) + { + var appearanceComp = new MappingDataNode + { + ["type"] = new ValueDataNode("Appearance") + }; + + ApplyPaintStyleToAppearance(appearanceComp, paintStylePrototype); + newComps.Add(appearanceComp); + } + + if ((removedFromPrototype != null && removedFromPrototype.Count > 0) || (protoMissing != null && protoMissing.Count > 0)) + { + var existingMissing = new HashSet(StringComparer.Ordinal); + if (entMap.TryGet("missingComponents", out SequenceDataNode? missingNode) && missingNode != null) + { + foreach (var missing in missingNode) + { + if (missing is ValueDataNode value) + existingMissing.Add(value.Value); + } + } + + var mergedSet = new HashSet(existingMissing, StringComparer.Ordinal); + if (removedFromPrototype != null) + { + foreach (var name in removedFromPrototype) + mergedSet.Add(name); + } + + if (protoMissing != null) + { + foreach (var name in protoMissing) + mergedSet.Add(name); + } + + if (allowFillComponents) + { + foreach (var name in FillComponentTypes) + mergedSet.Remove(name); + } + + if (mergedSet.Count > 0) + { + var mergedMissing = new SequenceDataNode(); + foreach (var name in mergedSet) + mergedMissing.Add(new ValueDataNode(name)); + + entMap["missingComponents"] = mergedMissing; + } + } + + if (newComps.Count > 0) + { + entMap["components"] = newComps; + } + else + { + if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) + removedEntityUids.Add(removedUidNode.Value); + + entitiesSeq.RemoveAt(i); + i--; + } + } + } + + // Final pass: remove stale container/storage references to entities dropped above. + PruneContainerReferencesToRemovedEntities(protoSeq, removedEntityUids); + } + + private static string? GetPaintStylePrototype(SequenceDataNode components) + { + foreach (var compNode in components) + { + if (compNode is not MappingDataNode compMap) + continue; + + if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null || typeNode.Value != "SprayPainted") + continue; + + if (!compMap.TryGet("paintedPrototype", out ValueDataNode? styleNode) || styleNode == null) + continue; + + if (!string.IsNullOrWhiteSpace(styleNode.Value)) + return styleNode.Value; + } + + return null; + } + + private static bool HasComponentNode(SequenceDataNode components, string componentType) + { + foreach (var compNode in components) + { + if (compNode is not MappingDataNode compMap) + continue; + + if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null) + continue; + + if (typeNode.Value == componentType) + return true; + } + + return false; + } + + private static HashSet CollectBlockedContainedImplantEntityUids(SequenceDataNode protoSeq) + { + // Resolve concrete entity UIDs for blocked implant prototypes so implanter checks are cheap per entity. + var blockedImplantUids = new HashSet(StringComparer.Ordinal); + + foreach (var protoNode in protoSeq) + { + if (protoNode is not MappingDataNode protoMap) + continue; + + if (!protoMap.TryGet("entities", out SequenceDataNode? entitiesSeq) || entitiesSeq == null) + continue; + + var protoIsBlockedImplant = false; + if (protoMap.TryGet("proto", out ValueDataNode? protoIdNode) + && protoIdNode != null + && !protoIdNode.IsNull) + { + protoIsBlockedImplant = BlockedContainedImplantPrototypes.Contains(protoIdNode.Value); + } + + foreach (var entityNode in entitiesSeq) + { + if (entityNode is not MappingDataNode entMap) + continue; + + if (!entMap.TryGet("uid", out ValueDataNode? uidNode) || uidNode == null || uidNode.IsNull) + continue; + + if (protoIsBlockedImplant) + blockedImplantUids.Add(uidNode.Value); + } + } + + return blockedImplantUids; + } + + private static bool HasBlockedContainedImplant(MappingDataNode entMap, HashSet blockedContainedImplantEntityUids) + { + if (blockedContainedImplantEntityUids.Count == 0) + return false; + + if (!entMap.TryGet("components", out SequenceDataNode? components) || components == null) + return false; + + foreach (var compNode in components) + { + if (compNode is not MappingDataNode compMap) + continue; + + if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null || typeNode.Value != "ContainerContainer") + continue; + + if (!compMap.TryGet("containers", out MappingDataNode? containersMap) || containersMap == null) + continue; + + if (!containersMap.TryGet("implanter_slot", out MappingDataNode? slotMap) || slotMap == null) + continue; + + if (slotMap.TryGet("ent", out ValueDataNode? entNode) && entNode != null && !entNode.IsNull && blockedContainedImplantEntityUids.Contains(entNode.Value)) + return true; + + if (!slotMap.TryGet("ents", out SequenceDataNode? entsNode) || entsNode == null) + continue; + + foreach (var entry in entsNode) + { + if (entry is not ValueDataNode valueNode || valueNode.IsNull) + continue; + + if (blockedContainedImplantEntityUids.Contains(valueNode.Value)) + return true; + } + } + + return false; + } + + private static void PruneContainerReferencesToRemovedEntities(SequenceDataNode protoSeq, HashSet removedEntityUids) + { + if (removedEntityUids.Count == 0) + return; + + // Remove dangling references from both ContainerContainer and Storage serialized structures. + foreach (var protoNode in protoSeq) + { + if (protoNode is not MappingDataNode protoMap) + continue; + + if (!protoMap.TryGet("entities", out SequenceDataNode? entitiesSeq) || entitiesSeq == null) + continue; + + foreach (var entityNode in entitiesSeq) + { + if (entityNode is not MappingDataNode entMap) + continue; + + if (!entMap.TryGet("components", out SequenceDataNode? comps) || comps == null) + continue; + + foreach (var compNode in comps) + { + if (compNode is not MappingDataNode compMap) + continue; + + if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null) + continue; + + var componentType = typeNode.Value; + + if (componentType == "ContainerContainer") + { + if (!compMap.TryGet("containers", out MappingDataNode? containersMap) || containersMap == null) + continue; + + foreach (var (_, containerNode) in containersMap) + { + if (containerNode is not MappingDataNode containerMap) + continue; + + if (containerMap.TryGet("ents", out SequenceDataNode? entsNode) && entsNode != null) + { + for (var idx = entsNode.Count - 1; idx >= 0; idx--) + { + if (entsNode[idx] is not ValueDataNode entValue || entValue.IsNull) + continue; + + if (removedEntityUids.Contains(entValue.Value)) + entsNode.RemoveAt(idx); + } + } + + if (containerMap.TryGet("ent", out ValueDataNode? entNode) && entNode != null && !entNode.IsNull) + { + if (removedEntityUids.Contains(entNode.Value)) + containerMap["ent"] = ValueDataNode.Null(); + } + } + + continue; + } + + if (componentType == "Storage" && compMap.TryGet("storedItems", out MappingDataNode? storedItemsMap) && storedItemsMap != null) + { + var removeKeys = new List(); + foreach (var (itemUid, _) in storedItemsMap) + { + if (removedEntityUids.Contains(itemUid)) + removeKeys.Add(itemUid); + } + + foreach (var key in removeKeys) + storedItemsMap.Remove(key); + } + } + } + } + } + + private static void ApplyPaintStyleToAppearance(MappingDataNode appearanceComp, string stylePrototype) + { + MappingDataNode appearanceDataInit; + if (appearanceComp.TryGet("appearanceDataInit", out MappingDataNode? existing) && existing != null) + { + appearanceDataInit = existing; + } + else + { + appearanceDataInit = new MappingDataNode(); + appearanceComp["appearanceDataInit"] = appearanceDataInit; + } + + appearanceDataInit["enum.PaintableVisuals.Prototype"] = new ValueDataNode(stylePrototype); + } +} diff --git a/Content.Server/_NF/Shipyard/Systems/ShipyardGridSaveSystem.cs b/Content.Server/_NF/Shipyard/Systems/ShipyardGridSaveSystem.cs index ce56e396f3b..66ef55603b8 100644 --- a/Content.Server/_NF/Shipyard/Systems/ShipyardGridSaveSystem.cs +++ b/Content.Server/_NF/Shipyard/Systems/ShipyardGridSaveSystem.cs @@ -1,49 +1,48 @@ +using System.IO; +using System.Threading.Tasks; +using Content.Server.Atmos.Piping.Components; using Content.Server.Chemistry.Components; -using Content.Shared.Chemistry.EntitySystems; +using Content.Server.Construction.Components; +using Content.Server.Power.Components; +using Content.Server.Power.EntitySystems; +using Content.Server.Store.Components; // HardLight +using Content.Server._HL.Shipyard; // HardLight +using Content.Shared._Common.Consent; // HardLight +using Content.Shared._HL.Shipyard; // HardLight using Content.Shared._NF.Shipyard.Components; using Content.Shared._NF.Shipyard.Events; +using Content.Shared.Chemistry.Components; +using Content.Shared.Chemistry.Components.SolutionManager; +using Content.Shared.Chemistry.EntitySystems; using Content.Shared.DeviceLinking; using Content.Shared.DeviceLinking.Components; +using Content.Shared.Implants.Components; // HardLight +using Content.Shared.Light.Components; // HardLight +using Content.Shared.Mind.Components; // HardLight using Content.Shared.Shuttles.Save; // For SendShipSaveDataClientMessage -using Content.Server.Atmos.Piping.Components; -using Content.Server.Power.Components; -using Content.Server.Power.EntitySystems; +using Content.Shared.SprayPainter.Components; // HardLight +using Content.Shared.SprayPainter.Prototypes; // HardLight +using Content.Shared.Storage.Components; using Content.Shared.VendingMachines; -using Robust.Shared.Player; -using Robust.Shared.Map.Components; -using Robust.Shared.EntitySerialization.Systems; -using Robust.Shared.Utility; -using Robust.Shared.ContentPack; +using Content.Shared.Wall; // WallMountComponent for preserving wall-mounted fixtures +using Robust.Server.GameObjects; // HardLight using Robust.Server.Player; +using Robust.Shared.Containers; +using Robust.Shared.ContentPack; using Robust.Shared.EntitySerialization; +using Robust.Shared.EntitySerialization.Systems; +using Robust.Shared.Map.Components; +using Robust.Shared.Physics; using Robust.Shared.Physics.Components; -using Robust.Shared.Containers; -using System.IO; -using System.Threading.Tasks; +using Robust.Shared.Player; +using Robust.Shared.Prototypes; // HardLight +using Robust.Shared.Serialization; using Robust.Shared.Serialization.Markdown.Mapping; using Robust.Shared.Serialization.Markdown.Sequence; using Robust.Shared.Serialization.Markdown.Value; -using YamlDotNet.RepresentationModel; +using Robust.Shared.Utility; using YamlDotNet.Core; -using Robust.Shared.Serialization; -using Content.Shared.Storage.Components; -using Content.Shared.Wall; // WallMountComponent for preserving wall-mounted fixtures -using Robust.Shared.Physics; -using Content.Shared.Chemistry.Components; -using Content.Shared.Chemistry.Components.SolutionManager; -using Content.Server.Construction.Components; -using Content.Shared._HL.Shipyard; -// HardLight start -using Content.Server.Store.Components; -using Content.Shared._Common.Consent; -using Content.Shared.Implants.Components; -using Content.Shared.Light.Components; -using Content.Shared.Mind.Components; -using Content.Shared.SprayPainter.Components; -using Content.Shared.SprayPainter.Prototypes; -using Robust.Server.GameObjects; -using Robust.Shared.Prototypes; -// HardLight end +using YamlDotNet.RepresentationModel; namespace Content.Server._NF.Shipyard.Systems; @@ -54,8 +53,7 @@ namespace Content.Server._NF.Shipyard.Systems; /// public sealed class ShipyardGridSaveSystem : EntitySystem { - // HardLight start - // List of currency prototypes that should be stripped from ship saves. + // HardLight: List of currency prototypes that should be stripped from ship saves. private static readonly HashSet NonPersistentShipSaveCurrencies = new(StringComparer.Ordinal) { // "FrontierUplinkCoin", @@ -64,15 +62,6 @@ public sealed class ShipyardGridSaveSystem : EntitySystem // triad change rare enough for these who cares }; - // Implants that should not persist when found inside implanters during ship save. - private static readonly HashSet BlockedContainedImplantPrototypes = new(StringComparer.Ordinal) - { - "DeathRattleImplantColcomm", - "RadioImplantColcomm", - "UplinkImplant", - }; - // HardLight end - [Dependency] private readonly IEntityManager _entityManager = default!; [Dependency] private readonly IEntitySystemManager _entitySystemManager = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; @@ -742,827 +731,9 @@ private void StampSprayPaintedPrototypesOnGrid(EntityUid gridUid) /// private void SanitizeShipSaveNode(MappingDataNode root) { - // Ensure nullspace is empty - try - { - root["nullspace"] = new SequenceDataNode(); - } - catch (Exception e) - { - _sawmill.Warning($"Failed to clear nullspace: {e.Message}"); - } - - if (!root.TryGet("entities", out SequenceDataNode? protoSeq) || protoSeq == null) - return; - - var filteredTypes = new HashSet // HardLight: Components you want removed from entities on save and restored on load go here. - { - "Joint", - "StationMember", - "NavMap", - "ShuttleDeed", - "IFF", - "LinkedLifecycleGridParent", - "AccessReader", // Door logs - // "DeviceList", Don't remove this it breaks air alarms. - "DeviceNetwork", - "DeviceNetworkComponent", - "UserInterface", // Contains invalid EntityUid references - "Docking", // Contains invalid EntityUid references to docked entities - "ActionGrant", // Contains invalid EntityUid references to granted actions - "Mind", // Contains player state that can't be serialized and isn't relevant to ship blueprints - "MindContainer", // Contains player state that can't be serialized and isn't relevant to ship blueprints - "VendingMachine", // Vending machines restock on ship load, sometimes infinitely; this should prevent that behavior - "Forensics", // Entirely useless information that causes extreme ship file bloat - }; - - var fillComponentTypes = new HashSet(StringComparer.Ordinal) // HardLight: Components you want permanently removed from entities go here. - { - "StorageFill", // Remove refill-on-spawn behavior on ship save only - "ContainerFill", // Remove refill-on-spawn behavior on ship save only - "EntityTableContainerFill", // Remove refill-on-spawn behavior on ship save only - "SurplusBundle", // Syndicate Surplus Crates refill on ship load; this should prevent that behavior - }; - - var fillComponentWhitelistPrototypes = new HashSet(StringComparer.InvariantCultureIgnoreCase) // HardLight: Entities you want to bypass the fill component removal for go here. - { - "AirAlarm", - }; - - /// - /// HardLight: Explicitly preserve case-sensitive component names that are known to cause issues if included in ship saves, even if they exist on entities. - /// This is a blunt tool but it ensures we won't accidentally break ship saves by adding new components in the future without remembering to filter them here. - /// - var forcedMissingComponents = new HashSet(StringComparer.Ordinal) - { - "StorageFill", - "ContainerFill", - "EntityTableContainerFill", - "SurplusBundle", - }; - - // HardLight start - // Prototype-level exclusions for obvious non-ship entities. - // If we encounter these, we drop them entirely from the export. - var filteredPrototypes = new HashSet(StringComparer.InvariantCultureIgnoreCase) - { - // Machines & circuit boards - "MachineFlatpacker", // One day. - "CommsComputerCircuitboard", - "ComputerDNAScanner", - "ComputerExpeditionDiskPrinter", - "ComputerFundingAllocation", - "ComputerPsionicsRecords", - "ComputerRoboticsControl", - "ComputerShuttleRecords", - "ComputerTabletopShuttleAntag", // I'm a bit confused about this one. - "DnaScannerConsoleComputerCircuitboard", - "IDComputerCircuitboard", - "StationAiUploadComputer", - // Vending machines - "DEBUGVendingMachineAmmoBoxes", - "DEBUGVendingMachineMagazines", - "DEBUGVendingMachineRangedWeapons", - "VendingMachineAmmoPOI", - "VendingMachineAstroVendPOI", - "VendingMachineBoozePOI", - "VendingMachineBountyVendPOI", - "VendingMachineCigsPOI", - "VendingMachineEngivendPOI", - "VendingMachineExpeditionaryFlatpackVend", - "VendingMachineFlatpackVend", - "VendingMachineFuelVend", - "VendingMachineGamesPOI", - "LessLethalVendingMachinePOI", - "VendingMachineMediDrobePOI", - "VendingMachineMercVend", - "VendingMachinePickNPackPOI", - "VendingMachinePottedPlantVendPOI", - "VendingMachineSalvagePOI", - "VendingMachineSyndieContraband", - "VendingMachineTankDispenserEVAPOI", - "VendingMachineVendomatPOI", - "VendingMachineYouToolPOI", - // Everything else - "ReactorGasPipe", // Nuclear reactors duplicate invisible inlet/outlet pipes on save. - "ShipShield", // Ship shield emitters duplicate ship shield visuals on save. - "BaseMercenaryUplinkRadio", - "BaseUplinkRadio", - "BaseUplinkRadio20TC", - "BaseUplinkRadio25TC", - "BaseUplinkRadio40TC", - "BaseUplinkRadio60TC", - "UplinkImplanter", - "BaseSecurityUplinkRadio", - "BaseSecurityUplinkRadioSheriff", - "BaseSecurityUplinkRadioOfficer", - "BaseSecurityUplinkRadioDeputy", - "BasePirateUplink", - "BasePirateUplinkRadioPirateCaptain", - "BasePirateUplinkPirateCrew", - }; - - // Component-level exclusions for non-ship entities. - // If an entity has ANY of these components, drop the entity from export entirely. - var filteredEntityByComponentTypes = new HashSet(StringComparer.Ordinal) - { - "CommunicationsConsole", - "ContrabandPalletConsole", - "CriminalRecordsConsole", - "DnaSequenceInjector", - "DoorRemote", - "EmergencyShuttleConsole", - "GeneralStationRecordConsole", - "GeneticAnalyzer", - "Ghost", - "GhostRole", - "HumanoidAppearance", - "IdCard", - "IdCardConsole", - "MarketConsole", - "NFCargoOrderConsole", - "Pda", - "ShipyardConsole", - "Store", - }; - - // Exclusion exceptions: if the excluded component is present alongside its paired allow component, - // keep the entity. - var componentExclusionExceptions = new Dictionary(StringComparer.Ordinal) - { - ["ShipyardConsole"] = "ShipyardListing", - }; - - // Track removed entities so we can prune stale container references afterward. - var removedEntityUids = new HashSet(StringComparer.Ordinal); - - // UIDs of implant entities that should be stripped from implanters on ship save. - // Matching is based on explicit prototype IDs. - var blockedContainedImplantEntityUids = CollectBlockedContainedImplantEntityUids(protoSeq); - // HardLight end - - foreach (var protoNode in protoSeq) - { - if (protoNode is not MappingDataNode protoMap) - continue; - - if (!protoMap.TryGet("entities", out SequenceDataNode? entitiesSeq) || entitiesSeq == null) - continue; - - for (var i = 0; i < entitiesSeq.Count; i++) - { - if (entitiesSeq[i] is not MappingDataNode entMap) - continue; - - // Remove map initialization flags - entMap.Remove("mapInit"); - entMap.Remove("paused"); - - // Optional: Drop entities that are clearly unrelated by prototype id. - // Each proto group node contains a "proto" key with the prototype id string. - var hasProtoGroup = false; - var allowFillComponents = false; // HardLight - HashSet? protoMissing = null; - var dropByPrototypeComponent = false; // HardLight - EntityPrototype? entityProto = null; // HardLight - if (protoMap.TryGet("proto", out ValueDataNode? protoIdNode) && protoIdNode != null) - { - hasProtoGroup = true; - var protoId = protoIdNode.Value; - allowFillComponents = fillComponentWhitelistPrototypes.Contains(protoId); // HardLight - if (filteredPrototypes.Contains(protoId)) - { - // Remove this entity entirely - if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) // HardLight - removedEntityUids.Add(removedUidNode.Value); // HardLight - entitiesSeq.RemoveAt(i); - i--; - continue; - } - - // HardLight start: If this prototype is on the fill component whitelist, allow fill components to remain even if they are on the forced missing list. - // This ensures critical entities like air alarms keep their fill components for proper functionality. - if (_prototypeManager.TryIndex(protoId, out var proto)) - { - entityProto = proto; - - foreach (var componentName in filteredEntityByComponentTypes) - { - if (!proto.Components.ContainsKey(componentName)) - continue; - - if (componentExclusionExceptions.TryGetValue(componentName, out var exceptionComponent) - && proto.Components.ContainsKey(exceptionComponent)) - continue; - - dropByPrototypeComponent = true; - break; - } - - if (!allowFillComponents && proto.Components.ContainsKey("Door")) - allowFillComponents = true; - - if (!allowFillComponents) // If a prototype contains components that are forced missing, track them so we can remove those components from all entities of that prototype below. - { - foreach (var name in forcedMissingComponents) - { - if (proto.Components.ContainsKey(name)) - { - protoMissing ??= new HashSet(StringComparer.Ordinal); - protoMissing.Add(name); - } - } - } - } - // HardLight end - } - - // Components cleanup - if (!entMap.TryGet("components", out SequenceDataNode? comps) || comps == null) - { - // If there are no components left, this entity is empty and can be removed - // HardLight start - if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) - removedEntityUids.Add(removedUidNode.Value); - entitiesSeq.RemoveAt(i); - i--; - continue; - // HardLight end - } - - // HardLight start - // If this entity is an implanter currently containing a filtered implant entity, - // remove the implanter from export. - var isImplanter = entityProto?.Components.ContainsKey("Implanter") == true || HasComponentNode(comps, "Implanter"); - if (isImplanter && HasBlockedContainedImplant(entMap, blockedContainedImplantEntityUids)) - { - if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) - removedEntityUids.Add(removedUidNode.Value); - entitiesSeq.RemoveAt(i); - i--; - continue; - } - - // Optional: Drop entities by component signature. - // If an entity has any component in filteredEntityByComponentTypes, remove the entire entity. - var dropByComponent = false; - foreach (var c in comps) - { - if (c is not MappingDataNode cm) - continue; - - if (!cm.TryGet("type", out ValueDataNode? t) || t == null) - continue; - - if (!filteredEntityByComponentTypes.Contains(t.Value)) - continue; - - if (componentExclusionExceptions.TryGetValue(t.Value, out var exceptionComponent) - && (HasComponentNode(comps, exceptionComponent) - || entityProto?.Components.ContainsKey(exceptionComponent) == true)) - continue; - - dropByComponent = true; - break; - } - - if (dropByComponent) - { - if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) - removedEntityUids.Add(removedUidNode.Value); - entitiesSeq.RemoveAt(i); - i--; - continue; - } - // HardLight end - - if (dropByPrototypeComponent) // HardLight - { - if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) // HardLight - removedEntityUids.Add(removedUidNode.Value); // HardLight - entitiesSeq.RemoveAt(i); - i--; - continue; - } - - // Determine if this entity is the grid root (has MapGrid component) - var hasMapGrid = false; - var compsNotNull = comps!; // Assert non-null for analyzer; guarded above. - var paintStylePrototype = GetPaintStylePrototype(compsNotNull); // HardLight - foreach (var c in compsNotNull) - { - if (c is MappingDataNode cm && cm.TryGet("type", out ValueDataNode? t) && t != null && t.Value == "MapGrid") - { - hasMapGrid = true; - break; - } - } - - // HardLight start: If this entity has a Door component, we should allow fill components to remain even if they are on the forced missing list, - // since many doors require a StorageFill or ContainerFill to function properly. - var hasDoorComponent = false; - foreach (var c in compsNotNull) - { - if (c is MappingDataNode cm && cm.TryGet("type", out ValueDataNode? t) && t != null && t.Value == "Door") - { - hasDoorComponent = true; - break; - } - } - - if (hasDoorComponent) - { - allowFillComponents = true; - // Door-based allow list should not mark fill components as missing. - protoMissing = null; - } - // HardLight end - - var newComps = new SequenceDataNode(); - var removedFromPrototype = hasProtoGroup ? new HashSet(StringComparer.Ordinal) : null; // HardLight: Track which components were removed due to prototype-level filtering so we can log them at the end. - foreach (var compNode in compsNotNull) - { - if (compNode is not MappingDataNode compMap) - continue; - - if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null) - { - newComps.Add(compMap); - continue; - } - - var typeName = typeNode.Value; - - // Filter out undesired component types entirely - if (filteredTypes.Contains(typeName)) - { - // HardLight start: If this component is on a prototype with forced missing components, track it so we can remove those components from all entities of that prototype below. - if (allowFillComponents && fillComponentTypes.Contains(typeName)) - { - newComps.Add(compMap); - continue; - } - - if (forcedMissingComponents.Contains(typeName)) - removedFromPrototype?.Add(typeName); - continue; - // HardLight end - } - - // Transform: remove rotation on the grid root to match blueprint expectations - if (typeName == "Transform" && hasMapGrid) - { - compMap.Remove("rot"); - } - - // HardLight - Appearance: if this entity has an Appearance component and we found a paint style prototype from SprayPainted, - // apply that paint style to the Appearance component so it gets saved with the ship. - if (typeName == "Appearance" && paintStylePrototype != null) - { - ApplyPaintStyleToAppearance(compMap, paintStylePrototype); - } - - // Gravity: preserve enabled state so gravity persists on ship load - // (Removed stripping of enabled field) - - // SpreaderGrid: strip accumulator fields - if (typeName == "SpreaderGrid") - { - compMap.Remove("updateAccumulator"); - compMap.Remove("UpdateAccumulator"); - } - - // VendingMachine: strip runtime inventory & timers to match blueprint expectations - if (typeName == "VendingMachine") - { - compMap.Remove("Inventory"); - compMap.Remove("EmaggedInventory"); - compMap.Remove("ContrabandInventory"); - compMap.Remove("Contraband"); - compMap.Remove("EjectEnd"); - compMap.Remove("DenyEnd"); - compMap.Remove("DispenseOnHitEnd"); - compMap.Remove("NextEmpEject"); - compMap.Remove("EjectRandomCounter"); - } - - // DeviceLink: Keep device links intact - they should persist - // (Removed clearing of linkedPorts and links) - - // Solution/SolutionContainer: Keep solution contents - they should persist - // (Removed clearing of solutions and contents) - - // ResearchServer: reset research server state - if (typeName == "ResearchServer") - { - compMap.Remove("points"); - compMap.Remove("Points"); - compMap.Remove("pointsPerSecond"); - compMap.Remove("PointsPerSecond"); - } - - // TechnologyDatabase: reset unlocked technologies and recipes - if (typeName == "TechnologyDatabase") - { - compMap.Remove("unlockedTechnologies"); - compMap.Remove("UnlockedTechnologies"); - compMap.Remove("unlockedRecipes"); - compMap.Remove("UnlockedRecipes"); - compMap.Remove("currentTechnologyCards"); - compMap.Remove("CurrentTechnologyCards"); - compMap.Remove("mainDiscipline"); - compMap.Remove("MainDiscipline"); - } - - // Battery: reset charge to 0 - // Battery: reset charge to 0 - if (typeName == "Battery") - { - compMap["currentCharge"] = new ValueDataNode("0"); - compMap["CurrentCharge"] = new ValueDataNode("0"); - } - - // SolutionContainerManager: DO NOT MODIFY - preserve all solution data - // This is critical for ChemMaster buffers and other solution containers - if (typeName == "SolutionContainerManager") - { - // Explicitly DO NOTHING - let the solution data pass through unchanged - // The bug was that solutions were being modified or cleared somewhere - - // Log the solution data for debugging - if (compMap.TryGetValue("solutions", out var solutionsNode) && solutionsNode is MappingDataNode solutionsMap) - { - foreach (var (solutionName, solutionData) in solutionsMap) - { - Logger.GetSawmill("hardlight").Info($"Preserving solution '{solutionName}' in SolutionContainerManager"); - - if (solutionData is MappingDataNode solutionMap) - { - if (solutionMap.TryGetValue("contents", out var contentsNode) && contentsNode is SequenceDataNode contents) - { - Logger.GetSawmill("hardlight").Info($" Solution has {contents.Count} reagent entries"); - - // Verify each reagent entry maintains its structure - foreach (var contentNode in contents) - { - if (contentNode is MappingDataNode reagentMap) - { - if (reagentMap.TryGetValue("ReagentId", out var reagentIdNode) && reagentIdNode is ValueDataNode reagentId && - reagentMap.TryGetValue("Quantity", out var quantityNode) && quantityNode is ValueDataNode quantity) - { - Logger.GetSawmill("hardlight").Info($" - ReagentId: {reagentId.Value}, Quantity: {quantity.Value}"); - } - } - } - } - } - } - } - } - - // DeviceNetwork: clear device lists that contain invalid EntityUid references - if (typeName == "DeviceNetwork") - { - compMap.Remove("devices"); - compMap.Remove("Devices"); - } - - // UserInterface: remove to prevent invalid EntityUid references - // (This is handled by filteredTypes but adding explicit note) - - // Docking: remove to prevent invalid EntityUid references to docked entities - // (This is handled by filteredTypes but adding explicit note) - - // ActionGrant: remove to prevent invalid EntityUid references to granted actions - // (This is handled by filteredTypes but adding explicit note) - - // Solution: Force canReact to false to prevent reagent mixing on load - // Only apply to solutions named "buffer" which are ChemMaster buffers, instead of literally everything - if (typeName == "Solution") - { - // Check if this component has a solution field - if (compMap.TryGetValue("solution", out var solutionNode) && - solutionNode is MappingDataNode solutionMap) - { - // Only set canReact to false for ChemMaster buffers - // Check if the solution name is "buffer" - if (solutionMap.TryGetValue("name", out var nameNode) && - nameNode is ValueDataNode nameValue && - nameValue.Value == "buffer") - { - // This is a ChemMaster buffer - prevent mixing - solutionMap["canReact"] = new ValueDataNode("false"); - Logger.GetSawmill("hardlight").Info("Set ChemMaster buffer to non-reactive"); - } - } - } - - // ReagentDispenser: Clear storage slot data to force regeneration - if (typeName == "ReagentDispenser") - { - compMap.Remove("storageSlots"); - compMap.Remove("storageSlotIds"); - compMap.Remove("autoLabel"); - - Logger.GetSawmill("hardlight").Info("Cleared ReagentDispenser storage slots for regeneration"); - } - newComps.Add(compMap); - } - - // HardLight start - // If we found a paint style prototype from SprayPainted but the Appearance component was missing, - // add an Appearance component with the paint style data so the ship saves with the correct paint appearance. - if (paintStylePrototype != null && !HasComponentNode(newComps, "Appearance")) - { - var appearanceComp = new MappingDataNode - { - ["type"] = new ValueDataNode("Appearance") - }; - ApplyPaintStyleToAppearance(appearanceComp, paintStylePrototype); - newComps.Add(appearanceComp); - } - - // If a prototype contains components that are forced missing, - // we need to remove those components from all entities of that prototype below and log them for debugging. - if (removedFromPrototype != null && removedFromPrototype.Count > 0 || protoMissing != null && protoMissing.Count > 0) - { - var existingMissing = new HashSet(StringComparer.Ordinal); - if (entMap.TryGet("missingComponents", out SequenceDataNode? missingNode) && missingNode != null) - { - foreach (var missing in missingNode) - { - if (missing is ValueDataNode value) - existingMissing.Add(value.Value); - } - } - - var mergedSet = new HashSet(existingMissing, StringComparer.Ordinal); - if (removedFromPrototype != null) - { - foreach (var name in removedFromPrototype) - mergedSet.Add(name); - } - - if (protoMissing != null) - { - foreach (var name in protoMissing) - mergedSet.Add(name); - } - - // If this entity is allowed to have fill components, - // make sure they aren't marked as missing even if the prototype has them forced missing. - if (allowFillComponents) - { - foreach (var name in fillComponentTypes) - mergedSet.Remove(name); - } - - if (mergedSet.Count > 0) - { - var mergedMissing = new SequenceDataNode(); - foreach (var name in mergedSet) - mergedMissing.Add(new ValueDataNode(name)); - entMap["missingComponents"] = mergedMissing; - } - } - // HardLight end - - if (newComps.Count > 0) - { - entMap["components"] = newComps; - } - else - { - // No components left; remove the entire entity - if (entMap.TryGet("uid", out ValueDataNode? removedUidNode) && removedUidNode != null && !removedUidNode.IsNull) // HardLight - removedEntityUids.Add(removedUidNode.Value); // HardLight - entitiesSeq.RemoveAt(i); - i--; - } - } - } - - // HardLight: Remove stale references to any entities deleted above. - // Without this, parent storages/containers can retain dangling links. - PruneContainerReferencesToRemovedEntities(protoSeq, removedEntityUids); // HardLight - } - - // HardLight start: Helper method to scan components for a SprayPainted component and extract the painted prototype if it exists, - // so we can apply that paint style to the Appearance component for proper saving. - private static string? GetPaintStylePrototype(SequenceDataNode components) - { - foreach (var compNode in components) - { - if (compNode is not MappingDataNode compMap) - continue; - - if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null || typeNode.Value != "SprayPainted") - continue; - - if (!compMap.TryGet("paintedPrototype", out ValueDataNode? styleNode) || styleNode == null) - continue; - - if (!string.IsNullOrWhiteSpace(styleNode.Value)) - return styleNode.Value; - } - - return null; - } - - private static bool HasComponentNode(SequenceDataNode components, string componentType) - { - foreach (var compNode in components) - { - if (compNode is not MappingDataNode compMap) - continue; - - if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null) - continue; - - if (typeNode.Value == componentType) - return true; - } - - return false; - } - - private HashSet CollectBlockedContainedImplantEntityUids(SequenceDataNode protoSeq) - { - var blockedImplantUids = new HashSet(StringComparer.Ordinal); - - foreach (var protoNode in protoSeq) - { - if (protoNode is not MappingDataNode protoMap) - continue; - - if (!protoMap.TryGet("entities", out SequenceDataNode? entitiesSeq) || entitiesSeq == null) - continue; - - var protoIsBlockedImplant = false; - if (protoMap.TryGet("proto", out ValueDataNode? protoIdNode) - && protoIdNode != null - && !protoIdNode.IsNull) - { - protoIsBlockedImplant = BlockedContainedImplantPrototypes.Contains(protoIdNode.Value); - } - - foreach (var entityNode in entitiesSeq) - { - if (entityNode is not MappingDataNode entMap) - continue; - - if (!entMap.TryGet("uid", out ValueDataNode? uidNode) || uidNode == null || uidNode.IsNull) - continue; - - if (protoIsBlockedImplant) - blockedImplantUids.Add(uidNode.Value); - } - } - - return blockedImplantUids; - } - - private static bool HasBlockedContainedImplant(MappingDataNode entMap, HashSet blockedContainedImplantEntityUids) - { - if (blockedContainedImplantEntityUids.Count == 0) - return false; - - if (!entMap.TryGet("components", out SequenceDataNode? components) || components == null) - return false; - - foreach (var compNode in components) - { - if (compNode is not MappingDataNode compMap) - continue; - - if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null || typeNode.Value != "ContainerContainer") - continue; - - if (!compMap.TryGet("containers", out MappingDataNode? containersMap) || containersMap == null) - continue; - - if (!containersMap.TryGet("implanter_slot", out MappingDataNode? slotMap) || slotMap == null) - continue; - - if (slotMap.TryGet("ent", out ValueDataNode? entNode) && entNode != null && !entNode.IsNull && blockedContainedImplantEntityUids.Contains(entNode.Value)) - return true; - - if (!slotMap.TryGet("ents", out SequenceDataNode? entsNode) || entsNode == null) - continue; - - foreach (var entry in entsNode) - { - if (entry is not ValueDataNode valueNode || valueNode.IsNull) - continue; - - if (blockedContainedImplantEntityUids.Contains(valueNode.Value)) - return true; - } - } - - return false; - } - - private static void PruneContainerReferencesToRemovedEntities(SequenceDataNode protoSeq, HashSet removedEntityUids) - { - if (removedEntityUids.Count == 0) - return; - - foreach (var protoNode in protoSeq) - { - if (protoNode is not MappingDataNode protoMap) - continue; - - if (!protoMap.TryGet("entities", out SequenceDataNode? entitiesSeq) || entitiesSeq == null) - continue; - - foreach (var entityNode in entitiesSeq) - { - if (entityNode is not MappingDataNode entMap) - continue; - - if (!entMap.TryGet("components", out SequenceDataNode? comps) || comps == null) - continue; - - foreach (var compNode in comps) - { - if (compNode is not MappingDataNode compMap) - continue; - - if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null) - continue; - - var componentType = typeNode.Value; - - if (componentType == "ContainerContainer") - { - if (!compMap.TryGet("containers", out MappingDataNode? containersMap) || containersMap == null) - continue; - - foreach (var (_, containerNode) in containersMap) - { - if (containerNode is not MappingDataNode containerMap) - continue; - - if (containerMap.TryGet("ents", out SequenceDataNode? entsNode) && entsNode != null) - { - for (var idx = entsNode.Count - 1; idx >= 0; idx--) - { - if (entsNode[idx] is not ValueDataNode entValue || entValue.IsNull) - continue; - - if (removedEntityUids.Contains(entValue.Value)) - entsNode.RemoveAt(idx); - } - } - - if (containerMap.TryGet("ent", out ValueDataNode? entNode) && entNode != null && !entNode.IsNull) - { - if (removedEntityUids.Contains(entNode.Value)) - containerMap["ent"] = ValueDataNode.Null(); - } - } - - continue; - } - - // Storage component also serializes entity UID references in storedItems keys. - if (componentType == "Storage" && compMap.TryGet("storedItems", out MappingDataNode? storedItemsMap) && storedItemsMap != null) - { - var removeKeys = new List(); - foreach (var (itemUid, _) in storedItemsMap) - { - if (removedEntityUids.Contains(itemUid)) - removeKeys.Add(itemUid); - } - - foreach (var key in removeKeys) - { - storedItemsMap.Remove(key); - } - } - } - } - } + ShipSaveYamlSanitizer.SanitizeShipSaveNode(root, _prototypeManager); // HardLight } - private static void ApplyPaintStyleToAppearance(MappingDataNode appearanceComp, string stylePrototype) - { - MappingDataNode appearanceDataInit; - if (appearanceComp.TryGet("appearanceDataInit", out MappingDataNode? existing) && existing != null) - { - appearanceDataInit = existing; - } - else - { - appearanceDataInit = new MappingDataNode(); - appearanceComp["appearanceDataInit"] = appearanceDataInit; - } - - appearanceDataInit["enum.PaintableVisuals.Prototype"] = new ValueDataNode(stylePrototype); - } - // HardLight end - private string WriteYamlToString(MappingDataNode node) { // Based on MapLoaderSystem.Write but to a string instead of file diff --git a/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.Consoles.cs b/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.Consoles.cs index 3cb51d4e754..d2bd8b8365f 100644 --- a/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.Consoles.cs +++ b/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.Consoles.cs @@ -496,7 +496,14 @@ public void OnLoadMessage(EntityUid uid, ShipyardConsoleComponent component, Shi bool loaded = false; try { - if (!string.IsNullOrWhiteSpace(args.SourceFilePath)) + // HardLight start + if (!string.IsNullOrWhiteSpace(args.YamlData)) + { + loaded = TryPurchaseShuttleFromYamlData(uid, args.YamlData, out shuttleUidOut); + } + + if (!loaded && !string.IsNullOrWhiteSpace(args.SourceFilePath)) + // HardLight end { // Normalize to a ResPath under /UserData var norm = args.SourceFilePath!.Replace('\\', '/'); @@ -508,12 +515,6 @@ public void OnLoadMessage(EntityUid uid, ShipyardConsoleComponent component, Shi var resPath = new ResPath(norm); loaded = TryPurchaseShuttleFromFile(uid, resPath, out shuttleUidOut); } - - // Fallback: write to a temp file and then load via purchase-from-file - if (!loaded) - { - loaded = TryPurchaseShuttleFromYamlData(uid, args.YamlData, out shuttleUidOut); - } } catch (Exception ex) { diff --git a/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.cs b/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.cs index 47075b24817..f624c949607 100644 --- a/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.cs +++ b/Content.Server/_NF/Shipyard/Systems/ShipyardSystem.cs @@ -2,6 +2,7 @@ using Content.Server.Shuttles.Components; using Content.Shared.Station.Components; using Content.Server.Cargo.Systems; +using Content.Server.Shuttles.Save; // HardLight using Robust.Shared.Timing; // For IGameTiming using Content.Server.Station.Systems; using Content.Shared._NF.Shipyard.Components; @@ -12,8 +13,16 @@ using Content.Shared._NF.CCVar; using Robust.Shared.Configuration; using System.Diagnostics.CodeAnalysis; +using System.IO; // HardLight using System.Linq; using System.Numerics; +using System.Text; // HardLight +using System.Text.RegularExpressions; // HardLight +using Robust.Shared.Serialization; // HardLight +using Robust.Shared.Serialization.Markdown; // HardLight +using Robust.Shared.Serialization.Markdown.Mapping; // HardLight +using Robust.Shared.Serialization.Markdown.Sequence; // HardLight +using Robust.Shared.Serialization.Markdown.Value; // HardLight using Content.Shared._NF.Shipyard.Events; using Content.Shared.Mobs.Components; using Robust.Shared.Containers; @@ -27,11 +36,31 @@ using Robust.Shared.Physics; using Robust.Shared.Map.Components; using Robust.Shared.Physics.Components; // For GravitySystem +using Robust.Shared.Map.Events; // HardLight +using YamlDotNet.Core; // HardLight +using YamlDotNet.RepresentationModel; // HardLight namespace Content.Server._NF.Shipyard.Systems; public sealed partial class ShipyardSystem : SharedShipyardSystem { + private static readonly Regex ShipSaveProtoLineRegex = new(@"^(\s*)- proto:\s*(.+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant); // HardLight + private static readonly Regex ShipSaveUidLineRegex = new(@"^\s*- uid:\s*\d+", RegexOptions.Compiled | RegexOptions.CultureInvariant); // HardLight + private static readonly Regex ShipSaveUidCaptureLineRegex = new(@"^\s*-\s*uid:\s*(\d+)\b", RegexOptions.Compiled | RegexOptions.CultureInvariant); // HardLight + private static readonly Regex ShipSaveEntitiesSectionRegex = new(@"^(\s*)entities\s*:\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant); // HardLight + private static readonly Regex ShipSaveLegacyUidLineRegex = new(@"^(\s*)-\s*uid\s*:\s*\d+\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant); // HardLight + private static readonly Regex ShipSaveLegacyTypeLineRegex = new(@"^\s*type\s*:\s*(.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant); // HardLight + + // HardLight: Set of tokens that, if found as UIDs in the YAML, indicate a stale or invalid UID + // that should be sanitized during load to prevent deserialization failures. + private static readonly HashSet StaleSerializedUidTokens = new(StringComparer.OrdinalIgnoreCase) + { + "invalid", + "null", + "~", + "0", + }; + [Dependency] private readonly IConfigurationManager _configManager = default!; [Dependency] private readonly DockingSystem _docking = default!; [Dependency] private readonly PricingSystem _pricing = default!; @@ -48,6 +77,7 @@ public sealed partial class ShipyardSystem : SharedShipyardSystem [Dependency] private readonly UseDelaySystem _useDelay = default!; [Dependency] private readonly EntityLookupSystem _lookup = default!; [Dependency] private readonly IGameTiming _timing = default!; // For cooldown timing + [Dependency] private readonly ShipSerializationSystem _shipSerialization = default!; // HardLight private EntityQuery _transformQuery; @@ -58,6 +88,9 @@ public sealed partial class ShipyardSystem : SharedShipyardSystem private bool _enabled; private float _baseSaleRate; private readonly Dictionary _lastLoadCharge = new(); // Per-player load charge cooldown + private readonly Dictionary _shipyardActionDelayUntil = new(); // HardLight + private static readonly TimeSpan ShipyardActionDelay = TimeSpan.FromSeconds(1); // HardLight + private HashSet? _activeLoadDeletedPrototypes; // HardLight // The type of error from the attempted sale of a ship. public enum ShipyardSaleError @@ -101,6 +134,7 @@ public override void Initialize() SubscribeLocalEvent(OnItemSlotChanged); SubscribeLocalEvent(OnRoundRestart); SubscribeLocalEvent(OnInitDeedSpawner); + SubscribeLocalEvent(OnBeforeEntityRead); // HardLight } public override void Shutdown() @@ -120,6 +154,19 @@ private void OnRoundRestart(RoundRestartCleanupEvent ev) CleanupShipyard(); } + // HardLight: Injects deleted prototype IDs into a temporary set used during ship load to allow YAML loads to succeed by ignoring missing prototypes, + // while still logging their absence for debugging and future cleanup. + private void OnBeforeEntityRead(BeforeEntityReadEvent ev) + { + if (_activeLoadDeletedPrototypes == null || _activeLoadDeletedPrototypes.Count == 0) + return; + + foreach (var prototypeId in _activeLoadDeletedPrototypes) + { + ev.DeletedPrototypes.Add(prototypeId); + } + } + private void SetShipyardEnabled(bool value) { if (_enabled == value) @@ -205,30 +252,215 @@ public bool TryPurchaseShuttle(EntityUid consoleUid, ResPath shuttlePath, [NotNu /// The EntityUid of the shuttle that was loaded public bool TryPurchaseShuttleFromFile(EntityUid consoleUid, ResPath shuttlePath, [NotNullWhen(true)] out EntityUid? shuttleEntityUid) { - // Get the grid the console is on - if (!_transformQuery.TryComp(consoleUid, out var consoleXform) || consoleXform.GridUid == null) + if (!TryAddShuttle(shuttlePath, out var shuttleGrid)) // HardLight { shuttleEntityUid = null; return false; } - if (!TryAddShuttle(shuttlePath, out var shuttleGrid)) + return TryFinalizeLoadedShuttle(consoleUid, shuttleGrid.Value, out shuttleEntityUid); // HardLight + } + + /// + /// HardLight: Loads a shuttle into the ShipyardMap from a file path + /// + /// The path to the grid file to load. Must be a grid file! + /// Returns the EntityUid of the shuttle + private bool TryAddShuttle(ResPath shuttlePath, [NotNullWhen(true)] out EntityUid? shuttleGrid) + { + shuttleGrid = null; + SetupShipyardIfNeeded(); + if (ShipyardMap == null) + return false; + + if (!_mapLoader.TryLoadGrid(ShipyardMap.Value, shuttlePath, out var grid, offset: new Vector2(500f + _shuttleIndex, 1f))) { - shuttleEntityUid = null; + //_sawmill.Error($"Unable to spawn shuttle {shuttlePath}"); return false; } - var grid = shuttleGrid.Value; + _shuttleIndex += grid.Value.Comp.LocalAABB.Width + ShuttleSpawnBuffer; - if (!TryComp(grid, out var shuttleComponent)) + shuttleGrid = grid.Value.Owner; + return true; + } + + /// + /// HardLight: Writes YAML data to a temporary file and attempts the same initial strict load path as purchase-from-file. + /// If that fails, applies compatibility recovery stages before falling back to tolerant ship-data reconstruction. + /// + private bool TryPurchaseShuttleFromYamlData(EntityUid consoleUid, string yamlData, [NotNullWhen(true)] out EntityUid? shuttleEntityUid) + { + shuttleEntityUid = null; + ResPath tempPath = default; + try { - shuttleEntityUid = null; + // Create a temp path under UserData/ShipyardTemp + var fileName = $"shipyard_load_{DateTime.UtcNow:yyyyMMdd_HHmmss_fff}_{Guid.NewGuid():N}.yml"; + var dir = new ResPath("/") / "UserData" / "ShipyardTemp"; + tempPath = dir / fileName; + + // Ensure directory exists and write file + _resources.UserData.CreateDir(dir); + using (var writer = _resources.UserData.OpenWriteText(tempPath)) + { + writer.Write(yamlData); + } + + // Fast path: strict load with original YAML; no extra scanning work. + if (TryPurchaseShuttleFromFileSafe(consoleUid, tempPath, out shuttleEntityUid)) + return true; + + _sawmill.Debug("[ShipLoad] Strict grid YAML load failed; attempting compatibility recovery stages."); + + var recoveryYaml = yamlData; + + // Recovery stage A: sanitize missing prototypes and inject deleted prototype IDs. + var sanitizedYaml = SanitizeLoadYamlMissingPrototypes(yamlData, out var removedProtoBlocks, out var removedEntities); + var deletedPrototypeIds = FindMissingPrototypeIdsForLoad(sanitizedYaml); + var needsSanitizedRetry = removedProtoBlocks > 0 + || deletedPrototypeIds.Count > 0 + || !string.Equals(sanitizedYaml, yamlData, StringComparison.Ordinal); + + if (needsSanitizedRetry) + { + if (removedProtoBlocks > 0) + { + _sawmill.Warning($"[ShipLoad] Removed {removedProtoBlocks} invalid prototype block(s) containing {removedEntities} entities from ship YAML before load."); + } + + if (deletedPrototypeIds.Count > 0) + { + _sawmill.Warning($"[ShipLoad] Ignoring {deletedPrototypeIds.Count} missing prototype id(s) during ship load."); + } + + _activeLoadDeletedPrototypes = deletedPrototypeIds.Count > 0 ? deletedPrototypeIds : null; + recoveryYaml = sanitizedYaml; + + using (var retryWriter = _resources.UserData.OpenWriteText(tempPath)) + { + retryWriter.Write(recoveryYaml); + } + + if (TryPurchaseShuttleFromFileSafe(consoleUid, tempPath, out shuttleEntityUid)) + return true; + } + + // Recovery path: strip serialized component payloads and retry strict load. + // This salvages ships when component schemas changed between versions. + var strippedYaml = StripSerializedComponentsForRecovery(recoveryYaml); + if (!string.Equals(strippedYaml, recoveryYaml, StringComparison.Ordinal)) + { + using (var retryWriter = _resources.UserData.OpenWriteText(tempPath)) + { + retryWriter.Write(strippedYaml); + } + + if (TryPurchaseShuttleFromFileSafe(consoleUid, tempPath, out shuttleEntityUid)) + { + _sawmill.Warning("[ShipLoad] Loaded ship after stripping serialized component payloads for compatibility recovery."); + return true; + } + + _sawmill.Debug("[ShipLoad] Component-stripped recovery load failed."); + } + + // Fallback: ship-data YAML path tolerates per-entity failures and skips bad entities. + if (TryPurchaseShuttleFromShipDataYaml(consoleUid, recoveryYaml, out shuttleEntityUid)) + return true; + + _sawmill.Warning("[ShipLoad] Ship-data tolerant fallback also failed."); + return false; } + catch (Exception ex) + { + _sawmill.Warning($"Failed to purchase shuttle from YAML data: {ex.Message}"); // HardLight: Error(grid, out var gridComp)) + { + _sawmill.Warning("[ShipLoad] Ship-data fallback created no grid component."); + return false; + } + + _shuttleIndex += gridComp.LocalAABB.Width + ShuttleSpawnBuffer; + + if (!TryFinalizeLoadedShuttle(consoleUid, grid, out shuttleEntityUid)) + { + SafeDelete(grid); + return false; + } + + _sawmill.Info("[ShipLoad] Loaded ship via tolerant ship-data fallback path."); + return true; + } + catch (Exception ex) + { + _sawmill.Warning($"[ShipLoad] Ship-data fallback failed: {ex.Message}"); + return false; + } + } + + // HardLight: Performs final setup and docking for a loaded shuttle, with error handling to prevent load crashes. + private bool TryFinalizeLoadedShuttle(EntityUid consoleUid, EntityUid grid, [NotNullWhen(true)] out EntityUid? shuttleEntityUid) + { + shuttleEntityUid = null; - //_sawmill.Info($"Shuttle loaded from file {shuttlePath} at {ToPrettyString(consoleUid)}"); + // Get the grid the console is on + if (!_transformQuery.TryComp(consoleUid, out var consoleXform) || consoleXform.GridUid == null) + return false; + + if (!TryComp(grid, out var shuttleComponent)) + return false; + + var targetGrid = consoleXform.GridUid.Value; // Ensure required components for docking and identification EnsureComp(grid); @@ -268,74 +500,484 @@ public bool TryPurchaseShuttleFromFile(EntityUid consoleUid, ResPath shuttlePath } /// - /// Loads a shuttle into the ShipyardMap from a file path + /// HardLight: Removes serialized entity groups whose prototype IDs no longer exist in code. + /// This lets old ship exports load even after content deprecations. /// - /// The path to the grid file to load. Must be a grid file! - /// Returns the EntityUid of the shuttle - private bool TryAddShuttle(ResPath shuttlePath, [NotNullWhen(true)] out EntityUid? shuttleGrid) + private string SanitizeLoadYamlMissingPrototypes(string yamlData, out int removedPrototypeBlocks, out int removedEntities) { - shuttleGrid = null; - SetupShipyardIfNeeded(); - if (ShipyardMap == null) - return false; + removedPrototypeBlocks = 0; + removedEntities = 0; - if (!_mapLoader.TryLoadGrid(ShipyardMap.Value, shuttlePath, out var grid, offset: new Vector2(500f + _shuttleIndex, 1f))) + if (string.IsNullOrWhiteSpace(yamlData)) + return yamlData; + + // Quick exit: if no grouped entity prototype declarations exist there is nothing to strip here. + if (yamlData.IndexOf("- proto:", StringComparison.Ordinal) < 0) + return yamlData; + + var normalized = yamlData.Replace("\r\n", "\n"); + var lines = normalized.Split('\n'); + var output = new StringBuilder(normalized.Length); + var removedEntityUids = new HashSet(StringComparer.Ordinal); + + for (var i = 0; i < lines.Length; i++) { - //_sawmill.Error($"Unable to spawn shuttle {shuttlePath}"); - return false; + var line = lines[i]; + var protoMatch = ShipSaveProtoLineRegex.Match(line); + + if (!protoMatch.Success) + { + output.AppendLine(line); + continue; + } + + var indent = protoMatch.Groups[1].Value; + var rawProto = protoMatch.Groups[2].Value; + var commentIndex = rawProto.IndexOf('#'); + if (commentIndex >= 0) + rawProto = rawProto[..commentIndex]; + + var protoId = rawProto.Trim().Trim('"', '\''); + + // Empty proto blocks contain runtime/synthetic entities and should be retained. + var keepBlock = string.IsNullOrWhiteSpace(protoId) || _prototypeManager.TryIndex(protoId, out _); + if (keepBlock) + { + output.AppendLine(line); + continue; + } + + removedPrototypeBlocks++; + + // Skip this whole proto block until the next proto declaration at the same indentation level. + for (i += 1; i < lines.Length; i++) + { + var blockLine = lines[i]; + if (ShipSaveUidLineRegex.IsMatch(blockLine)) + { + removedEntities++; + var uidMatch = ShipSaveUidCaptureLineRegex.Match(blockLine); + if (uidMatch.Success) + removedEntityUids.Add(uidMatch.Groups[1].Value); + } + + var nextProto = ShipSaveProtoLineRegex.Match(blockLine); + if (!nextProto.Success) + continue; + + var nextIndent = nextProto.Groups[1].Value; + if (nextIndent != indent) + continue; + + i -= 1; + break; + } } - _shuttleIndex += grid.Value.Comp.LocalAABB.Width + ShuttleSpawnBuffer; + var sanitizedYaml = output.ToString(); + if (removedEntityUids.Count == 0) + return sanitizedYaml; - shuttleGrid = grid.Value.Owner; - return true; + // Keep parent containers while removing only missing-prototype entities by pruning stale UID references. + return PruneLoadYamlReferencesToRemovedEntities(sanitizedYaml, removedEntityUids); } /// - /// Writes YAML data to a temporary file and loads it using the exact same method as purchasing a shuttle from a file. - /// Ensures identical setup/docking logic. + /// HardLight: Removes stale references to entities stripped during missing-prototype sanitation. + /// This preserves container/storage owner entities when only their contained items were removed. /// - private bool TryPurchaseShuttleFromYamlData(EntityUid consoleUid, string yamlData, [NotNullWhen(true)] out EntityUid? shuttleEntityUid) + private static string PruneLoadYamlReferencesToRemovedEntities(string yamlData, HashSet removedEntityUids) { - shuttleEntityUid = null; - ResPath tempPath = default; + if (string.IsNullOrWhiteSpace(yamlData) || removedEntityUids.Count == 0) + return yamlData; + + // Structured path: parse YAML into a data node tree and prune stale UID references precisely. + // Falls back to line-based pruning if parsing fails on malformed legacy input. try { - // Create a temp path under UserData/ShipyardTemp - var fileName = $"shipyard_load_{DateTime.UtcNow:yyyyMMdd_HHmmss_fff}_{Guid.NewGuid():N}.yml"; - var dir = new ResPath("/") / "UserData" / "ShipyardTemp"; - tempPath = dir / fileName; + using var reader = new StringReader(yamlData); + var documents = DataNodeParser.ParseYamlStream(reader).ToArray(); + if (documents.Length != 1 || documents[0].Root is not MappingDataNode root) + return PruneLoadYamlReferencesToRemovedEntitiesLineBased(yamlData, removedEntityUids); - // Ensure directory exists and write file - _resources.UserData.CreateDir(dir); - using (var writer = _resources.UserData.OpenWriteText(tempPath)) + PruneLoadNodeReferencesToRemovedEntities(root, removedEntityUids); + return WriteLoadYamlNodeToString(root); + } + catch + { + return PruneLoadYamlReferencesToRemovedEntitiesLineBased(yamlData, removedEntityUids); + } + } + + // HardLight: Structured node pass that prunes stale/invalid UID references from container/storage data during load recovery. + private static void PruneLoadNodeReferencesToRemovedEntities(MappingDataNode root, HashSet removedEntityUids) + { + if (!root.TryGet("entities", out SequenceDataNode? protoSeq) || protoSeq == null) + return; + + foreach (var protoNode in protoSeq) + { + if (protoNode is not MappingDataNode protoMap) + continue; + + if (!protoMap.TryGet("entities", out SequenceDataNode? entitiesSeq) || entitiesSeq == null) + continue; + + foreach (var entityNode in entitiesSeq) { - writer.Write(yamlData); - } + if (entityNode is not MappingDataNode entMap) + continue; - // Reuse purchase-from-file flow - if (!TryPurchaseShuttleFromFile(consoleUid, tempPath, out shuttleEntityUid)) - return false; + if (!entMap.TryGet("components", out SequenceDataNode? comps) || comps == null) + continue; - return true; + foreach (var compNode in comps) + { + if (compNode is not MappingDataNode compMap) + continue; + + if (!compMap.TryGet("type", out ValueDataNode? typeNode) || typeNode == null) + continue; + + var componentType = typeNode.Value; + + if (componentType == "ContainerContainer") + { + if (!compMap.TryGet("containers", out MappingDataNode? containersMap) || containersMap == null) + continue; + + foreach (var (_, containerNode) in containersMap) + { + if (containerNode is not MappingDataNode containerMap) + continue; + + if (containerMap.TryGet("ents", out SequenceDataNode? entsNode) && entsNode != null) + { + for (var idx = entsNode.Count - 1; idx >= 0; idx--) + { + if (entsNode[idx] is not ValueDataNode entValue || entValue.IsNull) + continue; + + if (IsStaleSerializedUidReference(entValue.Value, removedEntityUids)) + entsNode.RemoveAt(idx); + } + } + + if (containerMap.TryGet("ent", out ValueDataNode? entNode) && entNode != null && !entNode.IsNull) + { + if (IsStaleSerializedUidReference(entNode.Value, removedEntityUids)) + containerMap["ent"] = ValueDataNode.Null(); + } + } + + continue; + } + + if (componentType != "Storage" + || !compMap.TryGet("storedItems", out MappingDataNode? storedItemsMap) + || storedItemsMap == null) + { + continue; + } + + var removeKeys = new List(); + foreach (var (itemUid, _) in storedItemsMap) + { + if (IsStaleSerializedUidReference(itemUid, removedEntityUids)) + removeKeys.Add(itemUid); + } + + foreach (var key in removeKeys) + storedItemsMap.Remove(key); + } + } } - catch (Exception ex) + } + + // HardLight: Serializes a YAML data node tree back into a string, ensuring consistent formatting. + private static string WriteLoadYamlNodeToString(MappingDataNode root) + { + var document = new YamlDocument(root.ToYaml()); + using var writer = new StringWriter(); + var stream = new YamlStream { document }; + stream.Save(new YamlMappingFix(new Emitter(writer)), false); + return writer.ToString(); + } + + // HardLight: Line-based fallback for pruning stale UID references when YAML is too malformed for structured parsing. + private static string PruneLoadYamlReferencesToRemovedEntitiesLineBased(string yamlData, HashSet removedEntityUids) + { + if (string.IsNullOrWhiteSpace(yamlData)) + return yamlData; + + var normalized = yamlData.Replace("\r\n", "\n"); + var lines = normalized.Split('\n'); + var output = new StringBuilder(normalized.Length); + + var entsIndent = -1; + var storedItemsIndent = -1; + var skipSubtreeIndent = -1; + + for (var i = 0; i < lines.Length; i++) { - _sawmill.Warning($"Failed to purchase shuttle from YAML data: {ex.Message}"); // HardLight: Error= 0) + { + if (trimmed.Length == 0) + continue; + + if (indent > skipSubtreeIndent) + continue; + + skipSubtreeIndent = -1; + } + + if (trimmed.Length == 0) + { + output.AppendLine(line); + continue; + } + + if (entsIndent >= 0 && indent <= entsIndent) + entsIndent = -1; + + if (storedItemsIndent >= 0 && indent <= storedItemsIndent) + storedItemsIndent = -1; + + if (trimmed.StartsWith("ents:", StringComparison.Ordinal)) + { + entsIndent = indent; + output.AppendLine(line); + continue; + } + + if (trimmed.StartsWith("storedItems:", StringComparison.Ordinal)) + { + storedItemsIndent = indent; + output.AppendLine(line); + continue; + } + + // Prune sequence entries in ContainerContainer.ents lists. + if (entsIndent >= 0 && indent > entsIndent) + { + var listEntry = trimmed; + if (listEntry.StartsWith("- ", StringComparison.Ordinal)) + listEntry = listEntry[2..].Trim(); + + if (IsStaleSerializedUidReference(listEntry, removedEntityUids)) + continue; + } + + // Null stale single-reference entries in ContainerContainer.ent fields. + if (trimmed.StartsWith("ent:", StringComparison.Ordinal)) + { + var entValue = trimmed[4..].Trim(); + if (IsStaleSerializedUidReference(entValue, removedEntityUids)) + { + output.Append(' ', indent); + output.AppendLine("ent: null"); + continue; + } + } + + // Remove Storage.storedItems entries keyed by removed entity UID. + if (storedItemsIndent >= 0 && indent > storedItemsIndent) + { + var keySpan = trimmed; + var colonIndex = keySpan.IndexOf(':'); + if (colonIndex > 0) + { + var rawKey = keySpan[..colonIndex].Trim().Trim('"', '\''); + if (IsStaleSerializedUidReference(rawKey, removedEntityUids)) + { + skipSubtreeIndent = indent; + continue; + } + } + } + + output.AppendLine(line); + } + + return output.ToString(); + } + + // HardLight: Checks if a UID token from YAML matches known patterns of stale references to entities removed during load sanitation. + private static bool IsStaleSerializedUidReference(string uidToken, HashSet removedEntityUids) + { + var normalized = uidToken.Trim().Trim('"', '\''); + if (normalized.Length == 0) return false; + + if (removedEntityUids.Contains(normalized)) + return true; + + return StaleSerializedUidTokens.Contains(normalized); + } + + /// + /// HardLight: Finds missing prototype IDs referenced by ship YAML in both grouped (proto) and legacy (uid/type) entity formats. + /// Returned IDs are used as a temporary deleted-prototype map during this specific load operation. + /// + private HashSet FindMissingPrototypeIdsForLoad(string yamlData) + { + var missing = new HashSet(); + + if (string.IsNullOrWhiteSpace(yamlData)) + return missing; + + // Quick exit when there is no entities section. + if (yamlData.IndexOf("entities:", StringComparison.OrdinalIgnoreCase) < 0) + return missing; + + var normalized = yamlData.Replace("\r\n", "\n"); + var lines = normalized.Split('\n'); + + var inEntities = false; + var entitiesIndent = -1; + var currentLegacyEntityIndent = -1; + + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + var trimmed = line.TrimStart(); + + if (trimmed.Length == 0 || trimmed.StartsWith('#')) + continue; + + var indent = line.Length - trimmed.Length; + var entitiesSection = ShipSaveEntitiesSectionRegex.Match(line); + if (!inEntities && entitiesSection.Success) + { + inEntities = true; + entitiesIndent = entitiesSection.Groups[1].Value.Length; + continue; + } + + if (!inEntities) + continue; + + // Left the entities section. + if (indent <= entitiesIndent) + { + inEntities = false; + currentLegacyEntityIndent = -1; + continue; + } + + // Grouped format: "- proto: " + var protoMatch = ShipSaveProtoLineRegex.Match(line); + if (protoMatch.Success) + { + var protoId = ParseShipSavePrototypeValue(protoMatch.Groups[2].Value); + if (!string.IsNullOrWhiteSpace(protoId) + && !_prototypeManager.TryIndex(protoId, out _)) + { + missing.Add(protoId); + } + + currentLegacyEntityIndent = -1; + continue; + } + + // Legacy format entity boundary: "- uid: " + var uidMatch = ShipSaveLegacyUidLineRegex.Match(line); + if (uidMatch.Success) + { + currentLegacyEntityIndent = uidMatch.Groups[1].Value.Length; + continue; + } + + // If we're no longer in the current legacy entity block, clear it. + if (currentLegacyEntityIndent >= 0 && indent <= currentLegacyEntityIndent) + { + currentLegacyEntityIndent = -1; + } + + if (currentLegacyEntityIndent < 0) + continue; + + // Legacy format prototype: "type: " under the current uid block. + var legacyTypeMatch = ShipSaveLegacyTypeLineRegex.Match(line); + if (!legacyTypeMatch.Success) + continue; + + var legacyProtoId = ParseShipSavePrototypeValue(legacyTypeMatch.Groups[1].Value); + if (!string.IsNullOrWhiteSpace(legacyProtoId) + && !_prototypeManager.TryIndex(legacyProtoId, out _)) + { + missing.Add(legacyProtoId); + } } - finally + + return missing; + } + + // HardLight: Extracts the prototype ID from a raw YAML line value, + // stripping comments and extraneous whitespace/quotes. + private static string ParseShipSavePrototypeValue(string rawValue) + { + var commentIndex = rawValue.IndexOf('#'); + if (commentIndex >= 0) + rawValue = rawValue[..commentIndex]; + + return rawValue.Trim().Trim('"', '\''); + } + + /// + /// HardLight: Best-effort compatibility recovery; remove serialized component/missingComponents blocks so + /// entities can fall back to prototype defaults when component schemas drift across versions. + /// + private string StripSerializedComponentsForRecovery(string yamlData) + { + if (string.IsNullOrWhiteSpace(yamlData)) + return yamlData; + + if (yamlData.IndexOf("components:", StringComparison.Ordinal) < 0 + && yamlData.IndexOf("missingComponents:", StringComparison.Ordinal) < 0) + return yamlData; + + var normalized = yamlData.Replace("\r\n", "\n"); + var lines = normalized.Split('\n'); + var output = new StringBuilder(normalized.Length); + + for (var i = 0; i < lines.Length; i++) { - try + var line = lines[i]; + var trimmed = line.TrimStart(); + var indent = line.Length - trimmed.Length; + + var isComponentsStart = trimmed.StartsWith("components:", StringComparison.Ordinal) + || trimmed.StartsWith("missingComponents:", StringComparison.Ordinal); + if (!isComponentsStart) { - if (tempPath != default && _resources.UserData.Exists(tempPath)) - _resources.UserData.Delete(tempPath); + output.AppendLine(line); + continue; } - catch + + // Skip this block and all deeper-indented lines that belong to it. + for (i += 1; i < lines.Length; i++) { - // Best-effort cleanup + var nextLine = lines[i]; + var nextTrimmed = nextLine.TrimStart(); + + if (nextTrimmed.Length == 0) + continue; + + var nextIndent = nextLine.Length - nextTrimmed.Length; + if (nextIndent > indent) + continue; + + i -= 1; + break; } } + + return output.ToString(); // HardLight } /// diff --git a/Content.Shared/NPC/Systems/NpcFactionSystem.Exception.cs b/Content.Shared/NPC/Systems/NpcFactionSystem.Exception.cs index e69f0c2f7ad..a7c9faf3ce7 100644 --- a/Content.Shared/NPC/Systems/NpcFactionSystem.Exception.cs +++ b/Content.Shared/NPC/Systems/NpcFactionSystem.Exception.cs @@ -76,6 +76,9 @@ public IEnumerable GetHostiles(Entity ent /// public void IgnoreEntity(Entity ent, Entity target) { + if (!target.Owner.IsValid() || !EntityManager.EntityExists(target.Owner)) // HardLight + return; + ent.Comp ??= EnsureComp(ent); ent.Comp.Ignored.Add(target); target.Comp ??= EnsureComp(target); @@ -99,6 +102,9 @@ public void IgnoreEntities(Entity ent, IEnumerable public void AggroEntity(Entity ent, Entity target) { + if (!target.Owner.IsValid() || !EntityManager.EntityExists(target.Owner)) // HardLight + return; + ent.Comp ??= EnsureComp(ent); ent.Comp.Hostiles.Add(target); target.Comp ??= EnsureComp(target); From bc423848a720ae116ce7ce9331911e34aaf43a8b Mon Sep 17 00:00:00 2001 From: fenndragon Date: Sun, 29 Mar 2026 16:46:25 -0600 Subject: [PATCH 3/3] abyss laser abyss laser --- .../projectiles.rsi/abyss_laser.png | Bin 0 -> 438 bytes .../projectiles.rsi/impact_abyss_laser.png | Bin 0 -> 1093 bytes .../Projectiles/projectiles.rsi/meta.json | 45 ++++++++++++++++++ .../projectiles.rsi/muzzle_abyss_laser.png | Bin 0 -> 836 bytes 4 files changed, 45 insertions(+) create mode 100644 Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/abyss_laser.png create mode 100644 Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/impact_abyss_laser.png create mode 100644 Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/muzzle_abyss_laser.png diff --git a/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/abyss_laser.png b/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/abyss_laser.png new file mode 100644 index 0000000000000000000000000000000000000000..e6d77eac97859dd00331e83e7b7e7822189b2706 GIT binary patch literal 438 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9F5he4R}c>anMprCY# zYeY$Kep*R+Vo@rCb81d;WwAnVNoh)EzCuWTe$K(=4{I107#%%b978JRyuGzCm&sAU z;o@DpHQXofwghvmY~Y(?S^P{UgTGUT; z22loWhA@UT3=xbQU;>XB`KF8iZMgeb_jv!#IqS9^==GDI|66&k{c#DC14X+|eTanMprCY# zYeY$Kep*R+Vo@rCb81d;WwAnVNoh)EzCuWTe$K(=4{I10m@jy`IEGZrc{^ug&?5(# zWBb2|W!18;TvuAb5S=ln(Qn=X>n(PxcfVTkF01h|=Z49Wvp0Wbl!(%Hc|G~juNXn` zNi!Taan=~r?RT4)_;2>#w|SdRs`ovw6tI!ue4xabqSDaAGGQWvk&uHM(+Lj-Nlpc2 zM%I9JU-ulhkC&N!-m5{i_uLt2UiN?7;s;nZOx&=UNowxRJr5UuWa!`j)-6Ho)Yf_7 zE6ttjrF+?De2dnYTe|kvhGHh4?#Y-iOgXm;Xik`H!Ogn3$o~%u@&oH_X8g~) zHDUR_V5`?rzKt2|H@s$?j%oY7EluRM*%97`pSR{Ml->4KmZ5cxMV7?QkPKZAVTuz97r2N0;x{ z|M>a#@U2(oR>t`zatn&CxNNagJMi*jXK1Z9qndA;Q1apK=B@0DA7ueU&F6iTM(FPB zxtwlQOW$?tZK}&+JoukuW_nW0|GhRRH<-9s+eXv}Z&t8+(cr4V{h|NvvVEVw9uIz{ znP8=`gOAO4*6R~#vzYmM_gt?@(~ysP8>P=2d0XmD`<~Mig7;TN-eSz3bZFgwTEZF+@N8K7e#$D&k0s{+m^}pxU)TKGR8i$5 z{peQWZN|(?Hwz2DdhP9ec+~OTuUU+5Bo`DreA^~g#djez;G3BQ*G{fWb_u4@8~Hn& ztrIW#MSO2!^6B{ATiZ(;aa!2g9X$Ln z&ZCJlb?s(hpE?t*ADjNZiM7A`?u(AcUKUp4IS2PVTmN33agF)xn=j7nvDldw$&w)2 zbC*qs^+;>lW1&N8i*jf4`88*rHe#I3)h_CrV`U}bV7lhi31gO)Nm-L+MHfgOaDC2T xw2Mn=aV>-7+m@3r;shk`@~SFh%U+-5A3wTT>oi}KA6Nu1c)I$ztaD0e0syqI)#(5L literal 0 HcmV?d00001 diff --git a/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/meta.json b/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/meta.json index eb5844fe629..3c66150ca16 100644 --- a/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/meta.json +++ b/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/meta.json @@ -102,6 +102,51 @@ 0.060000002 ] ] + }, + { + "name": "abyss_laser", + "delays": [ + [ + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002 + ] + ] + }, + { + "name": "impact_abyss_laser", + "delays": [ + [ + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002 + ] + ] + }, + { + "name": "muzzle_abyss_laser", + "delays": [ + [ + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002, + 0.060000002 + ] + ] }, { "name": "beamlight", diff --git a/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/muzzle_abyss_laser.png b/Resources/Textures/Objects/Weapons/Guns/Projectiles/projectiles.rsi/muzzle_abyss_laser.png new file mode 100644 index 0000000000000000000000000000000000000000..fba09d4b4b563f7f2fcec1bc362a2ea5826441fb GIT binary patch literal 836 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9F5he4R}c>anMprCY# zYeY$Kep*R+Vo@rCb81d;WwAnVNoh)EzCuWTe$K(=4{I10n9h5;IEGZrd3$H0-|Ya2 zwuk!F?W>BVjW#ZdaR`r?kkG8%;iaz-eB5U*gHRRg%WGabsc%_B*39KeapQTj+_@x2 zy@=yv;h)w|cEK$Z>Ta+16?!SYR-oy@Wu*pj#xooVZVbjuGXxLxFr={oY~%JL&ntIEswyUl-gdQUQX`Q&k;GUKmi&yLUe>(52opyKaZZ+<0u*jK(iM5m{;|JrTAC{2ew z9}2DCJUP5j#dO@uvluiW#(m;)F* MUHx3vIVCg!0F=XI@c;k- literal 0 HcmV?d00001