From b1ecbdf51b9a62949d000f1ab443c18b04f3e3e7 Mon Sep 17 00:00:00 2001 From: iisaev Date: Sun, 18 Oct 2026 12:41:40 +0300 Subject: [PATCH] init mod --- locale/en.properties | 11 ++ locale/ru.properties | 11 ++ manifest.json | 22 +++ scenes/recruit_panel.tscn | 102 +++++++++++++ src/DefeatedCharactersRecruiter.cs | 73 ++++++++++ src/EnslaveCondition.cs | 34 +++++ src/IRecruitBehaviour.cs | 48 +++++++ src/IRecruitCondition.cs | 33 +++++ src/NutritionBehaviour.cs | 67 +++++++++ src/Recruit.cs | 35 +++++ src/RecruitBehaviourConsumer.cs | 21 +++ src/RecruitBehaviourRegistry.cs | 164 +++++++++++++++++++++ src/RecruitConditionConsumer.cs | 21 +++ src/RecruitJoinedEvent.cs | 17 +++ src/RecruitLeftEvent.cs | 16 +++ src/RecruitPartyBridge.cs | 145 +++++++++++++++++++ src/RecruitTickController.cs | 62 ++++++++ src/RecruitingData.cs | 150 ++++++++++++++++++++ src/RecruitingInitializer.cs | 81 +++++++++++ src/RecruitingSaveable.cs | 50 +++++++ src/UI/RecruitPanelBehaviour.cs | 221 +++++++++++++++++++++++++++++ ui/icons/recruits.png | Bin 0 -> 11377 bytes 22 files changed, 1384 insertions(+) create mode 100644 locale/en.properties create mode 100644 locale/ru.properties create mode 100644 manifest.json create mode 100644 scenes/recruit_panel.tscn create mode 100644 src/DefeatedCharactersRecruiter.cs create mode 100644 src/EnslaveCondition.cs create mode 100644 src/IRecruitBehaviour.cs create mode 100644 src/IRecruitCondition.cs create mode 100644 src/NutritionBehaviour.cs create mode 100644 src/Recruit.cs create mode 100644 src/RecruitBehaviourConsumer.cs create mode 100644 src/RecruitBehaviourRegistry.cs create mode 100644 src/RecruitConditionConsumer.cs create mode 100644 src/RecruitJoinedEvent.cs create mode 100644 src/RecruitLeftEvent.cs create mode 100644 src/RecruitPartyBridge.cs create mode 100644 src/RecruitTickController.cs create mode 100644 src/RecruitingData.cs create mode 100644 src/RecruitingInitializer.cs create mode 100644 src/RecruitingSaveable.cs create mode 100644 src/UI/RecruitPanelBehaviour.cs create mode 100644 ui/icons/recruits.png diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..3c1e5ac --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,11 @@ +mod_recruiting_name = Recruiting +mod_recruiting_desc = Recruitment of defeated characters: the roster panel, join conditions (e.g. enslaving a defeated enemy), per-recruit behaviours such as nutrition, the party bridge, tick updates and save state. + +ui/recruit/title = Recruits +ui/recruit/close = Close +ui/recruit/sub = Captured enemies · any of them can join the party +ui/recruit/empty = No captured recruits yet +ui/recruit/status = Captured: {0} · in party: {1} +ui/recruit/add = Add to party +ui/recruit/remove = Remove from party +ui/recruit/hp = HP diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..7db3577 --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,11 @@ +mod_recruiting_name = Рекрутинг +mod_recruiting_desc = Вербовка побеждённых персонажей: панель ростера, условия присоединения (например, порабощение врага), поведение рекрутов (например, питание), мост в партию, тики и сохранение состояния. + +ui/recruit/title = Рекруты +ui/recruit/close = Закрыть +ui/recruit/sub = Захваченные враги · любого можно добавить в отряд +ui/recruit/empty = Пока нет захваченных рекрутов +ui/recruit/status = Захвачено: {0} · в отряде: {1} +ui/recruit/add = В отряд +ui/recruit/remove = Из отряда +ui/recruit/hp = HP diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..28bcdfd --- /dev/null +++ b/manifest.json @@ -0,0 +1,22 @@ +{ + "id": "recruiting", + "name": "Recruiting", + "version": "1.1.0", + "author": "ct", + "description": "Recruitment of defeated characters: the roster panel, join conditions (e.g. enslaving a defeated enemy), per-recruit behaviours such as nutrition, the party bridge, tick updates and save state.", + "category": "gameplay", + "sources": [ + "src/**/*.cs" + ], + "depends": ["base2d>=1", "characters_base>=1", "interface>=1"], + "ui": [ + { + "id": "recruits", + "kind": "panel", + "slot": "ModBeforeUIPanel", + "scene_path": "scenes/recruit_panel.tscn", + "button": { "icon": "recruits", "locale": "ui/recruit/title", "index": 35 }, + "priority": 10 + } + ] +} diff --git a/scenes/recruit_panel.tscn b/scenes/recruit_panel.tscn new file mode 100644 index 0000000..9a87e93 --- /dev/null +++ b/scenes/recruit_panel.tscn @@ -0,0 +1,102 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="src/UI/RecruitPanelBehaviour.cs" id="1_script"] + +[node name="RecruitPanel" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 +script = ExtResource("1_script") + +[node name="Backdrop" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Panel" type="Panel" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 40.0 +offset_top = 70.0 +offset_right = -40.0 +offset_bottom = -30.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 1 + +[node name="Margin" type="MarginContainer" parent="Panel"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 24 +theme_override_constants/margin_top = 14 +theme_override_constants/margin_right = 24 +theme_override_constants/margin_bottom = 14 +mouse_filter = 1 + +[node name="Root" type="VBoxContainer" parent="Panel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 10 +mouse_filter = 1 + +[node name="Header" type="HBoxContainer" parent="Panel/Margin/Root"] +layout_mode = 2 +theme_override_constants/separation = 18 +mouse_filter = 2 + +[node name="Title" type="Label" parent="Panel/Margin/Root/Header"] +layout_mode = 2 +theme_override_font_sizes/font_size = 22 + +[node name="Spacer" type="Control" parent="Panel/Margin/Root/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +mouse_filter = 2 + +[node name="Sub" type="Label" parent="Panel/Margin/Root/Header"] +layout_mode = 2 +theme_override_font_sizes/font_size = 11 + +[node name="CloseButton" type="Button" parent="Panel/Margin/Root/Header"] +custom_minimum_size = Vector2(38, 38) +layout_mode = 2 +focus_mode = 0 +text = "✕" + +[node name="Rule1" type="Panel" parent="Panel/Margin/Root"] +custom_minimum_size = Vector2(0, 1) +layout_mode = 2 +mouse_filter = 2 + +[node name="Scroll" type="ScrollContainer" parent="Panel/Margin/Root"] +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="List" type="VBoxContainer" parent="Panel/Margin/Root/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 8 +mouse_filter = 0 + +[node name="Rule2" type="Panel" parent="Panel/Margin/Root"] +custom_minimum_size = Vector2(0, 1) +layout_mode = 2 +mouse_filter = 2 + +[node name="StatusLabel" type="Label" parent="Panel/Margin/Root"] +layout_mode = 2 +theme_override_font_sizes/font_size = 12 diff --git a/src/DefeatedCharactersRecruiter.cs b/src/DefeatedCharactersRecruiter.cs new file mode 100644 index 0000000..1a2b4be --- /dev/null +++ b/src/DefeatedCharactersRecruiter.cs @@ -0,0 +1,73 @@ +using Cthangover.Core.Characters; +using Cthangover.Core.Events; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Settings; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Relationship +{ + /// + /// Turns a into recruit attempts. The + /// recruiting mod subscribes to the core-neutral event, so the system that + /// defeated the characters (battle, trap, script) does not know about + /// recruiting — and recruiting does not depend on any combat mod. This + /// replaces the old hardcoded call inside the victory screen. + /// + /// For every reported id the character template is resolved through + /// (the battle copy and the template share + /// RecruitmentChance), skipped when that enemy is already captured + /// (only one recruit per enemy type is allowed), checked against the + /// condition gate (), + /// rolled 1–100 and, on success, added to the roster. The roll is skipped + /// entirely when no conditions are registered or the chance is zero, + /// matching the old flow. + /// + /// The event also marks the end of a fight for the recruiting mod, so the + /// final health of every squad recruit is pulled back from its placeholder + /// card before new recruits are resolved. + /// + public static class DefeatedCharactersRecruiter + { + /// Event-bus handler; safe to call with a null or empty payload. + public static void Process(CharactersDefeatedEvent evt) + { + if (evt?.CharacterIds == null) + return; + + var runtime = GameData.Instance?.Runtime; + if (runtime == null) + return; + + var registry = RecruitBehaviourRegistry.Instance; + + // A defeat resolves the battle: pull the final health of every + // squad recruit back from its placeholder card, since battle writes + // into the shared attributes object while the mod keeps its own data. + foreach (var recruit in RecruitingData.Instance.Data) + RecruitPartyBridge.SyncFromCard(recruit); + + foreach (var id in evt.CharacterIds) + { + if (string.IsNullOrEmpty(id)) + continue; + + var enemy = CharacterFactory.Instance.Get(id); + if (enemy == null || enemy.RecruitmentChance <= 0) + continue; + + if (RecruitingData.Instance.HasCharacterID(enemy.ID)) + continue; + + if (!registry.CanRecruit(enemy, runtime)) + continue; + + if (System.Random.Shared.Next(1, 101) > enemy.RecruitmentChance) + continue; + + RecruitingData.Instance.Add(enemy.ID, enemy.ID); + GameLogger.Log("RECRUIT", + $"Recruited '{enemy.ID}' after '{evt.Source}' defeat", LogLevel.Message); + } + } + } +} diff --git a/src/EnslaveCondition.cs b/src/EnslaveCondition.cs new file mode 100644 index 0000000..cdb4c57 --- /dev/null +++ b/src/EnslaveCondition.cs @@ -0,0 +1,34 @@ +using Cthangover.Core.Characters; +using Cthangover.Core.Relationship; +using Cthangover.Core.Settings; + +namespace Cthangover.Core.Relationship +{ + /// + /// Recruitment condition that gates the "enslave" recruit option + /// behind the slave_master skill. When the player attempts to + /// recruit an NPC, the recruitment system queries all registered + /// implementations; if this condition + /// is present and the player does not have the skill, the enslavement + /// option is hidden or disabled. + /// + public class EnslaveCondition : IRecruitCondition + { + /// + /// Identifies this condition as the enslavement gate for the + /// recruitment system (maps to enslave option). + /// + public string Id => "enslave"; + + /// + /// Determines whether enslavement is available for the given enemy. + /// Checks the runtime skill data for the slave_master skill. + /// The target is provided but not inspected + /// because the condition depends solely on the player's skills. + /// + public bool CanRecruit(Character enemy, RuntimeData runtime) + { + return runtime.SkillData.HasSkill("slave_master"); + } + } +} diff --git a/src/IRecruitBehaviour.cs b/src/IRecruitBehaviour.cs new file mode 100644 index 0000000..12e89b3 --- /dev/null +++ b/src/IRecruitBehaviour.cs @@ -0,0 +1,48 @@ +using Cthangover.Core.Settings; + +namespace Cthangover.Core.Relationship +{ + /// + /// Per-recruit behaviour plugin discovered via reflection from mod + /// assemblies. Each implementation is registered into + /// by + /// and then + /// invoked for every active recruit — not just those from the + /// owning mod — during tick, configure, and removal lifecycle events. + /// + /// This cross-recruit dispatch pattern is intentional: a behaviour + /// might want to introspect all recruits (e.g. a "rivalry" system + /// that checks for conflicting personalities), so the registry passes + /// the full recruit list to every behaviour rather than filtering by + /// ownership. + /// + public interface IRecruitBehaviour + { + /// Unique identifier for lookup via RecruitBehaviourRegistry.Get. + string Id { get; } + + /// + /// Called once when a recruit is added to the party + /// (via RecruitingData.Add). Use for initial stat + /// setup, buff application, or spawning companion nodes. + /// + void ConfigureRecruit(Recruit recruit, RuntimeData runtime); + + /// + /// Called every game tick for each active recruit. The + /// value is the global + /// RuntimeData.Time.Tick counter — use it for + /// cooldown-based or periodic logic rather than maintaining + /// a private timer. + /// + void OnTick(Recruit recruit, RuntimeData runtime, long currentTick); + + /// + /// Called when a recruit is removed from the party + /// (via RecruitingData.Remove). Clean up any + /// persisted state, buffs, or companion nodes. + /// + void OnRemove(Recruit recruit, RuntimeData runtime); + } + +} diff --git a/src/IRecruitCondition.cs b/src/IRecruitCondition.cs new file mode 100644 index 0000000..6c8619b --- /dev/null +++ b/src/IRecruitCondition.cs @@ -0,0 +1,33 @@ +using Cthangover.Core.Characters; +using Cthangover.Core.Settings; + +namespace Cthangover.Core.Relationship +{ + /// + /// Gating predicate for the recruit system. Implementations are + /// auto-discovered from mod assemblies by + /// and + /// evaluated conjunctively — all conditions must return + /// true for recruitment to succeed. + /// + /// Conditions receive the full and + /// so they can inspect character stats, + /// inventory, relationship flags, quest state, or any other + /// runtime-accessible data when deciding whether a given enemy is + /// recruitable. + /// + public interface IRecruitCondition + { + /// Unique identifier for debugging and override lookups. + string Id { get; } + + /// + /// Returns true if the given + /// can be recruited under current game state. Called from + /// RecruitBehaviourRegistry.CanRecruit as part of a + /// conjunctive gate — a single false blocks recruitment. + /// + bool CanRecruit(Character enemy, RuntimeData runtime); + } + +} diff --git a/src/NutritionBehaviour.cs b/src/NutritionBehaviour.cs new file mode 100644 index 0000000..bda67cb --- /dev/null +++ b/src/NutritionBehaviour.cs @@ -0,0 +1,67 @@ +using Cthangover.Core.Relationship; +using Cthangover.Core.Settings; + +namespace Cthangover.Core.Relationship +{ + /// + /// Tracks nutrition and starvation for party recruits. + /// Works by recording the tick when a recruit last ate (FullnessTime), + /// then each tick computes the elapsed time as a percentage of a 24-hour window. + /// Once the fullness window expires (100%+), the recruit loses 1 HP per tick + /// until fed again via a ration or meal action. + /// Registered with the behaviour pipeline and called + /// by the time system on each global tick. + /// + public class NutritionBehaviour : IRecruitBehaviour + { + /// + /// Unique behaviour identifier used to look up and configure this + /// behaviour from 's behaviour collection. + /// + public string Id => "nutrition"; + + /// + /// Called when the behaviour is first attached to a recruit. + /// Seeds FullnessTime with the current global tick so that + /// starve-damage begins counting from this point. + /// + public void ConfigureRecruit(Recruit recruit, RuntimeData runtime) + { + recruit.Properties.SetLong("FullnessTime", runtime.Time.Tick); + } + + /// + /// Evaluates whether the recruit has gone beyond the fullness window + /// (24 in-game hours, represented as 24 * 4 * 60 ticks) since + /// FullnessTime. If the window is exceeded, reduces the + /// recruit's PROP_HEALTH by 1 per tick until they are fed. + /// Tick-driven starvation allows gradual death when rations run out. + /// + public void OnTick(Recruit recruit, RuntimeData runtime, long currentTick) + { + var fullnessTime = recruit.Properties.GetLong("FullnessTime"); + if (fullnessTime == 0) + return; + var windowSize = 24 * 4 * 60L; + var deltaTime = currentTick - fullnessTime; + var fullness = (float)deltaTime / windowSize * 100f; + if (fullness > 100) + { + var hp = recruit.Properties.GetInt(Recruit.PROP_HEALTH); + if (hp > 0) + { + recruit.Properties.SetInt(Recruit.PROP_HEALTH, hp - 1); + RecruitPartyBridge.SyncToCard(recruit); + } + } + } + + /// + /// Cleanup hook invoked when the behaviour is detached from a recruit. + /// No persistent state requires teardown; exists for interface contract. + /// + public void OnRemove(Recruit recruit, RuntimeData runtime) + { + } + } +} diff --git a/src/Recruit.cs b/src/Recruit.cs new file mode 100644 index 0000000..ffea572 --- /dev/null +++ b/src/Recruit.cs @@ -0,0 +1,35 @@ +using System; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Relationship +{ + /// + /// Runtime model for a recruited party member. Stores the recruit's + /// unique ID, the character template it was created from, and a + /// modifiable bag that behaviours + /// read and mutate during tick/configure events. + /// + /// Marked [Serializable] so the recruiting roster survives + /// save/load — PropertyData serialises its nested values + /// automatically. The PROP_HEALTH constant is the conventional + /// key for health tracking; behaviours that need custom state + /// (e.g. morale, loyalty timers) add their own keys to the + /// Properties bag at configure time. + /// + [Serializable] + public class Recruit + { + /// Conventional key for the recruit's health value in the Properties bag. + public const string PROP_HEALTH = "Health"; + + /// Unique instance ID (auto-generated via RecruitingData.GenID). + public string ID { get; set; } + + /// Character template ID this recruit was spawned from. + public string CharacterID { get; set; } + + /// Mutable property bag — behaviours read/write custom state here. + public PropertyData Properties { get; set; } = new(); + } + +} diff --git a/src/RecruitBehaviourConsumer.cs b/src/RecruitBehaviourConsumer.cs new file mode 100644 index 0000000..590801e --- /dev/null +++ b/src/RecruitBehaviourConsumer.cs @@ -0,0 +1,21 @@ +namespace Cthangover.Core.Relationship +{ + using Cthangover.Core.Mods.Consumers; + + /// + /// Feeds with every mod's + /// implementations as the mods load and + /// removes them when their mod unloads. + /// + public class RecruitBehaviourConsumer : ModTypeConsumer + { + /// + public override string Id => "recruiting.behaviours"; + + /// + protected override void Add(IRecruitBehaviour instance, string modId) => RecruitBehaviourRegistry.Instance.RegisterBehaviour(instance); + + /// + protected override void Remove(IRecruitBehaviour instance, string modId) => RecruitBehaviourRegistry.Instance.UnregisterBehaviour(instance); + } +} diff --git a/src/RecruitBehaviourRegistry.cs b/src/RecruitBehaviourRegistry.cs new file mode 100644 index 0000000..6765f07 --- /dev/null +++ b/src/RecruitBehaviourRegistry.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Cthangover.Core.Characters; +using Cthangover.Core.Settings; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Relationship +{ + /// + /// Central registry and lifecycle dispatcher for recruit behaviours + /// and conditions. Behaviours () and + /// conditions () of every mod arrive + /// through the kernel's assembly consumers + /// (, ) + /// as mods load, and leave again when their mod unloads — the kernel + /// stays the only place that enumerates assemblies. + /// + /// Condition gating — evaluates every + /// registered condition conjunctively (logical AND), so a single + /// false blocks recruitment. This allows mods to compose + /// recruitment gates without coordination. + /// + /// Id-based deduplication — a registration replaces any earlier + /// implementation with the same Id, so two mods shipping the same + /// id never double-dispatch notifications. + /// + /// Tick registration — the first call to any lifecycle method + /// triggers , + /// which registers for the scene's timer event. This avoids paying + /// the tick overhead unless at least one behaviour is active. + /// + /// Singleton — accessed via the static Instance field + /// rather than a Lazy<T> wrapper because Godot + /// reflection-based discovery must tolerate partial-initialisation + /// scenarios where Lazy's thread-safety check could deadlock. + /// + public class RecruitBehaviourRegistry + { + /// Eagerly-initialised singleton for reflection-safe access. + public static readonly RecruitBehaviourRegistry Instance = new(); + + private readonly List _behaviours = new(); + private readonly List _conditions = new(); + private readonly RecruitTickController _tickController = new(); + + private void EnsureTickRegistered() + { + _tickController.EnsureRegistered(); + } + + /// + /// Registers a behaviour, replacing an earlier one with the same Id. Fed + /// by for every mod assembly. + /// + public void RegisterBehaviour(IRecruitBehaviour behaviour) + { + if (behaviour == null) + return; + _behaviours.RemoveAll(existing => existing.Id == behaviour.Id); + _behaviours.Add(behaviour); + } + + /// Removes exactly (its mod is being unloaded). + public void UnregisterBehaviour(IRecruitBehaviour behaviour) + { + _behaviours.Remove(behaviour); + } + + /// + /// Registers a condition, replacing an earlier one with the same Id. Fed + /// by for every mod assembly. + /// + public void RegisterCondition(IRecruitCondition condition) + { + if (condition == null) + return; + _conditions.RemoveAll(existing => existing.Id == condition.Id); + _conditions.Add(condition); + } + + /// Removes exactly (its mod is being unloaded). + public void UnregisterCondition(IRecruitCondition condition) + { + _conditions.Remove(condition); + } + + /// + /// Conjunctive gate: returns true only if every registered + /// returns true for the + /// given enemy and runtime state. + /// + public bool CanRecruit(Character enemy, RuntimeData runtime) + { + return _conditions.All(c => c.CanRecruit(enemy, runtime)); + } + + /// + /// Looks up a behaviour by its Id (linear scan — + /// behaviour lists are small enough that a dictionary is + /// unnecessary). Returns null if not found. + /// + public IRecruitBehaviour Get(string id) + { + return _behaviours.Find(b => b.Id == id); + } + + /// Returns all registered behaviours for external enumeration. + public IEnumerable All() + { + return _behaviours; + } + + /// + /// Dispatches to every + /// behaviour for every active recruit. Ensures tick registration + /// on first call via lazy init. + /// + public void OnTick() + { + EnsureTickRegistered(); + var runtime = GameData.Instance.Runtime; + if (runtime == null) + return; + var recruits = RecruitingData.Instance.Data; + foreach (var behaviour in _behaviours) + { + foreach (var recruit in recruits) + { + behaviour.OnTick(recruit, runtime, runtime.Time.Tick); + } + } + } + + /// + /// Dispatches to + /// every behaviour for the newly-added . + /// + public void OnConfigure(Recruit recruit) + { + EnsureTickRegistered(); + var runtime = GameData.Instance.Runtime; + if (runtime == null) + return; + foreach (var behaviour in _behaviours) + behaviour.ConfigureRecruit(recruit, runtime); + } + + /// + /// Dispatches to every + /// behaviour for the departing . + /// + public void OnRemove(Recruit recruit) + { + EnsureTickRegistered(); + var runtime = GameData.Instance.Runtime; + if (runtime == null) + return; + foreach (var behaviour in _behaviours) + behaviour.OnRemove(recruit, runtime); + } + } + +} diff --git a/src/RecruitConditionConsumer.cs b/src/RecruitConditionConsumer.cs new file mode 100644 index 0000000..15e4c95 --- /dev/null +++ b/src/RecruitConditionConsumer.cs @@ -0,0 +1,21 @@ +namespace Cthangover.Core.Relationship +{ + using Cthangover.Core.Mods.Consumers; + + /// + /// Feeds with every mod's + /// implementations as the mods load and + /// removes them when their mod unloads. + /// + public class RecruitConditionConsumer : ModTypeConsumer + { + /// + public override string Id => "recruiting.conditions"; + + /// + protected override void Add(IRecruitCondition instance, string modId) => RecruitBehaviourRegistry.Instance.RegisterCondition(instance); + + /// + protected override void Remove(IRecruitCondition instance, string modId) => RecruitBehaviourRegistry.Instance.UnregisterCondition(instance); + } +} diff --git a/src/RecruitJoinedEvent.cs b/src/RecruitJoinedEvent.cs new file mode 100644 index 0000000..6dda64e --- /dev/null +++ b/src/RecruitJoinedEvent.cs @@ -0,0 +1,17 @@ +namespace Cthangover.Core.Events +{ + /// + /// Published by the recruiting mod when a captured recruit is placed into + /// the active party (see RecruitPartyBridge.AddToSquad). Purely + /// informational — the core party data is already updated when subscribers + /// see the event. + /// + [EventKey("recruit_joined")] + public class RecruitJoinedEvent : ModEvent + { + /// Instance id of the recruit. + public string RecruitId { get; init; } + /// Character template id the recruit was spawned from. + public string RecruitName { get; init; } + } +} diff --git a/src/RecruitLeftEvent.cs b/src/RecruitLeftEvent.cs new file mode 100644 index 0000000..c0b4c5c --- /dev/null +++ b/src/RecruitLeftEvent.cs @@ -0,0 +1,16 @@ +namespace Cthangover.Core.Events +{ + /// + /// Published by the recruiting mod when a recruit leaves the active party + /// (see RecruitPartyBridge.RemoveFromSquad). The placeholder card and + /// its progression survive the leave — only the party slot is dropped. + /// + [EventKey("recruit_left")] + public class RecruitLeftEvent : ModEvent + { + /// Instance id of the recruit. + public string RecruitId { get; init; } + /// Character template id the recruit was spawned from. + public string RecruitName { get; init; } + } +} diff --git a/src/RecruitPartyBridge.cs b/src/RecruitPartyBridge.cs new file mode 100644 index 0000000..f7770d8 --- /dev/null +++ b/src/RecruitPartyBridge.cs @@ -0,0 +1,145 @@ +using Cthangover.Core.Characters; +using Cthangover.Core.Settings; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Relationship +{ + /// + /// Bridge between the mod-owned recruit roster and the core party model. + /// Core knows nothing about recruiting, so the mod materializes a placeholder + /// for every recruit the player puts into the + /// squad: the entry is keyed by the template + /// (the roster guarantees at most one recruit per enemy, so the template id + /// stays a unique party key) and therefore needs no core changes — the + /// character panel, battle, item targeting and the save system all pick the + /// recruit up as a normal party member. + /// + /// The recruit itself remains the source of truth outside battle: the mod + /// calls whenever its own data changes (starvation + /// tick, joining the squad) and once battle is + /// over, where the battle systems have already written the final health back + /// through the shared reference. + /// must mutate the attribute in place — reassigning + /// the attributes object would detach it from the battle copies that still + /// point at the original instance. + /// + public static class RecruitPartyBridge + { + /// True when the recruit currently occupies a slot in the active party. + public static bool IsInSquad(Recruit recruit) + { + var characterData = GetCharacterData(); + return characterData != null + && recruit != null + && characterData.BattleSet.Contains(recruit.CharacterID); + } + + /// + /// Adds the recruit to the active party. On the first join a placeholder + /// card is created from the character template (attributes, portrait and + /// default action slots); the card is then immediately synchronized with + /// the recruit's own health. Repeated calls are idempotent. + /// + /// The template is resolved by CharacterData.AddCharacterToParty, + /// which refuses unknown IDs; a refusal aborts the join so the battle + /// set never references a card that the roster does not have (the battle + /// scene indexes the roster directly for every battle-set entry). + /// + public static void AddToSquad(Recruit recruit) + { + var characterData = GetCharacterData(); + if (characterData == null || recruit == null) + return; + + if (!characterData.Characters.ContainsKey(recruit.CharacterID) + && !characterData.AddCharacterToParty(recruit.CharacterID)) + { + GameLogger.Log("RECRUIT", + $"AddToSquad: no template for recruit '{recruit.CharacterID}' — join aborted", + LogLevel.Error); + return; + } + + characterData.BattleSet.Add(recruit.CharacterID); + SyncToCard(recruit); + + GameLogger.Log("RECRUIT", $"Recruit '{recruit.CharacterID}' joined the squad", LogLevel.Message); + } + + /// + /// Removes the recruit from the active party but keeps the placeholder + /// card in — the player's action + /// assignments and battle-earned experience survive a temporary leave and + /// are restored when the recruit joins again. + /// + public static void RemoveFromSquad(Recruit recruit) + { + var characterData = GetCharacterData(); + if (characterData == null || recruit == null) + return; + + if (characterData.BattleSet.Remove(recruit.CharacterID)) + GameLogger.Log("RECRUIT", $"Recruit '{recruit.CharacterID}' left the squad", LogLevel.Message); + } + + /// + /// Drops every trace of the recruit: leaves the party and deletes the + /// placeholder card. Called when the recruit is removed from the roster + /// for good, so no orphan party entry survives in the core save. + /// + public static void Forget(Recruit recruit) + { + var characterData = GetCharacterData(); + if (characterData == null || recruit == null) + return; + + RemoveFromSquad(recruit); + characterData.Characters.Remove(recruit.CharacterID); + } + + /// + /// Pushes the recruit's health from its property bag into the placeholder + /// card. The attribute is mutated in place: battle copies share the same + /// instance, so a reassignment here + /// would leave the fight running on the stale object. + /// + public static void SyncToCard(Recruit recruit) + { + var info = GetCard(recruit); + if (info == null) + return; + + info.Attributes ??= new CharacterAttributes(); + info.Attributes.Health.Value = recruit.Properties.GetInt(Recruit.PROP_HEALTH); + } + + /// + /// Pulls the health written by battle back into the recruit's property + /// bag. Called on the core-neutral characters_defeated event, which + /// the battle mod publishes after the victory screen resolves, so the + /// mod's own data stays authoritative once the fight is over. + /// + public static void SyncFromCard(Recruit recruit) + { + var info = GetCard(recruit); + if (info?.Attributes?.Health == null) + return; + + recruit.Properties.SetInt(Recruit.PROP_HEALTH, info.Attributes.Health.Value); + } + + private static CharacterInfoData GetCard(Recruit recruit) + { + var characterData = GetCharacterData(); + if (characterData?.Characters == null || recruit == null) + return null; + + return characterData.Characters.TryGetValue(recruit.CharacterID, out var info) ? info : null; + } + + private static CharacterData GetCharacterData() + { + return GameData.Instance?.Runtime?.CharacterData; + } + } +} diff --git a/src/RecruitTickController.cs b/src/RecruitTickController.cs new file mode 100644 index 0000000..022e484 --- /dev/null +++ b/src/RecruitTickController.cs @@ -0,0 +1,62 @@ +using Cthangover.Core.Scenes; +using Cthangover.Core.UI.Event; + +namespace Cthangover.Core.Relationship +{ + /// + /// Lazy bridge between the scene timer system and the recruit + /// behaviour tick loop. Implements + /// so it can be registered with + /// via AddTimerTickEventListener. + /// + /// Registration is lazy ( is a no-op + /// after the first call) because the recruit system should not + /// force the timer subsystem to activate if no behaviours are + /// actually loaded. The first behaviour lifecycle method in + /// calls + /// EnsureRegistered, which finds the scene's + /// EventController node and subscribes. + /// + /// Priority = 0 so recruit ticks fire at a neutral point in + /// the timer event chain — before or after other listeners as + /// determined by their relative priorities. + /// + public class RecruitTickController : IOnTimeEvent + { + /// Neutral priority — doesn't preempt or follow any specific listener. + public int Priority => 0; + + private bool _registered; + + /// + /// Finds the scene's EventController node and subscribes + /// this controller as a timer-tick listener. Idempotent — + /// subsequent calls do nothing. + /// + public void EnsureRegistered() + { + if (_registered) + return; + var eventController = SceneContextNode.FindNode("EventController"); + if (eventController == null) + return; + eventController.AddTimerTickEventListener(this); + _registered = true; + } + + /// + /// Called by on every + /// timer tick. Delegates to + /// . + /// Ticks also fire during the launcher boot window, so the registry + /// side must not touch GameData.Instance.Runtime or mod data + /// until Boot.ModsReady (see the boot-window contract on + /// IOnTimeEvent; core-side reference: TimeTickController). + /// + public void OnTimerTick() + { + RecruitBehaviourRegistry.Instance.OnTick(); + } + } + +} diff --git a/src/RecruitingData.cs b/src/RecruitingData.cs new file mode 100644 index 0000000..a5fbbb5 --- /dev/null +++ b/src/RecruitingData.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Utils; + +namespace Cthangover.Core.Relationship +{ + /// + /// Container and factory for the party's recruit roster. The roster is owned + /// by the recruiting mod through the static instead of + /// living in the core RuntimeData, so the core knows nothing about + /// recruiting. Persistence is handled by the mod's RecruitingSaveable + /// (IModSaveable), and a new game clears the roster through the + /// RecruitingReset IResettable that RecruitingInitializer + /// registers with AppServices. + /// + /// Provides ID-based lookup, duplicate-safe addition (auto-generates a unique + /// Guid if the requested ID collides, and rejects a second recruit of + /// the same enemy type), and removal that notifies + /// so behaviours can clean up and + /// detaches the recruit from the squad through . + /// initialises the recruit's Properties with a default + /// health of 10 via and immediately calls + /// RecruitBehaviourRegistry.OnConfigure, giving every registered + /// behaviour a chance to set up per-recruit state before the next tick fires. + /// + public class RecruitingData + { + /// + /// Session-wide roster. There is exactly one roster per playthrough — + /// starting a new game clears it, loading a save replaces its contents. + /// + public static readonly RecruitingData Instance = new(); + + /// The current recruit roster. + public List Data { get; set; } = new(); + + /// True if a recruit with the given ID exists in the roster. + public bool HasID(string id) + { + return GetByID(id) != null; + } + + /// Returns the recruit with the given ID, or null if not found. + public Recruit GetByID(string id) + { + foreach (var item in Data) + { + if (item.ID == id) + return item; + } + return null; + } + + /// + /// True if a recruit spawned from the given character template is already + /// in the roster. Recruitment allows only one instance per enemy type, + /// which also keeps the template id usable as a unique party key. + /// + public bool HasCharacterID(string characterID) + { + return GetByCharacterID(characterID) != null; + } + + /// Returns the recruit spawned from the given template, or null if not found. + public Recruit GetByCharacterID(string characterID) + { + foreach (var item in Data) + { + if (item.CharacterID == characterID) + return item; + } + return null; + } + + /// + /// Generates a unique ID: if is free, + /// returns it unchanged; otherwise appends a Guid suffix. + /// Loops until an unused ID is found (guaranteed because Guid + /// is unique enough for any practical roster size). + /// + private string GenID(string id) + { + for (;;) + { + if (!HasID(id)) + return id; + id = Guid.NewGuid().ToString(); + } + } + + /// + /// Creates a new from the given + /// , assigns a unique instance ID, + /// initialises default health, appends to the roster, and fires + /// RecruitBehaviourRegistry.OnConfigure so behaviours + /// can set up per-recruit state. + /// + /// Only one recruit per enemy type is allowed: a second attempt for an + /// already captured is ignored and the + /// existing entry is returned, so callers cannot create duplicates and + /// the template id remains a unique party card key. + /// + public Recruit Add(string id, string characterID) + { + var existing = GetByCharacterID(characterID); + if (existing != null) + { + GameLogger.Log("RECRUIT", + $"Recruit '{characterID}' already captured, duplicate ignored", LogLevel.Warning); + return existing; + } + + var data = new Recruit + { + ID = GenID(id), + CharacterID = characterID, + }; + data.Properties.SetInt(Recruit.PROP_HEALTH, 10); + Data.Add(data); + + RecruitBehaviourRegistry.Instance.OnConfigure(data); + return data; + } + + /// + /// Removes the recruit with the given ID from the roster. + /// Notifies + /// before deleting the entry so behaviours can persist state + /// or clean up companion nodes, then detaches the recruit from the + /// party and deletes its placeholder card via + /// , so no orphan party member + /// survives in the core save. + /// + public void Remove(string id) + { + for (int i = Data.Count - 1; i >= 0; i--) + { + if (Data[i].ID == id) + { + RecruitBehaviourRegistry.Instance.OnRemove(Data[i]); + RecruitPartyBridge.Forget(Data[i]); + Data.RemoveAt(i); + return; + } + } + } + + } + +} diff --git a/src/RecruitingInitializer.cs b/src/RecruitingInitializer.cs new file mode 100644 index 0000000..1af952c --- /dev/null +++ b/src/RecruitingInitializer.cs @@ -0,0 +1,81 @@ +using Cthangover.Core.Bootstrap; +using Cthangover.Core.Events; +using Cthangover.Core.Mods; + +namespace Cthangover.Core.Relationship +{ + /// + /// Lifecycle entry point of the recruiting mod. Behaviours and conditions of + /// all mods are not discovered here: the kernel delivers them through + /// and . + /// + /// It registers a with + /// , so the core's "New Game" pipeline + /// (GameData.StartNewGame → AppServices.ResetForNewGame) + /// clears the roster — the core used to discard it implicitly with + /// RuntimeData, but the roster now lives in + /// and must be reset explicitly on + /// "New Game". Registration is idempotent: AppServices deduplicates by + /// IResettable.Id, so a repeated OnModResourcesReady is safe. + /// It subscribes to and routes it + /// into — the publisher (battle, + /// trap, script) stays unaware of recruiting, so the whole system can be + /// disabled by removing this mod. + /// + /// Persistence is not handled here: is an + /// IModSaveable and is discovered by ModSaveableRegistry automatically. + /// + public class RecruitingInitializer : IModInitializer + { + private static bool _defeatedSubscribed; + + /// Nothing to do per mod: recruit plugins arrive through the kernel's assembly consumers. + public void OnModLoaded(string modId) + { + } + + /// + /// Registers the roster reset owner with AppServices (idempotent — + /// duplicates by Id are ignored) and installs the + /// subscription. The roster is no longer cleared through a + /// NewGameStartedEvent subscription: the core's reset loop + /// invokes instead. + /// + public void OnModResourcesReady() + { + AppServices.Current.RegisterResettable(new RecruitingReset()); + EnsureDefeatedSubscription(); + } + + private static void EnsureDefeatedSubscription() + { + if (_defeatedSubscribed) + return; + + _defeatedSubscribed = true; + ModEventBus.Subscribe(DefeatedCharactersRecruiter.Process); + } + } + + /// + /// New-game state owner of the recruiting mod: clears the session roster + /// (). Registered from + /// ; + /// deduplicates by Id, so repeated registration + /// is a no-op. The clear is a plain list clear with no per-recruit side + /// effects — unlike it does not notify + /// RecruitBehaviourRegistry.OnRemove or detach companions through + /// RecruitPartyBridge — matching the previous + /// NewGameStartedEvent handler's behavior. Save loading is a + /// separate path: RecruitingSaveable replaces the roster contents + /// when a save is loaded. + /// + internal sealed class RecruitingReset : IResettable + { + /// Resettable key under which AppServices deduplicates registration. + public string Id => "recruiting"; + + /// Clears the roster wholesale on "New Game", without per-recruit notifications. + public void ResetForNewGame() => RecruitingData.Instance.Data.Clear(); + } +} diff --git a/src/RecruitingSaveable.cs b/src/RecruitingSaveable.cs new file mode 100644 index 0000000..6edc6fa --- /dev/null +++ b/src/RecruitingSaveable.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using Cthangover.Core.Mods; +using Cthangover.Core.Settings; + +namespace Cthangover.Core.Relationship +{ + /// + /// Persists the recruit roster in the mod's own save slot. The core save used + /// to carry a "Recruits" blob inside the __core__ slot, but the + /// roster now belongs to the recruiting mod — so it saves through the generic + /// contract under the mod id recruiting. + /// + /// replaces the roster wholesale and does not re-fire + /// ConfigureRecruit: persisted per-recruit properties (for example the + /// nutrition timer) are already in the blob and behaviours continue their + /// tick logic from the restored state, matching the old core behaviour. + /// Backward compatibility with the old core blob is intentionally not + /// provided — old saves start with an empty roster. + /// + public class RecruitingSaveable : IModSaveable + { + private const string BlobKey = "Recruits"; + + /// Writes the session roster into the mod slot. + public void OnSave(ModSlot slot) + { + slot.Data[BlobKey] = new DataBlob(1, RecruitingData.Instance.Data); + } + + /// Restores the roster from the mod slot; an absent blob yields an empty roster. + public bool OnLoad(ModSlot slot, out string errorMessage) + { + errorMessage = null; + + var data = new List(); + if (slot?.Data != null && slot.Data.TryGetValue(BlobKey, out var blob)) + data = blob.ReadJson>() ?? new List(); + + RecruitingData.Instance.Data = data; + + // The core slot (party cards) is restored before mod slots, so any + // squad recruit already has a placeholder card again. The mod owns + // its data outside battle, so push the saved health onto that card. + foreach (var recruit in data) + RecruitPartyBridge.SyncToCard(recruit); + + return true; + } + } +} diff --git a/src/UI/RecruitPanelBehaviour.cs b/src/UI/RecruitPanelBehaviour.cs new file mode 100644 index 0000000..f0b94d6 --- /dev/null +++ b/src/UI/RecruitPanelBehaviour.cs @@ -0,0 +1,221 @@ +using Cthangover.Core.Characters; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.UI; +using Cthangover.Core.UI.CharacterPanel; +using Cthangover.Core.UI.Theming; +using Godot; + +using Cthangover.Mods.Interface.Base.Lists; +using Cthangover.Mods.Interface.Theming; +namespace Cthangover.Core.Relationship +{ + /// + /// Mod-owned panel of captured recruits. It is declared in the mod manifest + /// as a `kind:panel` GUI entry, so the core toolbar renders its button + /// and the host caches the instance for the whole session; the core + /// character panel stays unaware of recruiting. + /// + /// The static layout (backdrop, framed panel, header row, scroll host, + /// rules and status footer) lives in scenes/recruit_panel.tscn; + /// this behaviour resolves those nodes and owns the dynamic layer: roster + /// rows rebuilt from on every show + /// and after every squad toggle. A recruit joins the active party through + /// , which materializes the placeholder + /// card the core character panel and battle systems consume. + /// + public class RecruitPanelBehaviour : Widget + { + // Scene nodes (recruit_panel.tscn) + private Label _title; + private Label _sub; + private Button _closeButton; + private VBoxContainer _list; + private Label _statusLabel; + + /// Binds scene nodes and applies theme and localized captions. + protected override void OnceConstruct() + { + MouseFilter = MouseFilterEnum.Stop; + + _title = GetNode