namespace Cthangover.Mods.RpgPlus.Tests { using System.Collections.Generic; using System.Linq; using Cthangover.Core.Battle; using Cthangover.Core.Battle.Actions; using Cthangover.Core.Bootstrap; using Cthangover.Core.Characters; using Cthangover.Core.Factories.Impls; using Cthangover.Core.Games; using Cthangover.Core.Items; using Cthangover.Core.Scenes; using Cthangover.Core.Settings; using Cthangover.Core.UI; using Cthangover.Core.Utils; using Cthangover.Mods.Dialog; using Cthangover.Mods.Equipment; using Cthangover.Mods.Interface.View; using Cthangover.Mods.Items; using Cthangover.Mods.Rpg; using Cthangover.Mods.Stats; using Cthangover.Mods.Trading; using Godot; /// /// Plays the RPG Plus sample end to end, the way a player would, and checks that the /// mods agree with each other at every step: the prologue's class choice reaches the /// sheet, the sheet reaches the battle attributes, gear and tonics change both, a /// formula skill hits the stat-patched wolf, the training yard levels the hero up and /// teaches the class skill, the sheet panel takes gear off, and a save brings it all back. /// public partial class RpgPlusTestDriver : Node { private const float TickSeconds = 0.1f; // The battle opens with an intro animation (the backdrop zooms in): shots wait it out. private const int BattleIntroTicks = 20; private const float TimeoutSeconds = 150f; private const string Hero = "rpg_hero"; private const string Crossroads = "rpg_plus_crossroads"; private const string Yard = "rpg_plus_training_yard"; private const string Village = "rpg_village"; private const string Rogue = "classic/rogue"; private const string Dagger = "classic/dagger"; private const string IronSword = "classic/iron_sword"; private const string Staff = "classic/oak_staff"; private const string Tonic = "classic/strength_tonic"; private const string Charm = "classic/clover_charm"; private const string WinFlag = "rpg_plus_test_win"; private const string SaveSlot = "slot_9902"; private const string SheetEntry = "sheet"; private readonly string shotsDir; private readonly List failures = new(); private readonly Queue choices = new(); private int passed; private int step; private int waitTicks; private int pagesPhase; private int nightPhase; private long savedTime; private double pagesWait; private bool slimeMoved; private int coinsBeforeChest; private double elapsed; private string checkValue; private string checkResult; private bool sawYard; public RpgPlusTestDriver(string shotsDir) { this.shotsDir = shotsDir; Name = "RpgPlusTestDriver"; var timer = new Timer { WaitTime = TickSeconds, Autostart = true }; AddChild(timer); timer.Timeout += Tick; } private static RpgWorldView View => RpgWorldHost.Current?.View; private static IInventory Inventory => GameData.Instance.Runtime.Inventory; private static CharacterInfoData Entry => StatsService.RosterEntry(Hero); private static DialogBox Box => RpgInput.FindDialogBox(); private string SceneName => GetTree().Root.GetNodeOrNull("SceneManager")?.CurrentSceneName; private static string Flag(string name) => GameData.Instance.Runtime.Flags.TryGetValue(name, out var value) ? value : null; private void Tick() { if (step < 0) return; elapsed += TickSeconds; if (elapsed > TimeoutSeconds) { Fail($"timeout at step {step} (scene {SceneName}, map {View?.Map?.ID}, dialog running {Box?.IsRunning})"); Finish(); return; } try { RunStep(); } catch (System.Exception ex) { Fail($"step {step}: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}"); Finish(); } } private void RunStep() { switch (step) { case 0: if (!Boot.IsReady || SceneName != Crossroads || Box?.IsRunning != true) return; Shot("prologue"); // The class question: the rogue; the seer: ask the way (the charisma check). choices.Enqueue(2); choices.Enqueue(0); Next(); return; case 1: RememberCheck(); if (AdvanceDialog()) return; if (View?.Map?.ID != Village || GodotSceneService.IsTransitioning || Entry == null || ++waitTicks < 3) return; CheckPrologue(); CheckSheet(); Next(); return; case 2: CheckGear(); CheckConditionsAndShop(); Check(RpgBattleService.Start("rpg_wolf", WinFlag), "a battle with the stat-patched wolf starts from the map"); Godot.Engine.TimeScale = 4; Next(); return; case 3: if (GetTree().CurrentScene is not BattleSceneContext battle) return; if (battle.ActiveBattleCore is not IBattleAutoPlayable auto || !auto.IsWaitingForPlayerInput || GodotSceneService.IsTransitioning || ++waitTicks < BattleIntroTicks) return; Shot("battle"); CheckBattle(); battle.ShowWinground(); Next(); return; case 4: if (GetTree().Root.FindChild("ContinueButton", true, false) is not Button button || !button.IsVisibleInTree()) return; button.EmitSignal(BaseButton.SignalName.Pressed); Next(); return; case 5: if (View?.Map?.ID != Village || GodotSceneService.IsTransitioning || AdvanceDialog()) return; Godot.Engine.TimeScale = 1; // The victory is taken by the platform's shortcut (the wolf is not killed), so it // awards no exp; the exp path is the training yard below. if (!CheckPagesAndRoute() || !CheckNight()) return; Check(Flag(WinFlag) == "1", "the win flag is set after the victory"); // To the trainer, added to the demo's village by this mod's own map events file. Check(View.FindEvent("rpg_plus_trainer")?.Visible == true, "the trainer stands in the demo's village"); View.PlacePlayer(26, 10, RpgDirection.Right); choices.Enqueue(0); // the trainer: to the yard choices.Enqueue(0); // the yard: drill choices.Enqueue(2); // the yard: back to the village Tap(Key.E); Next(); return; case 6: sawYard |= SceneName == Yard; if (AdvanceDialog()) return; if (!sawYard || View?.Map?.ID != Village || GodotSceneService.IsTransitioning || ++waitTicks < 3) return; CheckTraining(); Next(); return; case 7: // The toolbar button's own path. UiRegistry.Toggle(SheetEntry); var panel = SheetPanel(); Check(panel != null && panel.Visible, "the toolbar entry 'sheet' opens the character sheet"); if (panel == null) { Finish(); return; } panel.Select(Hero); Next(); return; case 8: if (++waitTicks < 2) return; Shot("sheet_full"); CheckPanelAndTakeOff(SheetPanel()); Next(); return; case 9: if (++waitTicks < 3) return; var sheetPanel = SheetPanel(); Check(EquipmentService.ItemIn(Hero, "weapon") == null, "Take off in the sheet empties the weapon slot"); Check(Inventory.CheckCount(IronSword) == 1, "the sword is back in the bag"); Check(sheetPanel.FindChild("Slot_weapon", true, false)?.FindChild("TakeOff", true, false) == null, "the redrawn sheet shows the slot empty"); Shot("sheet"); sheetPanel.Hide(); Check(EquipmentService.Equip(Hero, Dagger), "equip.put's service puts the dagger on from the bag"); SaveService.Save(SaveSlot); GameSession.OpenMainMenu(GetTree()); Next(); return; case 10: if (RpgWorldHost.Current != null || ++waitTicks < 5) return; Check(GameSession.LoadAndEnter(this, SaveSlot), "the save loads"); Next(); return; case 11: if (View?.Map?.ID != Village || GodotSceneService.IsTransitioning || ++waitTicks < 5) return; CheckLoaded(); SaveService.DeleteSave(SaveSlot); Finish(); return; } } private void RememberCheck() { var value = Box?.Runtime?.GetVariable("check_value"); if (!string.IsNullOrEmpty(value)) { checkValue = value; checkResult = Box.Runtime.GetVariable("check_result"); } } private void CheckPrologue() { Check(StatsService.ArchetypeOf(Hero) == Rogue, $"the prologue's choice made the hero a rogue, got {StatsService.ArchetypeOf(Hero)}"); Check(EquipmentService.ItemIn(Hero, "weapon") == Dagger, "the rogue starts with the dagger in the weapon slot"); Check(Inventory.CheckCount("money/coin") >= 25, "the purse of the prologue is in the bag"); var cha = StatsService.Sheet(Hero).Rounded("cha"); Check(checkValue == cha.ToString(), $"the check line rolled the hero's charisma ({checkValue} vs {cha})"); var success = checkResult == TranslationServer.Translate("checks/success").ToString(); Check(success == Inventory.HasItem(Charm), $"the seer's charm follows the check's outcome ({checkResult})"); } private void CheckSheet() { var sheet = StatsService.Sheet(Hero); Check(sheet.GetBase("agi") == 10 && sheet.Get("agi") == 12, $"rogue agility 8 + 2*level, +2 from the dagger: {sheet.GetBase("agi")} -> {sheet.Get("agi")}"); Check(sheet.Rounded("atk") == 13, $"attack = str + level + agi/4 (rogue trait) + 3 (dagger) = 13, got {sheet.Get("atk")}"); Check(Entry.Attributes.Attack.Value == 13 && Entry.Attributes.Attack.BaseValue == 13, $"the bound attack reached the battle attribute: {Entry.Attributes.Attack.Value}"); Check(Entry.Attributes.Health.BaseValue == sheet.Rounded("hp") && sheet.Rounded("hp") == 29, $"max health 12 + vit*3 + level*2 = 29 reached the battle attribute: {Entry.Attributes.Health.BaseValue}"); Check(Entry.ActionSlots?.Contains("classic/power_strike") == true, "the rogue's level 1 skill went into an action slot"); } private void CheckGear() { Inventory.Add(IronSword, 1); Check(UseOnHero(IronSword), "the bag's equip action puts the iron sword on"); Check(EquipmentService.ItemIn(Hero, "weapon") == IronSword, "the sword is in the weapon slot"); Check(Inventory.CheckCount(Dagger) == 1 && Inventory.CheckCount(IronSword) == 0, "the dagger went back to the bag, the sword left it"); var sheet = StatsService.Sheet(Hero); Check(sheet.Get("agi") == 10 && sheet.Rounded("atk") == 14, $"without the dagger: agi 10, atk 7 + 2 + 5 = 14, got {sheet.Get("agi")} / {sheet.Get("atk")}"); Check(Entry.Attributes.Attack.Value == 14, "the battle attribute follows the gear change"); Inventory.Add(Staff, 1); Check(!UseOnHero(Staff), "a mages-only staff refuses a rogue"); Check(Inventory.CheckCount(Staff) == 1 && EquipmentService.ItemIn(Hero, "weapon") == IronSword, "the refused staff stays in the bag"); Inventory.Add(Tonic, 1); Check(UseOnHero(Tonic), "the strength tonic is applied"); Check(StatsService.Sheet(Hero).Rounded("str") == 7 && StatsService.Sheet(Hero).Rounded("atk") == 15, "the tonic raised strength for good and attack with it"); } private void CheckConditionsAndShop() { Check(ScenarioCondition.Evaluate("equipped(rpg_hero, 'classic/iron_sword') && archetype(rpg_hero, 'classic/rogue') && stat(rpg_hero, str) >= 7 && level(rpg_hero) == 1"), "conditions read equipment, archetype, stats and level"); Check(!ScenarioCondition.Evaluate("equipped(rpg_hero, 'classic/dagger')"), "a dagger in the bag is not worn"); var shop = ShopFactory.Instance.Get("rpg_demo/village_merchant"); Check(shop?.FindSell(IronSword)?.Price == 30, "the shop patch added the gear to the demo's merchant"); Check(shop?.FindSell("rpg_demo/elixir") != null, "the merchant still sells his own elixir"); } private void CheckBattle() { var wolf = BattleRequest.Current?.EnemiesCards?.FirstOrDefault(e => e?.ID == "rpg_wolf"); Check(wolf != null, "the wolf is in the battle"); if (wolf == null) return; var wolfSheet = StatsService.Sheet("rpg_wolf"); Check(wolf.Attributes.Health.BaseValue == wolfSheet.Rounded("hp") && wolfSheet.Rounded("hp") == 31, $"the patched wolf fights with its sheet's health 31, got {wolf.Attributes.Health.BaseValue}"); Check(wolf.Attributes.Defence.Value == 2, $"and its sheet's shield floor(vit/2) = 2, got {wolf.Attributes.Defence.Value}"); var before = wolf.Attributes.Health.Value; var hero = new Character { ID = Hero, Level = StatsService.LevelOf(Hero), Attributes = Entry.Attributes }; hero.Attributes.Point.Value = System.Math.Max(1, hero.Attributes.Point.Value); var result = ActionExecutorHub.Instance.Execute(ActionCharacterFactory.Instance.Get("classic/power_strike"), hero, wolf); Check(result.Result && result.Target.Damage > 0, $"the formula skill power strike hits, damage {result.Target.Damage}"); Check(wolf.Attributes.Health.Value == before - result.Target.Damage, "the damage left the wolf's health"); Check(result.Target.Defence == -2, $"the shield took its 2 first, change {result.Target.Defence}"); // Mana as a bar of the battle core: battle_formulas gives the pool, ff_battle draws it. var mana = BattleUnitGauges.Read(hero).FirstOrDefault(g => g.Id == "mp"); Check(mana != null && mana.Max == StatsService.Sheet(Hero).Rounded("mp") && mana.Current <= mana.Max, $"the hero has a mana gauge of the sheet's mp, got {mana?.Current}/{mana?.Max}"); var bars = GetTree().Root.FindChildren("Gauges", "VBoxContainer", true, false).OfType().Where(c => c.IsVisibleInTree()).ToList(); Check(bars.Any(b => b.FindChildren("Value", "Label", true, false).OfType