From c6a3bee879f1b572f4df582d5bcff09add21af5f Mon Sep 17 00:00:00 2001 From: Nibu Date: Tue, 10 Nov 2026 22:10:49 +0000 Subject: [PATCH] Continue refuses a run whose equipment no longer fits its hull's edited layout A mod package edited in place keeps its hull key, so RequireDesigns passed while saved gear could sit outside the new hardpoints or interior cells and be dropped on load. RequireLayouts applies the equip rules (HardpointData.TakesAt, interior cells for tools) to each saved entity and names the changed hulls. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/SavedGame.cs | 31 +++++++++++++++- tests/Aetheria.Shared.Tests/RunSaveTests.cs | 41 +++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) 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. -- 2.47.3