A new run replaces the saved run only once it has staged; a failed start says why and leaves the saved run

Launch cleared the run store before generating, and a generation or staging failure only reached the log: the
Generating Galaxy dialog stayed up, StartGame threw, and the saved run was already gone.

RunSave.Replace runs a start with the saved run still stored: when it returns a staged run the saved run's records go,
and when it returns null or throws, only the records it wrote (the arena's orbits and bodies) go. StartGame's
new-run branch is StartScenario, chosen by PendingScenario rather than by the absence of a SavedGame; it mints into an
empty ledger, and a failed start clears CurrentGalaxy (so quitting saves nothing) and refuses on the main menu with
every failure. Launch no longer clears; a galaxy that throws is refused on the title menu. The menu offers Continue
whenever no run is live, which replaces MainMenu.InGame (cut, with its prefab value and scene override), so a
failed start can resume the saved run in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 22:03:50 +02:00
commit 8a1e205cf0
6 changed files with 132 additions and 38 deletions

View file

@ -915,7 +915,6 @@ MonoBehaviour:
m_EditorClassIdentifier:
Settings: {fileID: 11400000, guid: cd2344e2001a37544aab0960c164d4ff, type: 2}
Dialog: {fileID: 4533226043449861657}
InGame: 0
PanelPrototype: {fileID: 7329274282942065594}
FadeTime: 0.5
FadeDistance: 1024

View file

@ -9636,11 +9636,6 @@ PrefabInstance:
propertyPath: m_SizeDelta.y
value: 90.91
objectReference: {fileID: 0}
- target: {fileID: 4533226043457584178, guid: 3d8d9cbf10b12214e98891c1f03c1e99,
type: 3}
propertyPath: InGame
value: 1
objectReference: {fileID: 0}
- target: {fileID: 4533226043457584178, guid: 3d8d9cbf10b12214e98891c1f03c1e99,
type: 3}
propertyPath: CloudRenderer

View file

@ -270,7 +270,9 @@ public class ActionGameManager : MonoBehaviour
EntityInstance.EffectManagerParent = EffectManagerParent;
ConsoleController.MessageReceiver = this;
ItemManager = new ItemManager(CultCache, RunSave.Lots(CultCache), Settings.GameplaySettings, Debug.Log);
// A new run mints into an empty ledger; the saved run's stays in the store until the new run has started.
ItemManager = new ItemManager(CultCache, PendingScenario != null ? new ProvenanceLedger() : RunSave.Lots(CultCache),
Settings.GameplaySettings, Debug.Log);
ZoneRenderer.ItemManager = ItemManager;
// If hiding minimap asteroids, turn them off to start with
@ -795,22 +797,10 @@ public class ActionGameManager : MonoBehaviour
{
if (CurrentGalaxy != null)
{
var saved = CultCache.GetGlobal<SavedGame>();
if (saved == null)
{
var scenario = PendingScenario ?? throw new InvalidOperationException("A new run needs a scenario; MainMenu.Launch sets one.");
PendingScenario = null;
SectorMap.QueueZoneReveal(CurrentGalaxy.Entrance.AdjacentZones.Prepend(CurrentGalaxy.Entrance));
// RunStart owns what a new run starts with (docs/scenarios-cut.md, R.4); this only enters and binds.
RunStart.GenerateArena(ItemManager, Settings.ZoneSettings, CurrentGalaxy, scenario);
PopulateLevel(CurrentGalaxy.Entrance);
var failures = new List<string>();
var staged = RunStart.Stage(ItemManager, Zone, scenario, Settings.StartingHullName, Settings.TutorialGenerationSettings, failures)
?? throw new InvalidOperationException($"Scenario {scenario.Name} could not stage: {string.Join("; ", failures)}");
BindToEntity(staged.Player);
}
if (PendingScenario != null) StartScenario();
else
{
var saved = CultCache.GetGlobal<SavedGame>();
foreach(var group in CurrentGalaxy.DiscoveredZones
.GroupBy(dz=>dz.Distance[CurrentGalaxy.Entrance]))
SectorMap.QueueZoneReveal(group);
@ -835,6 +825,41 @@ public class ActionGameManager : MonoBehaviour
}
}
// RunStart owns what a new run starts with (docs/scenarios-cut.md, R.4); this enters and binds. The saved run is
// replaced only once the scenario has staged. A start that fails leaves the saved run as it was and says why on
// the main menu, where Continue still resumes the saved run.
private void StartScenario()
{
var scenario = PendingScenario;
PendingScenario = null;
var failures = new List<string>();
RunStart.Staged staged = null;
try
{
staged = RunSave.Replace(CultCache, () =>
{
RunStart.GenerateArena(ItemManager, Settings.ZoneSettings, CurrentGalaxy, scenario);
PopulateLevel(CurrentGalaxy.Entrance);
return RunStart.Stage(ItemManager, Zone, scenario, Settings.StartingHullName, Settings.TutorialGenerationSettings, failures);
});
}
catch (Exception exception)
{
Debug.LogException(exception);
failures.Add(exception.Message);
}
if (staged == null)
{
CurrentGalaxy = null;
MainMenu.gameObject.SetActive(true);
MainMenu.Refuse($"{scenario.Name} could not start", string.Join("\n", failures));
return;
}
SectorMap.QueueZoneReveal(CurrentGalaxy.Entrance.AdjacentZones.Prepend(CurrentGalaxy.Entrance));
BindToEntity(staged.Player);
}
private IEnumerator IntroCutscene(Ship ship)
{
ZoneRenderer.PerspectiveEntity = ship;

View file

@ -155,17 +155,37 @@ public static class RunSave
}
// Removes every run record (SavedGame, SavedZone, OrbitData, BodyData, ProvenanceLedger) in one Commit.
public static void Clear(CultCache cache)
public static void Clear(CultCache cache) => Remove(cache, Records(cache));
// A new run replaces the saved one only once it has started: start runs with the saved run still in the store.
// When it returns a value, the saved run's records go. When it returns null or throws, the records it wrote go and
// the saved run stays as it was.
public static T Replace<T>(CultCache cache, Func<T> start) where T : class
{
var run = cache.AllStoredDocuments
var saved = Records(cache);
T started = null;
try
{
started = start();
}
finally
{
Remove(cache, started != null ? saved : Records(cache).Except(saved).ToArray());
}
return started;
}
private static CultRecordKey[] Records(CultCache cache) =>
cache.AllStoredDocuments
.Where(stored => IsRunRecord(stored.Descriptor.DocumentType))
.Select(stored => stored.Key)
.ToArray();
private static void Remove(CultCache cache, CultRecordKey[] records) =>
cache.Commit(batch =>
{
foreach (var key in run) batch.Remove(key);
foreach (var key in records) batch.Remove(key);
});
}
public static bool IsRunRecord(Type documentType) =>
AetheriaStores.RunTypes.Any(home => home.IsAssignableFrom(documentType));

View file

@ -17,7 +17,6 @@ public class MainMenu : MonoBehaviour
public VolumeCloudRenderer CloudRenderer;
public GameSettings Settings;
public ConfirmationDialog Dialog;
public bool InGame;
public Prototype PanelPrototype;
public float FadeTime = .5f;
public float FadeDistance = 512;
@ -92,9 +91,10 @@ public class MainMenu : MonoBehaviour
{
_nextMenu.panel.Clear();
_nextMenu.panel.Title.text = TitleSubtitle("aetheria", "terminus");
if (!InGame)
if (ActionGameManager.CurrentGalaxy == null)
{
// A run exists only as the run store's SavedGame global.
// No run is live: at the title, after death, or after a new run failed to start. A saved run exists only as
// the run store's SavedGame global.
var cache = ActionGameManager.CultCache;
if (ActionGameManager.ModExclusions.Length > 0 && !_exclusionsShown)
{
@ -156,11 +156,11 @@ public class MainMenu : MonoBehaviour
}
}
// The only way into a new run: clears the saved run, generates the scenario's galaxy off the main thread, and
// enters the game, where StartGame has RunStart stage it.
// The only way into a new run: generates the scenario's galaxy off the main thread and enters the game, where
// StartGame has RunStart stage it and only then replaces the saved run. A galaxy that cannot generate is refused
// here, with the saved run untouched.
private void Launch(Scenario scenario)
{
RunSave.Clear(ActionGameManager.CultCache);
var generatorState = "Loading Database Contents";
Fade(true);
@ -181,16 +181,21 @@ public class MainMenu : MonoBehaviour
ActionGameManager.GameDataDirectory.CreateSubdirectory("Narrative"),
Debug.Log,
state => generatorState = state);
Task.Run(() =>
{
var galaxy = RunStart.Generate(scenario, stage);
Task.Run(() => RunStart.Generate(scenario, stage)).ContinueWith(generation =>
Observable.NextFrame().Subscribe(_ =>
{
ActionGameManager.CurrentGalaxy = galaxy;
if (generation.IsFaulted)
{
Debug.LogException(generation.Exception);
ShowMain();
Fade(false);
Refuse($"{scenario.Name} could not generate its galaxy", generation.Exception.GetBaseException().Message);
return;
}
ActionGameManager.CurrentGalaxy = generation.Result;
ActionGameManager.PendingScenario = scenario;
EnterGame();
});
}).ContinueWith(t => Debug.LogException(t.Exception), TaskContinuationOptions.OnlyOnFaulted);
}));
}
// The one way into the game scene. Mod ship prototypes import asynchronously at boot, so the scene waits for them,
@ -209,7 +214,7 @@ public class MainMenu : MonoBehaviour
SceneManager.LoadScene("ARPG");
}
private void Refuse(string title, string reason)
public void Refuse(string title, string reason)
{
Debug.LogError($"{title}: {reason}");
Dialog.Clear();

View file

@ -126,6 +126,56 @@ public sealed class RunSaveTests : IDisposable
Assert.Equal(before, new[] { Catalog, Player }.Select(Hash).ToArray());
}
// A new run's start writes its arena's orbits and bodies while the saved run is still stored. A start that fails,
// by returning nothing or by throwing, takes away only what it wrote; one that starts takes away the saved run.
[Fact]
public void ANewRunReplacesTheSavedRunOnlyOnceItHasStarted()
{
using (var cache = Open())
RunSave.Commit(cache, Game(cache), Zones(3, StageZoneContents(cache), 0), new ProvenanceLedger());
string[] saved;
using (var cache = Open())
saved = RunKeys(cache);
Assert.Equal(8, saved.Length);
using (var cache = Open())
Assert.Null(RunSave.Replace<ZonePack>(cache, () =>
{
StageZoneContents(cache);
return null;
}));
using (var cache = Open())
{
Assert.Equal(saved, RunKeys(cache));
Assert.NotNull(cache.GetGlobal<SavedGame>());
}
using (var cache = Open())
Assert.Throws<InvalidOperationException>(() => RunSave.Replace<ZonePack>(cache, () =>
{
StageZoneContents(cache);
throw new InvalidOperationException("the arena would not generate");
}));
using (var cache = Open())
Assert.Equal(saved, RunKeys(cache));
ZonePack started;
using (var cache = Open())
started = RunSave.Replace(cache, () => StageZoneContents(cache));
using (var cache = Open())
{
Assert.Null(cache.GetGlobal<SavedGame>());
Assert.Equal(started.Orbits.Select(orbit => orbit.Key.Value).Concat(started.Planets.Select(body => body.Key.Value))
.OrderBy(key => key, StringComparer.Ordinal), RunKeys(cache));
}
}
private static string[] RunKeys(CultCache cache) => cache.AllStoredDocuments
.Where(stored => RunSave.IsRunRecord(stored.Descriptor.DocumentType))
.Select(stored => stored.Key.Value)
.OrderBy(key => key, StringComparer.Ordinal)
.ToArray();
// Lot 1 sits on the root ship's hull; lot 3 (Produced from facility 4, input 5) sits in a docked child's
// docking-bay contents; lots 2 and 6 are minted but referenced by nothing packed. Only the reachable closure
// {1, 3, 4, 5} survives into the written copy; the live ledger keeps every lot.