diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index ca078459d..3ace90249 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -3,7 +3,6 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ using GameCult.Caching; -using GameCult.Caching.MessagePack; using System; using System.Collections; using System.Collections.Generic; @@ -49,12 +48,36 @@ public class ActionGameManager : MonoBehaviour { if (_cultCache != null) return _cultCache; - _cultCache = AetheriaStores.Open(Path.Combine(GameDataDirectory.FullName, "Aetheria.cc")); - + // All three stores attach once and stay attached until the process exits. The run lifecycle is record-level + // inside this cache; reopening would replace the catalog instances the galaxy and live entities hold. + _cultCache = AetheriaStores.Open( + Path.Combine(GameDataDirectory.FullName, "Aetheria.cc"), + runPath: Path.Combine(GameDataDirectory.FullName, "run.cc"), + playerPath: Path.Combine(GameDataDirectory.FullName, "player.cc")); + return _cultCache; } } + // The only creator of PlayerSettings: an absent global means first launch, and the defaults are committed. + public static PlayerSettings PlayerSettings + { + get + { + var settings = CultCache.GetGlobal(); + if (settings != null) return settings; + settings = GetDefaultPlayerSettings(); + CultCache.Commit(batch => batch.Upsert(settings)); + return settings; + } + } + + public static void SavePlayerSettings() + { + var settings = PlayerSettings; + CultCache.Commit(batch => batch.Upsert(settings)); + } + private static PlayerSettings GetDefaultPlayerSettings() { var settings = new PlayerSettings(); @@ -205,6 +228,23 @@ public class ActionGameManager : MonoBehaviour get => MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(Settings.GameplaySettings.DefaultEntitySettings)); } + private void OnApplicationQuit() + { + SaveRun(); + SavePlayerSettings(); + } + + // Wormhole arrival and quit save the run; a dead run has no galaxy, so it is not saved. + public void SaveRun() + { + if (CurrentGalaxy != null) + { + var (game, zones) = RunSave.Capture(CultCache, CurrentGalaxy, Zone, DockedEntity ?? CurrentEntity, IsTutorial, + _actionBarSlots.Select(s => s.Save()).ToArray()); + RunSave.Commit(CultCache, game, zones); + } + } + private void OnDisable() { Input.Dispose(); @@ -561,7 +601,7 @@ public class ActionGameManager : MonoBehaviour ship.ExitWormhole(ZoneRenderer.WormholeInstances.Keys.First(w => w.Target == oldZone.GalaxyZone).Position, Settings.GameplaySettings.WormholeExitVelocity * ItemManager.Random.NextFloat2Direction()); CurrentEntity.Zone = Zone; - SaveState(); + SaveRun(); }; } @@ -655,7 +695,8 @@ public class ActionGameManager : MonoBehaviour { if (CurrentGalaxy != null) { - if (PlayerSettings.SavedRun == null) + var saved = CultCache.GetGlobal(); + if (saved == null) { SectorMap.QueueZoneReveal(CurrentGalaxy.Entrance.AdjacentZones.Prepend(CurrentGalaxy.Entrance)); PopulateLevel(CurrentGalaxy.Entrance); @@ -678,8 +719,8 @@ public class ActionGameManager : MonoBehaviour foreach(var group in CurrentGalaxy.DiscoveredZones .GroupBy(dz=>dz.Distance[CurrentGalaxy.Entrance])) SectorMap.QueueZoneReveal(group); - PopulateLevel(CurrentGalaxy.Zones[PlayerSettings.SavedRun.CurrentZone]); - var targetEntity = Zone.Entities[PlayerSettings.SavedRun.CurrentZoneEntity]; + PopulateLevel(CurrentGalaxy.Zones[saved.CurrentZone]); + var targetEntity = Zone.Entities[saved.CurrentZoneEntity]; if (targetEntity is OrbitalEntity orbitalEntity) { CurrentEntity = targetEntity.Children.First(c => c is Ship {IsPlayerShip: true}); @@ -693,7 +734,7 @@ public class ActionGameManager : MonoBehaviour for (var i = 0; i < _actionBarSlots.Count; i++) { - _actionBarSlots[i].Restore(PlayerSettings.SavedRun.ActionBarBindings[i], CurrentEntity); + _actionBarSlots[i].Restore(saved.ActionBarBindings[i], CurrentEntity); } } } @@ -1015,6 +1056,7 @@ public class ActionGameManager : MonoBehaviour MainMenu.gameObject.SetActive(true); Menu.gameObject.SetActive(false); CurrentGalaxy = null; + RunSave.Clear(CultCache); SavePlayerSettings(); Observable.EveryUpdate() .Where(_ => Time.time - deathTime < DeathPostTransitionTime) diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index 94c1659d9..d6863b60a 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -47,7 +47,84 @@ public class SavedGame [Key(12)] public FactionRelationship[] Relationships; +} +// The run's lifecycle over the run store. Only Commit creates SavedGame and SavedZone records. +public static class RunSave +{ + // The live run as plain documents. Writes nothing. + public static (SavedGame Game, SavedZone[] Zones) Capture(CultCache cache, Galaxy galaxy, Zone currentZone, + Entity currentEntity, bool isTutorial, SavedActionBarBinding[] actionBar) + { + var factions = galaxy.HomeZones.Keys.ToArray(); + var game = new SavedGame + { + DiscoveredZones = galaxy.DiscoveredZones.Select(dz => Array.IndexOf(galaxy.Zones, dz)).ToArray(), + Background = galaxy.Background, + Factions = factions.Select(f => cache.RefOf(f)).ToArray(), + Relationships = galaxy.Factions.Select(f => galaxy.FactionRelationships[f]).ToArray(), + HomeZones = galaxy.HomeZones.ToDictionary( + x => Array.IndexOf(factions, x.Key), + x => Array.IndexOf(galaxy.Zones, x.Value)), + BossZones = galaxy.BossZones.ToDictionary( + x => Array.IndexOf(factions, x.Key), + x => Array.IndexOf(galaxy.Zones, x.Value)), + CurrentZone = Array.FindIndex(galaxy.Zones, zone => zone.Contents == currentZone), + CurrentZoneEntity = currentZone.Entities.IndexOf(currentEntity), + Entrance = Array.IndexOf(galaxy.Zones, galaxy.Entrance), + Exit = Array.IndexOf(galaxy.Zones, galaxy.Exit), + IsTutorial = isTutorial, + ActionBarBindings = actionBar + }; + + // A zone generated earlier but not loaded this session has no live Contents; its packed contents carry over. + var zones = galaxy.Zones.Select(zone => new SavedZone + { + Name = zone.Name, + Position = zone.Position, + AdjacentZones = zone.AdjacentZones.Select(az => Array.IndexOf(galaxy.Zones, az)).ToArray(), + Factions = zone.Factions.Select(f => Array.IndexOf(factions, f)).ToArray(), + Contents = zone.Contents?.PackZone() ?? zone.PackedContents, + Owner = zone.Owner == null ? -1 : Array.IndexOf(factions, zone.Owner) + }).ToArray(); + return (game, zones); + } + + // The only writer of SavedGame and SavedZone: one Commit to the run store. Zone i lands at savedzone-{i}, stable + // because a run's zone array is fixed at generation, and every stored SavedZone outside that set is removed. The + // run store's single-file commit writes its whole view, so orbits and bodies staged by zone generation land too. + public static void Commit(CultCache cache, SavedGame game, IReadOnlyList zones) + { + var keys = zones.Select((_, i) => new CultRecordKey($"savedzone-{i}")).ToArray(); + var kept = new HashSet(keys); + var stale = cache.AllStoredDocuments + .Where(stored => stored.Document is SavedZone && !kept.Contains(stored.Key)) + .Select(stored => stored.Key) + .ToArray(); + game.Zones = keys.Select(key => new CultRecordRef(key)).ToArray(); + cache.Commit(batch => + { + for (var i = 0; i < keys.Length; i++) batch.Upsert(typeof(SavedZone), zones[i], keys[i]); + batch.Upsert(game); + foreach (var key in stale) batch.Remove(key); + }); + } + + // Removes every run record (SavedGame, SavedZone, OrbitData, BodyData) in one Commit. + public static void Clear(CultCache cache) + { + var run = cache.AllStoredDocuments + .Where(stored => IsRunRecord(stored.Descriptor.DocumentType)) + .Select(stored => stored.Key) + .ToArray(); + cache.Commit(batch => + { + foreach (var key in run) batch.Remove(key); + }); + } + + public static bool IsRunRecord(Type documentType) => + AetheriaStores.RunTypes.Any(home => home.IsAssignableFrom(documentType)); } [CultDocument("aetheria.savedzone", "1"), MessagePackObject] diff --git a/Assets/Scripts/UI/InputScreen/InputDisplayLayout.cs b/Assets/Scripts/UI/InputScreen/InputDisplayLayout.cs index 5c727996a..0045f676f 100644 --- a/Assets/Scripts/UI/InputScreen/InputDisplayLayout.cs +++ b/Assets/Scripts/UI/InputScreen/InputDisplayLayout.cs @@ -2,6 +2,7 @@ using System; using System.Collections; using System.Collections.Generic; using System.Linq; +using GameCult.Caching; using TMPro; using UniRx; using UnityEngine; @@ -81,6 +82,8 @@ public class InputDisplayLayout : MonoBehaviour void Start() { _canvas = transform.root.GetComponent(); + _inputLayout = ActionGameManager.CultCache.Get(new CultRecordKey(LayoutFile.name)) + ?? throw new InvalidOperationException($"catalog has no InputLayout '{LayoutFile.name}'"); DisplayLayout(_inputLayout); _buttonMappings.Add(MapMouseButton(MouseLeft, "/leftButton")); diff --git a/Assets/Scripts/UI/MainMenu.cs b/Assets/Scripts/UI/MainMenu.cs index e8c75a627..8e56eb4b4 100644 --- a/Assets/Scripts/UI/MainMenu.cs +++ b/Assets/Scripts/UI/MainMenu.cs @@ -2,6 +2,7 @@ using System; using System.Collections; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using TMPro; @@ -91,9 +92,24 @@ public class MainMenu : MonoBehaviour { _nextMenu.panel.Clear(); _nextMenu.panel.Title.text = TitleSubtitle("aetheria", "terminus"); + if (!InGame) + { + // A run exists only as the run store's SavedGame global. + var cache = ActionGameManager.CultCache; + var saved = cache.GetGlobal(); + if (saved == null && cache.AllStoredDocuments.Any(stored => RunSave.IsRunRecord(stored.Descriptor.DocumentType))) + Debug.Log("run store has no SavedGame; Continue disabled"); + _nextMenu.panel.AddButton("Continue", saved == null ? (Action) null : () => + { + ActionGameManager.IsTutorial = saved.IsTutorial; + ActionGameManager.CurrentGalaxy = new Galaxy(cache, saved, Debug.Log); + SceneManager.LoadScene("ARPG"); + }); + } _nextMenu.panel.AddButton("New Game", () => { + RunSave.Clear(ActionGameManager.CultCache); var generatorState = "Loading Database Contents"; Action setState = s => generatorState = s; diff --git a/tests/Aetheria.Shared.Tests/RunSaveTests.cs b/tests/Aetheria.Shared.Tests/RunSaveTests.cs new file mode 100644 index 000000000..f80a98d74 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RunSaveTests.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using CultMath; +using GameCult.Caching; +using GameCult.Caching.MessagePack; +using Xunit; + +// The run lifecycle over all three stores: a save is one commit with stable zone keys, and a clear removes every run record. +public sealed class RunSaveTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-runsave-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + private string Run => Path.Combine(_root, "run.cc"); + private string Player => Path.Combine(_root, "player.cc"); + + public RunSaveTests() + { + Directory.CreateDirectory(_root); + using var cache = AetheriaStores.Open(Catalog, catalogWritable: true); + cache.Commit(batch => + { + batch.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + batch.Upsert(new Faction { Name = "Adrasteia", ShortName = "ADR" }); + }); + } + + public void Dispose() => Directory.Delete(_root, true); + + private CultCache Open() => AetheriaStores.Open(Catalog, Run, Player); + + [Fact] + public void RepeatedSavesKeepRecordCountConstant() + { + ZonePack contents = null; + for (var i = 0; i < 5; i++) + { + using (var cache = Open()) + { + contents ??= StageZoneContents(cache); + RunSave.Commit(cache, Game(cache), Zones(3, contents, 0)); + } + + using (var cache = Open()) + { + Assert.Equal(7, RunRecordCount(cache)); + Assert.Equal(7, RecordsIn(Run)); + Assert.Equal(new[] { "savedzone-0", "savedzone-1", "savedzone-2" }, SavedZoneKeys(cache)); + } + } + } + + [Fact] + public void SaveRemovesZonesNoLongerInTheRun() + { + using (var cache = Open()) + { + RunSave.Commit(cache, Game(cache), Zones(3, null, -1)); + RunSave.Commit(cache, Game(cache), Zones(2, null, -1)); + } + + using (var cache = Open()) + Assert.Equal(new[] { "savedzone-0", "savedzone-1" }, SavedZoneKeys(cache)); + } + + [Fact] + public void SaveWritesOnlyTheRunStore() + { + using (var cache = Open()) + cache.Commit(batch => batch.Upsert(new PlayerSettings())); + var before = new[] { Catalog, Player }.Select(Hash).ToArray(); + + using (var cache = Open()) + RunSave.Commit(cache, Game(cache), Zones(3, StageZoneContents(cache), 0)); + + Assert.Equal(before, new[] { Catalog, Player }.Select(Hash).ToArray()); + Assert.Equal(7, RecordsIn(Run)); + } + + [Fact] + public void ResumeThenSaveKeepsUnloadedZoneContents() + { + using (var cache = Open()) + RunSave.Commit(cache, Game(cache), Zones(2, StageZoneContents(cache), 1)); + + using (var cache = Open()) + { + var galaxy = new Galaxy(cache, cache.GetGlobal(), _ => { }); + var itemManager = new ItemManager(cache, TestSettings(), _ => { }); + galaxy.Zones[0].Contents = new Zone(itemManager, new PlanetSettings(), new ZonePack(), galaxy.Zones[0], galaxy); + var (game, zones) = RunSave.Capture(cache, galaxy, galaxy.Zones[0].Contents, null, false, new SavedActionBarBinding[0]); + RunSave.Commit(cache, game, zones); + } + + using (var cache = Open()) + { + var unloaded = cache.Get(cache.GetGlobal().Zones[1]); + Assert.NotNull(unloaded.Contents); + Assert.NotNull(cache.Get(unloaded.Contents.Orbits[1])); + Assert.NotNull(cache.Get(unloaded.Contents.Planets[0])); + } + } + + [Fact] + public void ClearRemovesEveryRunRecord() + { + using (var cache = Open()) + { + cache.Commit(batch => batch.Upsert(new PlayerSettings())); + RunSave.Commit(cache, Game(cache), Zones(3, StageZoneContents(cache), 0)); + } + var before = new[] { Catalog, Player }.Select(Hash).ToArray(); + + using (var cache = Open()) + RunSave.Clear(cache); + + using (var cache = Open()) + { + Assert.Equal(0, RunRecordCount(cache)); + Assert.Null(cache.GetGlobal()); + } + Assert.Equal(0, RecordsIn(Run)); + Assert.Equal(before, new[] { Catalog, Player }.Select(Hash).ToArray()); + } + + // As in AetherDb's loadout command: the settings an ItemManager needs to build units, and nothing authored. + internal static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp() + }; + + // Two orbits and a body upserted into the run store and left staged, as zone generation leaves them. + private static ZonePack StageZoneContents(CultCache cache) + { + var root = cache.Upsert(new OrbitData()); + var moon = cache.Upsert(new OrbitData { Parent = root, Distance = 5 }); + var body = cache.Upsert(new PlanetData { Name = "Rock", Orbit = moon }); + return new ZonePack { Orbits = { root, moon }, Planets = { body } }; + } + + private static SavedGame Game(CultCache cache) => new SavedGame + { + Factions = new[] { cache.RefOf(cache.GetAll().Single()) }, + Relationships = new[] { FactionRelationship.Neutral }, + HomeZones = new Dictionary { { 0, 0 } }, + BossZones = new Dictionary(), + DiscoveredZones = new[] { 0 }, + ActionBarBindings = new SavedActionBarBinding[0], + Exit = -1 + }; + + // Fresh instances every call; zone contentsIndex holds the given contents, every other zone was never visited. + private static SavedZone[] Zones(int count, ZonePack contents, int contentsIndex) => Enumerable.Range(0, count) + .Select(i => new SavedZone + { + Name = $"Zone {i}", + Position = new float2(i * 10, 0), + AdjacentZones = Enumerable.Range(0, count).Where(j => j != i).ToArray(), + Factions = new[] { 0 }, + Owner = 0, + Contents = i == contentsIndex ? contents : null + }) + .ToArray(); + + private static int RunRecordCount(CultCache cache) => + cache.AllStoredDocuments.Count(stored => RunSave.IsRunRecord(stored.Descriptor.DocumentType)); + + private static string[] SavedZoneKeys(CultCache cache) => cache.AllStoredDocuments + .Where(stored => stored.Document is SavedZone) + .Select(stored => stored.Key.Value) + .OrderBy(key => key, StringComparer.Ordinal) + .ToArray(); + + private static int RecordsIn(string path) => + CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Count(); + + private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))); +}