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); } } }