using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Loader;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Games;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
using Cthangover.Core.Utils;
using Godot;
namespace Mods.ResettablesTest
{
///
/// Plan 3.1: a mod that leaves the set takes its new-game owner
/// (IResettable) with it, and a mod loaded again registers exactly one
/// owner, from its new assembly. The stats mod (stats_base, id
/// stats) is the probe: it is in rpg_plus only.
///
/// - rpg_plus (new game): one stats owner, from the live
/// stats_base context; a character gains a stat.
/// - novel_template: stats_base unloaded, no stats
/// owner left, its old context released.
/// - rpg_plus again: exactly one stats owner, from the new
/// context; the character gains a stat again.
/// - New game: the loaded state is reset (no characters left).
///
/// The state is read through the mod's own types (reflection), because a suite
/// cannot depend on stats_base without keeping it loaded in every game.
/// Prints [TEST_RESULT] name=resettables.
///
public partial class ResettablesTestDriverNode : Node
{
public const string Flag = "--itest=resettables";
private const string StatsMod = "stats_base";
private const string StatsOwnerId = "stats";
private const string StatStateType = "Cthangover.Mods.Stats.StatState";
private const string Probe = "itest_resettables";
private const string GameWithStats = "rpg_plus";
private const string GameWithoutStats = "novel_template";
private const float TimeoutSeconds = 420f;
private readonly List failures = new();
private readonly List unloadReport = new();
private int passed;
private double elapsed;
private bool started;
private bool done;
public override void _Ready()
{
// The driver keeps ticking across every mod-set switch of the run.
ProcessMode = ProcessModeEnum.Always;
GameLogger.LogEmitted += OnLogEmitted;
}
public override void _ExitTree()
{
GameLogger.LogEmitted -= OnLogEmitted;
}
private void OnLogEmitted(string category, string message, LogLevel level)
{
if (category != "MODS_UNLOAD")
return;
lock (unloadReport)
unloadReport.Add(message);
}
public override void _Process(double delta)
{
if (done)
return;
elapsed += delta;
if (elapsed > TimeoutSeconds)
{
failures.Add("timeout");
Finish();
return;
}
if (started || elapsed < 3 || !Boot.IsReady || GetTree().CurrentScene == null)
return;
if (GameSession.IsSwitching || ModManager.Instance.IsReloading)
return;
started = true;
RunAsync();
}
private async void RunAsync()
{
try
{
await Run();
}
catch (Exception ex)
{
failures.Add($"unhandled {ex.GetType().Name}: {ex.Message}");
}
Finish();
}
private async System.Threading.Tasks.Task Run()
{
// 1. Enter the game with the stats mod: one owner, from the live context.
var entered = await GameSession.SwitchToAsync(this, GameWithStats);
Check($"switched into '{GameWithStats}'", entered && ActiveGame.Id == GameWithStats);
Check($"'{GameWithStats}' released every old context", ModManager.Instance.LastReload?.Leaked.Count == 0);
Check($"'{StatsMod}' is loaded in '{GameWithStats}'", LiveStatsContexts().Count == 1);
Check("one stats owner is registered", StatsEntries().Count == 1);
Check("the stats owner comes from the loaded stats mod", OwnerIsLive());
Check("stats state is reachable", StateReachable());
MarkProbe();
Check("the stats state holds the probe before leaving", ProbeCount() == 1);
// 2. Leave for a game without the stats mod: its owner goes with it.
lock (unloadReport)
unloadReport.Clear();
entered = await GameSession.SwitchToAsync(this, GameWithoutStats);
Check($"switched into '{GameWithoutStats}'", entered && ActiveGame.Id == GameWithoutStats);
Check($"'{StatsMod}' left the set", ModManager.Instance.LastReload?.Plan?.Unload.Contains(StatsMod) == true);
Check($"'{GameWithoutStats}' released the old stats context", ModManager.Instance.LastReload?.Leaked.Count == 0);
Check("no stats owner remains after it left", StatsEntries().Count == 0);
Check("no stats context stays alive", LiveStatsContexts().Count == 0);
// The unload pass drops the owners itself; the static sweep dropping them
// behind the owner's back is only a fallback that the report names.
lock (unloadReport)
{
Check("the unload pass dropped the mod's new-game owners itself", unloadReport.Any(m => m.Contains("new-game resettable(s) of mod code")));
Check("the static sweep did not have to drop a new-game owner", !unloadReport.Any(m => m.Contains("AppServices.resettables: removed")));
}
// 3. Back with the stats mod: a single owner, from the new context.
entered = await GameSession.SwitchToAsync(this, GameWithStats);
Check($"switched back into '{GameWithStats}'", entered && ActiveGame.Id == GameWithStats);
Check($"'{GameWithStats}' (again) released every old context", ModManager.Instance.LastReload?.Leaked.Count == 0);
Check("exactly one stats owner after the reload", StatsEntries().Count == 1);
Check("the stats owner comes from the reloaded stats mod", OwnerIsLive());
Check("stats state is reachable after the reload", StateReachable());
MarkProbe();
Check("the reloaded state holds the probe before New game", ProbeCount() == 1);
// 4. New game resets the state of the loaded mod.
GameData.Instance.StartNewGame();
Check("New game reset the stats of the loaded mod", ProbeCount() == 0);
Check($"still in '{GameWithStats}' after New game", ActiveGame.Id == GameWithStats);
}
/// Live (not yet released) contexts of the stats mod.
private static List LiveStatsContexts()
=> AssemblyLoadContext.All.OfType()
.Where(c => c.ModId == StatsMod && !c.IsReleased)
.ToList();
private static List StatsEntries()
=> AppServices.Current.Resettables.Where(r => r.Id == StatsOwnerId).ToList();
/// True when the single stats owner's assembly is one of the live stats mod's assemblies.
private static bool OwnerIsLive()
{
var entries = StatsEntries();
if (entries.Count != 1)
return false;
var owner = entries[0].GetType().Assembly;
return LiveStatsContexts().Any(c => c.Assemblies.Contains(owner));
}
// The state objects below are only ever used inside these helpers: a local
// holding a StatState would keep the stats context alive across the next
// switch (debug builds extend locals to the end of the method).
/// Reads StatState.Instance of the live stats mod; null when it is not reachable.
private static object CurrentStatState()
{
foreach (var context in LiveStatsContexts())
{
foreach (var assembly in context.Assemblies)
{
var type = assembly.GetType(StatStateType);
if (type != null)
return type.GetProperty("Instance")?.GetValue(null);
}
}
return null;
}
private static bool StateReachable() => CurrentStatState() != null;
/// Gives the loaded stats state one character with a gained stat.
private static void MarkProbe()
{
var state = CurrentStatState();
state?.GetType().GetMethod("Of", new[] { typeof(string) })?.Invoke(state, new object[] { Probe });
}
/// Number of characters with stat gains in the loaded stats state; -1 when unreachable.
private static int ProbeCount()
{
var state = CurrentStatState();
var data = state?.GetType().GetProperty("Data")?.GetValue(state);
var characters = data?.GetType().GetProperty("Characters")?.GetValue(data) as IDictionary;
return characters?.Count ?? -1;
}
private void Check(string name, bool ok)
{
if (ok)
{
passed++;
GameLogger.Log("TEST", $"ResettablesTest: [OK] {name}");
}
else
{
failures.Add(name);
GameLogger.Log("TEST", $"ResettablesTest: [FAIL] {name}", LogLevel.Error);
}
}
private void Finish()
{
if (done)
return;
done = true;
foreach (var failure in failures)
GameLogger.Log("TEST", $"ResettablesTest: failed: {failure}");
GameLogger.Log("TEST", $"[TEST_RESULT] name=resettables passed={passed} failed={failures.Count}");
ApplicationExit.Quit(GetTree(), failures.Count == 0 ? 0 : 1);
}
}
}