commit 50be9ad273e9ebe47adc568b6cd5f12bdb9f1989 Author: iisaev Date: Sat Oct 24 10:01:15 2026 +0200 Stat checks: check command with stat roll outcomes and its 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..9e8c0a7 --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,17 @@ +mod_stat_checks_name = Stat Checks +mod_stat_checks_desc = Stat checks in any scenario: “check cha 12 -> :success” rolls a stat plus dice against a difficulty, shows the outcome line and branches the story — talking a guard round in a novel, forcing a door in an RPG. The roll stays in dialog variables for lines of your own. Dice default to the game setting checks.dice, else d20; dice=none makes a plain threshold. +checks/line = [${check_stat}: ${check_total} vs ${check_dc}] ${check_result} +checks/success = Success +checks/failure = Failure +checks/schema/checks/check = Stat check +checks/schema/checks/check_desc = Stat + dice + bonus against a difficulty: a line with the outcome and a jump +checks/schema/checks/arrow_success = On success, go to +checks/schema/field/stat = Stat +checks/schema/field/difficulty = Difficulty +checks/schema/field/who = Who +checks/schema/field/dice = Dice +checks/schema/checks/dice_desc = d20, 2d6, none — without dice the check is a threshold +checks/schema/field/bonus = Bonus +checks/schema/field/else = Else label +checks/schema/field/quiet = Quiet +checks/schema/checks/quiet_desc = Do not show the roll line diff --git a/locale/es.properties b/locale/es.properties new file mode 100644 index 0000000..a661192 --- /dev/null +++ b/locale/es.properties @@ -0,0 +1,17 @@ +mod_stat_checks_name = Tiradas de característica +mod_stat_checks_desc = Tiradas de característica en cualquier escenario: «check cha 12 -> :éxito» tira una estadística más dados contra una dificultad, muestra la línea con el resultado y ramifica la historia: convencer a un guardia en una novela, forzar una puerta en un RPG. La tirada queda en variables del diálogo para tus propias líneas. Los dados por defecto son el ajuste checks.dice, si no d20; dice=none es un umbral simple. +checks/line = [${check_stat}: ${check_total} contra ${check_dc}] ${check_result} +checks/success = Éxito +checks/failure = Fallo +checks/schema/checks/check = Tirada de característica +checks/schema/checks/check_desc = Estadística + dados + bonificación contra una dificultad: una línea con el resultado y un salto +checks/schema/checks/arrow_success = Si hay éxito, ir a +checks/schema/field/stat = Estadística +checks/schema/field/difficulty = Dificultad +checks/schema/field/who = Quién +checks/schema/field/dice = Dados +checks/schema/checks/dice_desc = d20, 2d6, none: sin dados la tirada es un umbral +checks/schema/field/bonus = Bonificación +checks/schema/field/else = Etiqueta si no +checks/schema/field/quiet = Silencioso +checks/schema/checks/quiet_desc = No mostrar la línea de la tirada diff --git a/locale/ja.properties b/locale/ja.properties new file mode 100644 index 0000000..aaf9850 --- /dev/null +++ b/locale/ja.properties @@ -0,0 +1,17 @@ +mod_stat_checks_name = 能力値判定 +mod_stat_checks_desc = どのシナリオでも使える能力値判定:「check cha 12 -> :成功」はステータスとダイスを難易度と比べ、結果の行を出して物語を分岐させる——ノベルで門番を説得し、RPG で扉をこじ開ける。出目は会話変数に残り、自分の行で使える。ダイスの既定はゲーム設定 checks.dice、なければ d20。dice=none なら単純なしきい値。 +checks/line = [${check_stat}:${check_total} 対 ${check_dc}] ${check_result} +checks/success = 成功 +checks/failure = 失敗 +checks/schema/checks/check = 能力値判定 +checks/schema/checks/check_desc = ステータス + ダイス + ボーナス 対 難易度:結果の行とジャンプ +checks/schema/checks/arrow_success = 成功時のラベル +checks/schema/field/stat = ステータス +checks/schema/field/difficulty = 難易度 +checks/schema/field/who = 誰 +checks/schema/field/dice = ダイス +checks/schema/checks/dice_desc = d20、2d6、none——ダイスなしなら判定はしきい値 +checks/schema/field/bonus = ボーナス +checks/schema/field/else = それ以外のラベル +checks/schema/field/quiet = 結果を表示しない +checks/schema/checks/quiet_desc = ダイスの行を表示しない diff --git a/locale/ko.properties b/locale/ko.properties new file mode 100644 index 0000000..cdd02ef --- /dev/null +++ b/locale/ko.properties @@ -0,0 +1,17 @@ +mod_stat_checks_name = 능력치 판정 +mod_stat_checks_desc = 모든 시나리오에서 쓰는 능력치 판정: “check cha 12 -> :성공”은 능력치와 주사위를 난이도와 겨루고, 결과 줄을 보여 주며 이야기를 갈래로 나눕니다 — 노벨에서 경비병 설득, RPG에서 문 부수기. 굴림 결과는 대화 변수에 남아 직접 쓴 줄에 쓸 수 있습니다. 주사위 기본값은 게임 설정 checks.dice, 없으면 d20. dice=none은 단순 기준값. +checks/line = [${check_stat}: ${check_total} 대 ${check_dc}] ${check_result} +checks/success = 성공 +checks/failure = 실패 +checks/schema/checks/check = 능력치 판정 +checks/schema/checks/check_desc = 능력치 + 주사위 + 보너스 대 난이도: 결과 줄과 이동 +checks/schema/checks/arrow_success = 성공 시 이동 +checks/schema/field/stat = 능력치 +checks/schema/field/difficulty = 난이도 +checks/schema/field/who = 누구 +checks/schema/field/dice = 주사위 +checks/schema/checks/dice_desc = d20, 2d6, none — 주사위가 없으면 판정은 기준값 +checks/schema/field/bonus = 보너스 +checks/schema/field/else = 아니면 라벨 +checks/schema/field/quiet = 결과 숨김 +checks/schema/checks/quiet_desc = 굴림 줄을 표시하지 않음 diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..6ac7ed9 --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,17 @@ +mod_stat_checks_name = Проверки характеристик +mod_stat_checks_desc = Проверки характеристик в любом сценарии: «check cha 12 -> :успех» бросает стат плюс кубики против сложности, показывает строку с исходом и ветвит историю — уговорить стражника в новелле, выбить дверь в RPG. Бросок остаётся в переменных диалога для своих строк. Кубики по умолчанию — настройка игры checks.dice, иначе d20; dice=none — простой порог. +checks/line = [${check_stat}: ${check_total} против ${check_dc}] ${check_result} +checks/success = Успех +checks/failure = Провал +checks/schema/checks/check = Проверка характеристики +checks/schema/checks/check_desc = Стат + кубики + бонус против сложности: строка с исходом и переход +checks/schema/checks/arrow_success = При успехе — метка +checks/schema/field/stat = Стат +checks/schema/field/difficulty = Сложность +checks/schema/field/who = Кто +checks/schema/field/dice = Кубики +checks/schema/checks/dice_desc = d20, 2d6, none — без кубиков проверка становится порогом +checks/schema/field/bonus = Бонус +checks/schema/field/else = Иначе — метка +checks/schema/field/quiet = Без строки исхода +checks/schema/checks/quiet_desc = Не показывать строку с броском diff --git a/locale/vi.properties b/locale/vi.properties new file mode 100644 index 0000000..6d114f9 --- /dev/null +++ b/locale/vi.properties @@ -0,0 +1,17 @@ +mod_stat_checks_name = Kiểm tra chỉ số +mod_stat_checks_desc = Kiểm tra chỉ số trong mọi kịch bản: «check cha 12 -> :thành_công» tung chỉ số cộng xúc xắc so với độ khó, hiện dòng kết quả và rẽ nhánh câu chuyện — thuyết phục lính gác trong tiểu thuyết, phá cửa trong RPG. Kết quả tung còn lại trong biến hội thoại cho dòng của riêng bạn. Xúc xắc mặc định theo cài đặt checks.dice, không thì d20; dice=none là ngưỡng đơn giản. +checks/line = [${check_stat}: ${check_total} so với ${check_dc}] ${check_result} +checks/success = Thành công +checks/failure = Thất bại +checks/schema/checks/check = Kiểm tra chỉ số +checks/schema/checks/check_desc = Chỉ số + xúc xắc + thưởng so với độ khó: một dòng kết quả và bước nhảy +checks/schema/checks/arrow_success = Khi thành công, đến +checks/schema/field/stat = Chỉ số +checks/schema/field/difficulty = Độ khó +checks/schema/field/who = Ai +checks/schema/field/dice = Xúc xắc +checks/schema/checks/dice_desc = d20, 2d6, none — không xúc xắc thì kiểm tra là ngưỡng +checks/schema/field/bonus = Thưởng +checks/schema/field/else = Nhãn nếu không +checks/schema/field/quiet = Không hiện kết quả +checks/schema/checks/quiet_desc = Không hiện dòng tung xúc xắc diff --git a/locale/zh.properties b/locale/zh.properties new file mode 100644 index 0000000..ff7c243 --- /dev/null +++ b/locale/zh.properties @@ -0,0 +1,17 @@ +mod_stat_checks_name = 属性检定 +mod_stat_checks_desc = 任何剧本中的属性检定:“check cha 12 -> :成功”用属性加骰子对抗难度,显示结果行并让故事分支——在小说里说服守卫,在 RPG 里撬开门。掷骰结果留在对话变量里,供你自己的台词使用。骰子默认取游戏设置 checks.dice,否则为 d20;dice=none 即简单门槛。 +checks/line = [${check_stat}:${check_total} 对 ${check_dc}] ${check_result} +checks/success = 成功 +checks/failure = 失败 +checks/schema/checks/check = 属性检定 +checks/schema/checks/check_desc = 属性 + 骰子 + 加成对抗难度:显示结果并跳转 +checks/schema/checks/arrow_success = 成功时跳转 +checks/schema/field/stat = 属性 +checks/schema/field/difficulty = 难度 +checks/schema/field/who = 谁 +checks/schema/field/dice = 骰子 +checks/schema/checks/dice_desc = d20、2d6、none——没有骰子时检定就是门槛 +checks/schema/field/bonus = 加成 +checks/schema/field/else = 否则跳转 +checks/schema/field/quiet = 不显示结果 +checks/schema/checks/quiet_desc = 不显示掷骰行 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..766c1eb --- /dev/null +++ b/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "stat_checks", + "name": "Stat Checks", + "version": "1.0.0", + "author": "ct", + "description": "Stat checks for any scenario: `check cha 12 [dice=d20] [bonus=2] [else=label] -> :success` rolls a stat plus dice against a difficulty, shows the outcome line and branches (a novel's persuasion, a tile RPG's locked door). The roll is left in dialog variables (check_total, check_roll, …) for ptext lines. Dice default to the game setting checks.dice, else d20; dice=none makes a plain threshold. The conditional jump `when` and option conditions live in dialog_base.", + "category": "gameplay", + "sources": [ + "src/**/*.cs" + ], + "test_sources": [ + "tests/*.cs" + ], + "depends": [ + "stats_base>=1", + "dialog_base>=1" + ] +} diff --git a/mod.jpg b/mod.jpg new file mode 100644 index 0000000..fd4078e Binary files /dev/null and b/mod.jpg differ diff --git a/schemas/stat_checks.json b/schemas/stat_checks.json new file mode 100644 index 0000000..933f45f --- /dev/null +++ b/schemas/stat_checks.json @@ -0,0 +1,56 @@ +{ + "Commands": [ + { + "Id": "check", + "Name": "checks/schema/checks/check", + "Description": "checks/schema/checks/check_desc", + "Category": "flow", + "Arrow": "checks/schema/checks/arrow_success", + "Positional": [ + { + "Key": "stat", + "Type": "ref", + "Kind": "stat", + "Required": true, + "Name": "checks/schema/field/stat" + }, + { + "Key": "difficulty", + "Type": "int", + "Required": true, + "Name": "checks/schema/field/difficulty" + } + ], + "Params": [ + { + "Key": "who", + "Type": "who", + "Name": "checks/schema/field/who" + }, + { + "Key": "dice", + "Type": "text", + "Default": "d20", + "Name": "checks/schema/field/dice", + "Description": "checks/schema/checks/dice_desc" + }, + { + "Key": "bonus", + "Type": "int", + "Name": "checks/schema/field/bonus" + }, + { + "Key": "else", + "Type": "label", + "Name": "checks/schema/field/else" + }, + { + "Key": "quiet", + "Type": "bool", + "Name": "checks/schema/field/quiet", + "Description": "checks/schema/checks/quiet_desc" + } + ] + } + ] +} diff --git a/src/CheckCommandStrategy.cs b/src/CheckCommandStrategy.cs new file mode 100644 index 0000000..d20ef28 --- /dev/null +++ b/src/CheckCommandStrategy.cs @@ -0,0 +1,95 @@ +namespace Cthangover.Mods.StatChecks +{ + using System.Collections.Generic; + using System.Globalization; + using Cthangover.Core.Games; + using Cthangover.Core.Localization; + using Cthangover.Mods.Dialog; + using Cthangover.Mods.Dialog.Scenarios; + using Cthangover.Mods.Stats; + using Godot; + + /// + /// The DSL command of a stat check: + /// check cha 12 [who=player] [dice=d20] [bonus=2] [else=fail] [quiet=1] -> :success + /// rolls stat + dice + bonus against the difficulty, shows one line with the outcome + /// ("[Charisma 15 / 12] Success") unless quiet, and jumps to the arrow label on + /// success, to else on failure (or goes on with the next line). + /// + /// The same check serves a visual novel ("persuade the guard"), a tile RPG ("force the + /// rusty door") and any other game with stats: the command only knows the stats + /// facade. The dice default is the game's setting checks.dice, else d20; + /// dice=none makes it a plain threshold. After the line the dialog variables + /// check_value, check_roll, check_bonus, check_total, + /// check_dc, check_stat and check_result hold the roll for + /// ptext lines that want to tell it their own way. + /// + public class CheckCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec + { + /// Default line when the game has no checks/line translation. + public const string DefaultLine = "[${check_stat} ${check_total} / ${check_dc}] ${check_result}"; + + /// + public string Command => "check"; + + /// + public int MinPositional => 2; + + /// + public int MaxPositional => 2; + + /// + public IReadOnlyList AllowedNamed => new[] { "who", "dice", "bonus", "else", "quiet" }; + + /// + public string JumpTargetNamedKey => "else"; + + /// + public void Execute(DialogQueue dlg, List positional, Dictionary named, string arrowTarget, ILocalizationProvider locale) + { + named.TryGetValue("who", out var who); + named.TryGetValue("dice", out var dice); + named.TryGetValue("bonus", out var bonus); + named.TryGetValue("else", out var otherwise); + var check = new StatCheck + { + Who = who, + Stat = positional.Count > 0 ? positional[0].ToLowerInvariant() : string.Empty, + Difficulty = Int(positional.Count > 1 ? positional[1] : null), + Dice = dice ?? DefaultDice(), + Bonus = Int(bonus), + }; + + dlg.Run(check.Perform); + dlg.Set("check_stat", () => StatName(check.Stat)); + dlg.Set("check_value", () => check.Value.ToString(CultureInfo.InvariantCulture)); + dlg.Set("check_roll", () => check.Roll.ToString(CultureInfo.InvariantCulture)); + dlg.Set("check_bonus", () => check.Bonus.ToString(CultureInfo.InvariantCulture)); + dlg.Set("check_total", () => check.Total.ToString(CultureInfo.InvariantCulture)); + dlg.Set("check_dc", () => check.Difficulty.ToString(CultureInfo.InvariantCulture)); + dlg.Set("check_result", () => Tr(check.Success ? "checks/success" : "checks/failure")); + if (!named.ContainsKey("quiet")) + dlg.PText(locale?.Get("checks/line") ?? DefaultLine); + dlg.IfGoTo(() => check.Success, Label(arrowTarget), Label(otherwise)); + } + + private static string DefaultDice() + { + var setting = ActiveGame.GetSetting("checks.dice"); + return string.IsNullOrWhiteSpace(setting) ? "d20" : setting; + } + + private static int Int(string text) + => int.TryParse(text?.Trim(), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var value) ? value : 0; + + private static string Label(string target) => string.IsNullOrWhiteSpace(target) ? null : target.Trim().TrimStart(':'); + + private static string StatName(string stat) + { + var key = StatsService.Definition(stat)?.Name; + return string.IsNullOrEmpty(key) ? stat : Tr(key); + } + + private static string Tr(string key) => TranslationServer.Translate(key).ToString(); + } +} diff --git a/src/DiceRoll.cs b/src/DiceRoll.cs new file mode 100644 index 0000000..50be2bb --- /dev/null +++ b/src/DiceRoll.cs @@ -0,0 +1,50 @@ +namespace Cthangover.Mods.StatChecks +{ + using System; + using System.Globalization; + + /// + /// Dice in tabletop notation: d20, 2d6, 1d100; none (or + /// empty, or 0) rolls nothing, which makes a check deterministic — a novel's + /// "you are brave enough, or you are not". A malformed text rolls nothing too, so a + /// typo never breaks a scene; the validator reports it. + /// + public static class DiceRoll + { + /// Whether is valid notation (or a "no dice" word). + public static bool IsValid(string dice) => TryParse(dice, out _, out _); + + /// Rolls : the sum of the faces, 0 for no dice. + public static int Roll(string dice, Random random = null) + { + if (!TryParse(dice, out var count, out var sides) || count == 0) + return 0; + var rng = random ?? Random.Shared; + var sum = 0; + for (var i = 0; i < count; i++) + sum += rng.Next(1, sides + 1); + return sum; + } + + /// Parses NdM; is 0 for "no dice". + public static bool TryParse(string dice, out int count, out int sides) + { + count = 0; + sides = 0; + var text = dice?.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(text) || text is "none" or "0") + return true; + var d = text.IndexOf('d'); + if (d < 0) + return false; + var countText = text.Substring(0, d); + if (countText.Length == 0) + count = 1; + else if (!int.TryParse(countText, NumberStyles.None, CultureInfo.InvariantCulture, out count)) + return false; + if (!int.TryParse(text.Substring(d + 1), NumberStyles.None, CultureInfo.InvariantCulture, out sides)) + return false; + return count is >= 1 and <= 100 && sides >= 2; + } + } +} diff --git a/src/StatCheck.cs b/src/StatCheck.cs new file mode 100644 index 0000000..0353263 --- /dev/null +++ b/src/StatCheck.cs @@ -0,0 +1,53 @@ +namespace Cthangover.Mods.StatChecks +{ + using System; + using Cthangover.Mods.Stats; + + /// + /// One check of a check line: stat value + dice + bonus against a difficulty. + /// The line is parsed once and the check object lives in the dialog queue, so every + /// pass through the line (a goto loop, a replay) rolls again and the dialog + /// variables show the latest roll. + /// + public class StatCheck + { + /// Whose stat (a character id or player). + public string Who { get; init; } + + /// The stat id. + public string Stat { get; init; } + + /// The total needed to succeed. + public int Difficulty { get; init; } + + /// Dice notation (). + public string Dice { get; init; } + + /// A flat bonus (or malus) of this check only. + public int Bonus { get; init; } + + /// The stat value of the last roll. + public int Value { get; private set; } + + /// The dice of the last roll. + public int Roll { get; private set; } + + /// Value + dice + bonus of the last roll. + public int Total { get; private set; } + + /// Whether the last roll reached the difficulty. + public bool Success { get; private set; } + + /// Rolls the check now (reads the stat as it is at this moment). + public void Perform() => Perform(StatsService.Sheet(Who).Rounded(Stat), null); + + /// Rolls with a given stat value and dice generator (tests, previews). + public void Perform(int value, Random random) + { + Value = value; + Roll = DiceRoll.Roll(Dice, random); + Total = Value + Roll + Bonus; + Success = Total >= Difficulty; + } + } +} diff --git a/tests/CheckCommandStrategyTests.cs b/tests/CheckCommandStrategyTests.cs new file mode 100644 index 0000000..b61928d --- /dev/null +++ b/tests/CheckCommandStrategyTests.cs @@ -0,0 +1,56 @@ +namespace Cthangover.Mods.StatChecks.Tests +{ + using Cthangover.Core.Mods.Testing; + using Cthangover.Mods.Dialog; + using Cthangover.Mods.Dialog.Scenarios; + + /// + /// check lines in a real scenario text: the queue rolls when it reaches the line, + /// jumps to the arrow label on success and to else (or the next line) on failure, + /// and leaves the roll in dialog variables. + /// + public class CheckCommandStrategyTests + { + private static DialogRuntime Run(string scenario) + { + var dlg = ScenarioParser.Parse(scenario); + var runtime = new DialogRuntime(); + runtime.SetDialogQueueAndRun(null, dlg.Queue, null); + runtime.Run(); + return runtime; + } + + // A stat no mod defines reads 0 for anyone, so the outcomes below are fixed whatever packs are loaded. + private const string Nobody = "who=test_nobody_has_stats"; + + [ModTest("command/success_jumps_to_the_arrow")] + static void SuccessJumps(ModTestContext ctx) + { + var runtime = Run($"check test_undefined_stat 0 {Nobody} dice=none bonus=3 quiet=1 -> :ok\nset outcome failed\nend\n:ok\nset outcome passed\nend\n"); + + ModAssert.Equal("passed", runtime.GetVariable("outcome"), "jumped to :ok"); + ModAssert.Equal("3", runtime.GetVariable("check_total"), "0 + 0 + 3"); + ModAssert.Equal("0", runtime.GetVariable("check_dc"), "difficulty"); + } + + [ModTest("command/failure_falls_through_or_takes_else")] + static void FailureFallsThroughOrTakesElse(ModTestContext ctx) + { + var through = Run($"check test_undefined_stat 5 {Nobody} dice=none quiet=1 -> :ok\nset outcome failed\nend\n:ok\nset outcome passed\nend\n"); + ModAssert.Equal("failed", through.GetVariable("outcome"), "fell through to the next line"); + + var other = Run($"check test_undefined_stat 5 {Nobody} dice=none quiet=1 else=no -> :ok\nset outcome next\nend\n:ok\nset outcome passed\nend\n:no\nset outcome else\nend\n"); + ModAssert.Equal("else", other.GetVariable("outcome"), "jumped to else"); + } + + [ModTest("command/spec_for_the_validator")] + static void SpecForTheValidator(ModTestContext ctx) + { + var spec = new CheckCommandStrategy(); + ModAssert.Equal(2, spec.MinPositional, "stat and difficulty"); + ModAssert.Equal(2, spec.MaxPositional, "nothing more"); + ModAssert.Equal("else", spec.JumpTargetNamedKey, "else is a label"); + ModAssert.NotNull(ScenarioCommandStrategyFactory.Get("check"), "registered as a DSL command"); + } + } +} diff --git a/tests/DiceRollTests.cs b/tests/DiceRollTests.cs new file mode 100644 index 0000000..37f4540 --- /dev/null +++ b/tests/DiceRollTests.cs @@ -0,0 +1,38 @@ +namespace Cthangover.Mods.StatChecks.Tests +{ + using System; + using Cthangover.Core.Mods.Testing; + + /// Tabletop dice notation: valid forms roll within their range, "no dice" rolls 0, typos roll 0 and are invalid. + public class DiceRollTests + { + [ModTest("dice/notation")] + static void Notation(ModTestContext ctx) + { + ModAssert.True(DiceRoll.TryParse("2d6", out var count, out var sides) && count == 2 && sides == 6, "2d6"); + ModAssert.True(DiceRoll.TryParse("D20", out count, out sides) && count == 1 && sides == 20, "d20 in any case"); + ModAssert.True(DiceRoll.TryParse("none", out count, out _) && count == 0, "none"); + ModAssert.True(DiceRoll.TryParse("", out count, out _) && count == 0, "empty"); + foreach (var bad in new[] { "d1", "2x6", "-1d6", "d", "3d", "1000d6", "1.5d6" }) + ModAssert.False(DiceRoll.IsValid(bad), $"'{bad}' is not dice"); + } + + [ModTest("dice/rolls_stay_in_range")] + static void RollsStayInRange(ModTestContext ctx) + { + var random = new Random(3); + var seenLow = false; + var seenHigh = false; + for (var i = 0; i < 400; i++) + { + var roll = DiceRoll.Roll("2d6", random); + ModAssert.True(roll >= 2 && roll <= 12, $"2d6 gave {roll}"); + seenLow |= roll == 2; + seenHigh |= roll == 12; + } + ModAssert.True(seenLow && seenHigh, "both ends of 2d6 come up in 400 rolls"); + ModAssert.Equal(0, DiceRoll.Roll("none"), "no dice"); + ModAssert.Equal(0, DiceRoll.Roll("oops"), "a typo rolls nothing"); + } + } +} diff --git a/tests/StatCheckTests.cs b/tests/StatCheckTests.cs new file mode 100644 index 0000000..7464204 --- /dev/null +++ b/tests/StatCheckTests.cs @@ -0,0 +1,37 @@ +namespace Cthangover.Mods.StatChecks.Tests +{ + using System; + using Cthangover.Core.Mods.Testing; + + /// A check is value + dice + bonus against the difficulty; reaching it exactly succeeds. + public class StatCheckTests + { + [ModTest("check/total_and_threshold")] + static void TotalAndThreshold(ModTestContext ctx) + { + var check = new StatCheck { Stat = "cha", Difficulty = 12, Dice = "none", Bonus = 2 }; + + check.Perform(10, null); + ModAssert.Equal(12, check.Total, "10 + 0 + 2"); + ModAssert.True(check.Success, "reaching the difficulty exactly succeeds"); + + check.Perform(9, null); + ModAssert.False(check.Success, "one short fails"); + ModAssert.Equal(0, check.Roll, "no dice"); + } + + [ModTest("check/dice_add_to_the_total")] + static void DiceAddToTheTotal(ModTestContext ctx) + { + var check = new StatCheck { Stat = "str", Difficulty = 100, Dice = "1d6", Bonus = -1 }; + var random = new Random(11); + for (var i = 0; i < 50; i++) + { + check.Perform(5, random); + ModAssert.True(check.Roll is >= 1 and <= 6, $"1d6 gave {check.Roll}"); + ModAssert.Equal(5 + check.Roll - 1, check.Total, "value + roll + bonus"); + ModAssert.False(check.Success, "100 is out of reach"); + } + } + } +}