diff --git a/Assets/Prefabs/UI/Main Menu Canvas.prefab b/Assets/Prefabs/UI/Main Menu Canvas.prefab index a03628ba0..866d5fb33 100644 --- a/Assets/Prefabs/UI/Main Menu Canvas.prefab +++ b/Assets/Prefabs/UI/Main Menu Canvas.prefab @@ -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 diff --git a/Assets/Scenes/ARPG.unity b/Assets/Scenes/ARPG.unity index 1ef50e0ea..ad5c3d536 100644 --- a/Assets/Scenes/ARPG.unity +++ b/Assets/Scenes/ARPG.unity @@ -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 diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 8ebcf413a..422ad2327 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -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(); - 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(); - 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(); 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(); + 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; diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index a674df81d..87bbeda60 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -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(CultCache cache, Func 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)); diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index b5bdab2ee..f20208ff7 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -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(); diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs index 6ce426995..51db6b137 100644 --- a/tests/Aetheria.Shared.Tests/RunSaveTests.cs +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -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(cache, () => + { + StageZoneContents(cache); + return null; + })); + using (var cache = Open()) + { + Assert.Equal(saved, RunKeys(cache)); + Assert.NotNull(cache.GetGlobal()); + } + + using (var cache = Open()) + Assert.Throws(() => RunSave.Replace(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()); + 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.