Desert notes after retiring the alternative renders; mod documentation
This commit is contained in:
1 parent
c2a5074337
commit
36882e50cf
40 files changed
+1867
-70
No files matched your search
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Desert and supply barrel test",
|
||||
"description": "West/east transitions, one-time barrel loot, save/load and both desert monster battles.",
|
||||
"sources": ["src/*.cs"],
|
||||
"depends": ["rpg_plus_game>=1", "ff_battle>=1"]
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
namespace Cthangover.Mods.RpgPlus.Tests
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Battle;
|
||||
using Cthangover.Core.Bootstrap;
|
||||
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.Interface.View;
|
||||
using Cthangover.Mods.Rpg;
|
||||
using Cthangover.FFBattle.UI;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises authored data through real interaction, battle rewards and save/load.
|
||||
/// Enemy HP is reduced only after the battle starts, keeping the UI victory/loot path.
|
||||
/// </summary>
|
||||
public partial class DesertTestDriver : Node
|
||||
{
|
||||
private const string Slot = "slot_9918";
|
||||
private const string VillageBarrel = "supply_rpg_village_19_11";
|
||||
private readonly Queue<int> choices = new(new[] { 0, 1 });
|
||||
private readonly string shots;
|
||||
private int step, ticks, passed, failed, coins, branches, elixirs, stones, resin;
|
||||
private double elapsed;
|
||||
private ulong nextAttack;
|
||||
private BattleSceneContext testedBattle;
|
||||
private Key? releaseKey;
|
||||
private static RpgWorldView View => RpgWorldHost.Current?.View;
|
||||
private static DialogBox Box => RpgInput.FindDialogBox();
|
||||
private static IInventory Bag => GameData.Instance.Runtime.Inventory;
|
||||
|
||||
public DesertTestDriver(string shots)
|
||||
{
|
||||
this.shots = shots;
|
||||
Name = "DesertTestDriver";
|
||||
var timer = new Timer { WaitTime = 0.1, Autostart = true };
|
||||
timer.Timeout += Tick;
|
||||
AddChild(timer);
|
||||
}
|
||||
|
||||
private void Tick()
|
||||
{
|
||||
if (step < 0) return;
|
||||
if (releaseKey.HasValue)
|
||||
{
|
||||
var key = releaseKey.Value;
|
||||
Input.ParseInputEvent(new InputEventKey { PhysicalKeycode = key, Keycode = key, Pressed = false });
|
||||
releaseKey = null;
|
||||
}
|
||||
elapsed += 0.1;
|
||||
if (elapsed > 180) { Check(false, $"timeout at step {step}"); Finish(); return; }
|
||||
try { RunStep(); }
|
||||
catch (Exception ex) { Check(false, ex.ToString()); Finish(); }
|
||||
}
|
||||
|
||||
private void RunStep()
|
||||
{
|
||||
var scene = GetTree().Root.GetNodeOrNull<SceneManager>("SceneManager")?.CurrentSceneName;
|
||||
if (!Boot.IsReady || string.IsNullOrEmpty(scene)) return;
|
||||
if (Box?.IsRunning == true)
|
||||
{
|
||||
if (Box.IsWaitingAnswer) Box.TrySelectVariant(choices.Count > 0 ? choices.Dequeue() : 0);
|
||||
else Box.NextAction();
|
||||
return;
|
||||
}
|
||||
if (GodotSceneService.IsTransitioning || ++ticks < 3) return;
|
||||
switch (step)
|
||||
{
|
||||
case 0:
|
||||
if (View?.Map.ID != "rpg_village") return;
|
||||
Check(View.FindEvent("village_to_desert")?.Current.Teleport.Map == "rpg_desert", "west exit points to desert");
|
||||
Check(View.CanEnter(new Vector2I(0, 9)), "west village border is passable");
|
||||
coins = Bag.CheckCount("money/coin"); branches = Bag.CheckCount("rpg_demo/branches");
|
||||
View.PlacePlayer(19, 12, RpgDirection.Up);
|
||||
Check(View.Interact(), "closed supply barrel can be opened");
|
||||
Next(); return;
|
||||
case 1:
|
||||
Check(Bag.CheckCount("money/coin") == coins + 2 && Bag.CheckCount("rpg_demo/branches") == branches + 1, "barrel grants the authored small loot");
|
||||
Check(View.FindEvent(VillageBarrel)?.Current.Sprite == "desert/barrel_open", "barrel switches to open graphic");
|
||||
View.Interact(); Next(); return;
|
||||
case 2:
|
||||
Check(Bag.CheckCount("money/coin") == coins + 2 && Bag.CheckCount("rpg_demo/branches") == branches + 1, "opening twice does not duplicate loot");
|
||||
SaveService.Save(Slot);
|
||||
Check(GameSession.LoadAndEnter(this, Slot), "barrel save reloads");
|
||||
Next(); return;
|
||||
case 3:
|
||||
if (View?.Map.ID != "rpg_village") return;
|
||||
Check(View.FindEvent(VillageBarrel)?.PageIndex == 1, "open barrel survives reload");
|
||||
Check(Bag.CheckCount("money/coin") == coins + 2, "barrel coins survive reload without duplication");
|
||||
View.PlacePlayer(1, 9, RpgDirection.Left); Tap(Key.Left); Next(); return;
|
||||
case 4:
|
||||
if (View?.Map.ID != "rpg_desert") return;
|
||||
Check(View.Player.Cell == new Vector2I(38, 12), "walking west arrives at the east desert entrance");
|
||||
Check(View.Tileset.ID == "desert", "desert uses its own art and sand surface");
|
||||
Check(View.FindEvent("desert_scorpion_1")?.Visible == true && View.FindEvent("desert_beetle_2")?.Visible == true, "both desert monster species are present");
|
||||
Check(View.CanEnter(new Vector2I(39, 12)), "return passage is open");
|
||||
Shot("desert");
|
||||
coins = Bag.CheckCount("money/coin"); elixirs = Bag.CheckCount("rpg_demo/elixir");
|
||||
View.PlacePlayer(17, 9, RpgDirection.Up); Check(View.Interact(), "desert supply barrel opens"); Next(); return;
|
||||
case 5:
|
||||
Check(Bag.CheckCount("money/coin") == coins + 3 && Bag.CheckCount("rpg_demo/elixir") == elixirs + 1, "desert barrel grants coins and an elixir");
|
||||
View.Interact(); Next(); return;
|
||||
case 6:
|
||||
Check(Bag.CheckCount("money/coin") == coins + 3 && Bag.CheckCount("rpg_demo/elixir") == elixirs + 1, "desert barrel is repeat safe");
|
||||
stones = Bag.CheckCount("rpg_demo/stones");
|
||||
View.PlacePlayer(26, 12, RpgDirection.Right);
|
||||
Check(View.Interact(), "scorpion interaction starts its battle"); Next(); return;
|
||||
case 7:
|
||||
if (!Win("rpg_desert_scorpion")) return;
|
||||
Next(); return;
|
||||
case 8:
|
||||
if (!ContinueVictory()) return;
|
||||
Next(); return;
|
||||
case 9:
|
||||
if (View?.Map.ID != "rpg_desert") return;
|
||||
Check(View.FindEvent("desert_scorpion_1")?.Visible == false, "defeated scorpion disappears");
|
||||
Check(Bag.CheckCount("rpg_demo/stones") >= stones + 1, "scorpion victory grants its loot");
|
||||
resin = Bag.CheckCount("rpg_plus/resin");
|
||||
View.PlacePlayer(22, 14, RpgDirection.Up);
|
||||
Check(View.Interact(), "beetle interaction starts its battle"); Next(); return;
|
||||
case 10:
|
||||
if (!Win("rpg_desert_beetle")) return;
|
||||
Next(); return;
|
||||
case 11:
|
||||
if (!ContinueVictory()) return;
|
||||
Next(); return;
|
||||
case 12:
|
||||
if (View?.Map.ID != "rpg_desert") return;
|
||||
Check(View.FindEvent("desert_beetle_2")?.Visible == false, "defeated beetle disappears");
|
||||
Check(Bag.CheckCount("rpg_plus/resin") >= resin + 1, "beetle grants its loot");
|
||||
SaveService.Save(Slot);
|
||||
Check(GameSession.LoadAndEnter(this, Slot), "desert progress reloads");
|
||||
Next(); return;
|
||||
case 13:
|
||||
if (View?.Map.ID != "rpg_desert") return;
|
||||
Check(View.FindEvent("desert_scorpion_1")?.Visible == false && View.FindEvent("desert_beetle_2")?.Visible == false, "defeated monsters stay gone after reload");
|
||||
Check(View.FindEvent("desert_barrel_supply")?.PageIndex == 1, "desert barrel stays open after reload");
|
||||
View.PlacePlayer(38, 12, RpgDirection.Right); Tap(Key.Right); Next(); return;
|
||||
case 14:
|
||||
if (View?.Map.ID != "rpg_village") return;
|
||||
Check(View.Player.Cell == new Vector2I(1, 9), "east desert exit returns inside west village edge");
|
||||
Check(View.FindEvent(VillageBarrel)?.PageIndex == 1, "village barrel remains open after the round trip");
|
||||
SaveService.DeleteSave(Slot); Finish(); return;
|
||||
}
|
||||
}
|
||||
|
||||
private bool Win(string id)
|
||||
{
|
||||
if (GetTree().CurrentScene is not BattleSceneContext battle) return false;
|
||||
if (battle.IsDestroyed) return true;
|
||||
if (battle.ActiveBattleCore is not IBattleAutoPlayable auto || !auto.IsWaitingForPlayerInput || Time.GetTicksMsec() < nextAttack) return false;
|
||||
var enemies = battle.FindChild("FFEnemyPanel", true, false) as FFEnemyPanel;
|
||||
var players = battle.FindChild("FFPlayerPanel", true, false) as FFPlayerPanel;
|
||||
var menu = battle.FindChildren("*", "", true, false).OfType<FFMenuPanel>().FirstOrDefault();
|
||||
if (enemies == null || players == null || menu == null) return false;
|
||||
if (testedBattle != battle)
|
||||
{
|
||||
testedBattle = battle;
|
||||
Check(enemies.Widgets.Count == 1 && enemies.Widgets[0].Card.ID == id, "battle contains " + id);
|
||||
foreach (var enemy in enemies.Widgets) { enemy.Card.Attributes.Defence.Value = 0; enemy.Card.Attributes.Health.Value = 1; }
|
||||
}
|
||||
var target = enemies.Widgets.FirstOrDefault(w => !w.IsDead);
|
||||
var player = players.Widgets.FirstOrDefault(w => !w.IsDead);
|
||||
if (target == null || player == null) return false;
|
||||
player.Card.Attributes.Point.Value = 100;
|
||||
Click(player);
|
||||
menu.SelectedIndex = menu.Entries.FindIndex(e => e.Key == "attack"); menu.ConfirmSelection();
|
||||
menu.SelectedIndex = menu.Entries.FindIndex(e => e.Data is Cthangover.Core.Characters.ActionCharacter action && action.Type == Cthangover.Core.Characters.ActionCharacterType.ToEnemy);
|
||||
menu.ConfirmSelection(); Click(target); nextAttack = Time.GetTicksMsec() + 1600;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool ContinueVictory()
|
||||
{
|
||||
if (GetTree().Root.FindChild("ContinueButton", true, false) is not Button button || !button.IsVisibleInTree()) return false;
|
||||
button.EmitSignal(BaseButton.SignalName.Pressed); return true;
|
||||
}
|
||||
private static void Click(Control c) => c.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true });
|
||||
private void Tap(Key key)
|
||||
{
|
||||
// Movement samples held input in _Process; retain the press until
|
||||
// the next timer tick so at least one real simulation frame sees it.
|
||||
Input.ParseInputEvent(new InputEventKey { PhysicalKeycode = key, Keycode = key, Pressed = true });
|
||||
releaseKey = key;
|
||||
}
|
||||
private void Shot(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(shots) || DisplayServer.GetName() == "headless") return;
|
||||
DirAccess.MakeDirRecursiveAbsolute(shots);
|
||||
ViewportCapture.SavePng(GetViewport(), shots.TrimEnd('/') + "/" + name + ".png");
|
||||
}
|
||||
private void Next() { step++; ticks = 0; }
|
||||
private void Check(bool ok, string message)
|
||||
{
|
||||
if (ok) passed++; else failed++;
|
||||
GameLogger.Log("TEST", (ok ? "PASS " : "FAIL ") + message);
|
||||
}
|
||||
private void Finish()
|
||||
{
|
||||
step = -1;
|
||||
GameLogger.Log("TEST", $"[TEST_RESULT] name=desert passed={passed} failed={failed}");
|
||||
GetTree().Quit(failed == 0 ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Cthangover.Mods.RpgPlus.Tests
|
||||
{
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Mods;
|
||||
using Godot;
|
||||
|
||||
/// <summary>Mounts the opt-in desert playthrough without adding production gameplay code.</summary>
|
||||
public class DesertTestInitializer : IModInitializer
|
||||
{
|
||||
private static bool queued;
|
||||
public void OnModLoaded(string id) { }
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
var args = OS.GetCmdlineUserArgs();
|
||||
if (queued || !args.Contains("--itest=desert")) return;
|
||||
queued = true;
|
||||
string shots = null;
|
||||
foreach (var arg in args)
|
||||
if (arg.StartsWith("--shots=")) shots = arg.Substring(8);
|
||||
ModRoot.AddDeferred(new DesertTestDriver(shots));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,9 +419,9 @@ namespace Cthangover.Mods.RpgPlus.Tests
|
||||
nightPhase++;
|
||||
return false;
|
||||
case 1:
|
||||
var material = View.GetNodeOrNull<TileMapLayer>("Main")?.Material as ShaderMaterial;
|
||||
var material = View.Presentation?.SharedMaterial;
|
||||
Check(material != null, "the tiles have the map's light material");
|
||||
Check(material != null && View.Player.Material == material, "the player shares it");
|
||||
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");
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace Cthangover.Mods.RpgPlus.Tests
|
||||
private readonly Queue<int> choices = new();
|
||||
private readonly bool pact;
|
||||
private readonly string shots;
|
||||
private readonly bool terrainReview;
|
||||
private int step;
|
||||
private int ticks;
|
||||
private int passed;
|
||||
@@ -60,6 +61,7 @@ namespace Cthangover.Mods.RpgPlus.Tests
|
||||
{
|
||||
this.pact = pact;
|
||||
this.shots = shots;
|
||||
terrainReview = OS.GetCmdlineUserArgs().Contains("--terrain-review");
|
||||
Name = "WoodlandTestDriver";
|
||||
var timer = new Timer { WaitTime = 0.1, Autostart = true };
|
||||
timer.Timeout += Tick;
|
||||
@@ -204,6 +206,7 @@ namespace Cthangover.Mods.RpgPlus.Tests
|
||||
Go("rpg_elder_house", 6, 4, RpgDirection.Up); Next(); return;
|
||||
case 21:
|
||||
if (View?.Map.ID != "rpg_elder_house") return;
|
||||
Shot("interior");
|
||||
Tap(Key.E); Next(); return;
|
||||
case 22:
|
||||
Check(Quest("ward").Data.Status == 2, "elder supplies the lantern recipe objective");
|
||||
@@ -423,12 +426,49 @@ namespace Cthangover.Mods.RpgPlus.Tests
|
||||
if (villageShotPhase == 1)
|
||||
{
|
||||
Shot("village");
|
||||
time.SetTime(shotTime);
|
||||
if (terrainReview)
|
||||
CheckWaterMaterials();
|
||||
else
|
||||
time.SetTime(shotTime);
|
||||
villageShotPhase = 2;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void CheckWaterMaterials()
|
||||
{
|
||||
var tileset = View.Tileset;
|
||||
var textures = new RpgTextures();
|
||||
Check(tileset.Image == "village/objects" && tileset.SurfaceImage == "village/surfaces",
|
||||
"objects and surfaces use separate atlases");
|
||||
Check(tileset.TileOf(40)?.Surface != null && tileset.AutotileOf(40) == null,
|
||||
"water uses a base material with runtime diffusion");
|
||||
Check(tileset.Tiles.Count(t => t.Surface != null) == 7 &&
|
||||
tileset.Tiles.All(t => t.Index < 192 || t.Index >= 1000 && t.Index < 1120),
|
||||
"only seven base surfaces remain, with no baked phase or transition ids");
|
||||
var roomTop = tileset.TileOf(1000)?.AtlasIndex ?? -1;
|
||||
var roomPacked = roomTop >= 0 && roomTop % tileset.Columns + 12 <= tileset.Columns;
|
||||
for (var cell = 0; cell < 120; cell++)
|
||||
roomPacked &= tileset.TileOf(1000 + cell)?.AtlasIndex == roomTop + cell / 12 * tileset.Columns + cell % 12;
|
||||
Check(roomPacked, "the authored room occupies a connected 12x10 atlas block");
|
||||
Check(View.Presentation?.SurfaceChunkCount > 0,
|
||||
"the world renders the computed surface layer");
|
||||
var caveTop = tileset.TileOf(128)?.AtlasIndex ?? -1;
|
||||
Check(caveTop >= 0 && caveTop % tileset.Columns + 1 < tileset.Columns &&
|
||||
tileset.TileOf(129)?.AtlasIndex == caveTop + 1 &&
|
||||
tileset.TileOf(27)?.AtlasIndex == caveTop + tileset.Columns &&
|
||||
tileset.TileOf(130)?.AtlasIndex == caveTop + tileset.Columns + 1,
|
||||
"the cave occupies a complete 2x2 atlas rectangle");
|
||||
var lampTop = tileset.TileOf(29)?.AtlasIndex ?? -1;
|
||||
Check(lampTop >= 0 && tileset.TileOf(28)?.AtlasIndex == lampTop + tileset.Columns,
|
||||
"the streetlamp head and foot form one vertical atlas rectangle");
|
||||
var grid = RpgMapGrid.Parse(View.Map);
|
||||
var generated = RpgSurfaceRenderer.Build(grid, RpgLayer.Back, tileset, textures);
|
||||
Check(generated?.GetWidth() == grid.Width * tileset.TileSize,
|
||||
"diffusion is generated from the current map at runtime");
|
||||
generated?.Dispose();
|
||||
}
|
||||
|
||||
private void Shot(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(shots) || DisplayServer.GetName() == "headless") return;
|
||||
|
||||
Reference in new issue
Block a user