diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index ff4de6e9b..88ff8c9bf 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -158,7 +158,7 @@ public static class RunSave var named = designs.Where(key => key.IsSet()).Distinct() .Where(key => cache.Get(key) == null) .Select(key => key.Value).OrderBy(key => key, StringComparer.Ordinal).ToArray(); - if (named.Length == 0) return; + if (named.Length == 0) { RequireLayouts(cache, game); return; } const string modHull = "mod-hull:"; var mods = named.Where(key => key.StartsWith(modHull, StringComparison.Ordinal)).Select(key => key.Substring(modHull.Length)).ToArray(); var others = named.Where(key => !key.StartsWith(modHull, StringComparison.Ordinal)).ToArray(); @@ -168,6 +168,35 @@ public static class RunSave ". Reinstall them, or start a new game."); } + // Continue's second gate: a hull's cells or hardpoints edited under a saved run must still hold what the run + // equipped on it (a package edited in place keeps its key, so the design check above passes). Each saved piece of + // gear must sit where equipping would accept it on the hull as the catalog holds it now, by the same rules + // (HardpointData.TakesAt for hardpoint gear, interior cells for tools); occupancy is not rechecked, the save + // carries only what equipping admitted. + private static void RequireLayouts(CultCache cache, SavedGame game) + { + var moved = new SortedSet(StringComparer.Ordinal); + void Check(EntityPack pack) + { + if (pack.Hull.Data.Key.IsSet() && cache.Get(pack.Hull.Data.Key) is HullData hull) + foreach (var (position, item) in pack.Equipment.Concat(pack.CargoBays).Concat(pack.DockingBays)) + { + if (!item.Data.Key.IsSet() || !(cache.Get(item.Data.Key) is EquippableItemData design)) continue; + var fits = design.HardpointType == HardpointType.Tool + ? design.Shape.Coordinates.All(cell => hull.InteriorCells[position + design.Shape.Rotate(cell, item.Rotation)]) + : hull.Hardpoints.Any(hardpoint => hardpoint.TakesAt(design, position - hardpoint.Position)); + if (!fits) moved.Add(hull.Name); + } + foreach (var child in pack.Children) Check(child); + } + foreach (var zoneRef in game.Zones ?? Array.Empty>()) + foreach (var pack in cache.Get(zoneRef).Contents?.Entities ?? new List()) + Check(pack); + if (moved.Count > 0) + throw new InvalidOperationException("This run's equipment no longer fits its hulls' layouts; changed hulls: " + + string.Join(", ", moved) + ". Restore the earlier version of the mod, or start a new game."); + } + private static InvalidOperationException MalformedSave(string where) => new InvalidOperationException($"This run's save is malformed: {where}. Start a new game."); diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 05c0a86ac..545c1681a 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -592,6 +592,47 @@ public sealed class RunSaveTests : IDisposable Assert.Equal(before, File.ReadAllBytes(Run)); } + // Continue from a hull's edited layout: a mod package edited in place keeps its key, so the design check passes, but + // gear saved against the old hardpoints must not be loaded onto the new ones (equipping would silently drop it). + [Fact] + public void RunWhoseHullHardpointMovedRefusesContinueNamingTheHull() + { + var hullKey = new CultRecordKey("mod-hull:mod.skiff"); + HullData Skiff(int2 hardpointAt) => new HullData + { + Name = "Skiff", + Shape = new Shape(4, 4), + Hardpoints = new List { new HardpointData { Type = HardpointType.Sensors, Position = hardpointAt, Shape = new Shape() } } + }; + CultRecordRef lamp; + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + cache.UpsertAsync(typeof(HullData), Skiff(new int2(1, 1)), hullKey).GetAwaiter().GetResult(); + lamp = new CultRecordRef(cache.Upsert(new GearData { Name = "Lamp", Hardpoint = HardpointType.Sensors, Shape = new Shape() }).Key); + cache.FlushAsync().Wait(); + } + using (var cache = Open()) + { + var pack = BarePack(new EquippableItem { Data = new CultRecordRef(hullKey), Lot = 1 }); + pack.Equipment = new[] { (new int2(1, 1), new EquippableItem { Data = lamp, Lot = 1 }) }; + var lots = new ProvenanceLedger { NextLot = 2 }; + lots.Lots[1] = new Lot { Origin = new Attributed() }; + RunSave.Commit(cache, Game(cache), new[] { ZoneOf(pack) }, lots); + } + using (var cache = Open()) RunSave.RequireDesigns(cache, cache.GetGlobal()); + + using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true)) + { + cache.UpsertAsync(typeof(HullData), Skiff(new int2(3, 3)), hullKey).GetAwaiter().GetResult(); + cache.FlushAsync().Wait(); + } + using (var cache = Open()) + { + var refusal = Assert.Throws(() => RunSave.RequireDesigns(cache, cache.GetGlobal())); + Assert.Contains("changed hulls: Skiff", refusal.Message); + } + } + // --- RequireDesigns: what it walks, what it lists --------------------------------------------------------------- // Commits a run whose zones the caller builds, then returns RequireDesigns' refusal message, or null when it passes.