diff --git a/characters/actions.json b/characters/actions.json
new file mode 100644
index 0000000..fc7f09a
--- /dev/null
+++ b/characters/actions.json
@@ -0,0 +1,5 @@
+{ "Items": [
+ ${actions/physics_attack},
+ ${actions/physics_defence},
+ ${actions/physics_stun}
+]}
\ No newline at end of file
diff --git a/characters/actions/physics_attack.json b/characters/actions/physics_attack.json
new file mode 100644
index 0000000..6f061fe
--- /dev/null
+++ b/characters/actions/physics_attack.json
@@ -0,0 +1,12 @@
+{
+ "Id": "physics/attack",
+ "Name": "characters/action/physics_attack/name",
+ "Description": "characters/action/physics_attack/description",
+ "Image": "characters/actions/physics_attack",
+ "Type": "ToEnemy",
+ "Values": [
+ "RequiredPoint=1",
+ "Attack=1.0",
+ "AttackType=Physics"
+ ]
+}
\ No newline at end of file
diff --git a/characters/actions/physics_attack.png b/characters/actions/physics_attack.png
new file mode 100644
index 0000000..3c82744
Binary files /dev/null and b/characters/actions/physics_attack.png differ
diff --git a/characters/actions/physics_defence.json b/characters/actions/physics_defence.json
new file mode 100644
index 0000000..7465c31
--- /dev/null
+++ b/characters/actions/physics_defence.json
@@ -0,0 +1,11 @@
+{
+ "Id": "physics/defence",
+ "Name": "characters/action/defence/name",
+ "Description": "characters/action/defence/description",
+ "Image": "characters/actions/physics_defence",
+ "Type": "ToAlias",
+ "Values": [
+ "RequiredPoint=1",
+ "Defence=4"
+ ]
+}
\ No newline at end of file
diff --git a/characters/actions/physics_defence.png b/characters/actions/physics_defence.png
new file mode 100644
index 0000000..2ea0797
Binary files /dev/null and b/characters/actions/physics_defence.png differ
diff --git a/characters/actions/physics_stun.json b/characters/actions/physics_stun.json
new file mode 100644
index 0000000..ecff35d
--- /dev/null
+++ b/characters/actions/physics_stun.json
@@ -0,0 +1,12 @@
+{
+ "Id": "physics/stun",
+ "Name": "characters/action/stun/name",
+ "Description": "characters/action/stun/description",
+ "Image": "characters/actions/physics_stun",
+ "Type": "ToEnemy",
+ "Values": [
+ "RequiredPoint=2",
+ "AttackType=Physics",
+ "Turn=3"
+ ]
+}
\ No newline at end of file
diff --git a/characters/actions/physics_stun.png b/characters/actions/physics_stun.png
new file mode 100644
index 0000000..f97dbc6
Binary files /dev/null and b/characters/actions/physics_stun.png differ
diff --git a/characters/effects/stun.png b/characters/effects/stun.png
new file mode 100644
index 0000000..3cf4d2a
Binary files /dev/null and b/characters/effects/stun.png differ
diff --git a/manifest.json b/manifest.json
index 44f507a..24b1d49 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,9 +1,9 @@
-{
+{
"id": "turn_battle_base",
"name": "Turn-based battle engine",
- "version": "1.0.0",
+ "version": "2.0.0",
"author": "ct",
- "description": "Turn-based battle GUI: battle scene, victory/defeat screens, EXP and loot reports",
+ "description": "Turn-based battle platform: battle ruleset contracts, core registry, action executors, status effects, battle scene, victory/defeat screens, EXP and loot reports",
"category": "engine, gameplay",
"sources": [
"src/**/*.cs"
diff --git a/scenes/battle/battle.tscn b/scenes/battle/battle.tscn
index 3f23a62..eb76acd 100644
--- a/scenes/battle/battle.tscn
+++ b/scenes/battle/battle.tscn
@@ -1,7 +1,7 @@
[gd_scene format=3 uid="uid://bsrphp6130ptb"]
-[ext_resource type="Script" uid="uid://dq85ubii8cfid" path="res://Core/Battle/Actions/BattleActionMachine.cs" id="BattleActionMachineScript"]
-[ext_resource type="Script" uid="uid://c1rwrejr5aoc0" path="res://Core/Battle/BattleSceneContext.cs" id="BattleSceneContextScript"]
+[ext_resource type="Script" path="src/Actions/BattleActionMachine.cs" id="BattleActionMachineScript"]
+[ext_resource type="Script" path="src/BattleSceneContext.cs" id="BattleSceneContextScript"]
[ext_resource type="Script" path="src/BattleSpeedControl.cs" id="BattleSpeedControlScript"]
[ext_resource type="PackedScene" uid="uid://bjhtml8ujba7p" path="res://scenes/battle/deadground.tscn" id="DeadgroundScene"]
[ext_resource type="Script" uid="uid://icsu2hv2c68r" path="res://Core/UI/Lights/UiLightController.cs" id="UiLightControllerScript"]
@@ -47,7 +47,6 @@ grow_vertical = 0
script = ExtResource("BattleActionMachineScript")
[node name="BattleSpeedControl" type="Control" parent="."]
-layout_mode = 0
script = ExtResource("BattleSpeedControlScript")
[node name="Deadground" parent="." unique_id=2064995106 instance=ExtResource("DeadgroundScene")]
diff --git a/src/Actions/ActionExecutorHub.cs b/src/Actions/ActionExecutorHub.cs
new file mode 100644
index 0000000..15fa226
--- /dev/null
+++ b/src/Actions/ActionExecutorHub.cs
@@ -0,0 +1,79 @@
+using System.Collections.Generic;
+using Cthangover.Core.Characters;
+
+namespace Cthangover.Core.Battle.Actions
+{
+ ///
+ /// Central action-execution dispatcher. Resolves an action ID to an
+ /// IActionExecutor in two tiers: first the active provider (set by
+ /// the current IBattleCore, allowing per-battle-engine overrides),
+ /// then a global fallback registry. This two-level lookup lets mods
+ /// supply custom executors for specific battle cores without replacing
+ /// the global ones. Execute returns ChangedAttributes to allow the
+ /// caller to inspect the result (success/failure, stat deltas).
+ ///
+ public class ActionExecutorHub
+ {
+ ///
+ /// Singleton hub instance. There is one global dispatcher shared
+ /// across all battle cores; cores swap providers via
+ /// when they activate.
+ ///
+ public static readonly ActionExecutorHub Instance = new();
+
+ private IActionExecutorProvider _activeProvider;
+ private readonly Dictionary _globalExecutors = new();
+
+ ///
+ /// Installs a per-core executor provider. The active provider is
+ /// consulted before the global registry, allowing the current
+ /// battle core to override specific action handlers.
+ ///
+ public void SetActiveProvider(IActionExecutorProvider provider)
+ {
+ _activeProvider = provider;
+ }
+
+ ///
+ /// Registers a fallback executor in the global pool, keyed by
+ /// . Global executors are
+ /// used when no active provider supplies a match.
+ ///
+ public void RegisterGlobal(IActionExecutor executor)
+ {
+ if (executor != null && !string.IsNullOrEmpty(executor.ActionId))
+ _globalExecutors[executor.ActionId] = executor;
+ }
+
+ ///
+ /// Dispatches an action to the appropriate executor. Resolution
+ /// order: active provider first, then global registry. Returns
+ /// Result = false if no executor is found or if
+ /// is null.
+ ///
+ public ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
+ {
+ if (action == null)
+ return new ChangedAttributes { Result = false };
+
+ var executor = Resolve(action.ID);
+ if (executor == null)
+ return new ChangedAttributes { Result = false };
+
+ return executor.Execute(action, user, target);
+ }
+
+ private IActionExecutor Resolve(string actionId)
+ {
+ if (_activeProvider != null)
+ {
+ var executor = _activeProvider.GetExecutor(actionId);
+ if (executor != null)
+ return executor;
+ }
+
+ _globalExecutors.TryGetValue(actionId, out var fallback);
+ return fallback;
+ }
+ }
+}
diff --git a/src/Actions/BattleActionMachine.cs b/src/Actions/BattleActionMachine.cs
new file mode 100644
index 0000000..1e1fb9c
--- /dev/null
+++ b/src/Actions/BattleActionMachine.cs
@@ -0,0 +1,119 @@
+using System.Collections.Generic;
+using Cthangover.Core.UI;
+using Godot;
+
+namespace Cthangover.Core.Battle.Actions
+{
+
+ ///
+ /// Sequential action queue that processes one IBattleAction per frame
+ /// until it signals completion (DoAction returns true), then moves to
+ /// the next. Fires OnStopMachine when the queue drains naturally or
+ /// is explicitly stopped — used by battle cores to detect when a turn's
+ /// animation sequence is finished. Actions can be added before or during
+ /// processing; the machine starts automatically once at least one action
+ /// is queued and no current action is running.
+ ///
+ /// Derives from so the engine lifecycle callbacks reach
+ /// this runtime-compiled script through the core base class (mod assemblies
+ /// have no Godot source generation of their own).
+ ///
+ public partial class BattleActionMachine : ModNode
+ {
+
+ ///
+ /// Pending action queue. Actions are dequeued one-by-one in the process
+ /// hook, starting the next as soon as the current signals completion.
+ /// Can be extended while the machine is running — new actions will
+ /// be processed after the current queue drains.
+ ///
+ public List Actions { get; set; } = new();
+ private IBattleAction CurrentAction { get; set; }
+ private bool _hadActions;
+
+ ///
+ /// Fired when the machine drains naturally or is explicitly stopped.
+ /// Battle cores subscribe to advance the turn phase without polling.
+ ///
+ public event StopMachineDelegate OnStopMachine;
+
+ protected override void OnExitTree()
+ {
+ base.OnExitTree();
+ StopMachine();
+ }
+
+ protected override void OnProcess(double delta)
+ {
+ if (Actions.Count == 0 && CurrentAction == null)
+ {
+ if (_hadActions)
+ {
+ _hadActions = false;
+ StopMachine();
+ }
+ return;
+ }
+
+ var action = CurrentAction;
+ if (action == null)
+ {
+ CurrentAction = Actions[0];
+ Actions.RemoveAt(0);
+ action = CurrentAction;
+ _hadActions = true;
+ action?.DoStart();
+ }
+
+ if (action == null)
+ {
+ _hadActions = false;
+ StopMachine();
+ return;
+ }
+
+ if (action.DoAction())
+ {
+ action.DoEnd();
+ CurrentAction = null;
+ }
+ }
+
+ ///
+ /// Enqueues a single action. If the machine is idle (no current
+ /// action), processing starts on the next _Process tick.
+ ///
+ public void AddAction(IBattleAction action)
+ {
+ if (action == null)
+ return;
+ Actions.Add(action);
+ }
+
+ ///
+ /// Batch-enqueues multiple actions. All are appended to the existing
+ /// queue and processed in FIFO order on subsequent frames.
+ ///
+ public void AddActions(List actions)
+ {
+ if(actions == null || actions.Count == 0)
+ return;
+ Actions.AddRange(actions);
+ }
+
+ ///
+ /// Immediately clears the queue, aborts the current action, and
+ /// fires . Called on tree exit and
+ /// when the queue drains naturally.
+ ///
+ public void StopMachine()
+ {
+ CurrentAction = null;
+ Actions.Clear();
+ _hadActions = false;
+ OnStopMachine?.Invoke();
+ }
+
+ }
+
+}
diff --git a/src/Actions/GDBattleActionExecutor.cs b/src/Actions/GDBattleActionExecutor.cs
new file mode 100644
index 0000000..f2af034
--- /dev/null
+++ b/src/Actions/GDBattleActionExecutor.cs
@@ -0,0 +1,48 @@
+using Cthangover.Core.Characters;
+using Godot;
+
+namespace Cthangover.Core.Battle.Actions
+{
+ ///
+ /// Adapter that lets a GDScript mod act as a battle action executor. The
+ /// script is detected by when it exposes
+ /// get_action_id() and execute(action, user, target); this adapter
+ /// flattens the C# arguments into Godot dictionaries via
+ /// and maps the returned dictionary back to
+ /// . Dictionaries are used instead of custom
+ /// RefCounted DTOs because runtime-compiled mod classes get no Godot source
+ /// generation, so their members would not be visible to GDScript.
+ ///
+ public class GDBattleActionExecutor : IActionExecutor
+ {
+ private readonly GodotObject _instance;
+
+ /// Creates an adapter around a live GDScript object. The object is retained by reference.
+ public GDBattleActionExecutor(string actionId, GodotObject instance)
+ {
+ ActionId = actionId;
+ _instance = instance;
+ }
+
+ /// Action id reported by the script through get_action_id().
+ public string ActionId { get; }
+
+ ///
+ /// Calls the script and converts its result. A script that returns
+ /// anything other than a dictionary (or throws) yields a failed result so
+ /// one broken mod cannot abort the action pipeline.
+ ///
+ public ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
+ {
+ var gdAction = BattleGDScriptBridge.ActionToDictionary(action);
+ var gdUser = BattleGDScriptBridge.CharacterToDictionary(user);
+ var gdTarget = BattleGDScriptBridge.CharacterToDictionary(target);
+
+ var variant = _instance.Call("execute", gdAction, gdUser, gdTarget);
+ if (variant.VariantType != Variant.Type.Dictionary)
+ return new ChangedAttributes { Result = false };
+
+ return BattleGDScriptBridge.ResultFromDictionary(variant.AsGodotDictionary());
+ }
+ }
+}
diff --git a/src/Actions/IActionExecutor.cs b/src/Actions/IActionExecutor.cs
new file mode 100644
index 0000000..6c4ef98
--- /dev/null
+++ b/src/Actions/IActionExecutor.cs
@@ -0,0 +1,27 @@
+using Cthangover.Core.Characters;
+
+namespace Cthangover.Core.Battle.Actions
+{
+ ///
+ /// Executes a specific battle action. The executor is keyed by ActionId
+ /// (matching the action's ID) so the hub can look it up without
+ /// knowing the concrete type. Returns ChangedAttributes so callers
+ /// can apply stat deltas and check whether the action succeeded.
+ ///
+ public interface IActionExecutor
+ {
+ ///
+ /// String key that matches .
+ /// The hub uses this to resolve executors without knowing
+ /// concrete types.
+ ///
+ string ActionId { get; }
+
+ ///
+ /// Applies the action's effects. Receives the action definition
+ /// and the user/target instances; returns
+ /// with deltas and success/failure.
+ ///
+ ChangedAttributes Execute(ActionCharacter action, Character user, Character target);
+ }
+}
diff --git a/src/Actions/IActionExecutorProvider.cs b/src/Actions/IActionExecutorProvider.cs
new file mode 100644
index 0000000..c2d238b
--- /dev/null
+++ b/src/Actions/IActionExecutorProvider.cs
@@ -0,0 +1,18 @@
+namespace Cthangover.Core.Battle.Actions
+{
+ ///
+ /// Typed executor source. Each IBattleCore returns its own provider,
+ /// giving cores the ability to supply custom executor sets without
+ /// polluting the global registry.
+ ///
+ public interface IActionExecutorProvider
+ {
+ ///
+ /// Returns the executor registered for ,
+ /// or null if no override is configured. Called by
+ /// before falling back to the
+ /// global registry.
+ ///
+ IActionExecutor GetExecutor(string actionId);
+ }
+}
diff --git a/src/Actions/IBattleAction.cs b/src/Actions/IBattleAction.cs
new file mode 100644
index 0000000..090c9b7
--- /dev/null
+++ b/src/Actions/IBattleAction.cs
@@ -0,0 +1,32 @@
+namespace Cthangover.Core.Battle.Actions
+{
+ ///
+ /// A single step in the battle action queue (animation, damage, effect).
+ /// DoStart is called once when the action begins, DoAction is polled
+ /// every frame — it returns true when the action is done — and DoEnd
+ /// is the cleanup callback. This polling model lets actions drive
+ /// their own duration (e.g. a tween runs until finished) without
+ /// the machine needing timers.
+ ///
+ public interface IBattleAction
+ {
+ ///
+ /// Called once when the action is dequeued and begins execution.
+ /// Use for setup: spawning nodes, caching references, starting
+ /// tweens or coroutines.
+ ///
+ void DoStart();
+ ///
+ /// Polled every frame. Returns true when the action has
+ /// finished its work; returns false to remain active.
+ /// This model eliminates the need for the machine to manage timers.
+ ///
+ bool DoAction();
+ ///
+ /// Cleanup callback fired after returns
+ /// true. Use for teardown: freeing nodes, resetting state,
+ /// applying final stat deltas.
+ ///
+ void DoEnd();
+ }
+}
diff --git a/src/Actions/StopMachineDelegate.cs b/src/Actions/StopMachineDelegate.cs
new file mode 100644
index 0000000..fcba1b0
--- /dev/null
+++ b/src/Actions/StopMachineDelegate.cs
@@ -0,0 +1,9 @@
+namespace Cthangover.Core.Battle.Actions
+{
+ ///
+ /// Signal emitted when the action machine drains or is stopped.
+ /// Battle cores subscribe to this to advance to the next phase
+ /// (e.g. end turn, start enemy turn) without polling.
+ ///
+ public delegate void StopMachineDelegate();
+}
diff --git a/src/Adapters/AnimationRegistry.cs b/src/Adapters/AnimationRegistry.cs
new file mode 100644
index 0000000..759aa44
--- /dev/null
+++ b/src/Adapters/AnimationRegistry.cs
@@ -0,0 +1,42 @@
+using System.Collections.Generic;
+
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Global animation-player index keyed by AnimationType string.
+ /// Mods or cores register players; the action machine looks up the
+ /// player for a given animation type. Singleton — there is one set
+ /// of players for the entire runtime, but each player can make
+ /// per-core decisions internally.
+ ///
+ public class AnimationRegistry
+ {
+ ///
+ /// Singleton registry. One set of animation players serves the
+ /// entire runtime; individual players dispatch per-core via
+ /// internal logic.
+ ///
+ public static readonly AnimationRegistry Instance = new();
+
+ private readonly Dictionary _players = new();
+
+ ///
+ /// Registers a player under .
+ /// Replaces any existing player for the same key — last write wins.
+ ///
+ public void Register(string animType, IBattleAnimationPlayer player)
+ {
+ _players[animType] = player;
+ }
+
+ ///
+ /// Retrieves the player registered for ,
+ /// or null if no match exists.
+ ///
+ public IBattleAnimationPlayer Get(string animType)
+ {
+ _players.TryGetValue(animType, out var player);
+ return player;
+ }
+ }
+}
diff --git a/src/Adapters/BattleContextImpl.cs b/src/Adapters/BattleContextImpl.cs
new file mode 100644
index 0000000..19d4369
--- /dev/null
+++ b/src/Adapters/BattleContextImpl.cs
@@ -0,0 +1,79 @@
+using Cthangover.Core.Characters;
+using Godot;
+
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Default IBattleContext wired to the current BattleSceneContext.
+ /// ApplyDamage clamps at 0 (no negative health from defence overflow),
+ /// EndBattle delegates to ShowWinground/ShowDeadground on the scene
+ /// context. ScheduleAnimation and EndTurn are stubs — specific battle
+ /// cores are expected to override or supplement this behaviour.
+ ///
+ public class BattleContextImpl : IBattleContext
+ {
+ private readonly BattleSceneContext _sceneContext;
+
+ ///
+ /// Root Godot of the battle scene, used by
+ /// animation actions to parent spawned visual effects.
+ ///
+ public Node RootNode => _sceneContext;
+
+ ///
+ /// The owning that manages
+ /// the battle lifecycle.
+ ///
+ public BattleSceneContext SceneContext => _sceneContext;
+
+ ///
+ /// Creates a context adapter around the given scene context.
+ ///
+ public BattleContextImpl(BattleSceneContext sceneContext)
+ {
+ _sceneContext = sceneContext;
+ }
+
+ ///
+ /// Subtracts from
+ /// , clamping at 0 to prevent negative
+ /// health from defence overflow, and applies the result to
+ /// 's health.
+ ///
+ public void ApplyDamage(Character source, Character target, int rawDamage, int defence)
+ {
+ var finalDamage = Mathf.Max(0, rawDamage - defence);
+ target.Attributes.Health.Value -= finalDamage;
+ }
+
+ ///
+ /// Stub — specific battle cores override this to enqueue
+ /// animation actions into the .
+ ///
+ public void ScheduleAnimation(IBattleAnimation animation)
+ {
+ }
+
+ ///
+ /// Stub — cores implement this to advance the turn sequence
+ /// when a side finishes its action phase.
+ ///
+ public void EndTurn(BattleSide side)
+ {
+ }
+
+ ///
+ /// Resolves the battle outcome by delegating to
+ /// for player
+ /// victory or
+ /// for enemy victory.
+ ///
+ public void EndBattle(BattleSide winner)
+ {
+ if (winner == BattleSide.Player)
+ _sceneContext.ShowWinground();
+ else
+ _sceneContext.ShowDeadground();
+ }
+ }
+}
diff --git a/src/Adapters/IBattleAnimation.cs b/src/Adapters/IBattleAnimation.cs
new file mode 100644
index 0000000..aa38d21
--- /dev/null
+++ b/src/Adapters/IBattleAnimation.cs
@@ -0,0 +1,33 @@
+using System.Collections.Generic;
+using Cthangover.Core.Characters;
+
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Describes a battle animation without binding to a concrete player.
+ /// Carries Source/Target characters, an AnimationType key for dispatch,
+ /// a Speed multiplier, and an open-ended Parameters dictionary so
+ /// custom animations can pass arbitrary data (damage value, element
+ /// type, etc.) without interface changes.
+ ///
+ public interface IBattleAnimation
+ {
+ /// The performing the action.
+ Character Source { get; }
+ /// The receiving the action.
+ Character Target { get; }
+ ///
+ /// Key used by to dispatch to the
+ /// correct .
+ ///
+ string AnimationType { get; }
+ /// Multiplier for the playback speed (1.0 = normal).
+ float Speed { get; }
+ ///
+ /// Open-ended data bag for animation-specific values
+ /// (e.g. damage amount, elemental flag) — no interface changes
+ /// needed when adding new animation types.
+ ///
+ Dictionary Parameters { get; }
+ }
+}
diff --git a/src/Adapters/IBattleAnimationPlayer.cs b/src/Adapters/IBattleAnimationPlayer.cs
new file mode 100644
index 0000000..d903399
--- /dev/null
+++ b/src/Adapters/IBattleAnimationPlayer.cs
@@ -0,0 +1,26 @@
+using System;
+
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Plays a specific animation type. Each player is keyed by
+ /// AnimationType so the registry can dispatch animations without
+ /// knowing concrete player types. The onComplete callback chains
+ /// into the action machine's polling loop.
+ ///
+ public interface IBattleAnimationPlayer
+ {
+ ///
+ /// String key used by to select
+ /// this player for a given .
+ ///
+ string AnimationType { get; }
+
+ ///
+ /// Starts playing the animation described by .
+ /// Must invoke once the animation
+ /// finishes, so the action machine can advance to the next step.
+ ///
+ void Play(IBattleAnimation anim, Action onComplete);
+ }
+}
diff --git a/src/Adapters/IBattleContext.cs b/src/Adapters/IBattleContext.cs
new file mode 100644
index 0000000..2d0191c
--- /dev/null
+++ b/src/Adapters/IBattleContext.cs
@@ -0,0 +1,27 @@
+using Cthangover.Core.Characters;
+using Godot;
+
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Bridge from the battle core back to the Godot scene. Cores work
+ /// against this interface so they remain engine-agnostic.
+ /// ApplyDamage computes final-damage-minus-defence and mutates
+ /// the target's Health directly (no event — the UI layer polls).
+ /// ScheduleAnimation is the hook for visual feedback; EndTurn/EndBattle
+ /// are phase signals. RootNode gives cores a Godot anchor for
+ /// instantiating nodes (e.g. damage numbers).
+ ///
+ public interface IBattleContext
+ {
+ void ApplyDamage(Character source, Character target, int rawDamage, int defence);
+
+ void ScheduleAnimation(IBattleAnimation animation);
+
+ void EndTurn(BattleSide side);
+
+ void EndBattle(BattleSide winner);
+
+ Node RootNode { get; }
+ }
+}
diff --git a/src/Adapters/IBattleCore.cs b/src/Adapters/IBattleCore.cs
new file mode 100644
index 0000000..53a62bc
--- /dev/null
+++ b/src/Adapters/IBattleCore.cs
@@ -0,0 +1,46 @@
+using Cthangover.Core.Battle.Actions;
+using Cthangover.Core.Characters;
+
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Pluggable battle engine contract. Each implementation is a
+ /// self-contained battle rule set (turn order, AI, phases).
+ /// Init receives the two character arrays and a context adapter;
+ /// Start begins the loop. The ActionProvider property lets the
+ /// core inject its own executor overrides via the hub.
+ /// Cores are instantiated fresh per battle — they carry no
+ /// persistent state between encounters.
+ ///
+ public interface IBattleCore
+ {
+ ///
+ /// Unique identifier for this battle engine (e.g. "card_battle",
+ /// "ff_battle"). Used by and
+ /// for lookup.
+ ///
+ string Id { get; }
+
+ ///
+ /// Provider that returns core-specific
+ /// overrides. Set on the when
+ /// this core activates, so actions route through custom logic
+ /// before falling back to the global registry.
+ ///
+ IActionExecutorProvider ActionProvider { get; }
+
+ ///
+ /// Initialises the core with the two character arrays and a
+ /// for callbacks. Called once per
+ /// battle before .
+ ///
+ void Init(Character[] playerChars, Character[] enemyChars, IBattleContext ctx);
+
+ ///
+ /// Begins the turn loop. Cores may use coroutines, polling, or
+ /// event-driven state machines — the core owns its own lifecycle
+ /// after this call.
+ ///
+ void Start();
+ }
+}
diff --git a/src/Adapters/IBattleSide.cs b/src/Adapters/IBattleSide.cs
new file mode 100644
index 0000000..e92f70e
--- /dev/null
+++ b/src/Adapters/IBattleSide.cs
@@ -0,0 +1,15 @@
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Identifies which side of the battle a character or turn belongs to.
+ /// Used by IBattleContext.EndTurn and EndBattle to route results
+ /// (victory vs defeat screen).
+ ///
+ public enum BattleSide
+ {
+ /// The player's party.
+ Player,
+ /// The opposing force (monsters, NPCs).
+ Enemy,
+ }
+}
diff --git a/src/Base/ActionBase.cs b/src/Base/ActionBase.cs
new file mode 100644
index 0000000..b6f5557
--- /dev/null
+++ b/src/Base/ActionBase.cs
@@ -0,0 +1,62 @@
+
+using Cthangover.Core.Battle.Actions;
+using Cthangover.Core.Utils;
+
+namespace Cthangover.Core.Characters
+{
+
+ ///
+ /// Convenience base for IActionExecutor implementations. Provides
+ /// CheckRequiredAndUsePoint which validates that the user has enough
+ /// "Point" attribute (a generic action-point / mana pool) before
+ /// deducting it — returning false lets the action fail gracefully.
+ /// ID is abstract so each action declares its own identity.
+ ///
+ public abstract class ActionBase : IActionExecutor
+ {
+
+ ///
+ /// Maps to the abstract so the hub can resolve
+ /// this executor by action name.
+ ///
+ public string ActionId => ID;
+
+ ///
+ /// Unique action identifier that matches
+ /// in data.
+ ///
+ public abstract string ID { get; }
+ ///
+ /// Applies the action's effects to
+ /// using 's stats and the
+ /// definition. Returns stat deltas
+ /// and a success/failure flag.
+ ///
+ public abstract ChangedAttributes Execute(ActionCharacter action, Character user, Character target);
+
+ ///
+ /// Validates that has at least
+ /// "RequiredPoint" remaining, deducts it, and
+ /// returns true. Returns false with a warning log
+ /// if the user lacks sufficient points, allowing the action to
+ /// fail gracefully.
+ ///
+ protected bool CheckRequiredAndUsePoint(ActionCharacter action, Character user)
+ {
+ var requiredPoint = action.GetInt("RequiredPoint");
+
+ if (user.Attributes.Point.Value < requiredPoint)
+ {
+ GameLogger.Log("BATTLE", user.ID + " not checked point required [" + user.Attributes.Point.Value + "] for action " + action.ID + " [" + requiredPoint + "]", LogLevel.Warning);
+ return false;
+ }
+
+ GameLogger.Log("BATTLE", user.ID + " use [" + requiredPoint + "] points for action " + action.ID, LogLevel.Debug);
+
+ user.Attributes.Point.Value -= requiredPoint;
+ return true;
+ }
+
+ }
+
+}
diff --git a/src/BattleCoreRegistry.cs b/src/BattleCoreRegistry.cs
new file mode 100644
index 0000000..1195449
--- /dev/null
+++ b/src/BattleCoreRegistry.cs
@@ -0,0 +1,134 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+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
+ /// 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.
+ ///
+ public class BattleCoreRegistry
+ {
+ /// Singleton registry instance.
+ public static readonly BattleCoreRegistry Instance = new();
+
+ private readonly Dictionary _cores = new();
+ 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.
+ ///
+ public void RegisterAssembly(Assembly assembly)
+ {
+ foreach (var instance in Reflections.FindAndCreate(assembly))
+ {
+ _cores[instance.Id] = instance.GetType();
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ public void RegisterAll()
+ {
+ foreach (var instance in Reflections.FindAndCreate())
+ {
+ _cores[instance.Id] = instance.GetType();
+ }
+
+ GameLogger.Log("BATTLE",
+ $"BattleCoreRegistry.RegisterAll: {_cores.Count} core(s) registered: [{string.Join(", ", CoreIds)}]");
+ }
+
+ ///
+ /// Selects the active core by ID. Throws if the core is not
+ /// registered. The selected core will be instantiated fresh on
+ /// the next call to .
+ ///
+ public void SetActive(string id)
+ {
+ if (!_cores.ContainsKey(id))
+ throw new Exception($"Battle core '{id}' not found");
+ _activeCoreId = id;
+ }
+
+ ///
+ /// Constructs a fresh instance of the active core type. Falls
+ /// back to the first registered core if no active ID was set.
+ /// Throws if no cores are registered at all.
+ ///
+ public IBattleCore GetActive()
+ {
+ if (_activeCoreId != null && _cores.ContainsKey(_activeCoreId))
+ return (IBattleCore)Activator.CreateInstance(_cores[_activeCoreId]);
+
+ var firstId = GetFirstCoreId();
+ if (firstId != null)
+ {
+ _activeCoreId = firstId;
+ return (IBattleCore)Activator.CreateInstance(_cores[firstId]);
+ }
+
+ throw new Exception("No active battle core set. Use battle.set_core first.");
+ }
+
+ ///
+ /// Returns the ID of the first registered core, or null if the
+ /// registry is empty. Used as a fallback when no active core
+ /// has been explicitly set.
+ ///
+ public string GetFirstCoreId()
+ {
+ return _cores.Keys.FirstOrDefault();
+ }
+
+ ///
+ /// Checks whether a core with the given
+ /// has been registered.
+ ///
+ public bool HasCore(string id)
+ {
+ return _cores.ContainsKey(id);
+ }
+
+ ///
+ /// Registered core IDs, sorted for stable output. Used by the battle
+ /// coverage tool as the single source of truth for how many battle
+ /// cores ship with the current mod set.
+ ///
+ public IReadOnlyList CoreIds
+ {
+ get
+ {
+ var ids = new List(_cores.Keys);
+ ids.Sort(System.StringComparer.Ordinal);
+ return ids;
+ }
+ }
+
+ ///
+ /// Returns the registered for a core, or
+ /// null. Lets tooling inspect a core's implemented interfaces
+ /// (e.g. ) without instantiating it.
+ ///
+ public Type GetCoreType(string id)
+ {
+ return _cores.TryGetValue(id, out var type) ? type : null;
+ }
+ }
+}
diff --git a/src/BattleData.cs b/src/BattleData.cs
new file mode 100644
index 0000000..acf1c98
--- /dev/null
+++ b/src/BattleData.cs
@@ -0,0 +1,98 @@
+using System.Collections.Generic;
+using System.Linq;
+using Cthangover.Core.Battle;
+using Cthangover.Core.Factories.Impls;
+using Cthangover.Core.Quests;
+using Cthangover.Core.Characters;
+using Godot;
+
+namespace Cthangover.Core.Settings
+{
+ ///
+ /// Immutable descriptor for a battle encounter, constructed via the
+ /// static factory overloads. Holds the
+ /// background texture, enemy roster, quest reference, card order side,
+ /// and the scene to return to when the battle ends. The private
+ /// constructor forces creation through the factories so that all
+ /// required data is supplied upfront.
+ ///
+ public class BattleData
+ {
+ /// The quest that triggered this battle; may be null
+ /// for non-quest skirmishes.
+ public QuestBase Quest { get; set; }
+ /// Auxiliary tag set on new characters acquired via recruitment
+ /// after this battle.
+ public string NewTag { get; set; }
+
+ /// List of enemy instances spawned from
+ /// the provided IDs or default templates.
+ public List EnemiesCards { get; private set; }
+ /// Battle background texture (full-resolution).
+ public Texture2D Background { get; private set; }
+ /// Depth map texture for the parallax post-process effect.
+ public Texture2D DepthMap { get; set; }
+ /// Albedo/colour map texture for the parallax post-process effect.
+ public Texture2D AlbedoMap { get; set; }
+ /// Which side of the screen the player's cards are drawn on.
+ public BattleSide BattleSide { get; private set; } = BattleSide.Player;
+ /// Identifier for the active battle core logic module.
+ public string ActiveBattleCore { get; set; }
+ /// Scene name to transition to when the battle finishes or is
+ /// fled. Stored as a string to avoid type coupling.
+ public string ReturnScene { get; private set; }
+
+ private BattleData()
+ { }
+
+ ///
+ /// Convenience overload that defaults to
+ /// BattleSide.Player. Constructs enemies from their string IDs
+ /// via ,
+ /// falling back to a level-1 template when the ID is unknown.
+ ///
+ public static BattleData InitBattle(Texture2D background, string returnScene, params string[] enemies)
+ {
+ return InitBattle(background, returnScene, BattleSide.Player, enemies);
+ }
+
+ ///
+ /// Creates a battle with a specified and
+ /// enemy IDs. Each ID is resolved through
+ /// .
+ /// Unknown IDs produce a fallback with 20 HP
+ /// and 3 action points.
+ ///
+ public static BattleData InitBattle(Texture2D background, string returnScene, BattleSide cardOrder = BattleSide.Player, params string[] enemies)
+ {
+ var list = enemies.Select(o => CharacterFactory.Instance.Get(o) ?? new Character
+ {
+ ID = o,
+ Attributes = new CharacterAttributes
+ {
+ Health = new Attribute { Value = 20, BaseValue = 20 },
+ Point = new Attribute { Value = 3, BaseValue = 3 },
+ },
+ Level = 1,
+ Exp = 10,
+ }).ToList();
+ return InitBattle(background, returnScene, list, cardOrder);
+ }
+
+ ///
+ /// Core factory that directly accepts a pre-built
+ /// for the enemy roster. This is the
+ /// overload that all other InitBattle variants delegate to.
+ ///
+ public static BattleData InitBattle(Texture2D background, string returnScene, List enemiesCards, BattleSide cardOrder = BattleSide.Player)
+ {
+ return new BattleData
+ {
+ Background = background,
+ ReturnScene = returnScene,
+ EnemiesCards = enemiesCards,
+ BattleSide = cardOrder,
+ };
+ }
+ }
+}
diff --git a/src/BattleEndedEvent.cs b/src/BattleEndedEvent.cs
new file mode 100644
index 0000000..50c7159
--- /dev/null
+++ b/src/BattleEndedEvent.cs
@@ -0,0 +1,11 @@
+namespace Cthangover.Core.Events
+{
+ [LifecycleEvent]
+ [EventKey("battle_ended")]
+ public class BattleEndedEvent : ModEvent
+ {
+ public string Result { get; init; }
+
+ public int Turns { get; init; }
+ }
+}
diff --git a/src/BattleGDScriptBridge.cs b/src/BattleGDScriptBridge.cs
new file mode 100644
index 0000000..1b315e8
--- /dev/null
+++ b/src/BattleGDScriptBridge.cs
@@ -0,0 +1,190 @@
+using Cthangover.Core.Characters;
+using Cthangover.Core.Factories.Impls;
+using Cthangover.Core.Items;
+using Godot;
+
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Converts battle data to and from plain Godot collections so GDScript mods
+ /// can work with it. The previous bridge exposed [GlobalClass] DTOs
+ /// (GDCharacter, GDBattleResult, GDWingroundContext), but classes compiled at
+ /// runtime inside mods never go through Godot's C# source generator, so their
+ /// properties and methods are invisible to GDScript. Dictionaries and arrays
+ /// are native variants and work regardless of which assembly produced them.
+ ///
+ /// Keys are snake_case to match GDScript conventions. The same helpers are
+ /// used by (action/character snapshots in,
+ /// result deltas out) and (victory context
+ /// in, exp/loot mutations out).
+ ///
+ public static class BattleGDScriptBridge
+ {
+ /// Flattens a character into a dictionary of primitive stats and identity fields.
+ public static Godot.Collections.Dictionary CharacterToDictionary(Character character)
+ {
+ var data = new Godot.Collections.Dictionary();
+ if (character == null)
+ return data;
+
+ data["id"] = character.ID;
+ data["name"] = character.Name;
+ data["level"] = character.Level;
+ data["exp"] = character.Exp;
+ data["recruitment_chance"] = character.RecruitmentChance;
+
+ var attrs = character.Attributes;
+ if (attrs != null)
+ {
+ data["health_value"] = attrs.Health?.Value ?? 0;
+ data["health_max"] = attrs.Health?.BaseValue ?? 0;
+ data["defence_value"] = attrs.Defence?.Value ?? 0;
+ data["defence_max"] = attrs.Defence?.BaseValue ?? 0;
+ data["attack_value"] = attrs.Attack?.Value ?? 0;
+ data["attack_max"] = attrs.Attack?.BaseValue ?? 0;
+ data["point_value"] = attrs.Point?.Value ?? 0;
+ data["point_max"] = attrs.Point?.BaseValue ?? 0;
+
+ var properties = new Godot.Collections.Dictionary();
+ if (attrs.Properties?.Values != null)
+ {
+ foreach (var kvp in attrs.Properties.Values)
+ properties[kvp.Key] = kvp.Value;
+ }
+ data["properties"] = properties;
+ }
+
+ return data;
+ }
+
+ /// Flattens an action card into a dictionary of identity fields and raw properties.
+ public static Godot.Collections.Dictionary ActionToDictionary(ActionCharacter action)
+ {
+ var data = new Godot.Collections.Dictionary();
+ if (action == null)
+ return data;
+
+ data["id"] = action.ID;
+ data["name"] = action.Name;
+ data["description"] = action.Description;
+ data["type"] = (int)action.Type;
+
+ var properties = new Godot.Collections.Dictionary();
+ if (action.Properties?.Values != null)
+ {
+ foreach (var kvp in action.Properties.Values)
+ properties[kvp.Key] = kvp.Value;
+ }
+ data["properties"] = properties;
+
+ return data;
+ }
+
+ ///
+ /// Builds from the dictionary a GDScript
+ /// executor returns. Missing keys default to zero/false; result
+ /// defaults to true so a script that only reports deltas still
+ /// counts as a successful action.
+ ///
+ public static ChangedAttributes ResultFromDictionary(Godot.Collections.Dictionary data)
+ {
+ if (data == null)
+ return new ChangedAttributes { Result = false };
+
+ var result = new ChangedAttributes();
+ if (data.TryGetValue("source_damage", out var sourceDamage))
+ result.Source.Damage = sourceDamage.AsInt32();
+ if (data.TryGetValue("source_defence", out var sourceDefence))
+ result.Source.Defence = sourceDefence.AsInt32();
+ if (data.TryGetValue("target_damage", out var targetDamage))
+ result.Target.Damage = targetDamage.AsInt32();
+ if (data.TryGetValue("target_defence", out var targetDefence))
+ result.Target.Defence = targetDefence.AsInt32();
+
+ result.Result = !data.TryGetValue("result", out var success) || success.AsBool();
+ return result;
+ }
+
+ /// Builds the mutable victory context dictionary passed to GDScript winground hooks.
+ public static Godot.Collections.Dictionary WingroundToDictionary(WingroundContext ctx)
+ {
+ var data = new Godot.Collections.Dictionary();
+ if (ctx == null)
+ return data;
+
+ var expGained = new Godot.Collections.Dictionary();
+ if (ctx.PlayerExpGained != null)
+ {
+ foreach (var kvp in ctx.PlayerExpGained)
+ expGained[kvp.Key] = kvp.Value;
+ }
+ data["exp_gained"] = expGained;
+
+ var defeated = new Godot.Collections.Array();
+ if (ctx.DefeatedEnemies != null)
+ {
+ foreach (var enemy in ctx.DefeatedEnemies)
+ defeated.Add(CharacterToDictionary(enemy));
+ }
+ data["defeated_enemies"] = defeated;
+
+ var loot = new Godot.Collections.Array();
+ if (ctx.GeneratedLoot != null)
+ {
+ foreach (var item in ctx.GeneratedLoot)
+ {
+ loot.Add(new Godot.Collections.Dictionary
+ {
+ ["item_id"] = item.Item?.ID ?? string.Empty,
+ ["count"] = item.Count,
+ });
+ }
+ }
+ data["generated_loot"] = loot;
+
+ data["return_scene"] = ctx.BattleData?.ReturnScene ?? string.Empty;
+ return data;
+ }
+
+ ///
+ /// Writes GDScript mutations from a winground context dictionary back to
+ /// the C# pipeline. Only exp_gained and generated_loot are
+ /// synced — defeated enemies are read-only snapshots, matching the
+ /// documented contract of .
+ ///
+ public static void SyncWingroundBack(Godot.Collections.Dictionary data, WingroundContext ctx)
+ {
+ if (data == null || ctx == null)
+ return;
+
+ if (data.TryGetValue("exp_gained", out var expVariant)
+ && expVariant.AsGodotDictionary() is Godot.Collections.Dictionary expDict
+ && ctx.PlayerExpGained != null)
+ {
+ ctx.PlayerExpGained.Clear();
+ foreach (var key in expDict.Keys)
+ ctx.PlayerExpGained[key.AsString()] = expDict[key].AsInt32();
+ }
+
+ if (data.TryGetValue("generated_loot", out var lootVariant)
+ && lootVariant.AsGodotArray() is Godot.Collections.Array lootArray
+ && ctx.GeneratedLoot != null)
+ {
+ ctx.GeneratedLoot.Clear();
+ foreach (var entry in lootArray)
+ {
+ if (entry.AsGodotDictionary() is not Godot.Collections.Dictionary itemDict)
+ continue;
+
+ var itemId = itemDict.TryGetValue("item_id", out var idVariant) ? idVariant.AsString() : null;
+ var count = itemDict.TryGetValue("count", out var countVariant) ? countVariant.AsInt32() : 0;
+ ctx.GeneratedLoot.Add(new ItemContainer
+ {
+ Item = string.IsNullOrEmpty(itemId) ? null : ItemFactory.Instance.Get(itemId),
+ Count = count,
+ });
+ }
+ }
+ }
+ }
+}
diff --git a/src/BattleInitAction.cs b/src/BattleInitAction.cs
new file mode 100644
index 0000000..e49b358
--- /dev/null
+++ b/src/BattleInitAction.cs
@@ -0,0 +1,43 @@
+using Cthangover.Core.Actions;
+
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Dispatched by the scenario DSL to initiate battle. Reads "scene",
+ /// "enemies", "quest_id" and "new_tag" from dialog variables and calls
+ /// . The enemies string is a comma-separated
+ /// list embedded in the scenario script. Battle initialization must happen
+ /// *after* the background has been set by a prior background action, otherwise
+ /// the encounter gets a null backdrop.
+ ///
+ /// Registered by the battle mod's assembly scan, so the core scenario action
+ /// registry stays free of battle commands.
+ ///
+ public class BattleInitAction : IScenarioAction
+ {
+ /// Registered as "battle.init" — the scenario DSL's primary battle initiation command.
+ public string Name => "battle.init";
+
+ ///
+ /// Reads battle parameters from dialog variables and initiates battle
+ /// construction. Requires both "scene" and "enemies" to be non-empty —
+ /// returns early with a log if either is missing.
+ ///
+ public void Run(IActionContext ctx)
+ {
+ var sceneRaw = ctx.GetParam("scene");
+ var enemies = ctx.GetParam("enemies");
+ var questId = ctx.GetParam("quest_id");
+ var newTag = ctx.GetParam("new_tag");
+
+ if (string.IsNullOrEmpty(sceneRaw) || string.IsNullOrEmpty(enemies))
+ {
+ ctx.Log("EVENT", "BattleInitAction: missing 'scene' or 'enemies' variable");
+ return;
+ }
+
+ BattleService.Init(sceneRaw, enemies, questId, newTag);
+ ctx.Log("EVENT", $"BattleInitAction: battle started at '{sceneRaw}' with enemies '{enemies}'");
+ }
+ }
+}
diff --git a/src/BattleRequest.cs b/src/BattleRequest.cs
new file mode 100644
index 0000000..29e7db5
--- /dev/null
+++ b/src/BattleRequest.cs
@@ -0,0 +1,18 @@
+using Cthangover.Core.Settings;
+
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Static hand-off slot between the scenario side (which builds an encounter
+ /// through ) and the battle scene (which consumes it
+ /// in ). Lives in the battle mod instead of the
+ /// core runtime state so the core does not need to know about battle data;
+ /// is transient — it is never serialized and is cleared
+ /// by the victory/defeat screens when the player leaves the encounter.
+ ///
+ public static class BattleRequest
+ {
+ /// Pending encounter, or null when no battle was requested.
+ public static BattleData Current { get; set; }
+ }
+}
diff --git a/src/BattleSceneContext.cs b/src/BattleSceneContext.cs
new file mode 100644
index 0000000..d11a1fb
--- /dev/null
+++ b/src/BattleSceneContext.cs
@@ -0,0 +1,357 @@
+using System.Collections.Generic;
+using System.Linq;
+using Cthangover.Core.Events;
+using Cthangover.Core.Factories.Impls;
+using Cthangover.Core.Mods;
+using Cthangover.Core.Settings;
+using Cthangover.Core.UI;
+using Cthangover.Core.UI.Lights;
+using Cthangover.Core.Characters;
+using Cthangover.Core.Mods.Resolvers;
+using Cthangover.Core.Utils;
+using Godot;
+
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Singleton scene-root for battle, wired as a self-registering Node
+ /// (sets Instance on enter-tree, clears on exit-tree). On ready it
+ /// assembles player characters from CharacterData.BattleSet, enemy
+ /// cards from the pending , resolves the active
+ /// via , and defers
+ /// Init+Start to the next frame so the scene tree is fully built before core
+ /// logic runs. Tracks PlayerExpGained (per-character, split evenly among
+ /// survivors) and DefeatedEnemies for the victory/loot screen. Exposes
+ /// OnCharacterDied and OnBattleCleared events so external systems (e.g.
+ /// quests, the action machine) can react without coupling.
+ /// ShowWinground / ShowDeadground are mutually exclusive end-of-battle
+ /// paths — both set IsDestroyed to prevent double-triggering and
+ /// reset battle speed before clearing state.
+ ///
+ /// The context derives from because mod assemblies get
+ /// no Godot source generation: the core base receives the engine lifecycle
+ /// callbacks and forwards them to the OnXxx overrides. It also
+ /// implements so core UI (the inventory
+ /// panel's action-point row) can read the acting character without knowing
+ /// anything about battle.
+ ///
+ public partial class BattleSceneContext : ModNode, IActiveCharacterProvider
+ {
+ ///
+ /// Singleton reference to the active battle scene context.
+ /// Set on enter-tree, cleared on exit-tree.
+ /// External systems read this to access battle state.
+ ///
+ public static BattleSceneContext Instance { get; private set; }
+
+ private TextureRect battleBg;
+
+ ///
+ /// The node that
+ /// processes the animation queue for this battle. Cores push
+ /// animation actions through the context and the machine drives
+ /// playback frame-by-frame.
+ ///
+ public Actions.BattleActionMachine BattleActionMachine { get; private set; }
+
+ private IBattleSpeedControl _battleSpeedControl;
+ private Widget deadground;
+ private Widget winground;
+
+ ///
+ /// Per-character EXP earned during this battle, keyed by
+ /// . Populated by
+ /// ; consumed by the victory screen
+ /// provided by the turn-battle GUI mod.
+ ///
+ public Dictionary PlayerExpGained { get; private set; } = new();
+ ///
+ /// Enemies defeated during this battle (for loot generation
+ /// and recruitment rolls).
+ ///
+ public List DefeatedEnemies { get; private set; } = new();
+
+ ///
+ /// Guards against double-triggering end-of-battle paths.
+ /// Set to true by or
+ /// .
+ ///
+ public bool IsDestroyed { get; private set; }
+
+ ///
+ /// When true, the battle core pauses processing (e.g.
+ /// while a UI overlay is open). Set externally by pause widgets.
+ ///
+ public bool IsWait { get; set; }
+
+ ///
+ /// The whose turn is currently active
+ /// in battle. Set by the battle core when a character is selected
+ /// and cleared when the turn ends or selection is cancelled.
+ /// null means no character is active (out of battle,
+ /// between turns, or idle state). External panels such as the
+ /// inventory panel read this to check whether the active
+ /// character has enough action points before performing
+ /// item-related actions.
+ ///
+ public Character ActiveCharacter { get; set; }
+
+ /// Fired when a character's health reaches 0.
+ public event System.Action OnCharacterDied;
+ ///
+ /// Fired after battle state is cleared (both victory and defeat).
+ /// External systems can hook this to release battle resources.
+ ///
+ public event System.Action OnBattleCleared;
+
+ ///
+ /// Invokes to notify listeners
+ /// (quests, achievements, the battle core) that a character
+ /// has been defeated.
+ ///
+ public void NotifyCharacterDied(Character character)
+ {
+ OnCharacterDied?.Invoke(character);
+ ModEventBus.Publish(new CharacterDiedEvent
+ {
+ CharacterId = character?.ID,
+ CharacterName = character?.Name,
+ Source = "battle",
+ });
+ }
+
+ protected override void OnEnterTree()
+ {
+ if (Instance != null && GodotObject.IsInstanceValid(Instance))
+ {
+ var scene = GetTree()?.CurrentScene?.Name ?? "?";
+ var existingPath = Instance.GetPath().ToString();
+ var myPath = GetPath().ToString();
+ GameLogger.Log("DUPLICATE", $"BattleSceneContext.OnEnterTree: Instance ALREADY SET by '{existingPath}', overwriting with duplicate at '{myPath}' on scene '{scene}'", LogLevel.Error);
+ }
+ Instance = this;
+ ActiveCharacterRegistry.Provider = this;
+ }
+
+ protected override void OnExitTree()
+ {
+ base.OnExitTree();
+ if (Instance == this)
+ Instance = null;
+ if (ReferenceEquals(ActiveCharacterRegistry.Provider, this))
+ ActiveCharacterRegistry.Provider = null;
+ }
+
+ private List pendingPlayerCards;
+ private List pendingEnemyCards;
+ private IBattleCore activeBattleCore;
+
+ ///
+ /// The battle core driving this scene, resolved from
+ /// when the scene becomes ready. Exposed
+ /// so tooling and tests can query the active core (for example for
+ /// ) without reaching into UI nodes.
+ ///
+ public IBattleCore ActiveBattleCore => activeBattleCore;
+
+ protected override void OnReady()
+ {
+ battleBg = GetNodeOrNull("BattleBg");
+
+ if (battleBg == null)
+ {
+ GameLogger.Log("BATTLE", "battleBg not found!", LogLevel.Error);
+ return;
+ }
+
+ BattleActionMachine = GetNodeOrNull("ToolPanel/BattleActionMachine");
+ _battleSpeedControl = GetNodeOrNull("BattleSpeedControl") as IBattleSpeedControl;
+ deadground = GetNodeOrNull("Deadground");
+
+ winground = GetNodeOrNull("Winground");
+
+ var data = BattleRequest.Current;
+ if (data == null)
+ {
+ GameLogger.Log("BATTLE", "BattleRequest.Current is null!", LogLevel.Error);
+ return;
+ }
+
+ GameLogger.Log("BATTLE", $"set battle background: battleBg={(battleBg != null ? "found" : "NULL")}, texture={(data.Background != null ? "has" : "NULL")}", battleBg == null || data.Background == null ? LogLevel.Error : LogLevel.Debug);
+
+ if (data.Background != null)
+ {
+ battleBg.Texture = data.Background;
+ GameLogger.Log("BATTLE", $" battleBg.Texture assigned: width={battleBg.Texture.GetWidth()}, height={battleBg.Texture.GetHeight()}", LogLevel.Debug);
+ }
+ else
+ {
+ GameLogger.Log("BATTLE", $" data.Background is NULL — no background texture set", LogLevel.Error);
+ }
+
+ var timedShader = Shaders.Resolve("timed_sprite");
+ if (timedShader != null)
+ {
+ var bgMaterial = new ShaderMaterial { Shader = timedShader };
+ battleBg.Material = bgMaterial;
+
+ if (data.DepthMap != null)
+ bgMaterial.SetShaderParameter("depth_mask", data.DepthMap);
+ if (data.AlbedoMap != null)
+ bgMaterial.SetShaderParameter("albedo_map", data.AlbedoMap);
+
+ var lights = GetNodeOrNull("Lights");
+ if (lights != null)
+ {
+ var viewportSize = GetViewport().GetVisibleRect().Size;
+ lights.SetPlayerLight(viewportSize / 2, 500f, 1f);
+ lights.RegisterMaterial(bgMaterial);
+ }
+ GameLogger.Log("BATTLE", $"battleBg shader: timed_sprite applied, depthMap={(data.DepthMap != null ? "has" : "NULL")}, albedoMap={(data.AlbedoMap != null ? "has" : "NULL")}, lightsCtrl={(lights != null ? "found" : "NULL")}", lights == null ? LogLevel.Error : LogLevel.Debug);
+ }
+ else
+ {
+ GameLogger.Log("BATTLE", "battleBg shader: timed_sprite NOT FOUND", LogLevel.Error);
+ }
+
+ pendingPlayerCards = new List();
+ foreach (var characterType in GameData.Instance.Runtime.CharacterData.BattleSet)
+ {
+ var characterInfo = GameData.Instance.Runtime.CharacterData.Characters[characterType];
+ var template = CharacterFactory.Instance.Get(characterInfo.ID);
+ var character = template != null ? template.Copy() : new Character { ID = characterInfo.ID };
+ character.Attributes = characterInfo.Attributes ?? new CharacterAttributes();
+ character.Level = characterInfo.Level;
+ character.Exp = characterInfo.Exp;
+ if (characterInfo.ActionSlots != null && characterInfo.ActionSlots.Any(id => !string.IsNullOrEmpty(id)))
+ {
+ character.Actions = characterInfo.ActionSlots
+ .Where(id => !string.IsNullOrEmpty(id))
+ .Select(id => ActionCharacterFactory.Instance.Get(id))
+ .Where(a => a != null)
+ .ToList();
+ }
+ pendingPlayerCards.Add(character);
+ }
+
+ pendingEnemyCards = data.EnemiesCards?.Where(c => c != null).ToList() ?? new List();
+
+ if (!string.IsNullOrEmpty(data.ActiveBattleCore) && BattleCoreRegistry.Instance.HasCore(data.ActiveBattleCore))
+ BattleCoreRegistry.Instance.SetActive(data.ActiveBattleCore);
+
+ try
+ {
+ activeBattleCore = BattleCoreRegistry.Instance.GetActive();
+ }
+ catch
+ {
+ activeBattleCore = null;
+ }
+
+ if (activeBattleCore == null)
+ {
+ GameLogger.Log("BATTLE", "No battle core available", LogLevel.Error);
+ return;
+ }
+
+ Callable.From(DeferredInit).CallDeferred();
+ }
+
+ private void DeferredInit()
+ {
+ if (IsDestroyed || activeBattleCore == null)
+ return;
+
+ var ctx = new BattleContextImpl(this);
+ var playerArray = pendingPlayerCards?.ToArray() ?? new Character[0];
+ var enemyArray = pendingEnemyCards?.ToArray() ?? new Character[0];
+
+ activeBattleCore.Init(playerArray, enemyArray, ctx);
+ activeBattleCore.Start();
+
+ ModEventBus.Publish(new BattleStartedEvent
+ {
+ BattleType = activeBattleCore.GetType().Name,
+ Enemies = enemyArray.Select(c => c.Name).ToArray(),
+ });
+ }
+
+ ///
+ /// Triggers the defeat screen: sets ,
+ /// resets battle speed, clears battle state, and shows the
+ /// deadground widget.
+ ///
+ public void ShowDeadground()
+ {
+ if (IsDestroyed)
+ return;
+ IsDestroyed = true;
+ _battleSpeedControl?.ResetToNormal();
+ ClearBattle();
+ deadground?.Show();
+ }
+
+ ///
+ /// Triggers the victory screen: sets ,
+ /// resets battle speed, clears battle state, and shows the
+ /// winground widget.
+ ///
+ public void ShowWinground()
+ {
+ if (IsDestroyed)
+ return;
+ IsDestroyed = true;
+ _battleSpeedControl?.ResetToNormal();
+ ClearBattle();
+ winground?.Show();
+ }
+
+ ///
+ /// Records a defeated enemy: adds it to
+ /// and distributes its EXP
+ /// (Level * Exp) equally among all surviving player
+ /// characters in .
+ ///
+ public void RecordEnemyDefeated(Character enemy)
+ {
+ if (enemy == null)
+ return;
+ DefeatedEnemies.Add(enemy);
+
+ var enemyExp = enemy.Level * enemy.Exp;
+ var alivePlayers = 0;
+ foreach (var c in pendingPlayerCards)
+ {
+ if (c?.Attributes?.Health?.Value > 0)
+ alivePlayers++;
+ }
+ if (alivePlayers == 0)
+ return;
+
+ var expPerPlayer = enemyExp / alivePlayers;
+ foreach (var c in pendingPlayerCards)
+ {
+ if (c?.Attributes?.Health?.Value <= 0)
+ continue;
+ var id = c.ID;
+ if (id == null)
+ continue;
+ PlayerExpGained.TryGetValue(id, out var current);
+ PlayerExpGained[id] = current + expPerPlayer;
+ }
+ }
+
+ private void ClearBattle()
+ {
+ BattleActionMachine?.StopMachine();
+ ActiveCharacter = null;
+ pendingPlayerCards?.Clear();
+ pendingEnemyCards?.Clear();
+ OnBattleCleared?.Invoke();
+ ModEventBus.Publish(new BattleEndedEvent
+ {
+ Result = IsDestroyed ? "defeat" : "victory",
+ });
+ }
+ }
+}
diff --git a/src/BattleService.cs b/src/BattleService.cs
new file mode 100644
index 0000000..bdc2c41
--- /dev/null
+++ b/src/BattleService.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using Cthangover.Core.Factories.Impls;
+using Cthangover.Core.Quests;
+using Cthangover.Core.Scenes;
+using Cthangover.Core.Settings;
+using Cthangover.Core.UI.Lights;
+using Cthangover.Core.Utils;
+
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Entry point for starting a battle encounter. Constructs
+ /// from scenario parameters and stores it in for the
+ /// battle scene to pick up. Captures the current background texture (via
+ /// using )
+ /// so the battle scene has the correct visual context, and snapshots the
+ /// depth/albedo lighting maps from for visual
+ /// consistency. If a quest id is provided, the quest is bound to the encounter
+ /// and an optional new tag triggers a quest notification. The active battle core
+ /// is resolved from — wrapped in try/catch
+ /// because the registry may not be initialized when called from non-battle
+ /// contexts (e.g. preloading).
+ ///
+ public static class BattleService
+ {
+ ///
+ /// Constructs and stores for the upcoming
+ /// encounter. Must be called after a background has been set by a prior
+ /// scenario background action, otherwise the backdrop is null. The enemies
+ /// string is a comma-separated list resolved by .
+ ///
+ public static void Init(string sceneType, string enemies, string questId = null, string newTag = null)
+ {
+ var enemyList = enemies.Split(',');
+ var background = BackgroundFactory.Instance.Get(SceneContextNode.LastBackgroundID);
+ GameLogger.Log("BATTLE", $"BattleService.Init: lastBgId='{SceneContextNode.LastBackgroundID}', bgTexture={(background != null ? "loaded" : "NULL")}", background == null ? LogLevel.Error : LogLevel.Debug);
+ var data = BattleData.InitBattle(background, sceneType, enemyList);
+
+ data.DepthMap = UiLightController.Instance?.CurrentDepthMap;
+ data.AlbedoMap = UiLightController.Instance?.CurrentAlbedoMap;
+
+ GameLogger.Log("BATTLE", $"BattleService.Init: depthMap from LightsCtrl = {(data.DepthMap != null ? "captured" : "NULL")}, albedoMap = {(data.AlbedoMap != null ? "captured" : "NULL")}", data.DepthMap == null && data.AlbedoMap == null ? LogLevel.Error : LogLevel.Debug);
+
+ try
+ {
+ data.ActiveBattleCore = BattleCoreRegistry.Instance.GetActive()?.Id;
+ }
+ catch (Exception ex)
+ {
+ GameLogger.Log("BATTLE", $"BattleService.Init: {ex.Message}\n{ex.StackTrace}", LogLevel.Error);
+ }
+
+ if (!string.IsNullOrEmpty(questId))
+ {
+ try
+ {
+ var quest = QuestFactory.Instance.Get(questId);
+ data.Quest = quest;
+ if (!string.IsNullOrEmpty(newTag))
+ {
+ data.NewTag = newTag;
+ quest.SendNotification();
+ }
+ }
+ catch (KeyNotFoundException)
+ {
+ GameLogger.Log("BATTLE", $"BattleService.Init: quest '{questId}' not found, battle will proceed without quest", LogLevel.Error);
+ }
+ }
+
+ BattleRequest.Current = data;
+ }
+ }
+}
diff --git a/src/BattleSetCoreAction.cs b/src/BattleSetCoreAction.cs
new file mode 100644
index 0000000..ec35f4a
--- /dev/null
+++ b/src/BattleSetCoreAction.cs
@@ -0,0 +1,40 @@
+using Cthangover.Core.Actions;
+
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Selects which battle core (combat ruleset) the next battle will use.
+ /// The "core" variable maps to a entry by
+ /// string ID. Must be called before a battle starts, as
+ /// reads the active core at initialization time.
+ ///
+ public class BattleSetCoreAction : IScenarioAction
+ {
+ ///
+ /// Registered as "battle.set_core" — selects the combat ruleset
+ /// for the next battle encounter. If "core" is empty or missing, logs a
+ /// warning and returns without modifying the registry.
+ ///
+ public string Name => "battle.set_core";
+
+ ///
+ /// Reads the "core" variable and sets it as the active battle core
+ /// via . The core determines
+ /// combat mechanics (turn order, ability system, etc.) for the
+ /// next initiated battle.
+ ///
+ public void Run(IActionContext ctx)
+ {
+ var core = ctx.GetParam("core");
+ if (string.IsNullOrEmpty(core))
+ {
+ ctx.Log("EVENT", "BattleSetCoreAction: missing 'core' variable");
+ return;
+ }
+
+ BattleCoreRegistry.Instance.SetActive(core);
+ ctx.Log("EVENT", $"BattleSetCoreAction: battle core set to '{core}'");
+ }
+ }
+}
+
diff --git a/src/BattleStartedEvent.cs b/src/BattleStartedEvent.cs
new file mode 100644
index 0000000..c86c4d8
--- /dev/null
+++ b/src/BattleStartedEvent.cs
@@ -0,0 +1,10 @@
+namespace Cthangover.Core.Events
+{
+ [LifecycleEvent]
+ [EventKey("battle_started")]
+ public class BattleStartedEvent : ModEvent
+ {
+ public string BattleType { get; init; }
+ public string[] Enemies { get; init; }
+ }
+}
diff --git a/src/ChangedAttributes.cs b/src/ChangedAttributes.cs
new file mode 100644
index 0000000..57175f4
--- /dev/null
+++ b/src/ChangedAttributes.cs
@@ -0,0 +1,30 @@
+namespace Cthangover.Core.Characters
+{
+ ///
+ /// Result struct for battle action resolution. Source and Target carry
+ /// Damage/Defence snapshots for both sides of the exchange. Result indicates
+ /// whether the action succeeded (e.g. hit landed vs. missed/blocked).
+ /// A struct rather than a class because these are short-lived stack values
+ /// created in tight battle loops — no heap allocation.
+ ///
+ public struct ChangedAttributes
+ {
+ ///
+ /// Snapshot of the action source's damage and defence after the
+ /// exchange.
+ ///
+ public CharacterSet Source;
+ ///
+ /// Snapshot of the action target's damage and defence after the
+ /// exchange.
+ ///
+ public CharacterSet Target;
+ ///
+ /// Whether the action succeeded (e.g. hit landed vs. missed or
+ /// blocked).
+ ///
+ public bool Result;
+
+ }
+
+}
diff --git a/src/CharacterDiedEvent.cs b/src/CharacterDiedEvent.cs
new file mode 100644
index 0000000..32642c0
--- /dev/null
+++ b/src/CharacterDiedEvent.cs
@@ -0,0 +1,15 @@
+namespace Cthangover.Core.Events
+{
+ ///
+ /// Published when a character dies in combat. Payload copies data
+ /// as strings — does not pass a live Node (may be freed by the time
+ /// deferred subscribers are called).
+ ///
+ [EventKey("character_died")]
+ public class CharacterDiedEvent : ModEvent
+ {
+ public string CharacterId { get; init; }
+ public string CharacterName { get; init; }
+ public string Source { get; init; }
+ }
+}
diff --git a/src/CharacterSet.cs b/src/CharacterSet.cs
new file mode 100644
index 0000000..d3fa44c
--- /dev/null
+++ b/src/CharacterSet.cs
@@ -0,0 +1,20 @@
+namespace Cthangover.Core.Characters
+{
+ ///
+ /// Lightweight snapshot of damage/defence values for battle action results.
+ /// A struct to avoid heap allocation in the battle loop where these are
+ /// created for every action exchange. Used by ChangedAttributes to carry
+ /// both source and target state.
+ ///
+ public struct CharacterSet
+ {
+ ///
+ /// Damage dealt or received in this exchange.
+ ///
+ public int Damage;
+ ///
+ /// Defence value at the time of the exchange.
+ ///
+ public int Defence;
+ }
+}
diff --git a/src/DeadgroundBehaviour.cs b/src/DeadgroundBehaviour.cs
index 84300d6..8d977e1 100644
--- a/src/DeadgroundBehaviour.cs
+++ b/src/DeadgroundBehaviour.cs
@@ -1,4 +1,5 @@
using Cthangover.Core.Audio;
+using Cthangover.Core.Battle;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Scenes;
@@ -26,7 +27,7 @@ namespace Cthangover.TurnBattle
{
var sceneService = GetNode("/root/GodotSceneService");
sceneService?.SwitchToMenu();
- GameData.Instance.Runtime.BattleData = null;
+ BattleRequest.Current = null;
}
///
diff --git a/src/GDWingroundListener.cs b/src/GDWingroundListener.cs
new file mode 100644
index 0000000..0762e96
--- /dev/null
+++ b/src/GDWingroundListener.cs
@@ -0,0 +1,64 @@
+using Godot;
+
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// GDScript bridge for . Holds a reference to a
+ /// GDScript object and delegates each hook through
+ /// / .
+ ///
+ /// All three hooks are optional — the GDScript mod implements only the methods
+ /// it needs. The context is marshalled into a plain Godot dictionary
+ /// (exp_gained, defeated_enemies, generated_loot,
+ /// return_scene) before each call and synced back afterward, so
+ /// GDScript mutations to exp and loot propagate to the C# victory pipeline.
+ /// Dictionaries replace the old GDWingroundContext RefCounted DTO because mod
+ /// classes compiled at runtime are not visible to GDScript.
+ ///
+ public class GDWingroundListener : IWingroundListener
+ {
+ private readonly GodotObject _instance;
+
+ public string Id { get; }
+
+ ///
+ /// Creates a bridge around a live GDScript object. The caller must ensure
+ /// the object outlives the registry.
+ ///
+ public GDWingroundListener(string id, GodotObject instance)
+ {
+ Id = id;
+ _instance = instance;
+ }
+
+ public void OnBeforeExpAward(WingroundContext ctx)
+ {
+ if (!_instance.HasMethod("on_winground_before_exp"))
+ return;
+
+ var gdCtx = BattleGDScriptBridge.WingroundToDictionary(ctx);
+ _instance.Call("on_winground_before_exp", gdCtx);
+ BattleGDScriptBridge.SyncWingroundBack(gdCtx, ctx);
+ }
+
+ public void OnAfterLootGenerated(WingroundContext ctx)
+ {
+ if (!_instance.HasMethod("on_winground_after_loot"))
+ return;
+
+ var gdCtx = BattleGDScriptBridge.WingroundToDictionary(ctx);
+ _instance.Call("on_winground_after_loot", gdCtx);
+ BattleGDScriptBridge.SyncWingroundBack(gdCtx, ctx);
+ }
+
+ public void OnBeforeShow(WingroundContext ctx)
+ {
+ if (!_instance.HasMethod("on_winground_before_show"))
+ return;
+
+ var gdCtx = BattleGDScriptBridge.WingroundToDictionary(ctx);
+ _instance.Call("on_winground_before_show", gdCtx);
+ BattleGDScriptBridge.SyncWingroundBack(gdCtx, ctx);
+ }
+ }
+}
diff --git a/src/IBattleAutoPlayable.cs b/src/IBattleAutoPlayable.cs
new file mode 100644
index 0000000..5e550c1
--- /dev/null
+++ b/src/IBattleAutoPlayable.cs
@@ -0,0 +1,30 @@
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Optional automation contract for battle cores. The base
+ /// contract describes how a battle starts but
+ /// not how it advances: every core owns its own UI and waits for player
+ /// input in its own way. Integration tests and AI tooling therefore ask
+ /// the core itself when it waits for the player and how to advance the
+ /// turn, instead of hunting for core-specific buttons by node path.
+ /// Cores that don't implement this interface are still runnable but are
+ /// reported as not universally testable by the battle coverage tool.
+ ///
+ public interface IBattleAutoPlayable
+ {
+ ///
+ /// true while the core accepts a player turn advance. Must
+ /// become false immediately when the turn ends, so a polling
+ /// driver cannot trigger two turns in a row.
+ ///
+ bool IsWaitingForPlayerInput { get; }
+
+ ///
+ /// Ends the current player turn through the same path the core's UI
+ /// uses (skipping the player's actions is a valid strategy for
+ /// smoke tests). Must be a no-op when
+ /// is false.
+ ///
+ void AdvancePlayerTurn();
+ }
+}
diff --git a/src/IBattleSpeedControl.cs b/src/IBattleSpeedControl.cs
new file mode 100644
index 0000000..9b7a916
--- /dev/null
+++ b/src/IBattleSpeedControl.cs
@@ -0,0 +1,18 @@
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Minimal contract between and the
+ /// battle view's speed control. The concrete x1/x4/x8 widget lives in the
+ /// turn-battle GUI mod, so the core scene only needs the one call it makes
+ /// when a battle ends — resetting the global time scale without persisting it.
+ ///
+ public interface IBattleSpeedControl
+ {
+ ///
+ /// Restores Engine.TimeScale to 1x without saving the preference.
+ /// Called when the battle ends so menus and the lobby run at normal speed
+ /// regardless of the player's in-battle speed choice.
+ ///
+ void ResetToNormal();
+ }
+}
diff --git a/src/IWingroundListener.cs b/src/IWingroundListener.cs
new file mode 100644
index 0000000..013a12d
--- /dev/null
+++ b/src/IWingroundListener.cs
@@ -0,0 +1,61 @@
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Extension point for mods to inspect and mutate victory-screen
+ /// data. Implementations are auto-discovered via
+ /// (C#)
+ /// or detected by method signature in
+ /// (GDScript).
+ ///
+ /// The three hooks fire in order during
+ /// :
+ ///
+ /// - — EXP data is available
+ /// but has not yet been applied to characters.
+ /// - — loot has been
+ /// rolled from enemies but has not yet been added to inventory.
+ /// - — all processing is done;
+ /// final chance to mutate data before the UI appears.
+ ///
+ /// The same instance is passed to
+ /// all listeners across all three phases, so mutations from earlier
+ /// hooks are visible to later hooks and to subsequent listeners.
+ ///
+ public interface IWingroundListener
+ {
+ ///
+ /// Unique identifier for this listener. Used in logs and for
+ /// cross-mod dependency ordering. Convention:
+ /// "mod_id.listener_name".
+ ///
+ string Id { get; }
+
+ ///
+ /// Called before EXP is awarded to surviving characters.
+ /// and
+ /// are
+ /// populated;
+ /// is empty at this stage.
+ ///
+ void OnBeforeExpAward(WingroundContext ctx);
+
+ ///
+ /// Called after loot has been generated from
+ /// but before
+ /// the items are added to the player's inventory.
+ /// is populated
+ /// and mutable — adding, removing, or modifying entries here
+ /// directly affects the inventory outcome.
+ ///
+ void OnAfterLootGenerated(WingroundContext ctx);
+
+ ///
+ /// Called after EXP, loot, inventory, and recruitment have
+ /// all been processed, immediately before the victory UI
+ /// is shown (base.Show()). Use for post-processing
+ /// or cleanup that must happen before the player sees the
+ /// screen.
+ ///
+ void OnBeforeShow(WingroundContext ctx);
+ }
+}
diff --git a/src/StatusEffect/IStatusActions.cs b/src/StatusEffect/IStatusActions.cs
new file mode 100644
index 0000000..6be828d
--- /dev/null
+++ b/src/StatusEffect/IStatusActions.cs
@@ -0,0 +1,58 @@
+using Cthangover.Core.Characters;
+
+namespace Cthangover.Core.Cards.StatusEffect
+{
+ ///
+ /// Behaviour hooks for a status effect type. Each hook receives the
+ /// owning character so a single IStatusActions instance can be
+ /// shared across multiple characters. OnDealDamage / OnTakeDamage
+ /// use ref int to let effects amplify or reduce damage before it
+ /// is applied. ModifyAttributes is a passive stat modifier called
+ /// independently of turn phases. OnFinalAction fires once when the
+ /// effect expires. All hooks have empty default semantics — effects
+ /// override only the hooks they care about.
+ ///
+ public interface IStatusActions
+ {
+ /// Factory key matching the Actions field in
+ /// , e.g. "basic/stun".
+ string ID { get; }
+
+ /// Fires when the owning character's turn begins. Use for
+ /// start-of-turn damage-over-time or regeneration effects.
+ void OnTurnStart(Character target);
+ /// Fires when the owning character's turn ends. Mirrors
+ /// for symmetry; useful for effects that
+ /// activate after the character acts.
+ void OnTurnEnd(Character target);
+ /// Called before the owning character deals damage to
+ /// . Modify via
+ /// to amplify or reduce outgoing damage —
+ /// all active effects stack multiplicatively through the queue's
+ /// iteration.
+ void OnDealDamage(Character source, Character target, ref int damage);
+ /// Called before the owning character receives damage from
+ /// . Modify via
+ /// to add damage reduction or vulnerability.
+ /// Fires after in the damage
+ /// pipeline.
+ void OnTakeDamage(Character target, Character source, ref int damage);
+ /// Fires immediately when the effect is first placed on a
+ /// character. Use for one-shot stat boosts, visual feedback, or
+ /// initialisation logic.
+ void OnApply(Character target);
+ /// Fires when the effect is manually removed (e.g. via a
+ /// cleanse card). Not called on natural expiration — use
+ /// for that path.
+ void OnRemove(Character target);
+ /// Fires once when the effect expires naturally (timer reaches
+ /// zero). Use for end-of-effect triggers like explosion damage or
+ /// stat restoration. Not called on manual removal.
+ void OnFinalAction(Character target);
+ /// Passive stat modifier invoked independently of turn phases.
+ /// Receives a reference to the character's attribute container;
+ /// implementors can add flat or percentage bonuses that other
+ /// effects may also contribute to.
+ void ModifyAttributes(CharacterAttributes attributes);
+ }
+}
diff --git a/src/StatusEffect/IStatusEffect.cs b/src/StatusEffect/IStatusEffect.cs
new file mode 100644
index 0000000..5854d68
--- /dev/null
+++ b/src/StatusEffect/IStatusEffect.cs
@@ -0,0 +1,49 @@
+using Godot;
+
+namespace Cthangover.Core.Cards.StatusEffect
+{
+
+ ///
+ /// Status effect data contract. Separates identity (ID, Name, Icon)
+ /// from mutable state (Duration, RemainingTurns) and behaviour
+ /// (Actions — an IStatusActions hook set). Copy() is required so the
+ /// StatusEffectQueue can clone effects when characters are duplicated.
+ ///
+ public interface IStatusEffect
+ {
+ /// Unique mod identifier, e.g. "basic/stun". Used by factories
+ /// and the queue to look up and remove effects by key.
+ string ID { get; }
+ /// Display name shown in the battle UI.
+ string Name { get; }
+ /// Flavour text explaining the effect to the player.
+ string Description { get; }
+ /// High-level category (Buff, Debuff, Stun) used
+ /// for UI tinting and quick type checks like
+ /// .
+ StatusEffectType EffectType { get; }
+ /// Base duration in turns. Set during effect creation; may be
+ /// overridden by the caller via .
+ /// A value of 0 or negative means the effect never expires
+ /// naturally.
+ int Duration { get; set; }
+ /// Turns remaining before the effect expires. Decremented each
+ /// turn boundary by the queue. Independent of Duration so
+ /// that the original value is preserved for display or re-apply
+ /// logic.
+ int RemainingTurns { get; set; }
+ /// Icon texture loaded from the "characters" mod group,
+ /// displayed beside the effect name in the HUD.
+ Texture2D Icon { get; }
+ /// Behaviour hooks that fire during turn phases and on damage
+ /// events. Resolved by StatusEffectActionFactory from the
+ /// string ID.
+ IStatusActions Actions { get; }
+
+ /// Creates a shallow clone. Actions is a shared reference
+ /// (stateless singleton) so it is not deep-copied. Used by
+ /// when duplicating a
+ /// character.
+ IStatusEffect Copy();
+ }
+}
diff --git a/src/StatusEffect/Impls/StunStatusEffect.cs b/src/StatusEffect/Impls/StunStatusEffect.cs
new file mode 100644
index 0000000..09ceee4
--- /dev/null
+++ b/src/StatusEffect/Impls/StunStatusEffect.cs
@@ -0,0 +1,49 @@
+using Cthangover.Core.Characters;
+
+namespace Cthangover.Core.Cards.StatusEffect.Impls
+{
+ ///
+ /// Stun behaviour set — intentionally all no-ops. The stun condition
+ /// is checked separately via StatusEffectQueue.HasStun() rather than
+ /// through action hooks, so the Actions implementation is empty.
+ /// Exists as a concrete type so the factory can resolve "basic/stun"
+ /// to a valid instance.
+ ///
+ public class StunStatusEffect : IStatusActions
+ {
+ ///
+ public string ID => "basic/stun";
+
+ ///
+ public void OnTurnStart(Character target)
+ { }
+
+ ///
+ public void OnTurnEnd(Character target)
+ { }
+
+ ///
+ public void OnDealDamage(Character source, Character target, ref int damage)
+ { }
+
+ ///
+ public void OnTakeDamage(Character target, Character source, ref int damage)
+ { }
+
+ ///
+ public void OnApply(Character target)
+ { }
+
+ ///
+ public void OnRemove(Character target)
+ { }
+
+ ///
+ public void OnFinalAction(Character target)
+ { }
+
+ ///
+ public void ModifyAttributes(CharacterAttributes attributes)
+ { }
+ }
+}
diff --git a/src/StatusEffect/StatusEffectInfo.cs b/src/StatusEffect/StatusEffectInfo.cs
new file mode 100644
index 0000000..eb0decb
--- /dev/null
+++ b/src/StatusEffect/StatusEffectInfo.cs
@@ -0,0 +1,45 @@
+using System.Text.Json.Serialization;
+using Cthangover.Core.Factories;
+
+namespace Cthangover.Core.Cards.StatusEffect
+{
+
+ ///
+ /// JSON-serialisable status effect definition loaded from mod files.
+ /// The Actions field is an ID string resolved by StatusEffectActionFactory
+ /// into an IStatusActions instance at runtime. Type uses
+ /// JsonStringEnumConverter for human-readable config files.
+ /// Implements IIdentifiable so it works with PrefabFactory caching.
+ ///
+ [System.Serializable]
+ public class StatusEffectInfo : IIdentifiable
+ {
+ /// Unique effect identifier used as the factory key.
+ /// Serialised as "Id" in JSON for consistency with the
+ /// modding schema.
+ [JsonPropertyName("Id")]
+ public string ID { get; set; }
+ /// Display name for the effect, shown in tooltips and the
+ /// character status panel.
+ public string Name { get; set; }
+ /// Human-readable description explaining what the effect
+ /// does.
+ public string Description { get; set; }
+ /// Category of the effect. Serialised as a string
+ /// ("Buff", "Debuff", "Stun") via
+ /// JsonStringEnumConverter for readable mod configs.
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public StatusEffectType Type { get; set; }
+ /// Base duration in turns before the effect naturally
+ /// expires.
+ public int Duration { get; set; }
+ /// Factory ID string for the behaviour set, resolved by
+ /// StatusEffectActionFactory into an
+ /// instance at runtime.
+ public string Actions { get; set; }
+ /// Filename of the icon texture within the
+ /// "characters" mod group, loaded by
+ /// during construction.
+ public string Icon { get; set; }
+ }
+}
diff --git a/src/StatusEffect/StatusEffectItem.cs b/src/StatusEffect/StatusEffectItem.cs
new file mode 100644
index 0000000..8008bef
--- /dev/null
+++ b/src/StatusEffect/StatusEffectItem.cs
@@ -0,0 +1,78 @@
+using Cthangover.Core.Factories.Impls;
+using Cthangover.Core.Utils;
+using Godot;
+
+namespace Cthangover.Core.Cards.StatusEffect
+{
+
+ ///
+ /// Runtime instance of a status effect. Constructed from
+ /// StatusEffectInfo with the Actions behaviour resolved via
+ /// StatusEffectActionFactory. Icon is loaded from the "characters"
+ /// mod group via TextureUtils — effects share the same icon pool
+ /// as characters. Copy() is a shallow clone of primitives and
+ /// references (Actions is a shared singleton, not cloned) suitable
+ /// for StatusEffectQueue duplication.
+ ///
+ public class StatusEffectItem : IStatusEffect
+ {
+ ///
+ public string ID { get; private set; }
+ ///
+ public string Name { get; private set; }
+ ///
+ public string Description { get; private set; }
+ ///
+ public int Duration { get; set; }
+ ///
+ public int RemainingTurns { get; set; }
+ ///
+ public StatusEffectType EffectType { get; private set; }
+ ///
+ public Texture2D Icon { get; private set; }
+ ///
+ public IStatusActions Actions { get; private set; }
+
+ /// Constructs a runtime effect from its JSON-serialisable
+ /// definition. Resolves the icon via
+ /// TextureUtils.LoadFromModGroup and the action set via
+ /// StatusEffectActionFactory.
+ /// Deserialised effect data from a mod
+ /// config.
+ public StatusEffectItem(StatusEffectInfo info)
+ {
+ ID = info.ID;
+ Name = info.Name;
+ Description = info.Description;
+ Duration = info.Duration;
+ EffectType = info.Type;
+ Icon = TextureUtils.LoadFromModGroup("characters", info.Icon);
+ Actions = StatusEffectActionFactory.Instance.Get(info.Actions);
+ }
+
+ /// Parameterless constructor for cases where properties are
+ /// set manually (e.g. by or deserialisation
+ /// paths).
+ public StatusEffectItem() { }
+
+ /// Shallow clone that copies primitives and reuses the
+ /// shared Actions reference. Used when the queue
+ /// duplicates effects during character cloning.
+ /// A new with identical
+ /// property values.
+ public IStatusEffect Copy()
+ {
+ return new StatusEffectItem
+ {
+ ID = ID,
+ Name = Name,
+ Description = Description,
+ Duration = Duration,
+ RemainingTurns = RemainingTurns,
+ EffectType = EffectType,
+ Icon = Icon,
+ Actions = Actions,
+ };
+ }
+ }
+}
diff --git a/src/StatusEffect/StatusEffectQueue.cs b/src/StatusEffect/StatusEffectQueue.cs
new file mode 100644
index 0000000..d673469
--- /dev/null
+++ b/src/StatusEffect/StatusEffectQueue.cs
@@ -0,0 +1,223 @@
+using System.Collections.Generic;
+using System.Linq;
+using Cthangover.Core.Characters;
+using Cthangover.Core.Utils;
+using Godot;
+
+namespace Cthangover.Core.Cards.StatusEffect
+{
+
+ ///
+ /// Per-character status-effect pipeline. Manages a list of active
+ /// effects, advancing their timers on turn boundaries and removing
+ /// expired ones after firing OnFinalAction. Add resolves the effect
+ /// info via the factory and calls OnApply immediately. OnTurnStart
+ /// iterates backwards to safely remove expired effects during
+ /// traversal. OnDealDamage / OnTakeDamage pipe through every active
+ /// effect via ref parameters, enabling multiplicative stacking.
+ /// ActiveStatusEffects returns a defensive copy so external code
+ /// cannot mutate the internal list. Copy creates a deep clone for
+ /// character duplication, re-firing OnApply on the new owner.
+ ///
+ public class StatusEffectQueue
+ {
+ private readonly List activeStatusEffectData;
+ private readonly Character owner;
+
+ /// Defensive copy of the internal effect list. Returns
+ /// a new List each access so external code cannot mutate
+ /// the queue's backing store. Safe for enumeration.
+ public List ActiveStatusEffects => activeStatusEffectData.ToList();
+
+ /// Initialises an empty queue bound to .
+ /// The owner reference is used by all action-hook calls to pass
+ /// the affected character.
+ /// The character who carries these
+ /// effects.
+ public StatusEffectQueue(Character owner)
+ {
+ this.activeStatusEffectData = new List();
+ this.owner = owner;
+ }
+
+ /// Checks whether any active effect has
+ /// . Called by the battle
+ /// engine to determine if the owner should skip their turn.
+ /// if at least one Stun-type effect
+ /// is active.
+ public bool HasStun()
+ {
+ foreach (var statusEffect in activeStatusEffectData)
+ if (statusEffect.EffectType == StatusEffectType.Stun)
+ return true;
+ return false;
+ }
+
+ /// Looks up in the factory,
+ /// constructs a , optionally
+ /// overrides its duration, fires OnApply, and appends it
+ /// to the active list.
+ /// Factory key matching a
+ /// entry.
+ /// If positive, overwrites the default
+ /// duration from the effect definition.
+ /// if the factory lookup fails
+ /// (key not found); otherwise .
+ public bool Add(string statusEffectId, int duration = -1)
+ {
+ var statusEffectInfo = Factories.Impls.StatusEffectInfoFactory.Instance.Get(statusEffectId);
+
+ if (statusEffectInfo == null)
+ {
+ GameLogger.Log("STATUS", $"statusEffect with ID '{statusEffectId}' not found!", LogLevel.Error);
+ return false;
+ }
+
+ var statusEffect = new StatusEffectItem(statusEffectInfo);
+
+ if (duration > 0)
+ {
+ statusEffect.Duration = duration;
+ statusEffect.RemainingTurns = duration;
+ }
+
+ statusEffect.Actions?.OnApply(owner);
+ activeStatusEffectData.Add(statusEffect);
+
+ return true;
+ }
+
+ /// Removes the first effect matching
+ /// from the active list.
+ /// Fires OnRemove so the effect can clean up before
+ /// disappearing (e.g. revert stat modifications).
+ /// Factory key of the effect to
+ /// remove.
+ /// if no matching effect was
+ /// found; otherwise .
+ public bool Remove(string statusEffectId)
+ {
+ var statusEffect = activeStatusEffectData.FirstOrDefault(b => b.ID == statusEffectId);
+ if (statusEffect == null)
+ return false;
+
+ statusEffect.Actions?.OnRemove(owner);
+ activeStatusEffectData.Remove(statusEffect);
+
+ return true;
+ }
+
+ /// Advances all active effects at the start of the owner's
+ /// turn. Fires OnTurnStart on each effect, decrements
+ /// for timed effects,
+ /// and removes expired ones after firing
+ /// OnFinalAction. Iterates backwards so removals do not
+ /// corrupt the index.
+ public void OnTurnStart()
+ {
+ for (int i = activeStatusEffectData.Count - 1; i >= 0; i--)
+ {
+ var statusEffect = activeStatusEffectData[i];
+ statusEffect.Actions?.OnTurnStart(owner);
+ if (statusEffect.RemainingTurns > 0 && statusEffect.Duration > 0)
+ {
+ statusEffect.RemainingTurns--;
+ if (statusEffect.RemainingTurns <= 0)
+ {
+ statusEffect.Actions?.OnFinalAction(owner);
+ activeStatusEffectData.RemoveAt(i);
+ }
+ }
+ }
+ }
+
+ /// Fires end-of-turn hooks on all active effects. Operates
+ /// on a defensive copy so that OnTurnEnd callbacks that
+ /// add or remove effects do not invalidate the enumerator.
+ /// Decrements remaining turns and removes expired effects via
+ /// (which also fires
+ /// OnRemove).
+ public void OnTurnEnd()
+ {
+ foreach (var statusEffect in ActiveStatusEffects)
+ {
+ statusEffect.Actions?.OnTurnEnd(owner);
+ statusEffect.RemainingTurns--;
+ if (statusEffect.RemainingTurns <= 0)
+ {
+ statusEffect.Actions?.OnFinalAction(owner);
+ Remove(statusEffect.ID);
+ }
+ }
+ }
+
+ /// Pipes outgoing damage through every active effect's
+ /// OnDealDamage hook, allowing each to modify
+ /// via .
+ /// Effects iterate in insertion order; multiplicative stacking
+ /// means order-agnostic results for flat modifiers but
+ /// order-sensitive for percentage modifiers.
+ /// The character receiving the damage.
+ /// The damage value, modifiable by effects
+ /// through the parameter.
+ public void OnDealDamage(Character target, ref int damage)
+ {
+ foreach (var statusEffect in activeStatusEffectData)
+ {
+ statusEffect.Actions?.OnDealDamage(owner, target, ref damage);
+ }
+ }
+
+ /// Pipes incoming damage through every active effect's
+ /// OnTakeDamage hook. Called after
+ /// in the damage pipeline, so
+ /// defensive modifiers stack on top of the attacker's
+ /// amplified value.
+ /// The character dealing the damage.
+ /// The damage value, modifiable by effects
+ /// through the parameter.
+ public void OnTakeDamage(Character source, ref int damage)
+ {
+ foreach (var statusEffect in activeStatusEffectData)
+ {
+ statusEffect.Actions?.OnTakeDamage(owner, source, ref damage);
+ }
+ }
+
+ /// Removes all active effects, firing OnRemove on
+ /// each before clearing the list. Used when a character dies or
+ /// an encounter ends so effects do not leak between battles.
+ public void ClearAll()
+ {
+ foreach (var statusEffect in activeStatusEffectData)
+ {
+ statusEffect.Actions?.OnRemove(owner);
+ }
+ activeStatusEffectData.Clear();
+ }
+
+ /// Deep-clones the entire queue for a new character owner.
+ /// Each effect is copied via ,
+ /// and OnApply is re-fired on
+ /// so initialisation hooks run in the target's context. Used
+ /// when a character is duplicated mid-encounter (e.g. a summon
+ /// or mirror effect).
+ /// The character who will own the
+ /// clone.
+ /// A new bound to
+ /// with copies of all active
+ /// effects.
+ public StatusEffectQueue Copy(Character newOwner)
+ {
+ var newSystem = new StatusEffectQueue(newOwner);
+ foreach (var statusEffect in activeStatusEffectData)
+ {
+ var copiedstatusEffect = statusEffect.Copy();
+ copiedstatusEffect.Actions?.OnApply(newOwner);
+ newSystem.activeStatusEffectData.Add(copiedstatusEffect);
+ }
+ return newSystem;
+ }
+
+ }
+}
diff --git a/src/StatusEffect/StatusEffectService.cs b/src/StatusEffect/StatusEffectService.cs
new file mode 100644
index 0000000..ac7199b
--- /dev/null
+++ b/src/StatusEffect/StatusEffectService.cs
@@ -0,0 +1,40 @@
+using System.Runtime.CompilerServices;
+using Cthangover.Core.Characters;
+
+namespace Cthangover.Core.Cards.StatusEffect
+{
+ ///
+ /// Side-table that owns the of every character
+ /// participating in battle. The queue used to live on
+ /// itself, but the core character model must stay free of battle subsystems —
+ /// so the battle mod keeps queues keyed by character instance instead.
+ ///
+ /// Uses : queues die together
+ /// with their character, which matters because each battle constructs fresh
+ /// copies via and discards them afterwards. All
+ /// access happens on the main thread during battle, so no extra locking is
+ /// needed beyond what the weak table provides.
+ ///
+ public static class StatusEffectService
+ {
+ private static readonly ConditionalWeakTable queues = new();
+
+ ///
+ /// Returns the queue of , creating an empty one
+ /// on first access. Returns null for a null character so call sites
+ /// can keep their existing null-conditional patterns.
+ ///
+ public static StatusEffectQueue Of(Character character)
+ {
+ if (character == null)
+ return null;
+
+ return queues.GetValue(character, CreateQueue);
+ }
+
+ private static StatusEffectQueue CreateQueue(Character owner)
+ {
+ return new StatusEffectQueue(owner);
+ }
+ }
+}
diff --git a/src/StatusEffect/StatusEffectType.cs b/src/StatusEffect/StatusEffectType.cs
new file mode 100644
index 0000000..b5146fc
--- /dev/null
+++ b/src/StatusEffect/StatusEffectType.cs
@@ -0,0 +1,26 @@
+namespace Cthangover.Core.Cards.StatusEffect
+{
+ ///
+ /// High-level classification for status effects. Buff/Debuff are
+ /// informational (UI colouring, stacking rules); Stun is singled
+ /// out because the queue has a dedicated HasStun() check that the
+ /// battle engine queries to skip a stunned character's turn.
+ ///
+ public enum StatusEffectType
+ {
+ /// Positive effect that benefits the bearer (e.g. stat boost,
+ /// regeneration). Rendered with a friendly tint in the UI.
+ Buff,
+ /// Negative effect that harms the bearer (e.g. damage over
+ /// time, stat reduction). Rendered with a hostile tint in the
+ /// UI.
+ Debuff,
+ /// Crowd-control effect that causes the bearer to skip
+ /// their turn. Checked by
+ /// ; the action hooks
+ /// are deliberately no-ops — the skip is enforced by the battle
+ /// engine reading this type flag, not by the
+ /// callbacks.
+ Stun,
+ }
+}
diff --git a/src/StatusEffectActionFactory.cs b/src/StatusEffectActionFactory.cs
new file mode 100644
index 0000000..39ab5c4
--- /dev/null
+++ b/src/StatusEffectActionFactory.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using Cthangover.Core.Cards.StatusEffect;
+using Cthangover.Core.Utils;
+
+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.
+ ///
+ public class StatusEffectActionFactory
+ {
+ private readonly Dictionary actions;
+
+#region Singleton
+
+ private static readonly Lazy instance = new(() => new StatusEffectActionFactory());
+ public static StatusEffectActionFactory Instance => instance.Value;
+
+ public StatusEffectActionFactory()
+ {
+ actions = new Dictionary();
+ foreach (var action in Reflections.FindAndCreate())
+ {
+ actions.Add(action.ID, action);
+ }
+ }
+
+#endregion
+
+ public IStatusActions Get(string id)
+ {
+ if (actions.TryGetValue(id, out var result))
+ return result;
+ GameLogger.Log("STATUS", "actions '" + id + "' not found!", LogLevel.Error);
+ return null;
+ }
+
+ }
+}
diff --git a/src/StatusEffectInfoFactory.cs b/src/StatusEffectInfoFactory.cs
new file mode 100644
index 0000000..cc39a60
--- /dev/null
+++ b/src/StatusEffectInfoFactory.cs
@@ -0,0 +1,19 @@
+using System;
+using Cthangover.Core.Cards.StatusEffect;
+
+namespace Cthangover.Core.Factories.Impls
+{
+ ///
+ /// Thin FileFactory for status effect metadata — duration, stack
+ /// behaviour, visual icon, etc. Works in tandem with
+ /// StatusEffectActionFactory: this factory provides the data
+ /// (what the effect is), while the action factory provides the
+ /// behaviour (what the effect does each turn).
+ ///
+ public class StatusEffectInfoFactory : FileFactory
+ {
+ private static readonly Lazy lazy = new(() => new StatusEffectInfoFactory());
+ public static StatusEffectInfoFactory Instance => lazy.Value;
+ public override string GroupName => "status_effects";
+ }
+}
diff --git a/src/TurnBattleGDScriptHook.cs b/src/TurnBattleGDScriptHook.cs
new file mode 100644
index 0000000..3825eaf
--- /dev/null
+++ b/src/TurnBattleGDScriptHook.cs
@@ -0,0 +1,62 @@
+using Cthangover.Core.Battle;
+using Cthangover.Core.Battle.Actions;
+using Cthangover.Core.Mods;
+using Cthangover.Core.Utils;
+using Godot;
+
+namespace Cthangover.TurnBattle
+{
+ ///
+ /// GDScript hook of the turn-battle mod. The core's ModGDScriptLoader walks
+ /// every .gd file generically and hands objects to registered hooks;
+ /// this hook recognizes the two battle-side contracts:
+ ///
+ /// - get_action_id() + execute(action, user, target) — wraps
+ /// the script in a and registers it in the
+ /// global pool.
+ /// - get_winground_listener_id() plus any of the three
+ /// on_winground_* methods — wraps the script in a
+ /// .
+ ///
+ /// Keeping the recognition here means the core loader no longer mentions any
+ /// battle method name.
+ ///
+ public class TurnBattleGDScriptHook : IGDScriptHook
+ {
+ /// Stable id used by for replacement on reload.
+ public string Id => "turn_battle_base.gd_hook";
+
+ /// Inspects one compiled script and registers an adapter when a contract matches.
+ public void TryRegister(GodotObject obj, string modId, string filePath)
+ {
+ if (obj == null)
+ return;
+
+ if (obj.HasMethod("get_action_id") && obj.HasMethod("execute"))
+ {
+ var actionId = obj.Call("get_action_id").AsString();
+ if (!string.IsNullOrEmpty(actionId))
+ {
+ ActionExecutorHub.Instance.RegisterGlobal(new GDBattleActionExecutor(actionId, obj));
+ GameLogger.Log("GDS_MOD",
+ $"Registered GDScript battle executor '{actionId}' from mod '{modId}' ({filePath})");
+ }
+ }
+
+ var hasWingroundHook = obj.HasMethod("on_winground_before_exp")
+ || obj.HasMethod("on_winground_after_loot")
+ || obj.HasMethod("on_winground_before_show");
+
+ if (hasWingroundHook && obj.HasMethod("get_winground_listener_id"))
+ {
+ var listenerId = obj.Call("get_winground_listener_id").AsString();
+ if (!string.IsNullOrEmpty(listenerId))
+ {
+ WingroundListenerRegistry.Instance.RegisterListener(new GDWingroundListener(listenerId, obj));
+ GameLogger.Log("GDS_MOD",
+ $"Registered GDScript winground listener '{listenerId}' from mod '{modId}' ({filePath})");
+ }
+ }
+ }
+ }
+}
diff --git a/src/TurnBattleSceneInitializer.cs b/src/TurnBattleSceneInitializer.cs
index 0c656c4..1ef9c17 100644
--- a/src/TurnBattleSceneInitializer.cs
+++ b/src/TurnBattleSceneInitializer.cs
@@ -1,25 +1,77 @@
+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
{
///
- /// Registers the turn-based battle view scene with the core so that
- /// can route the logical "battle" scene to
- /// this mod's tscn. Runs at resources-ready — before any battle can be
- /// triggered — and is the reason the core no longer hardcodes a battle path.
+ /// Lifecycle entry point of the turn-battle mod. Three responsibilities:
+ ///
+ /// - Publishes the logical "Battle" scene route with fade transition and
+ /// combat music, so the core can route switch_scene Battle without
+ /// hardcoding a battle path.
+ /// - 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).
+ ///
///
public class TurnBattleSceneInitializer : IModInitializer
{
private const string BattleScenePath = "scenes/battle/battle.tscn";
- /// No-op: registration only needs the resources-ready stage.
- public void OnModLoaded(string modId) { }
+ ///
+ /// 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
+ /// 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 battle view scene path to .
+ ///
+ /// Publishes the scene route and rescans all loaded assemblies for battle
+ /// cores and winground listeners.
+ ///
public void OnModResourcesReady()
{
- BattleSceneRegistry.Register(BattleScenePath);
+ 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/WingroundBehaviour.cs b/src/WingroundBehaviour.cs
index 1d8b2dc..0b6594b 100644
--- a/src/WingroundBehaviour.cs
+++ b/src/WingroundBehaviour.cs
@@ -3,10 +3,10 @@ using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Audio;
using Cthangover.Core.Battle;
+using Cthangover.Core.Events;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Items;
using Cthangover.Core.Mods;
-using Cthangover.Core.Relationship;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.UI.Base.Lists;
@@ -25,8 +25,11 @@ namespace Cthangover.TurnBattle
/// for missing slots to keep layout stable), rolls loot from defeated
/// enemies using per-item probability/range, lays out loot in a grid
/// whose cell size is computed from the container width and
- /// lootAspectHeight, adds items to inventory, and processes character
- /// recruitment via RecruitBehaviourRegistry. A "return" button sets
+ /// lootAspectHeight, and adds items to inventory. When enemies were
+ /// defeated it also publishes on the
+ /// core event bus — so systems like the recruiting mod can react without
+ /// this screen knowing about them (and without a mod dependency).
+ /// A "return" button sets
/// the pending scene and reloads BaseScene — the actual destination
/// scene (town_entry or the quest's ReturnScene) is injected by
/// the scene manager.
@@ -81,7 +84,7 @@ namespace Cthangover.TurnBattle
bg.Texture = tex;
}
- data = GameData.Instance.Runtime.BattleData;
+ data = BattleRequest.Current;
GameLogger.Log("BATTLE_UI", $"Winground.Show: BattleData={(data != null ? "found" : "NULL")}", LogLevel.Error);
var expTracking = BuildExpTracking();
@@ -125,7 +128,7 @@ namespace Cthangover.TurnBattle
BuildLootDisplay(ctx.GeneratedLoot);
AddLootToInventory(ctx.GeneratedLoot);
- ProcessRecruitment(ctx.DefeatedEnemies);
+ PublishDefeatedCharacters(ctx.DefeatedEnemies);
}
WingroundListenerRegistry.Instance.NotifyBeforeShow(ctx);
@@ -338,24 +341,16 @@ namespace Cthangover.TurnBattle
}
}
- private void ProcessRecruitment(List enemies)
+ private static void PublishDefeatedCharacters(List enemies)
{
- var runtime = GameData.Instance.Runtime;
- var registry = RecruitBehaviourRegistry.Instance;
-
- foreach (var enemy in enemies)
+ ModEventBus.Publish(new CharactersDefeatedEvent
{
- if (enemy == null || enemy.RecruitmentChance <= 0)
- continue;
- if (!registry.CanRecruit(enemy, runtime))
- continue;
-
- var roll = random.Next(1, 101);
- if (roll > enemy.RecruitmentChance)
- continue;
-
- runtime.RecruitingData.Add(enemy.ID, enemy.ID);
- }
+ Source = "battle",
+ CharacterIds = enemies
+ .Where(enemy => enemy != null && !string.IsNullOrEmpty(enemy.ID))
+ .Select(enemy => enemy.ID)
+ .ToArray(),
+ });
}
private void ToReturnScene()
@@ -368,7 +363,7 @@ namespace Cthangover.TurnBattle
if (data?.Quest != null && data?.NewTag != null)
data.Quest.AddTag(data.NewTag);
var returnScene = data?.ReturnScene ?? "town_entry";
- GameData.Instance.Runtime.BattleData = null;
+ BattleRequest.Current = null;
var sceneService = GetNode("/root/GodotSceneService");
var sceneManager = GetNode("/root/SceneManager");
diff --git a/src/WingroundContext.cs b/src/WingroundContext.cs
new file mode 100644
index 0000000..6b8e6ee
--- /dev/null
+++ b/src/WingroundContext.cs
@@ -0,0 +1,52 @@
+using System.Collections.Generic;
+using Cthangover.Core.Characters;
+using Cthangover.Core.Items;
+using Cthangover.Core.Settings;
+
+namespace Cthangover.Core.Battle
+{
+ ///
+ /// Mutable context passed to hooks
+ /// during the victory-screen pipeline. Listeners receive the same
+ /// instance across all three phases (before EXP, after loot, before
+ /// show) and can mutate any field — including removing enemies,
+ /// adjusting earned EXP per character, or modifying the generated
+ /// loot list. The reads these
+ /// fields after each hook, so changes take effect immediately.
+ ///
+ public class WingroundContext
+ {
+ ///
+ /// Per-character EXP earned during this battle, keyed by
+ /// . Populated from
+ /// before the
+ /// first hook and consumed by
+ /// to award EXP to .
+ ///
+ public Dictionary PlayerExpGained { get; set; } = new();
+
+ ///
+ /// Enemies defeated during this battle. Used by the victory
+ /// screen for loot generation and recruitment rolls. Null
+ /// entries are skipped by .
+ ///
+ public List DefeatedEnemies { get; set; } = new();
+
+ ///
+ /// Loot items generated from .
+ /// Filled by between the
+ /// "before EXP" and "after loot" hooks. Listeners can
+ /// add, remove, or modify entries before the items are
+ /// committed to inventory.
+ ///
+ public List GeneratedLoot { get; set; } = new();
+
+ ///
+ /// The that started this battle.
+ /// Read-only reference — do not mutate the object itself (it
+ /// is shared with the quest system) but its fields such as
+ /// ReturnScene are writable.
+ ///
+ public BattleData BattleData { get; set; }
+ }
+}
diff --git a/src/WingroundListenerRegistry.cs b/src/WingroundListenerRegistry.cs
new file mode 100644
index 0000000..ab85eb7
--- /dev/null
+++ b/src/WingroundListenerRegistry.cs
@@ -0,0 +1,150 @@
+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
+ /// ) 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
+ {
+ public static readonly WingroundListenerRegistry Instance = new();
+
+ 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
+ /// underlying list is not thread-safe but all registrations happen during
+ /// mod initialisation before battle scenes are loaded.
+ ///
+ public void RegisterListener(IWingroundListener listener)
+ {
+ if (listener == null)
+ return;
+
+ _listeners.RemoveAll(existing => existing.Id == listener.Id);
+ _listeners.Add(listener);
+ GameLogger.Log("BATTLE",
+ $"WingroundListenerRegistry: registered listener '{listener.Id}'");
+ }
+
+ ///
+ /// Broadcasts
+ /// to every registered listener. Exceptions from individual
+ /// listeners are logged and swallowed so one broken mod cannot
+ /// break the victory screen for others.
+ ///
+ public void NotifyBeforeExpAward(WingroundContext ctx)
+ {
+ foreach (var listener in _listeners)
+ {
+ try
+ {
+ listener.OnBeforeExpAward(ctx);
+ }
+ catch (Exception ex)
+ {
+ GameLogger.Log("BATTLE",
+ $"WingroundListenerRegistry: {listener.Id}.OnBeforeExpAward threw: {ex.Message}",
+ LogLevel.Error);
+ }
+ }
+ }
+
+ ///
+ /// Broadcasts
+ /// to every registered listener.
+ ///
+ public void NotifyAfterLootGenerated(WingroundContext ctx)
+ {
+ foreach (var listener in _listeners)
+ {
+ try
+ {
+ listener.OnAfterLootGenerated(ctx);
+ }
+ catch (Exception ex)
+ {
+ GameLogger.Log("BATTLE",
+ $"WingroundListenerRegistry: {listener.Id}.OnAfterLootGenerated threw: {ex.Message}",
+ LogLevel.Error);
+ }
+ }
+ }
+
+ ///
+ /// Broadcasts
+ /// to every registered listener.
+ ///
+ public void NotifyBeforeShow(WingroundContext ctx)
+ {
+ foreach (var listener in _listeners)
+ {
+ try
+ {
+ listener.OnBeforeShow(ctx);
+ }
+ catch (Exception ex)
+ {
+ GameLogger.Log("BATTLE",
+ $"WingroundListenerRegistry: {listener.Id}.OnBeforeShow threw: {ex.Message}",
+ LogLevel.Error);
+ }
+ }
+ }
+ }
+}
diff --git a/status_effects/status_effects.json b/status_effects/status_effects.json
new file mode 100644
index 0000000..deacc5a
Binary files /dev/null and b/status_effects/status_effects.json differ