commit 69390b7743a3381a2579e191f5e7707e624e225e Author: iisaev Date: Sat Oct 24 10:01:12 2026 +0200 Battle formulas: action values as stat formulas, costs from stat pools, MP gauge for party members, action schema Co-Authored-By: Claude Opus 5.5 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3030c1a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,30 @@ +# Line endings: LF everywhere, on every OS, whatever core.autocrlf says. +* text=auto eol=lf + +# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF. +*.bat text eol=crlf +*.cmd text eol=crlf + +# Binaries: never converted, never diffed as text. +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.svg text +*.ogg binary +*.wav binary +*.mp3 binary +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary +*.dll binary +*.so binary +*.dylib binary +*.pdb binary +*.exe binary +*.zip binary +*.pck binary +*.moc3 binary +*.res binary +*.scn binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c301ca8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Godot 4.x +.godot/ +.idea/ +sdk/ +bin/ +obj/ + +#ai +.omo/ +.opencode/ + +gen/ +obj/ +bin/ + +*.tres~ +*.tscn~ + +# GDScript LSP +.godot/ + +# Export +*.exe +*.dmg +*.apk +*.aab +*.pck +*.zip +*.log + +# OS +.DS_Store +Thumbs.db + +# Editor +*.import +*.godot.uid diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..f2d1ff9 --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,19 @@ +mod_battle_formulas_name = Battle Formulas +mod_battle_formulas_desc = Battle actions as data for every battle core: an action with “Formula=a.mag * 3 - b.mdf + rand(0, 4)” needs no code — damage or healing, a mana price (Cost=mp:5), shield piercing, a status with a chance. a.* is the one acting, b.* the target: their stats and live health, attack, defence and points. The same skill works in the FF-style battle and in the card battle. Enemies with stats take their battle numbers from their sheets. +battle_formulas/schema/field/values = Values +battle_formulas/schema/field/values_desc = The action's parameters; keys the schema does not know are kept as they are +battle_formulas/schema/field/required_point = Action points +battle_formulas/schema/field/formula = Formula +battle_formulas/schema/battle/formula_desc = a.* is the one acting, b.* the target: a.atk * 2 - b.def + rand(0, 3) +battle_formulas/schema/field/effect = Effect +battle_formulas/schema/field/cost = Cost +battle_formulas/schema/battle/cost_desc = A pool cost, e.g. mp:4 +battle_formulas/schema/field/pierce = Pierce +battle_formulas/schema/battle/pierce_desc = Damage goes past the shield +battle_formulas/schema/field/state = Status +battle_formulas/schema/field/state_turns = Status turns +battle_formulas/schema/field/state_chance = Status chance, % +battle_formulas/schema/field/attack_factor = Attack factor +battle_formulas/schema/battle/attack_desc = For the cores' plain attacks: the attack factor +battle_formulas/schema/field/attack_type = Attack type +battle_formulas/schema/field/turn = Turns diff --git a/locale/es.properties b/locale/es.properties new file mode 100644 index 0000000..fce4673 --- /dev/null +++ b/locale/es.properties @@ -0,0 +1,19 @@ +mod_battle_formulas_name = Fórmulas de combate +mod_battle_formulas_desc = Acciones de combate como datos para cualquier núcleo: una acción con «Formula=a.mag * 3 - b.mdf + rand(0, 4)» no necesita código — daño o curación, precio en maná (Cost=mp:5), perforar escudos, un estado con probabilidad. a.* es quien actúa, b.* el objetivo: sus estadísticas y su salud, ataque, defensa y puntos actuales. La misma habilidad funciona en el combate estilo FF y en el de cartas. Los enemigos con estadísticas toman sus números de combate de su hoja. +battle_formulas/schema/field/values = Valores +battle_formulas/schema/field/values_desc = Los parámetros de la acción; las claves que el esquema no conoce se conservan +battle_formulas/schema/field/required_point = Puntos de acción +battle_formulas/schema/field/formula = Fórmula +battle_formulas/schema/battle/formula_desc = a.* es quien actúa, b.* el objetivo: a.atk * 2 - b.def + rand(0, 3) +battle_formulas/schema/field/effect = Efecto +battle_formulas/schema/field/cost = Coste +battle_formulas/schema/battle/cost_desc = Un coste de reserva, p. ej. mp:4 +battle_formulas/schema/field/pierce = Perforar +battle_formulas/schema/battle/pierce_desc = El daño ignora el escudo +battle_formulas/schema/field/state = Estado +battle_formulas/schema/field/state_turns = Turnos del estado +battle_formulas/schema/field/state_chance = Probabilidad del estado, % +battle_formulas/schema/field/attack_factor = Factor de ataque +battle_formulas/schema/battle/attack_desc = Para los ataques normales de los núcleos: el factor de ataque +battle_formulas/schema/field/attack_type = Tipo de ataque +battle_formulas/schema/field/turn = Turnos diff --git a/locale/ja.properties b/locale/ja.properties new file mode 100644 index 0000000..f5e1a37 --- /dev/null +++ b/locale/ja.properties @@ -0,0 +1,19 @@ +mod_battle_formulas_name = バトルの式 +mod_battle_formulas_desc = どのバトルコアにも使えるデータとしてのバトル行動:「Formula=a.mag * 3 - b.mdf + rand(0, 4)」を持つ行動はコード不要——ダメージか回復、MP 消費(Cost=mp:5)、シールド貫通、確率付きの状態付与。a.* は行動する者、b.* は対象:そのステータスと現在の HP、攻撃、防御、ポイント。同じスキルが FF 風バトルでもカードバトルでも動く。ステータスを持つ敵はシートから戦闘値を得る。 +battle_formulas/schema/field/values = 値 +battle_formulas/schema/field/values_desc = 行動のパラメーター。スキーマが知らないキーはそのまま残る +battle_formulas/schema/field/required_point = 行動ポイント +battle_formulas/schema/field/formula = 式 +battle_formulas/schema/battle/formula_desc = a.* は行動する者、b.* は対象:a.atk * 2 - b.def + rand(0, 3) +battle_formulas/schema/field/effect = 効果 +battle_formulas/schema/field/cost = コスト +battle_formulas/schema/battle/cost_desc = 蓄えのコスト。例:mp:4 +battle_formulas/schema/field/pierce = 貫通 +battle_formulas/schema/battle/pierce_desc = ダメージがシールドを抜ける +battle_formulas/schema/field/state = 状態 +battle_formulas/schema/field/state_turns = 状態ターン +battle_formulas/schema/field/state_chance = 状態の確率 % +battle_formulas/schema/field/attack_factor = 攻撃倍率 +battle_formulas/schema/battle/attack_desc = コアの通常攻撃用:攻撃倍率 +battle_formulas/schema/field/attack_type = 攻撃タイプ +battle_formulas/schema/field/turn = ターン diff --git a/locale/ko.properties b/locale/ko.properties new file mode 100644 index 0000000..cb01fb7 --- /dev/null +++ b/locale/ko.properties @@ -0,0 +1,19 @@ +mod_battle_formulas_name = 전투 공식 +mod_battle_formulas_desc = 모든 전투 코어에 쓰는 데이터 전투 행동: “Formula=a.mag * 3 - b.mdf + rand(0, 4)”를 가진 행동은 코드가 필요 없습니다 — 피해나 회복, 마나 비용(Cost=mp:5), 보호막 관통, 확률로 거는 상태. a.*는 행동하는 쪽, b.*는 대상: 그들의 능력치와 현재 체력, 공격, 방어, 포인트. 같은 스킬이 FF 스타일 전투와 카드 전투 모두에서 작동합니다. 능력치가 있는 적은 시트에서 전투 수치를 받습니다. +battle_formulas/schema/field/values = 값 +battle_formulas/schema/field/values_desc = 행동의 매개변수. 스키마가 모르는 키는 그대로 유지 +battle_formulas/schema/field/required_point = 행동 포인트 +battle_formulas/schema/field/formula = 공식 +battle_formulas/schema/battle/formula_desc = a.*는 행동하는 쪽, b.*는 대상: a.atk * 2 - b.def + rand(0, 3) +battle_formulas/schema/field/effect = 효과 +battle_formulas/schema/field/cost = 비용 +battle_formulas/schema/battle/cost_desc = 비축 비용. 예: mp:4 +battle_formulas/schema/field/pierce = 관통 +battle_formulas/schema/battle/pierce_desc = 피해가 보호막을 무시 +battle_formulas/schema/field/state = 상태 +battle_formulas/schema/field/state_turns = 상태 턴 +battle_formulas/schema/field/state_chance = 상태 확률 % +battle_formulas/schema/field/attack_factor = 공격 배율 +battle_formulas/schema/battle/attack_desc = 코어의 일반 공격용: 공격 배율 +battle_formulas/schema/field/attack_type = 공격 유형 +battle_formulas/schema/field/turn = 턴 diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..753ac79 --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,19 @@ +mod_battle_formulas_name = Боевые формулы +mod_battle_formulas_desc = Боевые действия как данные для любого боевого ядра: действие с «Formula=a.mag * 3 - b.mdf + rand(0, 4)» не требует кода — урон или лечение, цена в мане (Cost=mp:5), пробивание щита, наложение состояния с шансом. a.* — кто действует, b.* — цель: их статы и живые здоровье, атака, защита, очки. Один и тот же навык работает и в бою в стиле FF, и в карточном. Враги со статами получают боевые числа из своего листа. +battle_formulas/schema/field/values = Значения +battle_formulas/schema/field/values_desc = Параметры действия; ключи, которых схема не знает, сохраняются как есть +battle_formulas/schema/field/required_point = Очки действия +battle_formulas/schema/field/formula = Формула +battle_formulas/schema/battle/formula_desc = a.* — кто действует, b.* — цель: a.atk * 2 - b.def + rand(0, 3) +battle_formulas/schema/field/effect = Эффект +battle_formulas/schema/field/cost = Цена +battle_formulas/schema/battle/cost_desc = Расход запаса, например mp:4 +battle_formulas/schema/field/pierce = Сквозь щит +battle_formulas/schema/battle/pierce_desc = Урон мимо щита (защиты) +battle_formulas/schema/field/state = Состояние +battle_formulas/schema/field/state_turns = Ходов состояния +battle_formulas/schema/field/state_chance = Шанс состояния, % +battle_formulas/schema/field/attack_factor = Множитель атаки +battle_formulas/schema/battle/attack_desc = Для обычных ударов ядер: множитель атаки +battle_formulas/schema/field/attack_type = Тип атаки +battle_formulas/schema/field/turn = Ходов diff --git a/locale/vi.properties b/locale/vi.properties new file mode 100644 index 0000000..ae50818 --- /dev/null +++ b/locale/vi.properties @@ -0,0 +1,19 @@ +mod_battle_formulas_name = Công thức chiến đấu +mod_battle_formulas_desc = Hành động chiến đấu là dữ liệu cho mọi lõi chiến đấu: một hành động có «Formula=a.mag * 3 - b.mdf + rand(0, 4)» không cần mã — sát thương hoặc hồi máu, giá mana (Cost=mp:5), xuyên khiên, gây trạng thái theo xác suất. a.* là người hành động, b.* là mục tiêu: chỉ số của họ cùng máu, tấn công, phòng thủ và điểm hiện tại. Cùng một kỹ năng chạy được cả trong trận kiểu FF lẫn trận bài. Kẻ địch có chỉ số lấy con số chiến đấu từ bảng của mình. +battle_formulas/schema/field/values = Giá trị +battle_formulas/schema/field/values_desc = Tham số của hành động; khóa mà lược đồ không biết được giữ nguyên +battle_formulas/schema/field/required_point = Điểm hành động +battle_formulas/schema/field/formula = Công thức +battle_formulas/schema/battle/formula_desc = a.* là người hành động, b.* là mục tiêu: a.atk * 2 - b.def + rand(0, 3) +battle_formulas/schema/field/effect = Hiệu ứng +battle_formulas/schema/field/cost = Chi phí +battle_formulas/schema/battle/cost_desc = Chi phí nguồn, ví dụ mp:4 +battle_formulas/schema/field/pierce = Xuyên khiên +battle_formulas/schema/battle/pierce_desc = Sát thương bỏ qua khiên +battle_formulas/schema/field/state = Trạng thái +battle_formulas/schema/field/state_turns = Lượt trạng thái +battle_formulas/schema/field/state_chance = Tỉ lệ trạng thái, % +battle_formulas/schema/field/attack_factor = Hệ số tấn công +battle_formulas/schema/battle/attack_desc = Cho đòn thường của lõi: hệ số tấn công +battle_formulas/schema/field/attack_type = Loại tấn công +battle_formulas/schema/field/turn = Lượt diff --git a/locale/zh.properties b/locale/zh.properties new file mode 100644 index 0000000..2d3e3dc --- /dev/null +++ b/locale/zh.properties @@ -0,0 +1,19 @@ +mod_battle_formulas_name = 战斗公式 +mod_battle_formulas_desc = 适用于任何战斗核心的数据化战斗动作:带有“Formula=a.mag * 3 - b.mdf + rand(0, 4)”的动作无需代码——伤害或治疗、法力消耗(Cost=mp:5)、穿透护盾、按几率施加状态。a.* 是行动者,b.* 是目标:他们的属性以及实时的生命、攻击、防御和行动点。同一个技能在 FF 风格战斗和卡牌战斗中都能用。带属性的敌人从自己的面板获得战斗数值。 +battle_formulas/schema/field/values = 数值 +battle_formulas/schema/field/values_desc = 动作的参数;模式不认识的键原样保留 +battle_formulas/schema/field/required_point = 行动点 +battle_formulas/schema/field/formula = 公式 +battle_formulas/schema/battle/formula_desc = a.* 为行动者,b.* 为目标:a.atk * 2 - b.def + rand(0, 3) +battle_formulas/schema/field/effect = 效果 +battle_formulas/schema/field/cost = 消耗 +battle_formulas/schema/battle/cost_desc = 储备消耗,例如 mp:4 +battle_formulas/schema/field/pierce = 穿透 +battle_formulas/schema/battle/pierce_desc = 伤害无视护盾 +battle_formulas/schema/field/state = 状态 +battle_formulas/schema/field/state_turns = 状态回合 +battle_formulas/schema/field/state_chance = 状态几率 % +battle_formulas/schema/field/attack_factor = 攻击倍率 +battle_formulas/schema/battle/attack_desc = 用于核心的普通攻击:攻击倍率 +battle_formulas/schema/field/attack_type = 攻击类型 +battle_formulas/schema/field/turn = 回合 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..3e70ee7 --- /dev/null +++ b/manifest.json @@ -0,0 +1,15 @@ +{ + "id": "battle_formulas", + "name": "Battle Formulas", + "version": "1.0.0", + "author": "ct", + "description": "Battle actions as data for every battle core: an action entity with Formula= (a.mag * 3 - b.mdf + rand(0, 4)) needs no C# — Effect=damage|heal, Cost=mp:5 spends a stat pool, Pierce=1 ignores shields, State=basic/stun with StateTurns and StateChance puts a status on the target. a.* is the one acting, b.* the target: their stat sheets plus live hp, hp_max, attack, defence, point. Registered as global executors, so the same skill works in the FF core and in the card core. Enemies with stats data get their battle attributes from their sheets when the battle starts.", + "category": "gameplay", + "sources": [ + "src/**/*.cs" + ], + "test_sources": [ + "tests/*.cs" + ], + "depends": ["stats_base>=1", "turn_battle_base>=2", "characters_base>=1"] +} diff --git a/mod.jpg b/mod.jpg new file mode 100644 index 0000000..79f5360 Binary files /dev/null and b/mod.jpg differ diff --git a/schemas/battle_formulas.json b/schemas/battle_formulas.json new file mode 100644 index 0000000..1c4c490 --- /dev/null +++ b/schemas/battle_formulas.json @@ -0,0 +1,18 @@ +{ + "Kinds": [ + { "Kind": "action", + "Fields": [ + { "Key": "Values", "Type": "values", "Name": "battle_formulas/schema/field/values", "Description": "battle_formulas/schema/field/values_desc", "Of": [ + { "Key": "RequiredPoint", "Type": "int", "Name": "battle_formulas/schema/field/required_point" }, + { "Key": "Formula", "Type": "formula", "Name": "battle_formulas/schema/field/formula", "Description": "battle_formulas/schema/battle/formula_desc" }, + { "Key": "Effect", "Type": "choice", "Options": ["damage", "heal"], "Name": "battle_formulas/schema/field/effect" }, + { "Key": "Cost", "Type": "text", "Name": "battle_formulas/schema/field/cost", "Description": "battle_formulas/schema/battle/cost_desc" }, + { "Key": "Pierce", "Type": "bool", "Name": "battle_formulas/schema/field/pierce", "Description": "battle_formulas/schema/battle/pierce_desc" }, + { "Key": "State", "Type": "ref", "Kind": "status_effect", "Name": "battle_formulas/schema/field/state" }, + { "Key": "StateTurns", "Type": "int", "Name": "battle_formulas/schema/field/state_turns" }, + { "Key": "StateChance", "Type": "int", "Name": "battle_formulas/schema/field/state_chance" }, + { "Key": "Attack", "Type": "number", "Name": "battle_formulas/schema/field/attack_factor", "Description": "battle_formulas/schema/battle/attack_desc" }, + { "Key": "AttackType", "Type": "text", "Name": "battle_formulas/schema/field/attack_type" }, + { "Key": "Turn", "Type": "int", "Name": "battle_formulas/schema/field/turn" } ] } ] } + ] +} diff --git a/src/BattleFormulaScope.cs b/src/BattleFormulaScope.cs new file mode 100644 index 0000000..83b427a --- /dev/null +++ b/src/BattleFormulaScope.cs @@ -0,0 +1,77 @@ +namespace Cthangover.Mods.BattleFormulas +{ + using System; + using Cthangover.Core.Characters; + using Cthangover.Mods.Stats; + using Cthangover.Mods.Stats.Formulas; + + /// + /// What a battle formula can read: a.* is the one acting, b.* the target. + /// Each side answers its stat sheet (a.str, b.mag, a.level) and the + /// live battle attributes under fixed names — hp, hp_max, attack, + /// defence, point — so a formula may hit harder on a wounded target + /// (b.hp < b.hp_max / 2) whatever stats the game defines. + /// + public class BattleFormulaScope : IFormulaScope + { + private readonly Character user; + private readonly Character target; + + /// Scope of acting on (either may be null). + public BattleFormulaScope(Character user, Character target, Random random = null) + { + this.user = user; + this.target = target; + Random = random ?? Random.Shared; + } + + /// + public Random Random { get; } + + /// + public bool TryGet(string name, out double value) + { + value = 0; + if (name == null || name.Length < 3 || name[1] != '.') + return false; + var side = name[0] switch + { + 'a' => user, + 'b' => target, + _ => null + }; + return side != null && Read(side, name.Substring(2), out value); + } + + /// A value of one side: live attributes first, then its stat sheet. + public static bool Read(Character character, string key, out double value) + { + var attributes = character.Attributes; + switch (key) + { + case "hp": + value = attributes?.Health?.Value ?? 0; + return true; + case "hp_max": + value = attributes?.Health?.BaseValue ?? 0; + return true; + case "attack": + value = attributes?.Attack?.Value ?? 0; + return true; + case "defence": + value = attributes?.Defence?.Value ?? 0; + return true; + case "point": + value = attributes?.Point?.Value ?? 0; + return true; + } + var sheet = StatsService.Sheet(character.ID); + if (key == "level") + { + value = character.Level > 0 ? character.Level : sheet.Level; + return true; + } + return sheet.TryGet(key, out value); + } + } +} diff --git a/src/BattleFormulasInitializer.cs b/src/BattleFormulasInitializer.cs new file mode 100644 index 0000000..544085e --- /dev/null +++ b/src/BattleFormulasInitializer.cs @@ -0,0 +1,28 @@ +namespace Cthangover.Mods.BattleFormulas +{ + using Cthangover.Core.Events; + using Cthangover.Core.Mods; + + /// Registers the formula actions when resources are ready and hooks the battle start. + public class BattleFormulasInitializer : IModInitializer + { + private static bool subscribed; + + /// + public void OnModLoaded(string modId) + { + } + + /// + public void OnModResourcesReady() + { + FormulaActions.Refresh(); + if (subscribed) + return; + subscribed = true; + ModEventBus.Subscribe(BattleStatsBridge.OnRouteActivating); + ModEventBus.Subscribe(BattleStatsBridge.OnBattleStarted); + ModEventBus.Subscribe(PoolGauge.OnStatsChanged); + } + } +} diff --git a/src/BattleStatsBridge.cs b/src/BattleStatsBridge.cs new file mode 100644 index 0000000..5019e16 --- /dev/null +++ b/src/BattleStatsBridge.cs @@ -0,0 +1,42 @@ +namespace Cthangover.Mods.BattleFormulas +{ + using System.Linq; + using Cthangover.Core.Battle; + using Cthangover.Core.Events; + using Cthangover.Mods.Stats; + + /// + /// The moment a battle starts: formula actions are (re)registered, the party's bound + /// stats are synced (a member may have joined in the same dialog that started the + /// fight), and enemies whose templates carry stats data (Stats, + /// Archetype or Traits) get their battle attributes from their sheets. An + /// enemy without stats data keeps its template's numbers, so old content fights as before. + /// + public static class BattleStatsBridge + { + /// + /// The battle route is about to load: the battle scene builds its sides and HUD from + /// the attributes right after this, so they get their stats now. + /// + public static void OnRouteActivating(SceneRouteActivatingEvent evt) + { + if (evt?.LogicalName == "Battle") + OnBattleStarted(null); + } + + /// + /// Handler of (also run on the route activation); + /// every step is idempotent, so running twice changes nothing. + /// + public static void OnBattleStarted(BattleStartedEvent evt) + { + FormulaActions.Refresh(); + StatSync.SyncRoster(); + foreach (var enemy in BattleRequest.Current?.EnemiesCards?.Where(e => e != null) ?? Enumerable.Empty()) + { + if (StatsService.Participates(enemy.ID)) + StatBindings.Apply(StatsService.Sheet(enemy.ID), enemy.Attributes); + } + } + } +} diff --git a/src/FormulaActionExecutor.cs b/src/FormulaActionExecutor.cs new file mode 100644 index 0000000..94fc313 --- /dev/null +++ b/src/FormulaActionExecutor.cs @@ -0,0 +1,121 @@ +namespace Cthangover.Mods.BattleFormulas +{ + using System; + using Cthangover.Core.Cards.StatusEffect; + using Cthangover.Core.Characters; + using Cthangover.Core.Utils; + using Cthangover.Mods.Stats; + using Cthangover.Mods.Stats.Formulas; + + /// + /// Runs a battle action whose numbers are data. An "Kind": "action" entity with + /// these values needs no C# of its own: + /// + /// Formula= the amount (a.mag * 3 - b.mdf + rand(0, 4)), never below 0; + /// Effect=damage (default) or heal; + /// Cost=mp:5 spends a pool stat of a party member (enemies pay nothing); + /// RequiredPoint= spends action points as every core does; + /// Pierce=1 ignores the target's shield (the consumable defence attribute + /// the cards build up), otherwise the shield absorbs first, as in both cores; + /// State=effect/physics/stun (+ StateTurns=, StateChance= percent) puts a + /// status effect on the target. + /// + /// Status effect hooks see the damage like any core's damage, so passives and statuses + /// of other mods keep working. Executors are registered globally, which is why the same + /// skill works in the FF core and in the card core: each core's own provider is asked + /// first and falls back to the global ones for ids it does not know. + /// + public class FormulaActionExecutor : ActionBase + { + private readonly string id; + + /// Executor of the action entity . + public FormulaActionExecutor(string actionId) + { + id = actionId; + } + + /// + public override string ID => id; + + /// + public override ChangedAttributes Execute(ActionCharacter action, Character user, Character target) + { + if (user == null || action == null) + return new ChangedAttributes { Result = false }; + if (target == null || action.Type == ActionCharacterType.ToSelf) + target = user; + // The pool is only checked first and spent after the action points, so a refused + // action never costs mana. + if (!PayCost(action, user, false) || !CheckRequiredAndUsePoint(action, user)) + return new ChangedAttributes { Result = false }; + PayCost(action, user, true); + + var amount = Math.Max(0, (int)Math.Round(Formula.Evaluate(action.GetStr("Formula"), new BattleFormulaScope(user, target)), MidpointRounding.AwayFromZero)); + var result = string.Equals(action.GetStr("Effect"), "heal", StringComparison.OrdinalIgnoreCase) + ? Heal(target, amount) + : Damage(action, user, target, amount); + ApplyState(action, target); + GameLogger.Log("BATTLE", $"formula action '{id}': {user.ID} -> {target.ID}, amount {amount}, damage {result.Target.Damage}", LogLevel.Debug); + return result; + } + + private static ChangedAttributes Damage(ActionCharacter action, Character user, Character target, int amount) + { + StatusEffectService.Of(target).OnTakeDamage(user, ref amount); + StatusEffectService.Of(user).OnDealDamage(target, ref amount); + + var shield = 0; + var pierce = action.GetInt("Pierce") != 0 || user.HasPassiveEffect("IgnoreArmor"); + if (!pierce && target.Attributes.Defence.Value > 0) + { + shield = Math.Min(target.Attributes.Defence.Value, amount); + amount -= shield; + target.Attributes.Defence.Value -= shield; + } + target.Attributes.Health.Value = Math.Max(0, target.Attributes.Health.Value - amount); + return new ChangedAttributes { Result = true, Target = { Damage = amount, Defence = -shield } }; + } + + private static ChangedAttributes Heal(Character target, int amount) + { + var health = target.Attributes.Health; + var healed = Math.Max(0, Math.Min(amount, health.BaseValue - health.Value)); + health.Value += healed; + return new ChangedAttributes { Result = true, Target = { Damage = healed } }; + } + + private static bool PayCost(ActionCharacter action, Character user, bool spend) + { + var cost = action.GetStr("Cost"); + if (string.IsNullOrWhiteSpace(cost) || StatsService.RosterEntry(user.ID) == null) + return true; + var colon = cost.IndexOf(':'); + if (colon <= 0 || !int.TryParse(cost.Substring(colon + 1), out var amount)) + { + if (!spend) + GameLogger.Log("BATTLE", $"action '{action.ID}': Cost must look like 'mp:5', got '{cost}'", LogLevel.Error); + return true; + } + var stat = cost.Substring(0, colon).Trim(); + if (spend) + return StatPools.Spend(user.ID, stat, amount); + if (StatPools.Current(user.ID, stat) >= amount) + return true; + GameLogger.Log("BATTLE", $"{user.ID} lacks {stat} for '{action.ID}' ({StatPools.Current(user.ID, stat)} < {amount})", LogLevel.Warning); + return false; + } + + private static void ApplyState(ActionCharacter action, Character target) + { + var state = action.GetStr("State"); + if (string.IsNullOrWhiteSpace(state) || target.Attributes.Health.Value <= 0) + return; + var chance = action.Properties.Values.ContainsKey("StateChance") ? action.GetInt("StateChance") : 100; + if (Random.Shared.Next(100) >= chance) + return; + var turns = action.Properties.Values.ContainsKey("StateTurns") ? action.GetInt("StateTurns") : -1; + StatusEffectService.Of(target).Add(state.Trim(), turns); + } + } +} diff --git a/src/FormulaActions.cs b/src/FormulaActions.cs new file mode 100644 index 0000000..8c2c304 --- /dev/null +++ b/src/FormulaActions.cs @@ -0,0 +1,47 @@ +namespace Cthangover.Mods.BattleFormulas +{ + using System.Collections.Generic; + using System.Linq; + using Cthangover.Core.Battle.Actions; + using Cthangover.Core.Characters; + using Cthangover.Core.Mods.Resolvers; + using Cthangover.Core.Utils; + + /// + /// Finds the action entities that carry a Formula= value and registers a + /// for each as a global battle executor. Runs when + /// mod resources are ready and again as every battle starts, so an action added in an + /// editor or by a freshly loaded mod is playable in the next battle. + /// + /// The hub keys executors by action id and replaces on re-registration, so a reload + /// simply re-points the ids at this assembly's executors. + /// + public static class FormulaActions + { + private const string FormulaPrefix = "Formula="; + + private static object snapshot; + + /// The ids of the loaded formula actions. + public static List Ids() + => Entities.Collect().Values + .Where(IsFormulaAction).Select(a => a.ID).OrderBy(id => id, System.StringComparer.Ordinal).ToList(); + + /// Whether an action entity is driven by a formula. + public static bool IsFormulaAction(ActionCharacterInfo info) + => info?.Values != null && info.Values.Any(v => v != null && v.StartsWith(FormulaPrefix, System.StringComparison.OrdinalIgnoreCase)); + + /// Registers an executor per formula action (only when the data snapshot changed). + public static void Refresh() + { + var current = Entities.Collect(); + if (ReferenceEquals(current, snapshot)) + return; + snapshot = current; + var ids = Ids(); + foreach (var id in ids) + ActionExecutorHub.Instance.RegisterGlobal(new FormulaActionExecutor(id)); + GameLogger.Log("BATTLE", $"battle formulas: {ids.Count} formula action(s) registered"); + } + } +} diff --git a/src/PoolGauge.cs b/src/PoolGauge.cs new file mode 100644 index 0000000..d0f348b --- /dev/null +++ b/src/PoolGauge.cs @@ -0,0 +1,42 @@ +namespace Cthangover.Mods.BattleFormulas +{ + using System; + using System.Collections.Generic; + using System.Linq; + using Cthangover.Core.Battle; + using Cthangover.Core.Characters; + using Cthangover.Core.Events; + using Cthangover.Mods.Stats; + + /// + /// The pool stats ("Pool": true, like mana) of party members who take part in + /// the stats, as bars of the battle cores: current = what is left after spending, max = + /// the sheet value. Hidden stats and enemies (they never pay costs) get none. + /// + public class PoolGauge : IBattleUnitGauge + { + /// + public int Order => 100; + + /// + public IEnumerable Read(Character unit) + { + if (unit?.ID == null || StatsService.RosterEntry(unit.ID) == null || !StatsService.Participates(unit.ID)) + yield break; + var sheet = StatsService.Sheet(unit.ID); + foreach (var def in StatsService.Definitions.Where(d => d.Pool && !d.Hidden && sheet.Has(d.ID))) + { + yield return new BattleGauge + { + Id = def.ID, + Label = string.IsNullOrWhiteSpace(def.Short) ? def.Name ?? def.ID : def.Short, + Current = (int)Math.Round(StatPools.Current(unit.ID, def.ID), MidpointRounding.AwayFromZero), + Max = sheet.Rounded(def.ID), + }; + } + } + + /// Stats changed (mana spent or refilled): the cores redraw that unit's bars. + public static void OnStatsChanged(StatsChangedEvent evt) => BattleUnitGauges.NotifyChanged(evt?.CharacterId); + } +} diff --git a/tests/BattleFormulaScopeTests.cs b/tests/BattleFormulaScopeTests.cs new file mode 100644 index 0000000..cfabf2a --- /dev/null +++ b/tests/BattleFormulaScopeTests.cs @@ -0,0 +1,33 @@ +namespace Cthangover.Mods.BattleFormulas.Tests +{ + using Cthangover.Core.Characters; + using Cthangover.Core.Mods.Testing; + + /// a.* reads the one acting, b.* the target; live attributes under fixed names; other prefixes are unknown. + public class BattleFormulaScopeTests + { + [ModTest("scope/sides_and_live_names")] + static void SidesAndLiveNames(ModTestContext ctx) + { + var a = new Character { ID = "test_a_no_stats", Level = 4 }; + a.Attributes.Health.Init(40); + a.Attributes.Health.Value = 25; + a.Attributes.Attack.Init(7); + var b = new Character { ID = "test_b_no_stats", Level = 2 }; + b.Attributes.Defence.Init(3); + b.Attributes.Point.Init(2); + + var scope = new BattleFormulaScope(a, b); + + ModAssert.True(scope.TryGet("a.hp", out var hp) && hp == 25, "a.hp is the current health"); + ModAssert.True(scope.TryGet("a.hp_max", out var max) && max == 40, "a.hp_max"); + ModAssert.True(scope.TryGet("a.attack", out var atk) && atk == 7, "a.attack"); + ModAssert.True(scope.TryGet("a.level", out var level) && level == 4, "a.level from the battle character"); + ModAssert.True(scope.TryGet("b.defence", out var def) && def == 3, "b.defence"); + ModAssert.True(scope.TryGet("b.point", out var points) && points == 2, "b.point"); + ModAssert.False(scope.TryGet("c.hp", out _), "unknown side"); + ModAssert.False(scope.TryGet("hp", out _), "no side"); + ModAssert.False(scope.TryGet("b.no_such_stat", out _), "a stat nobody has"); + } + } +} diff --git a/tests/FormulaActionExecutorTests.cs b/tests/FormulaActionExecutorTests.cs new file mode 100644 index 0000000..c4c960c --- /dev/null +++ b/tests/FormulaActionExecutorTests.cs @@ -0,0 +1,101 @@ +namespace Cthangover.Mods.BattleFormulas.Tests +{ + using Cthangover.Core.Characters; + using Cthangover.Core.Mods.Testing; + using Cthangover.Core.Utils; + + /// + /// A formula action applied to two plain characters: damage through shields, heals + /// capped at the maximum, refused actions changing nothing. + /// + public class FormulaActionExecutorTests + { + private static Character Fighter(string id, int hp, int attack = 0, int defence = 0, int points = 3) + { + var c = new Character { ID = id, Level = 1 }; + c.Attributes.Health.Init(hp); + c.Attributes.Attack.Init(attack); + c.Attributes.Defence.Init(defence); + c.Attributes.Point.Init(points); + return c; + } + + private static ActionCharacter Action(ActionCharacterType type, params string[] values) + { + var props = new PropertyData(); + foreach (var value in values) + { + var eq = value.IndexOf('='); + props.Values[value.Substring(0, eq)] = value.Substring(eq + 1); + } + return new ActionCharacter { ID = "test/formula", Type = type, Properties = props }; + } + + [ModTest("executor/damage_from_live_attributes")] + static void DamageFromLiveAttributes(ModTestContext ctx) + { + var hero = Fighter("test_hero_no_stats", 20, attack: 10); + var slime = Fighter("test_slime_no_stats", 30); + + var result = new FormulaActionExecutor("test/formula").Execute(Action(ActionCharacterType.ToEnemy, "Formula=a.attack * 2 - 3"), hero, slime); + + ModAssert.True(result.Result, "applied"); + ModAssert.Equal(17, result.Target.Damage, "10 * 2 - 3"); + ModAssert.Equal(13, slime.Attributes.Health.Value, "health lost"); + } + + [ModTest("executor/shield_absorbs_unless_pierce")] + static void ShieldAbsorbsUnlessPierce(ModTestContext ctx) + { + var hero = Fighter("test_hero_no_stats", 20); + var guard = Fighter("test_guard_no_stats", 30, defence: 5); + + var blocked = new FormulaActionExecutor("test/formula").Execute(Action(ActionCharacterType.ToEnemy, "Formula=12"), hero, guard); + ModAssert.Equal(7, blocked.Target.Damage, "the shield took 5"); + ModAssert.Equal(-5, blocked.Target.Defence, "shield change"); + ModAssert.Equal(0, guard.Attributes.Defence.Value, "the shield is spent"); + + guard.Attributes.Defence.Value = 5; + var pierced = new FormulaActionExecutor("test/formula").Execute(Action(ActionCharacterType.ToEnemy, "Formula=12", "Pierce=1"), hero, guard); + ModAssert.Equal(12, pierced.Target.Damage, "pierce ignores the shield"); + ModAssert.Equal(5, guard.Attributes.Defence.Value, "the shield is kept"); + } + + [ModTest("executor/heal_is_capped_and_targets_self")] + static void HealIsCapped(ModTestContext ctx) + { + var hero = Fighter("test_hero_no_stats", 20); + hero.Attributes.Health.Value = 15; + + var result = new FormulaActionExecutor("test/formula").Execute(Action(ActionCharacterType.ToSelf, "Formula=10", "Effect=heal"), hero, null); + + ModAssert.True(result.Result, "applied"); + ModAssert.Equal(5, result.Target.Damage, "only the missing 5 are healed"); + ModAssert.Equal(20, hero.Attributes.Health.Value, "full"); + } + + [ModTest("executor/points_refuse_and_negative_is_zero")] + static void PointsRefuseAndNegativeIsZero(ModTestContext ctx) + { + var hero = Fighter("test_hero_no_stats", 20, points: 1); + var slime = Fighter("test_slime_no_stats", 30); + + var refused = new FormulaActionExecutor("test/formula").Execute(Action(ActionCharacterType.ToEnemy, "Formula=50", "RequiredPoint=2"), hero, slime); + ModAssert.False(refused.Result, "not enough action points"); + ModAssert.Equal(30, slime.Attributes.Health.Value, "nothing happened"); + ModAssert.Equal(1, hero.Attributes.Point.Value, "no points spent"); + + var zero = new FormulaActionExecutor("test/formula").Execute(Action(ActionCharacterType.ToEnemy, "Formula=b.hp - 999"), hero, slime); + ModAssert.Equal(0, zero.Target.Damage, "a negative amount is 0"); + ModAssert.Equal(30, slime.Attributes.Health.Value, "never heals by accident"); + } + + [ModTest("registry/formula_actions_are_detected")] + static void FormulaActionsAreDetected(ModTestContext ctx) + { + ModAssert.True(FormulaActions.IsFormulaAction(new ActionCharacterInfo { ID = "x", Values = new() { "RequiredPoint=1", "formula=a.attack" } }), "Formula= in any case"); + ModAssert.False(FormulaActions.IsFormulaAction(new ActionCharacterInfo { ID = "y", Values = new() { "Attack=2" } }), "a core action"); + ModAssert.False(FormulaActions.IsFormulaAction(new ActionCharacterInfo { ID = "z" }), "no values"); + } + } +}