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 <noreply@anthropic.com>
This commit is contained in:
cthangoverandClaude Opus 5.5 committed 2026-10-24 10:01:12 +02:00
commit 69390b7743
20 files changed
+724

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
namespace Cthangover.Mods.BattleFormulas
{
using System;
using Cthangover.Core.Characters;
using Cthangover.Mods.Stats;
using Cthangover.Mods.Stats.Formulas;
/// <summary>
/// What a battle formula can read: <c>a.*</c> is the one acting, <c>b.*</c> the target.
/// Each side answers its stat sheet (<c>a.str</c>, <c>b.mag</c>, <c>a.level</c>) and the
/// live battle attributes under fixed names — <c>hp</c>, <c>hp_max</c>, <c>attack</c>,
/// <c>defence</c>, <c>point</c> — so a formula may hit harder on a wounded target
/// (<c>b.hp &lt; b.hp_max / 2</c>) whatever stats the game defines.
/// </summary>
public class BattleFormulaScope : IFormulaScope
{
private readonly Character user;
private readonly Character target;
/// <summary>Scope of <paramref name="user"/> acting on <paramref name="target"/> (either may be null).</summary>
public BattleFormulaScope(Character user, Character target, Random random = null)
{
this.user = user;
this.target = target;
Random = random ?? Random.Shared;
}
/// <inheritdoc />
public Random Random { get; }
/// <inheritdoc />
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);
}
/// <summary>A value of one side: live attributes first, then its stat sheet.</summary>
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);
}
}
}
+28
View File
@@ -0,0 +1,28 @@
namespace Cthangover.Mods.BattleFormulas
{
using Cthangover.Core.Events;
using Cthangover.Core.Mods;
/// <summary>Registers the formula actions when resources are ready and hooks the battle start.</summary>
public class BattleFormulasInitializer : IModInitializer
{
private static bool subscribed;
/// <inheritdoc />
public void OnModLoaded(string modId)
{
}
/// <inheritdoc />
public void OnModResourcesReady()
{
FormulaActions.Refresh();
if (subscribed)
return;
subscribed = true;
ModEventBus.Subscribe<SceneRouteActivatingEvent>(BattleStatsBridge.OnRouteActivating);
ModEventBus.Subscribe<BattleStartedEvent>(BattleStatsBridge.OnBattleStarted);
ModEventBus.Subscribe<StatsChangedEvent>(PoolGauge.OnStatsChanged);
}
}
}
+42
View File
@@ -0,0 +1,42 @@
namespace Cthangover.Mods.BattleFormulas
{
using System.Linq;
using Cthangover.Core.Battle;
using Cthangover.Core.Events;
using Cthangover.Mods.Stats;
/// <summary>
/// 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 (<c>Stats</c>,
/// <c>Archetype</c> or <c>Traits</c>) get their battle attributes from their sheets. An
/// enemy without stats data keeps its template's numbers, so old content fights as before.
/// </summary>
public static class BattleStatsBridge
{
/// <summary>
/// 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.
/// </summary>
public static void OnRouteActivating(SceneRouteActivatingEvent evt)
{
if (evt?.LogicalName == "Battle")
OnBattleStarted(null);
}
/// <summary>
/// Handler of <see cref="BattleStartedEvent"/> (also run on the route activation);
/// every step is idempotent, so running twice changes nothing.
/// </summary>
public static void OnBattleStarted(BattleStartedEvent evt)
{
FormulaActions.Refresh();
StatSync.SyncRoster();
foreach (var enemy in BattleRequest.Current?.EnemiesCards?.Where(e => e != null) ?? Enumerable.Empty<Core.Characters.Character>())
{
if (StatsService.Participates(enemy.ID))
StatBindings.Apply(StatsService.Sheet(enemy.ID), enemy.Attributes);
}
}
}
}
+121
View File
@@ -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;
/// <summary>
/// Runs a battle action whose numbers are data. An <c>"Kind": "action"</c> entity with
/// these values needs no C# of its own:
/// <list type="bullet">
/// <item><c>Formula=</c> the amount (<c>a.mag * 3 - b.mdf + rand(0, 4)</c>), never below 0;</item>
/// <item><c>Effect=damage</c> (default) or <c>heal</c>;</item>
/// <item><c>Cost=mp:5</c> spends a pool stat of a party member (enemies pay nothing);
/// <c>RequiredPoint=</c> spends action points as every core does;</item>
/// <item><c>Pierce=1</c> ignores the target's shield (the consumable defence attribute
/// the cards build up), otherwise the shield absorbs first, as in both cores;</item>
/// <item><c>State=effect/physics/stun</c> (+ <c>StateTurns=</c>, <c>StateChance=</c> percent) puts a
/// status effect on the target.</item>
/// </list>
/// 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.
/// </summary>
public class FormulaActionExecutor : ActionBase
{
private readonly string id;
/// <summary>Executor of the action entity <paramref name="actionId"/>.</summary>
public FormulaActionExecutor(string actionId)
{
id = actionId;
}
/// <inheritdoc />
public override string ID => id;
/// <inheritdoc />
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);
}
}
}
+47
View File
@@ -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;
/// <summary>
/// Finds the action entities that carry a <c>Formula=</c> value and registers a
/// <see cref="FormulaActionExecutor"/> 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.
/// <para/>
/// 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.
/// </summary>
public static class FormulaActions
{
private const string FormulaPrefix = "Formula=";
private static object snapshot;
/// <summary>The ids of the loaded formula actions.</summary>
public static List<string> Ids()
=> Entities.Collect<ActionCharacterInfo>().Values
.Where(IsFormulaAction).Select(a => a.ID).OrderBy(id => id, System.StringComparer.Ordinal).ToList();
/// <summary>Whether an action entity is driven by a formula.</summary>
public static bool IsFormulaAction(ActionCharacterInfo info)
=> info?.Values != null && info.Values.Any(v => v != null && v.StartsWith(FormulaPrefix, System.StringComparison.OrdinalIgnoreCase));
/// <summary>Registers an executor per formula action (only when the data snapshot changed).</summary>
public static void Refresh()
{
var current = Entities.Collect<ActionCharacterInfo>();
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");
}
}
}
+42
View File
@@ -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;
/// <summary>
/// The pool stats (<c>"Pool": true</c>, 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.
/// </summary>
public class PoolGauge : IBattleUnitGauge
{
/// <inheritdoc />
public int Order => 100;
/// <inheritdoc />
public IEnumerable<BattleGauge> 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),
};
}
}
/// <summary>Stats changed (mana spent or refilled): the cores redraw that unit's bars.</summary>
public static void OnStatsChanged(StatsChangedEvent evt) => BattleUnitGauges.NotifyChanged(evt?.CharacterId);
}
}