Continue refuses a run whose equipment no longer fits its hull's edited layout #6

Merged
nibu merged 1 commit from nibu/continue-hull-layout into master 2026-11-10 22:11:46 +00:00
2 changed files with 71 additions and 1 deletions

View file

@ -158,7 +158,7 @@ public static class RunSave
var named = designs.Where(key => key.IsSet()).Distinct()
.Where(key => cache.Get<ItemData>(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<string>(StringComparer.Ordinal);
void Check(EntityPack pack)
{
if (pack.Hull.Data.Key.IsSet() && cache.Get<ItemData>(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<ItemData>(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<CultRecordRef<SavedZone>>())
foreach (var pack in cache.Get(zoneRef).Contents?.Entities ?? new List<EntityPack>())
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.");

View file

@ -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<HardpointData> { new HardpointData { Type = HardpointType.Sensors, Position = hardpointAt, Shape = new Shape() } }
};
CultRecordRef<ItemData> lamp;
using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true))
{
cache.UpsertAsync(typeof(HullData), Skiff(new int2(1, 1)), hullKey).GetAwaiter().GetResult();
lamp = new CultRecordRef<ItemData>(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<ItemData>(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<SavedGame>());
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<InvalidOperationException>(() => RunSave.RequireDesigns(cache, cache.GetGlobal<SavedGame>()));
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.