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.
This commit is contained in:
cthangover committed 2026-10-17 21:02:29 +02:00
1 parent bc615c2ddf
commit ba1b1f8c0e
7 files changed
+125 -132

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
namespace Cthangover.Core.Battle
{
using Cthangover.Core.Mods.Consumers;
/// <summary>
/// Feeds <see cref="BattleCoreRegistry"/> from the kernel's assembly
/// fan-out: every mod's <see cref="IBattleCore"/> 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.
/// </summary>
public class BattleCoreConsumer : ModTypeConsumer<IBattleCore>
{
/// <inheritdoc />
public override string Id => "turn_battle_base.battle_cores";
/// <inheritdoc />
protected override void Add(IBattleCore instance, string modId) => BattleCoreRegistry.Instance.Register(instance);
/// <inheritdoc />
protected override void Remove(IBattleCore instance, string modId) => BattleCoreRegistry.Instance.Unregister(instance);
}
}
+20 -27
View File
@@ -1,16 +1,16 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection;
using Cthangover.Core.Games; using Cthangover.Core.Games;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
namespace Cthangover.Core.Battle namespace Cthangover.Core.Battle
{ {
/// <summary> /// <summary>
/// Pluggable battle-core registry. Scans assemblies for non-abstract /// Pluggable battle-core registry. The kernel hands every mod's
/// IBattleCore implementations, registers them by ID (each core /// IBattleCore implementations to <see cref="BattleCoreConsumer"/>, which
/// self-identifies), and activates one by ID. GetActive constructs a /// 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 /// fresh instance every call — battle cores are stateless between
/// battles. Falls back to the first registered core if no active /// battles. Falls back to the first registered core if no active
/// ID was set, enabling mods to ship drop-in battle engines. /// ID was set, enabling mods to ship drop-in battle engines.
@@ -27,36 +27,29 @@ namespace Cthangover.Core.Battle
private string _activeCoreId; private string _activeCoreId;
/// <summary> /// <summary>
/// Scans <paramref name="assembly"/> for non-abstract /// Registers a core by its self-reported <see cref="IBattleCore.Id"/>; a
/// <see cref="IBattleCore"/> implementations, instantiates each /// later core with the same id replaces the earlier one. Fed by
/// to read its <see cref="IBattleCore.Id"/>, and registers the /// <see cref="BattleCoreConsumer"/> for every mod assembly.
/// type by ID. Called at startup so mods can ship drop-in cores.
/// </summary> /// </summary>
public void RegisterAssembly(Assembly assembly) public void Register(IBattleCore core)
{ {
foreach (var instance in Reflections.FindAndCreate<IBattleCore>(assembly)) if (core == null)
{ return;
_cores[instance.Id] = instance.GetType(); _cores[core.Id] = core.GetType();
} GameLogger.Log("BATTLE", $"BattleCoreRegistry: registered core '{core.Id}'");
} }
/// <summary> /// <summary>
/// Scans every assembly loaded in the current <see cref="System.AppDomain"/> /// Removes a core whose mod is being unloaded (only when the id still
/// for <see cref="IBattleCore"/> implementations and registers them by id. /// maps to that core's type); an active selection of it is dropped.
/// 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.
/// </summary> /// </summary>
public void RegisterAll() public void Unregister(IBattleCore core)
{ {
foreach (var instance in Reflections.FindAndCreate<IBattleCore>()) if (core == null || !_cores.TryGetValue(core.Id, out var type) || type != core.GetType())
{ return;
_cores[instance.Id] = instance.GetType(); _cores.Remove(core.Id);
} if (_activeCoreId == core.Id)
_activeCoreId = null;
GameLogger.Log("BATTLE",
$"BattleCoreRegistry.RegisterAll: {_cores.Count} core(s) registered: [{string.Join(", ", CoreIds)}]");
} }
/// <summary> /// <summary>
+24
View File
@@ -0,0 +1,24 @@
namespace Cthangover.Core.Factories.Impls
{
using Cthangover.Core.Cards.StatusEffect;
using Cthangover.Core.Mods.Consumers;
/// <summary>
/// Feeds <see cref="StatusEffectActionFactory"/> with every mod's
/// <see cref="IStatusActions"/> 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.
/// </summary>
public class StatusActionsConsumer : ModTypeConsumer<IStatusActions>
{
/// <inheritdoc />
public override string Id => "turn_battle_base.status_actions";
/// <inheritdoc />
protected override void Add(IStatusActions instance, string modId) => StatusEffectActionFactory.Instance.Register(instance);
/// <inheritdoc />
protected override void Remove(IStatusActions instance, string modId) => StatusEffectActionFactory.Instance.Unregister(instance);
}
}
+20 -13
View File
@@ -9,10 +9,10 @@ namespace Cthangover.Core.Factories.Impls
/// Reflection-based plugin registry for status effect behaviour /// Reflection-based plugin registry for status effect behaviour
/// classes, mirroring the pattern used by <c>ItemActionFactory</c>. /// classes, mirroring the pattern used by <c>ItemActionFactory</c>.
/// Each <c>IStatusActions</c> implementation corresponds to a status /// Each <c>IStatusActions</c> implementation corresponds to a status
/// effect ID referenced in battle data JSON — the factory auto-discovers /// effect ID referenced in battle data JSON. The kernel hands every mod's
/// them at startup so mod authors can add new status effects (e.g. /// implementations to <see cref="StatusActionsConsumer"/>, so mod authors
/// poison, burn, freeze) by implementing the interface in their /// add new status effects (e.g. poison, burn, freeze) by implementing the
/// assembly without touching core code. /// interface in their assembly; they leave again when their mod unloads.
/// </summary> /// </summary>
public class StatusEffectActionFactory public class StatusEffectActionFactory
{ {
@@ -21,23 +21,30 @@ namespace Cthangover.Core.Factories.Impls
#region Singleton #region Singleton
private static readonly Lazy<StatusEffectActionFactory> instance = new(() => new StatusEffectActionFactory()); private static readonly Lazy<StatusEffectActionFactory> instance = new(() => new StatusEffectActionFactory());
/// <summary>Lazy singleton; construction performs the AppDomain scan.</summary> /// <summary>Lazy singleton, filled by <see cref="StatusActionsConsumer"/>.</summary>
public static StatusEffectActionFactory Instance => instance.Value; public static StatusEffectActionFactory Instance => instance.Value;
public StatusEffectActionFactory() public StatusEffectActionFactory()
{ {
actions = new Dictionary<string, IStatusActions>(); actions = new Dictionary<string, IStatusActions>();
foreach (var action in Reflections.FindAndCreate<IStatusActions>())
{
// 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 #endregion
/// <summary>Registers a behaviour set under its <c>ID</c>; the first registration of an id wins.</summary>
public void Register(IStatusActions action)
{
if (action != null && !string.IsNullOrEmpty(action.ID) && !actions.ContainsKey(action.ID))
actions[action.ID] = action;
}
/// <summary>Removes exactly <paramref name="action"/> (its mod is being unloaded).</summary>
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);
}
/// <summary>Resolves the behaviour set registered under the effect's <c>Actions</c> id; unknown ids are logged and return <c>null</c>.</summary> /// <summary>Resolves the behaviour set registered under the effect's <c>Actions</c> id; unknown ids are logged and return <c>null</c>.</summary>
public IStatusActions Get(string id) public IStatusActions Get(string id)
{ {
+7 -41
View File
@@ -1,16 +1,12 @@
using System;
using System.Linq;
using System.Reflection;
using Cthangover.Core.Audio; using Cthangover.Core.Audio;
using Cthangover.Core.Battle; using Cthangover.Core.Battle;
using Cthangover.Core.Mods; using Cthangover.Core.Mods;
using Cthangover.Core.Scenes; using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
namespace Cthangover.TurnBattle namespace Cthangover.TurnBattle
{ {
/// <summary> /// <summary>
/// Lifecycle entry point of the turn-battle mod. Three responsibilities: /// Lifecycle entry point of the turn-battle mod. Two responsibilities:
/// <list type="number"> /// <list type="number">
/// <item>Publishes the logical "Battle" scene route with fade transition and /// <item>Publishes the logical "Battle" scene route with fade transition and
/// combat music, so the core can route <c>switch_scene Battle</c> without /// combat music, so the core can route <c>switch_scene Battle</c> without
@@ -18,60 +14,30 @@ namespace Cthangover.TurnBattle
/// <item>Registers <see cref="TurnBattleGDScriptHook"/> so GDScript mods can /// <item>Registers <see cref="TurnBattleGDScriptHook"/> so GDScript mods can
/// still supply action executors and winground listeners after the battle /// still supply action executors and winground listeners after the battle
/// bridge moved out of the core loader.</item> /// bridge moved out of the core loader.</item>
/// <item>Discovers <see cref="IBattleCore"/> and <see cref="IWingroundListener"/>
/// 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).</item>
/// </list> /// </list>
/// Battle cores, winground listeners and status effect behaviours of all
/// mods reach their registries through the kernel's assembly consumers
/// (<see cref="BattleCoreConsumer"/>, <see cref="WingroundListenerConsumer"/>,
/// <see cref="StatusActionsConsumer"/>), not from here.
/// </summary> /// </summary>
public class TurnBattleSceneInitializer : IModInitializer public class TurnBattleSceneInitializer : IModInitializer
{ {
private const string BattleScenePath = "scenes/battle/battle.tscn"; private const string BattleScenePath = "scenes/battle/battle.tscn";
/// <summary> /// <summary>
/// Registers battle cores and winground listeners from the mod that just /// Installs the GDScript hook. It must happen here, not at
/// 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
/// resources-ready: ModGDScriptLoader processes <c>.gd</c> sources during /// resources-ready: ModGDScriptLoader processes <c>.gd</c> sources during
/// assembly registration finalization, before resources become ready. /// assembly registration finalization, before resources become ready.
/// </summary> /// </summary>
public void OnModLoaded(string modId) public void OnModLoaded(string modId)
{ {
GDScriptHookRegistry.Register(new TurnBattleGDScriptHook()); GDScriptHookRegistry.Register(new TurnBattleGDScriptHook());
RegisterAssembly(FindAssembly(modId));
} }
/// <summary> /// <summary>Publishes the scene route.</summary>
/// Publishes the scene route and rescans all loaded assemblies for battle
/// cores and winground listeners.
/// </summary>
public void OnModResourcesReady() public void OnModResourcesReady()
{ {
SceneRouteRegistry.Register("Battle", BattleScenePath, fade: true, musicScene: "Battle", music: MusicType.Combat); 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);
} }
} }
} }
+21
View File
@@ -0,0 +1,21 @@
namespace Cthangover.Core.Battle
{
using Cthangover.Core.Mods.Consumers;
/// <summary>
/// Feeds <see cref="WingroundListenerRegistry"/> with every mod's C#
/// <see cref="IWingroundListener"/> implementations, and removes them when
/// their mod unloads. GDScript listeners still register through the hook.
/// </summary>
public class WingroundListenerConsumer : ModTypeConsumer<IWingroundListener>
{
/// <inheritdoc />
public override string Id => "turn_battle_base.winground_listeners";
/// <inheritdoc />
protected override void Add(IWingroundListener instance, string modId) => WingroundListenerRegistry.Instance.RegisterListener(instance);
/// <inheritdoc />
protected override void Remove(IWingroundListener instance, string modId) => WingroundListenerRegistry.Instance.UnregisterListener(instance);
}
}
+11 -51
View File
@@ -1,20 +1,15 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Reflection;
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
namespace Cthangover.Core.Battle namespace Cthangover.Core.Battle
{ {
/// <summary> /// <summary>
/// Singleton registry that collects <see cref="IWingroundListener"/> /// Singleton registry that collects <see cref="IWingroundListener"/>
/// implementations (C# via reflection, GDScript via /// implementations (C# from every mod through
/// <see cref="WingroundListenerConsumer"/>, GDScript via
/// <see cref="RegisterListener"/>) and broadcasts winground /// <see cref="RegisterListener"/>) and broadcasts winground
/// lifecycle hooks to all of them in registration order. /// lifecycle hooks to all of them in registration order.
///
/// Reflects the same pattern as the recruiting mod's
/// <c>RecruitBehaviourRegistry</c>: a
/// static Instance, reflection-based <c>RegisterAssembly</c>,
/// and manual <c>RegisterListener</c> for GDScript bridges.
/// </summary> /// </summary>
public class WingroundListenerRegistry public class WingroundListenerRegistry
{ {
@@ -22,52 +17,11 @@ namespace Cthangover.Core.Battle
private readonly List<IWingroundListener> _listeners = new(); private readonly List<IWingroundListener> _listeners = new();
/// <summary>
/// Scans <paramref name="assembly"/> for concrete types that
/// implement <see cref="IWingroundListener"/> and instantiates
/// them via their parameterless constructor. Called by the battle
/// mod's initializer as mods announce themselves.
/// </summary>
public void RegisterAssembly(Assembly assembly)
{
foreach (var instance in Reflections.FindAndCreate<IWingroundListener>(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);
}
}
}
/// <summary>
/// Scans every assembly loaded in the current <see cref="System.AppDomain"/>
/// and registers all <see cref="IWingroundListener"/> implementations.
/// Called by the battle mod's initializer so the core does not need to
/// reference battle types during its assembly fan-out.
/// </summary>
public void RegisterAll()
{
foreach (var instance in Reflections.FindAndCreate<IWingroundListener>())
RegisterListener(instance);
GameLogger.Log("BATTLE",
$"WingroundListenerRegistry.RegisterAll: {_listeners.Count} listener(s) registered");
}
/// <summary> /// <summary>
/// Registers a listener, replacing any earlier one with the same /// Registers a listener, replacing any earlier one with the same
/// <see cref="IWingroundListener.Id"/>. Deduplication matters because the /// <see cref="IWingroundListener.Id"/> (used by the GDScript bridge
/// registry is rescanned from the whole AppDomain on mod reload (used by /// <see cref="GDWingroundListener"/> and by
/// the GDScript bridge <see cref="GDWingroundListener"/> and by /// <see cref="WingroundListenerConsumer"/>). Safe to call from any thread — the
/// <see cref="RegisterAll"/>). Safe to call from any thread — the
/// underlying list is not thread-safe but all registrations happen during /// underlying list is not thread-safe but all registrations happen during
/// mod initialisation before battle scenes are loaded. /// mod initialisation before battle scenes are loaded.
/// </summary> /// </summary>
@@ -82,6 +36,12 @@ namespace Cthangover.Core.Battle
$"WingroundListenerRegistry: registered listener '{listener.Id}'"); $"WingroundListenerRegistry: registered listener '{listener.Id}'");
} }
/// <summary>Removes exactly <paramref name="listener"/> (its mod is being unloaded).</summary>
public void UnregisterListener(IWingroundListener listener)
{
_listeners.Remove(listener);
}
/// <summary> /// <summary>
/// Broadcasts <see cref="IWingroundListener.OnBeforeExpAward"/> /// Broadcasts <see cref="IWingroundListener.OnBeforeExpAward"/>
/// to every registered listener. Exceptions from individual /// to every registered listener. Exceptions from individual