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));
+ }
+ }
+}