diff --git a/manifest.json b/manifest.json index 96b303d..1720b2b 100644 --- a/manifest.json +++ b/manifest.json @@ -5,5 +5,8 @@ "author": "ct", "description": "Story content of the Cthangover game: scenes, scenarios, characters, items, quests, backgrounds and the main game collection.", "category": "story", - "depends": ["shell2d_base>=1", "dialog_base>=1", "interactive_base>=1", "skills_base>=1", "map_base>=1", "ui_base>=1", "characters_base>=1", "items_base>=1", "quests_base>=1", "town_pack>=1"] + "depends": ["shell2d_base>=1", "dialog_base>=1", "interactive_base>=1", "skills_base>=1", "map_base>=1", "ui_base>=1", "characters_base>=1", "items_base>=1", "quests_base>=1", "town_pack>=1"], + "test_suites": [ + {"id": "site_shots", "activate": ["--itest=site_shots"]} + ] } diff --git a/test_suites/site_shots/manifest.json b/test_suites/site_shots/manifest.json new file mode 100644 index 0000000..2baa509 --- /dev/null +++ b/test_suites/site_shots/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "Site Screenshots", + "description": "Takes the screenshots of the cthangover.ru site from the main game: the prologue dialog, several locations with their scenarios, the inventory, character, map, journal and skills panels, and a card battle in front of the town entry. Activated by --itest=site_shots with --game=cthangover --new and --shots= (windowed runs only); the language comes from the player's settings.", + "sources": [ + "src/*.cs" + ], + "depends": [ + "cthangover_game>=2", + "turn_battle_base>=2" + ] +} diff --git a/test_suites/site_shots/src/SiteShotsDriver.cs b/test_suites/site_shots/src/SiteShotsDriver.cs new file mode 100644 index 0000000..a67a721 --- /dev/null +++ b/test_suites/site_shots/src/SiteShotsDriver.cs @@ -0,0 +1,271 @@ +using System.Collections.Generic; +using Cthangover.Core.Bootstrap; +using Cthangover.Core.Events; +using Cthangover.Core.Scenes; +using Cthangover.Core.Settings; +using Cthangover.Core.UI; +using Cthangover.Core.Utils; +using Cthangover.Mods.Dialog; +using Godot; + +namespace Cthangover.Mods.CthangoverGame.Tests +{ + /// + /// Walks a new game of cthangover through the frames the site shows and saves + /// a picture of each: locations entered with + /// (their own scenarios start the dialog, so every frame is a real novel + /// screen), the toolbar panels opened with like + /// the toolbar buttons do, and Murakami's story at the town entry played by + /// answering its menu — the wolf scene and the card battle it starts. + /// + /// The prologue's scenarios have no locale keys (Russian only), so its dialog + /// is aborted at once and its scenes are left out. Before every switch the + /// clock is set to noon (a new game starts at night and the night lighting + /// leaves only lamps visible) and the running dialog is aborted the way the + /// scene editor's "Go to scene" does — otherwise the entered scene's scenario + /// waits behind it. Before the panels the inventory is seeded and Murakami + /// joins the party, so they are not empty. Waits are tick counts, not + /// conditions on the picture; the checks only make sure each scene, panel and + /// the battle were really reached. Result line: + /// [TEST_RESULT] name=site_shots passed=N failed=M, exit 0/1, and a + /// 240 s watchdog. + /// + public partial class SiteShotsDriver : Node + { + private const float TickSeconds = 0.1f; + private const float TimeoutSeconds = 240f; + private const int SettleTicks = 40; + private const int PanelTicks = 15; + private const int LineTicks = 25; + private const long MinutesPerDay = 24 * 60; + private const long Noon = 12 * 60; + private const string Prologue = "prolog_alley"; + private const string StoryScene = "town_entry"; + private const int AlleyOption = 1; + + private static readonly string[] Locations = + { + "home_outside", "home_vestibule", "home_livingroom", "home_kitchen", + "home_bedroom", "home_workroom", "home_basement", "town_outskirts" + }; + + private static readonly string[] Panels = { "inventory", "characters", "map", "journal", "skills" }; + + private readonly string shotsDir; + private readonly List failures = new(); + private int passed; + private int step; + private int index; + private int waitTicks; + private double elapsed; + private bool battleStarted; + + /// Creates the driver; null disables screenshots. + public SiteShotsDriver(string shotsDir) + { + this.shotsDir = shotsDir; + Name = "SiteShotsDriver"; + var timer = new Timer { WaitTime = TickSeconds, Autostart = true }; + AddChild(timer); + timer.Timeout += Tick; + } + + /// Listens for the battle scene. + public override void _EnterTree() => ModEventBus.Subscribe(OnBattleStarted); + + /// Stops listening. + public override void _ExitTree() => ModEventBus.Unsubscribe(OnBattleStarted); + + private SceneManager Scenes => GetTree().Root.GetNodeOrNull("SceneManager"); + + private static DialogBox Box => SceneContextNode.FindNode("DialogBox"); + + private void OnBattleStarted(BattleStartedEvent e) => battleStarted = true; + + private void Tick() + { + if (step < 0) + return; + elapsed += TickSeconds; + if (elapsed > TimeoutSeconds) + { + Fail($"timeout at step {step} (scene='{SceneManager.GetCurrentSceneName()}')"); + Finish(); + return; + } + if (waitTicks > 0) + { + waitTicks--; + return; + } + + try + { + RunStep(); + } + catch (System.Exception ex) + { + Fail($"step {step}: {ex.GetType().Name}: {ex.Message}"); + Finish(); + } + } + + private void RunStep() + { + switch (step) + { + case 0: + if (!Boot.IsReady || SceneManager.GetCurrentSceneName() != Prologue) + return; + Check(true, $"entered '{Prologue}'"); + index = 0; + Next(PanelTicks); + return; + case 1: + GoTo(Locations[index]); + Next(0); + return; + case 2: + if (SceneManager.GetCurrentSceneName() != Locations[index]) + return; + Check(true, $"entered '{Locations[index]}'"); + Next(SettleTicks); + return; + case 3: + Shot("location_" + Locations[index]); + index++; + if (index < Locations.Length) + { + step = 1; + return; + } + Seed(); + index = 0; + Next(PanelTicks); + return; + case 4: + UiRegistry.Toggle(Panels[index]); + Next(PanelTicks); + return; + case 5: + Check(UiHost.Instance.TryGetInstance(Panels[index], out var panel) && panel.Visible, + $"panel '{Panels[index]}' is open"); + Shot("panel_" + Panels[index]); + UiRegistry.Toggle(Panels[index]); + index++; + if (index < Panels.Length) + { + step = 4; + waitTicks = PanelTicks; + return; + } + Next(PanelTicks); + return; + case 6: + GoTo(StoryScene); + Next(0); + return; + case 7: + if (SceneManager.GetCurrentSceneName() != StoryScene || Box?.IsWaitingAnswer != true) + return; + Check(true, $"Murakami's story waits for an answer in '{StoryScene}'"); + Next(SettleTicks); + return; + case 8: + Shot("novel_menu"); + Check(Box.TrySelectVariant(AlleyOption), "the alley option is taken"); + Next(SettleTicks); + return; + case 9: + Shot("novel_wolf"); + Box.NextAction(); + Next(SettleTicks); + return; + case 10: + Shot("novel_marao"); + Next(0); + return; + case 11: + if (battleStarted) + { + Check(true, "the card battle started"); + Next(SettleTicks); + return; + } + if (Box != null && !Box.IsDialogEnded && !Box.IsWaitingAnswer) + Box.NextAction(); + waitTicks = LineTicks; + return; + case 12: + Shot("battle"); + Next(SettleTicks); + return; + case 13: + Shot("battle_later"); + Finish(); + return; + } + } + + /// Noon, no dialog left running, then the switch. + private void GoTo(string scene) + { + var time = GameData.Instance.Runtime.Time; + time.SetTime(time.Tick - time.Tick % MinutesPerDay + Noon); + Box?.Abort(); + Scenes.SwitchScene(scene); + } + + /// A few stacks for the inventory and a second hero for the party panel. + private static void Seed() + { + var runtime = GameData.Instance.Runtime; + runtime.Inventory.Add("food/ration", 3); + runtime.Inventory.Add("food/wolf_meat", 4); + runtime.Inventory.Add("resource/branches", 6); + runtime.Inventory.Add("recipe/wolf_meat_to_ration"); + runtime.CharacterData.AddCharacterToParty("Murakami"); + } + + private void Next(int ticks) + { + step++; + waitTicks = ticks; + } + + private void Check(bool condition, string what) + { + if (condition) + { + passed++; + GameLogger.Log("TEST", $"[PASS] {what}"); + } + else + { + Fail(what); + } + } + + private void Fail(string what) + { + failures.Add(what); + GameLogger.Log("TEST", $"[FAIL] {what}", LogLevel.Error); + } + + private void Shot(string name) + { + if (string.IsNullOrEmpty(shotsDir) || DisplayServer.GetName() == "headless") + return; + DirAccess.MakeDirRecursiveAbsolute(shotsDir); + GetViewport().GetTexture().GetImage()?.SavePng(shotsDir.TrimEnd('/') + "/" + name + ".png"); + GameLogger.Log("TEST", $"SiteShotsDriver: saved {name}.png"); + } + + private void Finish() + { + step = -1; + GameLogger.Log("TEST", $"[TEST_RESULT] name=site_shots passed={passed} failed={failures.Count}"); + GetTree().Quit(failures.Count == 0 ? 0 : 1); + } + } +} diff --git a/test_suites/site_shots/src/SiteShotsTestInitializer.cs b/test_suites/site_shots/src/SiteShotsTestInitializer.cs new file mode 100644 index 0000000..1263ea0 --- /dev/null +++ b/test_suites/site_shots/src/SiteShotsTestInitializer.cs @@ -0,0 +1,38 @@ +using System.Linq; +using Cthangover.Core.Mods; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Mods.CthangoverGame.Tests +{ + /// + /// Entry point of the site_shots suite: adds the driver to the tree root when + /// the exact flag --itest=site_shots is given (the driver quits the + /// process, so it must never run in another test). --shots=<dir> + /// is where the pictures go; without it the driver only walks the steps. + /// + public class SiteShotsTestInitializer : IModInitializer + { + private static bool driverQueued; + + /// Nothing per mod. + public void OnModLoaded(string modId) + { + } + + /// Queues the driver once. + public void OnModResourcesReady() + { + var args = OS.GetCmdlineUserArgs(); + if (driverQueued || !args.Any(a => a == "--itest=site_shots")) + return; + + driverQueued = true; + var shots = args.Where(a => a.StartsWith("--shots=", System.StringComparison.Ordinal)) + .Select(a => a.Substring("--shots=".Length)) + .FirstOrDefault(); + GameLogger.Log("TEST", $"SiteShotsTestInitializer: activating site_shots, shots={shots ?? "off"}"); + (Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new SiteShotsDriver(shots)); + } + } +}