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 developer toolset (dev_tools: editors, /// the scenario studio, …), 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 and the studio are loaded, the studio's button is on /// the game's toolbar 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 AddedToolset = "dev_tools"; private const string AddedMod = "editor_tools"; private const string StudioMod = "scenario_studio"; 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($"'{StudioMod}' is loaded after the edit", ModRegistry.Instance.Mods.ContainsKey(StudioMod)); Check("the studio's button is on the game's toolbar", HasToolbarButton(TranslationServer.Translate("studio/title").ToString())); 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 the developer toolset included, as a /// user package — what the collection editor does when the player adds it. /// private void AddEditorsToCollection() { MoveUserPackageAside(); var source = GameCatalog.LoadCollections()[GameId]; var edited = JsonSerializer.Deserialize(JsonSerializer.Serialize(source)); edited.Package = source.Package; edited.Includes ??= new List(); edited.Includes.Add(new ModCollectionInclude { Ref = AddedToolset + ">=1", Role = "tools" }); 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); } } private bool HasToolbarButton(string tooltip) => GetTree().Root.FindChildren("*", "Button", true, false).OfType