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.UI; 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. /// The pause menu's "Main menu" button leaves the game like a player /// does, and the pause menu does not stay above the title screen. /// 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 { private int readinessToken = int.MinValue; private ulong readinessStarted; private ulong readinessFrame; public const string Flag = "--itest=collection_edit"; private const string GameId = "novel_template"; private const string AddedToolset = "dev_tools"; private static readonly string[] AddedMods = { "scenario_editor", "scene_editor", "scene_builder", "map_editor", "interactive_editor", "light_editor", "catalog_export" }; private const string StudioMod = "scenario_studio"; private const string GameMenuId = "game_menu"; 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 bool startedOnPlayerPackage; private int passed; private int step; private double elapsed; 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); } /// Advances when observable scene/UI readiness has held for two real frames; the watchdog remains active. 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; } 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 => AddedMods.All(ModRegistry.Instance.Mods.ContainsKey); /// 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); } // Deferred UI/scene work must finish before the original assertions run. private bool WaitForReadyFrames(int token, bool ready, double timeoutSeconds = 30) { var now = global::Godot.Time.GetTicksMsec(); if (readinessToken != token) { readinessToken = token; readinessStarted = now; readinessFrame = 0; } if (now - readinessStarted > timeoutSeconds * 1000) throw new global::System.TimeoutException($"readiness at step {token}: scene/UI did not become ready"); if (!ready) { readinessFrame = 0; return false; } if (readinessFrame == 0) readinessFrame = global::Godot.Engine.GetProcessFrames(); return global::Godot.Engine.GetProcessFrames() - readinessFrame >= 2; } private void RunStep() { if (!WaitForReadyFrames(step, Boot.IsReady && Menu != null && !Busy && !GodotSceneService.IsTransitioning, 120)) return; 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()); MoveUserPackageAside(); // A player's own package of the game (say, with the editors added) was what // the game started on: then the editors may already be loaded and New game // need not switch the set. The package is restored at the end. startedOnPlayerPackage = movedAside.Count > 0; if (startedOnPlayerPackage) GameLogger.Log("TEST", "CollectionEditTest: the game started on the player's own package; the before/switch checks are skipped"); else Check("individual editors are not loaded before the edit", !AddedMods.Any(ModRegistry.Instance.Mods.ContainsKey)); AddEditorsToCollection(); Check("the edited collection makes the running game outdated", GameCatalog.IsActiveOutdated()); AppServices.Current.Ui.ToggleEntry(GameMenuId); break; case 1: { var open = UiHost.Instance.TryGetInstance(GameMenuId, out var menu) && menu.Visible; Check("the pause menu is open", open); if (menu?.FindChild("MainMenuBtn", true, false) is not Button mainMenu) { failures.Add("the pause menu has no Main menu button"); Finish(); return; } mainMenu.EmitSignal(BaseButton.SignalName.Pressed); break; } case 2: if (Busy || GetTree().CurrentScene is not Cthangover.BaseMenu.MainMenu) return; Check("the pause menu is closed on the title screen", !UiHost.Instance.TryGetInstance(GameMenuId, out var left) || !left.IsVisibleInTree()); break; case 3: if (Busy || !PressMenuPlay()) return; enteredScenes.Clear(); break; case 4: if (!StartFromPicker()) return; break; case 5: { if (Busy || ActiveGame.Id != GameId || enteredScenes.Count == 0) return; var reload = ModManager.Instance.LastReload; if (!startedOnPlayerPackage) Check("New game after the edit switched the mod set", reload?.Applied == true && AddedMods.All(id => reload.Plan?.Load.Contains(id) == true)); Check("all seven individual editors are 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(StudioButtonTooltip())); 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() { 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 + ">=0.1.0", 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() { // UserPath ends with a separator: without the trim the "parent" is the // collections folder itself, the backup was scanned as a package and // Cleanup's RemoveUser(GameId) deleted the player's package with it. var root = ModCollectionStore.UserPath.TrimEnd('/', '\\'); 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); } } /// /// Tooltip of the studio's toolbar button, read from the panel entry the studio /// registered: the suite does not depend on the studio (it is added by the edit), /// so its locale key must not be hard-coded here. /// private static string StudioButtonTooltip() { var locale = UiRegistry.PanelEntries.FirstOrDefault(e => e.ModId == StudioMod && e.Button != null)?.Button.Locale; return locale == null ? null : TranslationServer.Translate(locale).ToString(); } private bool HasToolbarButton(string tooltip) => GetTree().Root.FindChildren("*", "Button", true, false).OfType