Files
mod_rpg_plus_game/test_suites/play/src/RpgPlusTestDriver.cs
T

497 lines
24 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
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<string> failures = new();
private readonly Queue<int> 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>("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<Control>().Where(c => c.IsVisibleInTree()).ToList();
Check(bars.Any(b => b.FindChildren("Value", "Label", true, false).OfType<Label>().Any(l => l.Text == $"{mana?.Current}/{mana?.Max}")), "the FF party panel shows the mana bar with its numbers");
}
private void CheckTraining()
{
Check(Flag("rpg_plus_drilled") == "1", "the training yard's drill ran");
Check(StatsService.LevelOf(Hero) == 2, $"20 exp from the drill took the hero to level 2, level {StatsService.LevelOf(Hero)}, exp {StatsService.ExpOf(Hero)}");
var learned = Entry.ActionSlots?.Contains("classic/guard_break") == true || GameData.Instance.Runtime.ActionPool.ActionIds.Contains("classic/guard_break");
Check(learned, "level 2 taught the rogue guard break (a slot or the action pool)");
var sheet = StatsService.Sheet(Hero);
Check(Entry.Attributes.Health.BaseValue == sheet.Rounded("hp") && sheet.Rounded("hp") == 34, $"max health grew with the level to 34, got {Entry.Attributes.Health.BaseValue}");
}
private void CheckPanelAndTakeOff(StatSheetPanel panel)
{
Check(panel.Visible && panel.SelectedId == Hero, "the sheet shows the hero");
Check(panel.FindChild("Stat_agi", true, false) != null && panel.FindChild("Stat_hp", true, false) != null, "the sheet lists the pack's stats");
Check(panel.FindChild("Section_equipment", true, false) != null, "the equipment mod's block is in the sheet");
var takeOff = panel.FindChild("Slot_weapon", true, false)?.FindChild("TakeOff", true, false) as Button;
Check(takeOff != null, "the worn sword has a Take off button");
takeOff?.EmitSignal(BaseButton.SignalName.Pressed);
}
private void CheckLoaded()
{
Check(StatsService.ArchetypeOf(Hero) == Rogue, "the archetype survives the save");
Check(EquipmentService.ItemIn(Hero, "weapon") == Dagger, "the worn dagger survives the save");
Check(StatsService.LevelOf(Hero) == 2, "the level survives the save");
var sheet = StatsService.Sheet(Hero);
Check(StatState.Instance.Find(Hero)?.Bonus.GetValueOrDefault("str") == 1, "the tonic's permanent bonus survives the save");
Check(sheet.Rounded("str") == 8, $"strength at level 2: 5 + 2 + 1 (tonic) = 8, got {sheet.Get("str")}");
Check(Entry.Attributes.Attack.Value == sheet.Rounded("atk"), "the loaded battle attributes agree with the sheet");
}
private static bool UseOnHero(string itemId)
{
// What the inventory panel does after the player picks the hero as the target.
var item = ItemFactory.Instance.Get(itemId);
if (item?.ItemAction is not ITargetedItemAction targeted || !targeted.UseOn(item, Entry))
return false;
Inventory.Remove(item, 1);
return true;
}
private static StatSheetPanel SheetPanel()
=> UiHost.Instance != null && UiHost.Instance.TryGetInstance(SheetEntry, out var control) ? control as StatSheetPanel : null;
/// <summary>
/// Event pages and a move route (rpg): the tame slime walks its route, the chest gives
/// coins once and then shows its open page. True when done; runs over several ticks.
/// </summary>
private bool CheckPagesAndRoute()
{
var chest = View.FindEvent("rpg_plus_chest");
var slime = View.FindEvent("rpg_plus_pet_slime");
switch (pagesPhase)
{
case 0:
Check(chest?.Visible == true && chest.PageIndex == 0, "the closed chest shows its first page");
Check(slime?.Current?.Route != null, "the tame slime has a route");
pagesWait = 0;
pagesPhase++;
return false;
case 1:
// Left, wait, right, right…: within three seconds it has left its cell once.
slimeMoved |= slime != null && slime.Cell != new Vector2I(6, 15);
pagesWait += TickSeconds;
if (pagesWait < 3)
return false;
Check(slimeMoved, "the slime walks its route on its own");
coinsBeforeChest = Inventory.CheckCount("money/coin");
View.PlacePlayer(28, 3, RpgDirection.Right);
Tap(Key.E);
pagesPhase++;
return false;
case 2:
if (AdvanceDialog())
return false;
Check(Inventory.CheckCount("money/coin") == coinsBeforeChest + 10, "the chest gave ten coins");
Check(chest?.PageIndex == 1 && chest.Current.Tile == 15, "the flag switched the chest to its open page");
Check(chest?.Current?.Commands?.Count == 1, "the open page has its own commands");
pagesPhase++;
return true;
default:
return true;
}
}
/// <summary>
/// Light of the map (rpg): at 23:00 the layers and actors share one lit material with
/// night weights, and the player's glow, the lamp and the windows of the village tileset
/// are lit. Shot "night" next to the lamp; the clock is put back afterwards.
/// </summary>
private bool CheckNight()
{
var time = GameData.Instance.Runtime.Time;
switch (nightPhase)
{
case 0:
savedTime = time.Tick;
time.SetTime(savedTime - savedTime % (24 * 60) + 23 * 60);
View.PlacePlayer(12, 9, RpgDirection.Up);
nightPhase++;
return false;
case 1:
var material = View.Presentation?.SharedMaterial;
Check(material != null, "the tiles have the map's light material");
Check(material != null && View.Presentation.ActorsShareMaterial, "the player shares it");
var weights = material?.GetShaderParameter("time_weights").AsVector4() ?? Vector4.Zero;
Check(Mathf.IsEqualApprox(weights.W, 1f), "23:00 lights the map as night");
Check((material?.GetShaderParameter("light_count").AsInt32() ?? 0) >= 4, "the player, the lamp and windows give light");
Shot("night");
time.SetTime(savedTime);
nightPhase++;
return true;
default:
return true;
}
}
private bool AdvanceDialog()
{
var box = Box;
if (box == null || !box.IsRunning)
return false;
if (box.IsWaitingAnswer)
box.TrySelectVariant(choices.Count > 0 ? choices.Dequeue() : 0);
else
box.NextAction();
return true;
}
private static void Tap(Key key)
{
Input.ParseInputEvent(new InputEventKey { PhysicalKeycode = key, Keycode = key, Pressed = true });
Input.ParseInputEvent(new InputEventKey { PhysicalKeycode = key, Keycode = key, Pressed = false });
}
private void Next()
{
step++;
waitTicks = 0;
}
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);
ViewportCapture.SavePng(GetViewport(), shotsDir.TrimEnd('/') + "/rpg_plus_" + name + ".png");
}
private void Finish()
{
step = -1;
Godot.Engine.TimeScale = 1;
GameLogger.Log("TEST", $"[TEST_RESULT] name=rpg_plus passed={passed} failed={failures.Count}");
GetTree().Quit(failures.Count == 0 ? 0 : 1);
}
}
}