From ba1b1f8c0e5bebf6fb07db97babffbe22eed4176 Mon Sep 17 00:00:00 2001 From: iisaev Date: Sat, 17 Oct 2026 21:02:29 +0200 Subject: [PATCH] Battle registries are fed by the kernel's assembly consumers Battle cores, winground listeners and status effect behaviours reach their registries through ModTypeConsumer subclasses instead of per-mod assembly lookups and AppDomain rescans, and leave again when their mod unloads, so a game switch or hot reload never keeps a released mod's types alive. --- src/BattleCoreConsumer.cs | 22 +++++++++++ src/BattleCoreRegistry.cs | 47 ++++++++++------------- src/StatusActionsConsumer.cs | 24 ++++++++++++ src/StatusEffectActionFactory.cs | 33 +++++++++------- src/TurnBattleSceneInitializer.cs | 48 ++++-------------------- src/WingroundListenerConsumer.cs | 21 +++++++++++ src/WingroundListenerRegistry.cs | 62 ++++++------------------------- 7 files changed, 125 insertions(+), 132 deletions(-) create mode 100644 src/BattleCoreConsumer.cs create mode 100644 src/StatusActionsConsumer.cs create mode 100644 src/WingroundListenerConsumer.cs diff --git a/src/BattleCoreConsumer.cs b/src/BattleCoreConsumer.cs new file mode 100644 index 0000000..ecb1ff3 --- /dev/null +++ b/src/BattleCoreConsumer.cs @@ -0,0 +1,22 @@ +namespace Cthangover.Core.Battle +{ + using Cthangover.Core.Mods.Consumers; + + /// + /// Feeds from the kernel's assembly + /// fan-out: every mod's implementations are + /// registered as the mod loads and removed when it unloads, so a game + /// switch or hot reload never leaves a core of a released mod behind. + /// + public class BattleCoreConsumer : ModTypeConsumer + { + /// + public override string Id => "turn_battle_base.battle_cores"; + + /// + protected override void Add(IBattleCore instance, string modId) => BattleCoreRegistry.Instance.Register(instance); + + /// + protected override void Remove(IBattleCore instance, string modId) => BattleCoreRegistry.Instance.Unregister(instance); + } +} diff --git a/src/BattleCoreRegistry.cs b/src/BattleCoreRegistry.cs index c202919..a602ee2 100644 --- a/src/BattleCoreRegistry.cs +++ b/src/BattleCoreRegistry.cs @@ -1,16 +1,16 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using Cthangover.Core.Games; using Cthangover.Core.Utils; namespace Cthangover.Core.Battle { /// - /// Pluggable battle-core registry. Scans assemblies for non-abstract - /// IBattleCore implementations, registers them by ID (each core - /// self-identifies), and activates one by ID. GetActive constructs a + /// Pluggable battle-core registry. The kernel hands every mod's + /// IBattleCore implementations to , which + /// registers them by ID (each core self-identifies) and removes them + /// again when their mod unloads; one core is activated by ID. GetActive constructs a /// fresh instance every call — battle cores are stateless between /// battles. Falls back to the first registered core if no active /// ID was set, enabling mods to ship drop-in battle engines. @@ -27,36 +27,29 @@ namespace Cthangover.Core.Battle private string _activeCoreId; /// - /// Scans for non-abstract - /// implementations, instantiates each - /// to read its , and registers the - /// type by ID. Called at startup so mods can ship drop-in cores. + /// Registers a core by its self-reported ; a + /// later core with the same id replaces the earlier one. Fed by + /// for every mod assembly. /// - public void RegisterAssembly(Assembly assembly) + public void Register(IBattleCore core) { - foreach (var instance in Reflections.FindAndCreate(assembly)) - { - _cores[instance.Id] = instance.GetType(); - } + if (core == null) + return; + _cores[core.Id] = core.GetType(); + GameLogger.Log("BATTLE", $"BattleCoreRegistry: registered core '{core.Id}'"); } /// - /// Scans every assembly loaded in the current - /// for implementations and registers them by id. - /// Called by the battle mod's initializer after assemblies land, which - /// replaces the old core-side fan-out in ModAssemblyLoader — the core no - /// longer references battle types. Re-registering an id overwrites the - /// earlier type, so repeated scans (mod reload) stay idempotent. + /// Removes a core whose mod is being unloaded (only when the id still + /// maps to that core's type); an active selection of it is dropped. /// - public void RegisterAll() + public void Unregister(IBattleCore core) { - foreach (var instance in Reflections.FindAndCreate()) - { - _cores[instance.Id] = instance.GetType(); - } - - GameLogger.Log("BATTLE", - $"BattleCoreRegistry.RegisterAll: {_cores.Count} core(s) registered: [{string.Join(", ", CoreIds)}]"); + if (core == null || !_cores.TryGetValue(core.Id, out var type) || type != core.GetType()) + return; + _cores.Remove(core.Id); + if (_activeCoreId == core.Id) + _activeCoreId = null; } /// diff --git a/src/StatusActionsConsumer.cs b/src/StatusActionsConsumer.cs new file mode 100644 index 0000000..98a54a8 --- /dev/null +++ b/src/StatusActionsConsumer.cs @@ -0,0 +1,24 @@ +namespace Cthangover.Core.Factories.Impls +{ + using Cthangover.Core.Cards.StatusEffect; + using Cthangover.Core.Mods.Consumers; + + /// + /// Feeds with every mod's + /// implementations as the mods load, and + /// removes them when their mod unloads. Replaces the one-time AppDomain + /// scan, which saw only the mods loaded before the first lookup and kept + /// a released mod's behaviours after a game switch. + /// + public class StatusActionsConsumer : ModTypeConsumer + { + /// + public override string Id => "turn_battle_base.status_actions"; + + /// + protected override void Add(IStatusActions instance, string modId) => StatusEffectActionFactory.Instance.Register(instance); + + /// + protected override void Remove(IStatusActions instance, string modId) => StatusEffectActionFactory.Instance.Unregister(instance); + } +} diff --git a/src/StatusEffectActionFactory.cs b/src/StatusEffectActionFactory.cs index 6fb665b..8d0c575 100644 --- a/src/StatusEffectActionFactory.cs +++ b/src/StatusEffectActionFactory.cs @@ -9,10 +9,10 @@ namespace Cthangover.Core.Factories.Impls /// Reflection-based plugin registry for status effect behaviour /// classes, mirroring the pattern used by ItemActionFactory. /// Each IStatusActions implementation corresponds to a status - /// effect ID referenced in battle data JSON — the factory auto-discovers - /// them at startup so mod authors can add new status effects (e.g. - /// poison, burn, freeze) by implementing the interface in their - /// assembly without touching core code. + /// effect ID referenced in battle data JSON. The kernel hands every mod's + /// implementations to , so mod authors + /// add new status effects (e.g. poison, burn, freeze) by implementing the + /// interface in their assembly; they leave again when their mod unloads. /// public class StatusEffectActionFactory { @@ -21,23 +21,30 @@ namespace Cthangover.Core.Factories.Impls #region Singleton private static readonly Lazy instance = new(() => new StatusEffectActionFactory()); - /// Lazy singleton; construction performs the AppDomain scan. + /// Lazy singleton, filled by . public static StatusEffectActionFactory Instance => instance.Value; public StatusEffectActionFactory() { actions = new Dictionary(); - foreach (var action in Reflections.FindAndCreate()) - { - // First-wins: the AppDomain scan covers every loaded assembly and mod - // assemblies are never unloaded, so a hot-reload can discover the - // same implementation twice — keep the first instance (ItemActionFactory precedent). - if (!string.IsNullOrEmpty(action.ID) && !actions.ContainsKey(action.ID)) actions[action.ID] = action; - } } - + #endregion + /// Registers a behaviour set under its ID; the first registration of an id wins. + public void Register(IStatusActions action) + { + if (action != null && !string.IsNullOrEmpty(action.ID) && !actions.ContainsKey(action.ID)) + actions[action.ID] = action; + } + + /// Removes exactly (its mod is being unloaded). + public void Unregister(IStatusActions action) + { + if (action != null && !string.IsNullOrEmpty(action.ID) && actions.TryGetValue(action.ID, out var current) && ReferenceEquals(current, action)) + actions.Remove(action.ID); + } + /// Resolves the behaviour set registered under the effect's Actions id; unknown ids are logged and return null. public IStatusActions Get(string id) { diff --git a/src/TurnBattleSceneInitializer.cs b/src/TurnBattleSceneInitializer.cs index 1ef9c17..a54736f 100644 --- a/src/TurnBattleSceneInitializer.cs +++ b/src/TurnBattleSceneInitializer.cs @@ -1,16 +1,12 @@ -using System; -using System.Linq; -using System.Reflection; using Cthangover.Core.Audio; using Cthangover.Core.Battle; using Cthangover.Core.Mods; using Cthangover.Core.Scenes; -using Cthangover.Core.Utils; namespace Cthangover.TurnBattle { /// - /// Lifecycle entry point of the turn-battle mod. Three responsibilities: + /// Lifecycle entry point of the turn-battle mod. Two responsibilities: /// /// Publishes the logical "Battle" scene route with fade transition and /// combat music, so the core can route switch_scene Battle without @@ -18,60 +14,30 @@ namespace Cthangover.TurnBattle /// Registers so GDScript mods can /// still supply action executors and winground listeners after the battle /// bridge moved out of the core loader. - /// Discovers and - /// implementations across mod assemblies — this replaces the core-side - /// registry fan-out in ModAssemblyLoader. Every mod announcement triggers a - /// scan of that mod's assembly, and the resources-ready pass re-scans the - /// whole AppDomain as a safety net for assemblies whose name differs from - /// the mod id (precompiled DLLs). /// + /// Battle cores, winground listeners and status effect behaviours of all + /// mods reach their registries through the kernel's assembly consumers + /// (, , + /// ), not from here. /// public class TurnBattleSceneInitializer : IModInitializer { private const string BattleScenePath = "scenes/battle/battle.tscn"; /// - /// Registers battle cores and winground listeners from the mod that just - /// announced itself, and installs the GDScript hook. Assemblies are looked - /// up by mod id (the compiler names every source-compiled assembly after - /// its mod), so no event plumbing is needed — the initializer simply - /// rescans as the mod set grows. The hook must be installed here, not at + /// Installs the GDScript hook. It must happen here, not at /// resources-ready: ModGDScriptLoader processes .gd sources during /// assembly registration finalization, before resources become ready. /// public void OnModLoaded(string modId) { GDScriptHookRegistry.Register(new TurnBattleGDScriptHook()); - RegisterAssembly(FindAssembly(modId)); } - /// - /// Publishes the scene route and rescans all loaded assemblies for battle - /// cores and winground listeners. - /// + /// Publishes the scene route. public void OnModResourcesReady() { SceneRouteRegistry.Register("Battle", BattleScenePath, fade: true, musicScene: "Battle", music: MusicType.Combat); - BattleCoreRegistry.Instance.RegisterAll(); - WingroundListenerRegistry.Instance.RegisterAll(); - } - - private static void RegisterAssembly(Assembly assembly) - { - if (assembly == null) - return; - - BattleCoreRegistry.Instance.RegisterAssembly(assembly); - WingroundListenerRegistry.Instance.RegisterAssembly(assembly); - } - - private static Assembly FindAssembly(string modId) - { - if (string.IsNullOrEmpty(modId)) - return null; - - return AppDomain.CurrentDomain.GetAssemblies() - .FirstOrDefault(assembly => assembly.GetName().Name == modId); } } } diff --git a/src/WingroundListenerConsumer.cs b/src/WingroundListenerConsumer.cs new file mode 100644 index 0000000..645c59d --- /dev/null +++ b/src/WingroundListenerConsumer.cs @@ -0,0 +1,21 @@ +namespace Cthangover.Core.Battle +{ + using Cthangover.Core.Mods.Consumers; + + /// + /// Feeds with every mod's C# + /// implementations, and removes them when + /// their mod unloads. GDScript listeners still register through the hook. + /// + public class WingroundListenerConsumer : ModTypeConsumer + { + /// + public override string Id => "turn_battle_base.winground_listeners"; + + /// + protected override void Add(IWingroundListener instance, string modId) => WingroundListenerRegistry.Instance.RegisterListener(instance); + + /// + protected override void Remove(IWingroundListener instance, string modId) => WingroundListenerRegistry.Instance.UnregisterListener(instance); + } +} diff --git a/src/WingroundListenerRegistry.cs b/src/WingroundListenerRegistry.cs index ab85eb7..9122b71 100644 --- a/src/WingroundListenerRegistry.cs +++ b/src/WingroundListenerRegistry.cs @@ -1,20 +1,15 @@ using System; using System.Collections.Generic; -using System.Reflection; using Cthangover.Core.Utils; namespace Cthangover.Core.Battle { /// /// Singleton registry that collects - /// implementations (C# via reflection, GDScript via + /// implementations (C# from every mod through + /// , GDScript via /// ) and broadcasts winground /// lifecycle hooks to all of them in registration order. - /// - /// Reflects the same pattern as the recruiting mod's - /// RecruitBehaviourRegistry: a - /// static Instance, reflection-based RegisterAssembly, - /// and manual RegisterListener for GDScript bridges. /// public class WingroundListenerRegistry { @@ -22,52 +17,11 @@ namespace Cthangover.Core.Battle private readonly List _listeners = new(); - /// - /// Scans for concrete types that - /// implement and instantiates - /// them via their parameterless constructor. Called by the battle - /// mod's initializer as mods announce themselves. - /// - public void RegisterAssembly(Assembly assembly) - { - foreach (var instance in Reflections.FindAndCreate(assembly)) - { - try - { - RegisterListener(instance); - GameLogger.Log("BATTLE", - $"WingroundListenerRegistry: registered C# listener '{instance.Id}' from assembly '{assembly.GetName().Name}'"); - } - catch (Exception ex) - { - GameLogger.Log("BATTLE", - $"WingroundListenerRegistry: failed to create listener '{instance.GetType().FullName}': {ex.Message}", - LogLevel.Error); - } - } - } - - /// - /// Scans every assembly loaded in the current - /// and registers all implementations. - /// Called by the battle mod's initializer so the core does not need to - /// reference battle types during its assembly fan-out. - /// - public void RegisterAll() - { - foreach (var instance in Reflections.FindAndCreate()) - RegisterListener(instance); - - GameLogger.Log("BATTLE", - $"WingroundListenerRegistry.RegisterAll: {_listeners.Count} listener(s) registered"); - } - /// /// Registers a listener, replacing any earlier one with the same - /// . Deduplication matters because the - /// registry is rescanned from the whole AppDomain on mod reload (used by - /// the GDScript bridge and by - /// ). Safe to call from any thread — the + /// (used by the GDScript bridge + /// and by + /// ). Safe to call from any thread — the /// underlying list is not thread-safe but all registrations happen during /// mod initialisation before battle scenes are loaded. /// @@ -82,6 +36,12 @@ namespace Cthangover.Core.Battle $"WingroundListenerRegistry: registered listener '{listener.Id}'"); } + /// Removes exactly (its mod is being unloaded). + public void UnregisterListener(IWingroundListener listener) + { + _listeners.Remove(listener); + } + /// /// Broadcasts /// to every registered listener. Exceptions from individual