Stats: stat sheets, formulas, archetypes, traits, stat pools, actions, conditions and sheet panel

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:14 +02:00
commit c6e53dedd3
66 files changed
+3976

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
namespace Cthangover.Mods.Stats.Tests
{
using System.Collections.Generic;
using System.Text.Json;
using Cthangover.Core.Mods.Testing;
/// <summary>
/// Stats keys arrive as JSON extension data of foreign entities; they must read the way
/// authors write them: any key case, numbers or formula texts, broken parts skipped.
/// </summary>
public class ExtraDataTests
{
private static Dictionary<string, object> Extra(string json)
{
var result = new Dictionary<string, object>();
using var doc = JsonDocument.Parse(json);
foreach (var prop in doc.RootElement.EnumerateObject())
result[prop.Name] = prop.Value.Clone();
return result;
}
[ModTest("extra/stats_numbers_and_formulas")]
static void StatsNumbersAndFormulas(ModTestContext ctx)
{
var stats = ExtraData.Stats(Extra("{\"stats\": {\"STR\": 8, \"max_hp\": \"20 + vit * 4\", \"bad\": [1]}}"), "test");
ModAssert.Equal(2, stats.Count, "the array entry is skipped");
ModAssert.Equal("8", stats["str"], "number, key lowercased");
ModAssert.Equal("20 + vit * 4", stats["MAX_HP"], "formula text, lookup ignores case");
}
[ModTest("extra/traits_and_text")]
static void TraitsAndText(ModTestContext ctx)
{
var extra = Extra("{\"Archetype\": \"mage\", \"traits\": [{\"stat\": \"mag\", \"add\": 3}, {\"Stat\": \"mp\", \"Percent\": 10, \"Formula\": \"level\"}]}");
var traits = ExtraData.Traits(extra, "test");
ModAssert.Equal("mage", ExtraData.Text(extra, "archetype"), "text in any case");
ModAssert.Equal(2, traits.Count, "two traits");
ModAssert.Equal("mag", traits[0].Stat, "first stat");
ModAssert.CloseTo(3, traits[0].Add, what: "first add");
ModAssert.CloseTo(10, traits[1].Percent, what: "second percent");
ModAssert.Equal("level", traits[1].Formula, "second formula");
}
[ModTest("extra/missing_or_malformed_reads_empty")]
static void MissingOrMalformed(ModTestContext ctx)
{
ModAssert.Equal(0, ExtraData.Traits(null, "test").Count, "null extension data");
ModAssert.Equal(0, ExtraData.Traits(Extra("{\"Traits\": 5}"), "test").Count, "a number is not a list");
ModAssert.Equal(0, ExtraData.Stats(Extra("{\"Stats\": \"x\"}"), "test").Count, "a string is not an object");
ModAssert.Null(ExtraData.Text(Extra("{\"Archetype\": {}}"), "Archetype"), "an object is not a text");
}
}
}
+96
View File
@@ -0,0 +1,96 @@
namespace Cthangover.Mods.Stats.Tests
{
using System.Collections.Generic;
using Cthangover.Core.Mods.Testing;
using Cthangover.Mods.Stats.Formulas;
/// <summary>The formula language: what authors write in data must compute what a spreadsheet would.</summary>
public class FormulaTests
{
private static double Eval(string text, Dictionary<string, double> vars = null)
=> Formula.Evaluate(text, new TestScope(vars));
[ModTest("formula/precedence_and_parentheses")]
static void PrecedenceAndParentheses(ModTestContext ctx)
{
ModAssert.CloseTo(14, Eval("2 + 3 * 4"), what: "* before +");
ModAssert.CloseTo(20, Eval("(2 + 3) * 4"), what: "parentheses");
ModAssert.CloseTo(1, Eval("10 % 3"), what: "remainder");
ModAssert.CloseTo(-6, Eval("-2 * 3"), what: "unary minus");
ModAssert.CloseTo(512, Eval("2 ^ 3 ^ 2"), what: "power is right-associative");
ModAssert.CloseTo(2.5, Eval("5 / 2"), what: "real division");
}
[ModTest("formula/variables_with_dots_and_level")]
static void VariablesWithDotsAndLevel(ModTestContext ctx)
{
var vars = new Dictionary<string, double> { ["a.atk"] = 12, ["b.def"] = 5, ["level"] = 3, ["str"] = 8 };
ModAssert.CloseTo(19, Eval("a.atk * 2 - b.def", vars), what: "dotted names");
ModAssert.CloseTo(14, Eval("STR + Level * 2", vars), what: "names ignore case");
ModAssert.CloseTo(0, Eval("unknown + 0", vars), what: "unknown name reads as 0");
}
[ModTest("formula/comparisons_logic_and_conditional")]
static void ComparisonsLogicAndConditional(ModTestContext ctx)
{
var vars = new Dictionary<string, double> { ["hp"] = 0, ["luk"] = 7 };
ModAssert.CloseTo(1, Eval("luk >= 7", vars), what: ">=");
ModAssert.CloseTo(0, Eval("luk != 7", vars), what: "!=");
ModAssert.CloseTo(1, Eval("!hp", vars), what: "! of zero");
ModAssert.CloseTo(0, Eval("hp > 0 && 100 / hp", vars), what: "&& short-circuits");
ModAssert.CloseTo(30, Eval("luk > 5 ? 30 : 10", vars), what: "conditional true");
ModAssert.CloseTo(10, Eval("luk > 9 ? 30 : luk > 8 ? 20 : 10", vars), what: "nested conditional");
}
[ModTest("formula/functions")]
static void Functions(ModTestContext ctx)
{
ModAssert.CloseTo(1, Eval("max(1, 3 - 10)"), what: "max");
ModAssert.CloseTo(2, Eval("min(9, 2, 5)"), what: "n-ary min");
ModAssert.CloseTo(3, Eval("round(2.5)"), what: "round half away from zero");
ModAssert.CloseTo(2, Eval("floor(2.9)"), what: "floor");
ModAssert.CloseTo(10, Eval("clamp(42, 10, 0)"), what: "clamp with swapped bounds");
ModAssert.CloseTo(4, Eval("if(1, 4, 5)"), what: "if");
for (var i = 0; i < 50; i++)
{
var roll = Eval("rand(3, 5)");
ModAssert.True(roll >= 3 && roll <= 5 && roll == System.Math.Floor(roll), $"rand(3,5) gave {roll}");
}
ModAssert.CloseTo(1, Eval("chance(100)"), what: "chance 100");
ModAssert.CloseTo(0, Eval("chance(0)"), what: "chance 0");
}
[ModTest("formula/forgiving_arithmetic")]
static void ForgivingArithmetic(ModTestContext ctx)
{
ModAssert.CloseTo(0, Eval("5 / 0"), what: "division by zero");
ModAssert.CloseTo(0, Eval("5 % 0"), what: "remainder by zero");
ModAssert.CloseTo(42, Formula.Evaluate("42", null), what: "plain number without a scope");
ModAssert.CloseTo(7, Formula.Evaluate("", null, 7), what: "empty text gives the fallback");
ModAssert.CloseTo(9, Formula.Evaluate("2 +* 3", null, 9), what: "broken text gives the fallback");
}
[ModTest("formula/syntax_errors_are_reported")]
static void SyntaxErrorsAreReported(ModTestContext ctx)
{
foreach (var broken in new[] { "2 +", "(1 + 2", "foo(1)", "max()", "rand(1)", "1 2", "a ? b", "#" })
{
ModAssert.False(Formula.TryParse(broken, out _, out var error), $"'{broken}' must not parse");
ModAssert.NotNull(error, $"'{broken}' has a message");
}
ModAssert.True(Formula.TryParse(" max(1, a.atk) ", out var node, out _), "spaces around are fine");
ModAssert.Equal(FormulaNodeKind.Call.ToString(), node.Kind.ToString(), "call node");
}
[ModTest("formula/variables_lists_reads_once")]
static void VariablesListsReadsOnce(ModTestContext ctx)
{
var node = Formula.Parse("a.atk * 2 + max(level, a.atk) - b.def");
var names = node.Variables();
ModAssert.Equal(3, names.Count, "three distinct names");
ModAssert.Equal("a.atk", names[0], "first use first");
ModAssert.Equal("level", names[1], "then level");
ModAssert.Equal("b.def", names[2], "then b.def");
}
}
}
+86
View File
@@ -0,0 +1,86 @@
namespace Cthangover.Mods.Stats.Tests
{
using System.Collections.Generic;
using Cthangover.Core.Characters;
using Cthangover.Core.Mods.Testing;
/// <summary>
/// The bridge to battles: an attribute's maximum follows the stat, its current value
/// moves by the same amount, and a fallen character is never revived by a sync.
/// </summary>
public class StatBindingsTests
{
private static readonly StatInfo[] Defs =
{
new() { ID = "max_hp", Binds = "health" },
new() { ID = "atk", Binds = "attack" },
new() { ID = "luk" },
};
private static StatSheet Sheet(double hp, double atk = 0)
{
var values = new Dictionary<string, double> { ["max_hp"] = hp, ["atk"] = atk, ["luk"] = 99 };
return new StatSheet("hero", 1, null, values, values);
}
[ModTest("bindings/find_by_name_and_alias")]
static void FindByNameAndAlias(ModTestContext ctx)
{
var attributes = new CharacterAttributes();
ModAssert.True(ReferenceEquals(attributes.Health, StatBindings.Find(attributes, "Health")), "health");
ModAssert.True(ReferenceEquals(attributes.Attack, StatBindings.Find(attributes, "atk")), "atk alias");
ModAssert.True(ReferenceEquals(attributes.Defence, StatBindings.Find(attributes, "defense")), "defense spelling");
ModAssert.True(ReferenceEquals(attributes.Point, StatBindings.Find(attributes, "point")), "point");
ModAssert.Null(StatBindings.Find(attributes, "mana"), "unknown name");
}
[ModTest("bindings/loaded_stats_bind_known_attributes")]
static void LoadedStatsBindKnownAttributes(ModTestContext ctx)
{
foreach (var def in StatsService.Definitions)
{
if (def.Binds != null)
ModAssert.NotNull(StatBindings.Find(new CharacterAttributes(), def.Binds), $"stat '{def.ID}' binds '{def.Binds}'");
}
}
[ModTest("bindings/current_moves_with_max")]
static void CurrentMovesWithMax(ModTestContext ctx)
{
var attributes = new CharacterAttributes();
attributes.Health.Init(30);
attributes.Health.Value = 20;
attributes.Attack.Init(6);
var changed = StatBindings.Apply(Sheet(36, 9), attributes, Defs);
ModAssert.Equal(2, changed, "health and attack changed, luk binds nothing");
ModAssert.Equal(36, attributes.Health.BaseValue, "max follows the stat");
ModAssert.Equal(26, attributes.Health.Value, "a wounded hero gains the same 6");
ModAssert.Equal(9, attributes.Attack.Value, "attack follows");
ModAssert.Equal(0, StatBindings.Apply(Sheet(36, 9), attributes, Defs), "nothing changes twice");
StatBindings.Apply(Sheet(10, 9), attributes, Defs);
ModAssert.Equal(10, attributes.Health.Value, "current capped by a lower max");
StatBindings.Apply(Sheet(4, 9), attributes, Defs);
ModAssert.Equal(4, attributes.Health.Value, "still alive at the new max");
}
[ModTest("bindings/fallen_stay_fallen_unset_is_filled")]
static void FallenStayFallen(ModTestContext ctx)
{
var attributes = new CharacterAttributes();
attributes.Health.Init(30);
attributes.Health.Value = 0;
StatBindings.Apply(Sheet(50), attributes, Defs);
ModAssert.Equal(0, attributes.Health.Value, "a fallen hero stays fallen");
ModAssert.Equal(50, attributes.Health.BaseValue, "but the max still follows");
var fresh = new CharacterAttributes();
StatBindings.Apply(Sheet(44.6), fresh, Defs);
ModAssert.Equal(45, fresh.Health.Value, "an unset attribute is filled, rounded");
ModAssert.Equal(45, fresh.Health.BaseValue, "with its max");
}
}
}
+140
View File
@@ -0,0 +1,140 @@
namespace Cthangover.Mods.Stats.Tests
{
using System;
using System.Collections.Generic;
using Cthangover.Core.Mods.Testing;
/// <summary>
/// How a sheet is computed: own value over archetype over formula over default, then
/// bonuses and traits, then the bounds — and derived stats reading other stats.
/// </summary>
public class StatCalculatorTests
{
private static Dictionary<string, StatInfo> Defs(params StatInfo[] stats)
{
var defs = new Dictionary<string, StatInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var s in stats)
defs[s.ID] = s;
return defs;
}
[ModTest("calculator/precedence_own_archetype_formula_default")]
static void Precedence(ModTestContext ctx)
{
var inputs = new StatInputs
{
Level = 3,
Definitions = Defs(
new StatInfo { ID = "str", Default = 5 },
new StatInfo { ID = "agi", Default = 5, Formula = "4 + level" },
new StatInfo { ID = "int", Default = 5, Formula = "2 + level" },
new StatInfo { ID = "luk", Default = 5 }),
Own = new Dictionary<string, string> { ["str"] = "12" },
Archetype = new ArchetypeInfo { ID = "rogue", Stats = new Dictionary<string, string> { ["agi"] = "6 + level * 2", ["str"] = "1" } },
};
var sheet = StatCalculator.Calculate("hero", inputs);
ModAssert.CloseTo(12, sheet.Get("str"), what: "own value beats the archetype");
ModAssert.CloseTo(12, sheet.Get("agi"), what: "archetype beats the stat formula");
ModAssert.CloseTo(5, sheet.Get("int"), what: "stat formula beats the default");
ModAssert.CloseTo(5, sheet.Get("luk"), what: "default");
ModAssert.Equal("rogue", sheet.ArchetypeId, "archetype id");
ModAssert.Equal(3, sheet.Level, "level");
}
[ModTest("calculator/derived_stats_read_others_in_any_order")]
static void DerivedStats(ModTestContext ctx)
{
var inputs = new StatInputs
{
Level = 2,
Definitions = Defs(
new StatInfo { ID = "max_hp", Formula = "30 + vit * 6 + level * 4" },
new StatInfo { ID = "atk", Formula = "str * 2" },
new StatInfo { ID = "vit", Default = 3 },
new StatInfo { ID = "str", Default = 4 }),
};
var sheet = StatCalculator.Calculate("hero", inputs);
ModAssert.CloseTo(56, sheet.Get("max_hp"), what: "30 + 18 + 8");
ModAssert.CloseTo(8, sheet.Get("atk"), what: "atk from str");
}
[ModTest("calculator/bonus_traits_and_bounds")]
static void BonusTraitsAndBounds(ModTestContext ctx)
{
var inputs = new StatInputs
{
Level = 4,
Definitions = Defs(
new StatInfo { ID = "atk", Default = 10 },
new StatInfo { ID = "luk", Default = 5, Max = 9 },
new StatInfo { ID = "def", Default = 2, Min = 0 }),
Bonus = new Dictionary<string, double> { ["atk"] = 2 },
Traits = new List<TraitInfo>
{
new() { Stat = "atk", Add = 3 },
new() { Stat = "atk", Percent = 50 },
new() { Stat = "atk", Formula = "level" },
new() { Stat = "luk", Add = 10 },
new() { Stat = "def", Add = -8 },
},
};
var sheet = StatCalculator.Calculate("hero", inputs);
ModAssert.CloseTo(12, sheet.GetBase("atk"), what: "base = default + bonus");
ModAssert.CloseTo(28.5, sheet.Get("atk"), what: "(12 + 3 + 4) * 1.5");
ModAssert.Equal(29, sheet.Rounded("atk"), "rounded half away from zero");
ModAssert.CloseTo(9, sheet.Get("luk"), what: "clamped to Max");
ModAssert.CloseTo(0, sheet.Get("def"), what: "clamped to Min");
}
[ModTest("calculator/undefined_stats_still_count")]
static void UndefinedStatsStillCount(ModTestContext ctx)
{
var inputs = new StatInputs
{
Own = new Dictionary<string, string> { ["courage"] = "3" },
Traits = new List<TraitInfo> { new() { Stat = "trust", Add = 2 } },
};
var sheet = StatCalculator.Calculate("player", inputs);
ModAssert.True(sheet.Has("courage") && sheet.Has("trust"), "own and trait stats are on the sheet");
ModAssert.CloseTo(3, sheet.Get("courage"), what: "own undefined stat");
ModAssert.CloseTo(2, sheet.Get("trust"), what: "trait-only stat");
ModAssert.CloseTo(0, sheet.Get("nothing"), what: "unknown stat reads 0");
}
[ModTest("calculator/cycle_reads_zero_instead_of_hanging")]
static void CycleReadsZero(ModTestContext ctx)
{
var inputs = new StatInputs
{
Definitions = Defs(
new StatInfo { ID = "a", Formula = "b + 1" },
new StatInfo { ID = "b", Formula = "a + 1" }),
};
var sheet = StatCalculator.Calculate("loop", inputs);
ModAssert.True(sheet.Has("a") && sheet.Has("b"), "both computed");
ModAssert.True(sheet.Get("a") <= 2 && sheet.Get("b") <= 2, "the looping read counted as 0");
}
[ModTest("calculator/sheet_is_a_formula_scope")]
static void SheetIsAScope(ModTestContext ctx)
{
var sheet = StatCalculator.Calculate("hero", new StatInputs
{
Level = 5,
Definitions = Defs(new StatInfo { ID = "str", Default = 7 }),
});
ModAssert.CloseTo(17, Formulas.Formula.Evaluate("str + level * 2", sheet), what: "sheet answers stats and level");
}
}
}
+63
View File
@@ -0,0 +1,63 @@
namespace Cthangover.Mods.Stats.Tests
{
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Scenes;
/// <summary>
/// The stats join the shared condition language: the forms authors write parse into
/// stats conditions, mix with flags and logic, and broken forms are syntax errors.
/// </summary>
public class StatLeafParserTests
{
[ModTest("conditions/forms_parse_into_stat_conditions")]
static void FormsParse(ModTestContext ctx)
{
var two = ConditionParser.Parse("stat(rpg_hero, str) >= 5") as StatCompareCondition;
ModAssert.NotNull(two, "stat(who, stat)");
ModAssert.Equal("rpg_hero", two.Who, "who");
ModAssert.Equal("str", two.Stat, "stat");
ModAssert.Equal(">=", two.Op, "op");
ModAssert.Equal(5, two.Value, "value");
var one = ConditionParser.Parse("stat(cha) < 3") as StatCompareCondition;
ModAssert.NotNull(one, "stat(stat)");
ModAssert.Null(one.Who, "the player");
var quoted = ConditionParser.Parse("stat('village/slime', atk) == 2") as StatCompareCondition;
ModAssert.Equal("village/slime", quoted?.Who, "quoted id with a slash");
var level = ConditionParser.Parse("level() > 1") as StatCompareCondition;
ModAssert.NotNull(level, "level()");
ModAssert.Null(level.Stat, "level compares no stat");
var archetype = ConditionParser.Parse("archetype(rpg_hero, mage)") as ArchetypeCondition;
ModAssert.Equal("mage", archetype?.Archetype, "archetype(who, id)");
}
[ModTest("conditions/mix_with_flags_and_logic")]
static void MixWithFlags(ModTestContext ctx)
{
var node = ConditionParser.Parse("door == \"open\" && (stat(str) >= 10 || !archetype(rogue))");
ModAssert.NotNull(node, "parsed");
}
[ModTest("conditions/broken_forms_are_errors")]
static void BrokenForms(ModTestContext ctx)
{
foreach (var broken in new[] { "stat(a, b, c) > 1", "stat(str)", "level(a, b) > 1", "stat(str) >= x", "archetype()" })
{
var threw = false;
try
{
ConditionParser.Parse(broken);
}
catch (System.FormatException)
{
threw = true;
}
ModAssert.True(threw, $"'{broken}' is a syntax error");
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
namespace Cthangover.Mods.Stats.Tests
{
using System.Text.Json;
using Cthangover.Core.Mods.Testing;
/// <summary>The saved stats survive a JSON round trip in every field.</summary>
public class StatStateDataTests
{
[ModTest("state/json_round_trip")]
static void JsonRoundTrip(ModTestContext ctx)
{
var original = new StatStateData();
var hero = new CharacterStatState { Archetype = "warrior", Level = 3, Exp = 61 };
hero.Bonus["str"] = 2;
hero.Bonus["cha"] = -1.5;
hero.Pools["mp"] = 4;
original.Characters["rpg_hero"] = hero;
var restored = JsonSerializer.Deserialize<StatStateData>(JsonSerializer.Serialize(original));
var back = restored.Characters["rpg_hero"];
ModAssert.Equal("warrior", back.Archetype, "archetype");
ModAssert.Equal(3, back.Level, "level");
ModAssert.Equal(61, back.Exp, "exp");
ModAssert.CloseTo(2, back.Bonus["str"], what: "bonus str");
ModAssert.CloseTo(-1.5, back.Bonus["cha"], what: "negative bonus");
ModAssert.CloseTo(4, back.Pools["mp"], what: "pool");
}
[ModTest("state/of_creates_find_ignores_case")]
static void OfCreatesFindIgnoresCase(ModTestContext ctx)
{
var state = new StatState();
ModAssert.Null(state.Find("Hero"), "nothing yet");
var created = state.Of("Hero");
ModAssert.True(ReferenceEquals(created, state.Of("hero")), "the same entry in any case");
ModAssert.True(ReferenceEquals(created, state.Find("HERO")), "find ignores case");
}
}
}
+44
View File
@@ -0,0 +1,44 @@
namespace Cthangover.Mods.Stats.Tests
{
using Cthangover.Core.Mods.Testing;
/// <summary>
/// The facade's "who" rules and the opt-in of the battle bridge: a character with no
/// stats data of any kind is left alone, one that gains a bonus takes part.
/// </summary>
public class StatsServiceTests
{
private const string Nobody = "test_nobody_has_stats";
[ModTest("service/who_resolves")]
static void WhoResolves(ModTestContext ctx)
{
ModAssert.Equal(Nobody, StatsService.Resolve(" " + Nobody + " "), "an id is taken as written, trimmed");
ModAssert.NotNull(StatsService.Resolve("player"), "player always resolves to someone");
ModAssert.Equal(StatsService.Resolve(null), StatsService.Resolve("PLAYER"), "player in any case is the default");
ModAssert.True(StatsService.Targets("party").Count >= 1, "party is the roster, or the player alone");
ModAssert.Equal(1, StatsService.Targets(Nobody).Count, "an id is one target");
}
[ModTest("service/participation_is_opt_in")]
static void ParticipationIsOptIn(ModTestContext ctx)
{
var saved = StatState.Instance.Data;
try
{
StatState.Instance.Data = new StatStateData();
ModAssert.False(StatsService.Participates(Nobody), "no archetype, no stats data, no bonus, no gear: not touched");
StatsService.AddBonus(Nobody, "str", 1);
ModAssert.True(StatsService.Participates(Nobody), "a bonus gained during play opts in");
ModAssert.CloseTo(1, StatsService.Sheet(Nobody).GetBase("str") - (StatsService.Definition("str")?.Default ?? 0), what: "and shows on the sheet");
StatsService.SetArchetype(Nobody, "some_archetype");
ModAssert.Equal("some_archetype", StatsService.ArchetypeOf(Nobody), "a chosen archetype wins over the template's");
}
finally
{
StatState.Instance.Data = saved;
StatsService.ClearCache();
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
namespace Cthangover.Mods.Stats.Tests
{
using System;
using System.Collections.Generic;
using Cthangover.Mods.Stats.Formulas;
/// <summary>A formula scope over a plain dictionary and a seeded generator, for the tests.</summary>
public class TestScope : IFormulaScope
{
private readonly Dictionary<string, double> values;
public TestScope(Dictionary<string, double> values, int seed = 7)
{
this.values = new Dictionary<string, double>(values ?? new Dictionary<string, double>(), StringComparer.OrdinalIgnoreCase);
Random = new Random(seed);
}
public bool TryGet(string name, out double value) => values.TryGetValue(name, out value);
public Random Random { get; }
}
}