diff --git a/manifest.json b/manifest.json index e6af884..ba8411e 100644 --- a/manifest.json +++ b/manifest.json @@ -13,6 +13,7 @@ {"id": "menu_flow", "activate": ["--itest=menu_flow"]}, {"id": "save_dormancy", "activate": ["--itest=save_dormancy"]}, {"id": "new_game", "activate": ["--itest=new_game"]}, - {"id": "hot_reload", "activate": ["--itest=hot_reload"]} + {"id": "hot_reload", "activate": ["--itest=hot_reload"]}, + {"id": "collection_edit", "activate": ["--itest=collection_edit"]} ] } diff --git a/src/GameFlow.cs b/src/GameFlow.cs index 2c3719d..fa23d84 100644 --- a/src/GameFlow.cs +++ b/src/GameFlow.cs @@ -92,8 +92,13 @@ namespace Cthangover.BaseMenu ReloadRequired = false; } + /// + /// The game is loaded and still matches its collection — only then can it + /// be entered without a mod-set switch (an edited collection must apply). + /// private static bool IsRunning(string gameId) - => ActiveGame.IsActive && string.Equals(ActiveGame.Id, gameId, StringComparison.OrdinalIgnoreCase); + => ActiveGame.IsActive && string.Equals(ActiveGame.Id, gameId, StringComparison.OrdinalIgnoreCase) + && !GameCatalog.IsActiveOutdated(); /// Lists what a composition change does to the save, in player terms. public static string DescribeDiff(SaveCompositionDiff diff) diff --git a/test_suites/collection_edit/manifest.json b/test_suites/collection_edit/manifest.json new file mode 100644 index 0000000..d0bef49 --- /dev/null +++ b/test_suites/collection_edit/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Collection Edit Test", + "description": "Integration test of editing the running game's collection: the novel template is entered, its collection gets the editors mechanic, back to the menu and New game of the same game must switch the mod set in-process so the editors are loaded. Activated only by --itest=collection_edit (with --game=novel_template).", + "sources": [ + "src/*.cs" + ] +} diff --git a/test_suites/collection_edit/src/CollectionEditTestDriverNode.cs b/test_suites/collection_edit/src/CollectionEditTestDriverNode.cs new file mode 100644 index 0000000..592658d --- /dev/null +++ b/test_suites/collection_edit/src/CollectionEditTestDriverNode.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using Cthangover.Core.Bootstrap; +using Cthangover.Core.Events; +using Cthangover.Core.Games; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.HotReload; +using Cthangover.Core.Scenes; +using Cthangover.Core.Utils; +using Godot; + +namespace Mods.CollectionEditTest +{ + /// + /// The player edits the collection of the game that is running, in ONE process: + /// + /// The novel template is entered; it matches its collection and the + /// editors are not loaded. + /// The collection gets the editors mechanic (editor_tools), saved + /// the way the collection editor saves it (a user package with the same id): + /// the running game is now outdated. + /// Menu → Play → New game of the same game switches the mod set in + /// place: the editors are loaded and the game matches its collection again. + /// + /// A player's own package of the game is moved aside for the run and put + /// back afterwards. Prints [TEST_RESULT] name=collection_edit. + /// + public partial class CollectionEditTestDriverNode : Node + { + public const string Flag = "--itest=collection_edit"; + + private const string GameId = "novel_template"; + private const string AddedMod = "editor_tools"; + private const float TimeoutSeconds = 240f; + + private readonly List failures = new(); + private readonly List enteredScenes = new(); + private readonly List<(string From, string To)> movedAside = new(); + private int passed; + private int step; + private double elapsed; + private double stepAt; + private bool done; + + public override void _Ready() + { + // The driver must keep ticking across the mod-set switch. + ProcessMode = ProcessModeEnum.Always; + ModEventBus.Subscribe(OnSceneEntered); + } + + public override void _ExitTree() + { + ModEventBus.Unsubscribe(OnSceneEntered); + } + + public override void _Process(double delta) + { + if (done) + return; + + elapsed += delta; + if (elapsed > TimeoutSeconds) + { + failures.Add($"timeout at step {step} (scene='{SceneManager.GetCurrentSceneName()}', game='{ActiveGame.Id}')"); + Finish(); + return; + } + + if (elapsed - stepAt < 0.25) + return; + stepAt = elapsed; + + try + { + RunStep(); + } + catch (Exception ex) + { + failures.Add($"step {step}: {ex.GetType().Name}: {ex.Message}"); + Finish(); + } + } + + private Control Menu => GetTree().CurrentScene as Control; + + private static bool Busy => GameSession.IsSwitching || ModManager.Instance.IsReloading || ModHotReloadService.IsReloadRunning; + + private static bool EditorsLoaded => ModRegistry.Instance.Mods.ContainsKey(AddedMod); + + /// Scenes of the game entered since the last clear; the main menu does not count. + private void OnSceneEntered(SceneEnteredEvent e) + { + if (!string.Equals(e.SceneName, "mainmenu", StringComparison.OrdinalIgnoreCase)) + enteredScenes.Add(e.SceneName); + } + + private void RunStep() + { + switch (step) + { + case 0: + if (!Boot.IsReady || Busy || ActiveGame.Id != GameId || enteredScenes.Count == 0) + return; + Check($"game '{GameId}' entered ('{enteredScenes[0]}')", true); + Check("a freshly entered game matches its collection", !GameCatalog.IsActiveOutdated()); + Check($"'{AddedMod}' is not loaded before the edit", !EditorsLoaded); + + AddEditorsToCollection(); + Check("the edited collection makes the running game outdated", GameCatalog.IsActiveOutdated()); + GetTree().Root.GetNode("GodotSceneService").SwitchToMenu(); + break; + + case 1: + if (Busy || !PressMenuPlay()) + return; + enteredScenes.Clear(); + break; + + case 2: + if (!StartFromPicker()) + return; + break; + + case 3: + { + if (Busy || ActiveGame.Id != GameId || enteredScenes.Count == 0) + return; + var reload = ModManager.Instance.LastReload; + Check("New game after the edit switched the mod set", reload?.Applied == true && reload.Plan?.Load.Contains(AddedMod) == true); + Check($"'{AddedMod}' is loaded after the edit", EditorsLoaded); + Check("the game matches its edited collection", !GameCatalog.IsActiveOutdated()); + Check($"entered a scene of '{GameId}' ('{enteredScenes[0]}')", true); + Finish(); + return; + } + } + + step++; + } + + /// + /// Saves the game's collection with an extra editors mechanic as a user + /// package — what the collection editor does when the player adds one. + /// + private void AddEditorsToCollection() + { + MoveUserPackageAside(); + var source = GameCatalog.LoadCollections()[GameId]; + var edited = JsonSerializer.Deserialize(JsonSerializer.Serialize(source)); + edited.Package = source.Package; + edited.Mechanics ??= new List(); + edited.Mechanics.Add(new ModCollectionMechanic { Id = "editors", Name = "Editors", Mods = new List { AddedMod + ">=1" } }); + + var user = ModCollectionStore.LoadUser().Where(c => !string.Equals(c.Id, GameId, StringComparison.OrdinalIgnoreCase)).ToList(); + user.Add(edited); + ModCollectionStore.SaveUser(user); + } + + private void MoveUserPackageAside() + { + var root = ModCollectionStore.UserPath; + foreach (var path in new[] { Path.Combine(root, GameId), Path.Combine(root, GameId + ".zip") }) + { + var aside = Path.Combine(Path.GetDirectoryName(root) ?? root, $"collection_edit_backup_{Path.GetFileName(path)}"); + if (Directory.Exists(path)) + Directory.Move(path, aside); + else if (File.Exists(path)) + File.Move(path, aside); + else + continue; + movedAside.Add((path, aside)); + } + } + + private void Cleanup() + { + ModCollectionStore.RemoveUser(GameId); + foreach (var (from, aside) in movedAside) + { + if (Directory.Exists(aside)) + Directory.Move(aside, from); + else if (File.Exists(aside)) + File.Move(aside, from); + } + } + + /// The main menu's Play button, pressed; false while it is not on screen. + private bool PressMenuPlay() + { + if (Menu?.FindChild("play", true, false) is not Button play) + return false; + play.EmitSignal(BaseButton.SignalName.Pressed); + return true; + } + + /// Presses New game on the game's card in the picker; false until the picker is open. + private bool StartFromPicker() + { + var picker = Menu?.FindChild("GamePickerPanel", true, false) as Control; + if (picker?.Visible != true) + return false; + var game = GameCatalog.Games().FirstOrDefault(g => g.Collection.Id == GameId); + var start = StartButton(picker, game?.Name ?? GameId); + Check($"'{GameId}' card has an enabled New game button", start is { Disabled: false }); + if (start == null || start.Disabled) + { + Finish(); + return false; + } + + start.EmitSignal(BaseButton.SignalName.Pressed); + return true; + } + + /// The New game button on the card titled . + private static Button StartButton(Node picker, string title) + { + var newGameText = TranslationServer.Translate("menu/play/new_game").ToString(); + // A card is the innermost panel holding exactly one New game button (the picker frame holds them all). + foreach (var card in picker.FindChildren("*", "PanelContainer", true, false)) + { + var starts = card.FindChildren("*", "Button", true, false).OfType