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