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:
1 parent
bc615c2ddf
commit
ba1b1f8c0e
7 files changed
+124
-131
No files matched your search
@@ -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
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="BattleCoreConsumer"/>, 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;
|
||||
|
||||
/// <summary>
|
||||
/// Scans <paramref name="assembly"/> for non-abstract
|
||||
/// <see cref="IBattleCore"/> implementations, instantiates each
|
||||
/// to read its <see cref="IBattleCore.Id"/>, and registers the
|
||||
/// type by ID. Called at startup so mods can ship drop-in cores.
|
||||
/// Registers a core by its self-reported <see cref="IBattleCore.Id"/>; a
|
||||
/// later core with the same id replaces the earlier one. Fed by
|
||||
/// <see cref="BattleCoreConsumer"/> for every mod assembly.
|
||||
/// </summary>
|
||||
public void RegisterAssembly(Assembly assembly)
|
||||
public void Register(IBattleCore core)
|
||||
{
|
||||
foreach (var instance in Reflections.FindAndCreate<IBattleCore>(assembly))
|
||||
{
|
||||
_cores[instance.Id] = instance.GetType();
|
||||
}
|
||||
if (core == null)
|
||||
return;
|
||||
_cores[core.Id] = core.GetType();
|
||||
GameLogger.Log("BATTLE", $"BattleCoreRegistry: registered core '{core.Id}'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans every assembly loaded in the current <see cref="System.AppDomain"/>
|
||||
/// for <see cref="IBattleCore"/> 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.
|
||||
/// </summary>
|
||||
public void RegisterAll()
|
||||
public void Unregister(IBattleCore core)
|
||||
{
|
||||
foreach (var instance in Reflections.FindAndCreate<IBattleCore>())
|
||||
{
|
||||
_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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,10 @@ namespace Cthangover.Core.Factories.Impls
|
||||
/// Reflection-based plugin registry for status effect behaviour
|
||||
/// classes, mirroring the pattern used by <c>ItemActionFactory</c>.
|
||||
/// Each <c>IStatusActions</c> 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 <see cref="StatusActionsConsumer"/>, 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.
|
||||
/// </summary>
|
||||
public class StatusEffectActionFactory
|
||||
{
|
||||
@@ -21,23 +21,30 @@ namespace Cthangover.Core.Factories.Impls
|
||||
#region Singleton
|
||||
|
||||
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 StatusEffectActionFactory()
|
||||
{
|
||||
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
|
||||
|
||||
/// <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>
|
||||
public IStatusActions Get(string id)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Lifecycle entry point of the turn-battle mod. Three responsibilities:
|
||||
/// Lifecycle entry point of the turn-battle mod. Two responsibilities:
|
||||
/// <list type="number">
|
||||
/// <item>Publishes the logical "Battle" scene route with fade transition and
|
||||
/// 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
|
||||
/// still supply action executors and winground listeners after the battle
|
||||
/// 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>
|
||||
/// 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>
|
||||
public class TurnBattleSceneInitializer : IModInitializer
|
||||
{
|
||||
private const string BattleScenePath = "scenes/battle/battle.tscn";
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>.gd</c> sources during
|
||||
/// assembly registration finalization, before resources become ready.
|
||||
/// </summary>
|
||||
public void OnModLoaded(string modId)
|
||||
{
|
||||
GDScriptHookRegistry.Register(new TurnBattleGDScriptHook());
|
||||
RegisterAssembly(FindAssembly(modId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the scene route and rescans all loaded assemblies for battle
|
||||
/// cores and winground listeners.
|
||||
/// </summary>
|
||||
/// <summary>Publishes the scene route.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
namespace Cthangover.Core.Battle
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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>
|
||||
public class WingroundListenerRegistry
|
||||
{
|
||||
@@ -22,52 +17,11 @@ namespace Cthangover.Core.Battle
|
||||
|
||||
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>
|
||||
/// Registers a listener, replacing any earlier one with the same
|
||||
/// <see cref="IWingroundListener.Id"/>. Deduplication matters because the
|
||||
/// registry is rescanned from the whole AppDomain on mod reload (used by
|
||||
/// the GDScript bridge <see cref="GDWingroundListener"/> and by
|
||||
/// <see cref="RegisterAll"/>). Safe to call from any thread — the
|
||||
/// <see cref="IWingroundListener.Id"/> (used by the GDScript bridge
|
||||
/// <see cref="GDWingroundListener"/> and by
|
||||
/// <see cref="WingroundListenerConsumer"/>). 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.
|
||||
/// </summary>
|
||||
@@ -82,6 +36,12 @@ namespace Cthangover.Core.Battle
|
||||
$"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>
|
||||
/// Broadcasts <see cref="IWingroundListener.OnBeforeExpAward"/>
|
||||
/// to every registered listener. Exceptions from individual
|
||||
|
||||
Reference in new issue
Block a user