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