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:
commit
c6e53dedd3
66 files changed
+3976
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user