diff --git a/docs/en.md b/docs/en.md index d3d87e2..22a3fe7 100644 --- a/docs/en.md +++ b/docs/en.md @@ -97,3 +97,7 @@ Scenarios of all mods are collected together. A mod adds a story to someone else 2. **The line is shown in the wrong language.** The `key=` is missing from the mod's `locale` files for that language; the quoted text is shown instead. 3. **An answer leads nowhere.** The label after `->` must exist in the same file, written with a colon (`:bar`). 4. **An `action` does nothing.** The mod that owns the action is not part of the game (the log says the action "is not registered"), or a parameter name is wrong — compare it with the action in the scenario editor of Editor Tools. + +## Shared expressions + +Numeric expressions are supplied by `expressions_base`. Use `set money=rnd(3,5)` or `cset total "1+2+${money}"`; reuse `${money}` in localized text and action parameters. `set name value` still stores a literal string. See the [Expression Engine guide](../../expressions_base/docs/en.md) for syntax, strict assignments and mod-defined strategies. diff --git a/docs/ru.md b/docs/ru.md index 3a9f884..fc6d347 100644 --- a/docs/ru.md +++ b/docs/ru.md @@ -97,3 +97,7 @@ save_allowed: true 2. **Реплика показывается не на том языке.** Ключа `key=` нет в файлах `locale` мода для этого языка, и вместо перевода показывается текст в кавычках. 3. **Ответ ведёт в никуда.** Метка после `->` должна существовать в том же файле и записываться с двоеточием (`:bar`). 4. **`action` ничего не делает.** Мод — владелец действия не входит в игру (в логе написано, что действие "is not registered"), или имя параметра неверное — сверьте его с действием в редакторе сценариев Editor Tools. + +## Общие выражения + +Вычисления предоставляет `expressions_base`. `set money=rnd(3,5)` выбирает целое число; `cset total "1+2+${money}"` вычисляет выражение. `${money}` используется в локализованном тексте и параметрах действий. `set имя значение` сохраняет строку как раньше. Синтаксис и расширение стратегиями описаны в [руководстве движка выражений](../../expressions_base/docs/ru.md). diff --git a/locale/schema_en.properties b/locale/schema_en.properties index b83dc49..a3e8bed 100644 --- a/locale/schema_en.properties +++ b/locale/schema_en.properties @@ -27,3 +27,9 @@ dialog/schema/builtin/option = Answer dialog/schema/builtin/option_desc = An answer and the label it leads to; with a condition it is shown only while it holds dialog/schema/field/option_if = Show if dialog/schema/builtin/option_if_desc = For example archetype(rogue) or "stat(cha) >= 5"; empty — always +dialog/schema/builtin/set = Set variable +dialog/schema/builtin/set_desc = Store a literal string (name value); name=expression calculates a numeric value. +dialog/schema/builtin/cset = Calculate variable +dialog/schema/builtin/cset_desc = Calculate a numeric expression at execution; also accepts name=expression. +dialog/schema/field/variable = Variable +dialog/schema/field/expression = Numeric expression diff --git a/locale/schema_es.properties b/locale/schema_es.properties index 019bb85..678bdca 100644 --- a/locale/schema_es.properties +++ b/locale/schema_es.properties @@ -27,3 +27,9 @@ dialog/schema/builtin/option = Respuesta dialog/schema/builtin/option_desc = Una respuesta y la etiqueta a la que lleva; con condición solo se muestra mientras se cumple dialog/schema/field/option_if = Mostrar si dialog/schema/builtin/option_if_desc = Por ejemplo archetype(rogue) o "stat(cha) >= 5"; vacío: siempre +dialog/schema/builtin/set = Asignar variable +dialog/schema/builtin/set_desc = Guarda texto (nombre valor); nombre=expresión calcula un número. +dialog/schema/builtin/cset = Calcular variable +dialog/schema/builtin/cset_desc = Calcula una expresión numérica al ejecutar; admite nombre=expresión. +dialog/schema/field/variable = Variable +dialog/schema/field/expression = Expresión numérica diff --git a/locale/schema_ja.properties b/locale/schema_ja.properties index 9ad00da..71db9a4 100644 --- a/locale/schema_ja.properties +++ b/locale/schema_ja.properties @@ -27,3 +27,9 @@ dialog/schema/builtin/option = 選択肢 dialog/schema/builtin/option_desc = 答えと行き先のラベル。条件付きなら成り立つ間だけ表示 dialog/schema/field/option_if = 表示条件 dialog/schema/builtin/option_if_desc = 例:archetype(rogue) や "stat(cha) >= 5"。空なら常に +dialog/schema/builtin/set = 変数を設定 +dialog/schema/builtin/set_desc = 文字列を保存(名前 値)。名前=式 は数値を計算します。 +dialog/schema/builtin/cset = 変数を計算 +dialog/schema/builtin/cset_desc = 実行時に数式を計算。名前=式 も使用できます。 +dialog/schema/field/variable = 変数 +dialog/schema/field/expression = 数式 diff --git a/locale/schema_ko.properties b/locale/schema_ko.properties index 6691d3f..a498005 100644 --- a/locale/schema_ko.properties +++ b/locale/schema_ko.properties @@ -27,3 +27,9 @@ dialog/schema/builtin/option = 선택지 dialog/schema/builtin/option_desc = 답과 이어지는 라벨. 조건이 있으면 참일 때만 보입니다 dialog/schema/field/option_if = 표시 조건 dialog/schema/builtin/option_if_desc = 예: archetype(rogue) 또는 "stat(cha) >= 5". 비우면 항상 +dialog/schema/builtin/set = 변수 설정 +dialog/schema/builtin/set_desc = 문자열 저장(이름 값). 이름=수식은 숫자를 계산합니다. +dialog/schema/builtin/cset = 변수 계산 +dialog/schema/builtin/cset_desc = 실행 시 수식을 계산합니다. 이름=수식도 지원합니다. +dialog/schema/field/variable = 변수 +dialog/schema/field/expression = 수식 diff --git a/locale/schema_ru.properties b/locale/schema_ru.properties index 3e6eb41..0345a40 100644 --- a/locale/schema_ru.properties +++ b/locale/schema_ru.properties @@ -27,3 +27,9 @@ dialog/schema/builtin/option = Вариант ответа dialog/schema/builtin/option_desc = Вариант выбора и метка, куда он ведёт; с условием — показывается, только пока оно верно dialog/schema/field/option_if = Показывать, если dialog/schema/builtin/option_if_desc = Например archetype(rogue) или "stat(cha) >= 5"; пусто — всегда +dialog/schema/builtin/set = Задать переменную +dialog/schema/builtin/set_desc = Сохранить строку (имя значение); имя=выражение вычисляет число. +dialog/schema/builtin/cset = Вычислить переменную +dialog/schema/builtin/cset_desc = Вычислить числовое выражение при выполнении; также принимает имя=выражение. +dialog/schema/field/variable = Переменная +dialog/schema/field/expression = Числовое выражение diff --git a/locale/schema_vi.properties b/locale/schema_vi.properties index 8f934c8..af38e71 100644 --- a/locale/schema_vi.properties +++ b/locale/schema_vi.properties @@ -27,3 +27,9 @@ dialog/schema/builtin/option = Đáp án dialog/schema/builtin/option_desc = Một đáp án và nhãn nó dẫn tới; có điều kiện thì chỉ hiện khi điều kiện đúng dialog/schema/field/option_if = Hiện nếu dialog/schema/builtin/option_if_desc = Ví dụ archetype(rogue) hoặc "stat(cha) >= 5"; để trống — luôn hiện +dialog/schema/builtin/set = Gán biến +dialog/schema/builtin/set_desc = Lưu chuỗi (tên giá trị); tên=biểu_thức tính giá trị số. +dialog/schema/builtin/cset = Tính biến +dialog/schema/builtin/cset_desc = Tính biểu thức số khi chạy; cũng hỗ trợ tên=biểu_thức. +dialog/schema/field/variable = Biến +dialog/schema/field/expression = Biểu thức số diff --git a/locale/schema_zh.properties b/locale/schema_zh.properties index 8f1ca15..d25d9d4 100644 --- a/locale/schema_zh.properties +++ b/locale/schema_zh.properties @@ -27,3 +27,9 @@ dialog/schema/builtin/option = 回答选项 dialog/schema/builtin/option_desc = 一个回答及其跳转标签;带条件时仅在条件成立时显示 dialog/schema/field/option_if = 显示条件 dialog/schema/builtin/option_if_desc = 例如 archetype(rogue) 或 "stat(cha) >= 5";留空——总是显示 +dialog/schema/builtin/set = 设置变量 +dialog/schema/builtin/set_desc = 保存字符串(名称 值);名称=表达式 用于计算数值。 +dialog/schema/builtin/cset = 计算变量 +dialog/schema/builtin/cset_desc = 执行时计算数值表达式;也支持 名称=表达式。 +dialog/schema/field/variable = 变量 +dialog/schema/field/expression = 数值表达式 diff --git a/manifest.json b/manifest.json index 71ee6ae..4ca3e37 100644 --- a/manifest.json +++ b/manifest.json @@ -11,11 +11,34 @@ "test_sources": [ "tests/*.cs" ], - "depends": ["shell2d_base>=1", "interactive_base>=1", "effects_base>=1", "time_base>=1", "map_base>=1", "ui_base>=1"], + "depends": [ + "shell2d_base>=1", + "interactive_base>=1", + "effects_base>=1", + "time_base>=1", + "ui_base>=1", + "expressions_base>=1" + ], "ui": [], "test_suites": [ - {"id": "autoplay", "activate": ["--choices=", "--timeout="]}, - {"id": "gdscript", "activate": ["--itest=gdscript"]}, - {"id": "doc_shots", "activate": ["--itest=doc_shots"]} + { + "id": "autoplay", + "activate": [ + "--choices=", + "--timeout=" + ] + }, + { + "id": "gdscript", + "activate": [ + "--itest=gdscript" + ] + }, + { + "id": "doc_shots", + "activate": [ + "--itest=doc_shots" + ] + } ] } diff --git a/mod.jpg b/mod.jpg index f467715..b7b8073 100644 Binary files a/mod.jpg and b/mod.jpg differ diff --git a/schemas/dialog_base.json b/schemas/dialog_base.json index 3da982c..3b0a698 100644 --- a/schemas/dialog_base.json +++ b/schemas/dialog_base.json @@ -159,6 +159,46 @@ "Description": "dialog/schema/builtin/option_if_desc" } ] + }, + { + "Id": "set", + "Name": "dialog/schema/builtin/set", + "Description": "dialog/schema/builtin/set_desc", + "Category": "flow", + "Positional": [ + { + "Key": "name", + "Type": "text", + "Required": true, + "Name": "dialog/schema/field/variable" + }, + { + "Key": "value", + "Type": "text", + "Required": true, + "Name": "dialog/schema/field/value" + } + ] + }, + { + "Id": "cset", + "Name": "dialog/schema/builtin/cset", + "Description": "dialog/schema/builtin/cset_desc", + "Category": "flow", + "Positional": [ + { + "Key": "name", + "Type": "text", + "Required": true, + "Name": "dialog/schema/field/variable" + }, + { + "Key": "value", + "Type": "expression", + "Required": true, + "Name": "dialog/schema/field/expression" + } + ] } ] } diff --git a/src/Actions/ScenarioActionContext.cs b/src/Actions/ScenarioActionContext.cs index 432246a..36c0ddd 100644 --- a/src/Actions/ScenarioActionContext.cs +++ b/src/Actions/ScenarioActionContext.cs @@ -13,7 +13,6 @@ using Cthangover.Core.UI.Executable; using Cthangover.Core.Scenarios; using Cthangover.Core.Scenes; using Cthangover.Core.Interactive; -using Cthangover.Mods.Map; using Cthangover.Mods.Time; namespace Cthangover.Mods.Dialog @@ -70,12 +69,12 @@ namespace Cthangover.Mods.Dialog /// public IInventory Inventory { get; } /// - /// Map zone control. Stateless adapter over the - /// MapZoneService singleton, which owns the actual state; - /// safe to instantiate per-context like the quest service, because the - /// state does not live in the adapter. + /// Map zone control, resolved from the kernel slot + /// that the map_base engine mod + /// fills (the dialog engine does not depend on the map). null + /// in games without the world map, where no map.* action exists. /// - public IMapZoneService Map { get; } = new MapZoneServiceImpl(); + public IMapZoneService Map => MapZoneServiceRegistry.Provider; /// /// Creates a context wired to the given DialogRuntime. The diff --git a/src/DialogExpressionScope.cs b/src/DialogExpressionScope.cs new file mode 100644 index 0000000..94c38e6 --- /dev/null +++ b/src/DialogExpressionScope.cs @@ -0,0 +1,22 @@ +namespace Cthangover.Mods.Dialog +{ + using System; + using System.Globalization; + using Cthangover.Mods.Expressions; + using Cthangover.Mods.Stats.Formulas; + /// Numeric reads from the current dialog only; strings never become executable expression fragments. + public class DialogExpressionScope : IFormulaScope + { + private readonly DialogRuntime runtime; + public DialogExpressionScope(DialogRuntime runtime){this.runtime=runtime;} + public Random Random=>runtime.ExpressionRandom; + public bool TryGet(string name,out double value) + { + var text=runtime.GetVariable(name);value=0; + if(text==null)return false; + if(!double.TryParse(text,NumberStyles.Float,CultureInfo.InvariantCulture,out value)) + throw new ExpressionEvaluationException("Variable '"+name+"' is not numeric."); + return true; + } + } +} diff --git a/src/Scenarios/IScenarioCommandSpec.cs b/src/Scenarios/IScenarioCommandSpec.cs index f476c3d..ad15c77 100644 --- a/src/Scenarios/IScenarioCommandSpec.cs +++ b/src/Scenarios/IScenarioCommandSpec.cs @@ -24,6 +24,15 @@ namespace Cthangover.Mods.Dialog.Scenarios /// public interface IScenarioCommandSpec { + /// Assignment variable names retain their case; ordinary named parameters remain case insensitive. + bool PreserveNamedArgumentCase => false; + + /// Optional command-owned semantic validation, shared by runtime construction and static author tools. + string ValidateArguments(List positional, Dictionary named, string arrowTarget) => null; + + /// Normalize a copy of parsed arguments to the canonical schema form; runtime syntax stays untouched. + void NormalizeArguments(List positional, Dictionary named) { } + /// Minimum number of positional arguments. Default: 0. int MinPositional => 0; diff --git a/src/Scenarios/Impls/ActionCommandStrategy.cs b/src/Scenarios/Impls/ActionCommandStrategy.cs index 9a963cc..3bd25ae 100644 --- a/src/Scenarios/Impls/ActionCommandStrategy.cs +++ b/src/Scenarios/Impls/ActionCommandStrategy.cs @@ -38,7 +38,7 @@ namespace Cthangover.Mods.Dialog.Scenarios foreach (var kv in named) { if (kv.Key is "name" or "key") continue; - dlg.Set(kv.Key, kv.Value); + dlg.Set(kv.Key, kv.Value).UseProcessText = true; } dlg.Action(actionName); diff --git a/src/Scenarios/Impls/CsetCommandStrategy.cs b/src/Scenarios/Impls/CsetCommandStrategy.cs new file mode 100644 index 0000000..e17703c --- /dev/null +++ b/src/Scenarios/Impls/CsetCommandStrategy.cs @@ -0,0 +1,8 @@ +namespace Cthangover.Mods.Dialog.Scenarios +{ + /// Explicit numeric assignment; calculation happens when the queue reaches the action, never while parsing. + public class CsetCommandStrategy : NumericAssignmentCommandStrategy + { + public override string Command => "cset"; + } +} diff --git a/src/Scenarios/Impls/SetCommandStrategy.cs b/src/Scenarios/Impls/SetCommandStrategy.cs index 9da25e7..2a35e19 100644 --- a/src/Scenarios/Impls/SetCommandStrategy.cs +++ b/src/Scenarios/Impls/SetCommandStrategy.cs @@ -1,39 +1,9 @@ -using System.Collections.Generic; -using Cthangover.Core.Localization; -using Cthangover.Mods.Dialog; -using Cthangover.Core.UI; -using Cthangover.Mods.Interface.Base.Lists; -using Cthangover.Core.UI.Dialog; -using Cthangover.Core.UI.Event; -using Cthangover.Core.UI.Executable; -using Cthangover.Core.Scenarios; -using Cthangover.Core.Scenes; -using Cthangover.Core.Interactive; - namespace Cthangover.Mods.Dialog.Scenarios { - /// - /// Handles the set DSL command. Stores an arbitrary key-value pair - /// into the dialog session's variable state via . - /// Values are read back later through ${var} substitution in - /// ptext lines and via - /// in scenario actions. - /// - public class SetCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec + /// Legacy set name value remains literal. The previously unsupported name=expression form calculates a number. + public class SetCommandStrategy : NumericAssignmentCommandStrategy { - /// - public string Command => "set"; - /// - public int MinPositional => 2; - /// - public int MaxPositional => 2; - - /// - public void Execute(DialogQueue dlg, List positional, Dictionary named, string arrowTarget, ILocalizationProvider locale) - { - var name = positional.Count > 0 ? positional[0] : ""; - var value = positional.Count > 1 ? positional[1] : ""; - dlg.Set(name, value); - } + public override string Command => "set"; + protected override bool CalculatePositional => false; } } diff --git a/src/Scenarios/Impls/WhenCommandStrategy.cs b/src/Scenarios/Impls/WhenCommandStrategy.cs index 7c2af52..5938e7b 100644 --- a/src/Scenarios/Impls/WhenCommandStrategy.cs +++ b/src/Scenarios/Impls/WhenCommandStrategy.cs @@ -35,7 +35,8 @@ namespace Cthangover.Mods.Dialog.Scenarios { var condition = string.Join(" ", positional); named.TryGetValue("else", out var otherwise); - dlg.IfGoTo(() => ScenarioCondition.Evaluate(condition), Label(arrowTarget), Label(otherwise)); + var branch = dlg.IfGoTo(null, Label(arrowTarget), Label(otherwise)); + branch.ScopedCondition = scope => ScenarioCondition.Evaluate(condition, scope); } private static string Label(string target) => string.IsNullOrWhiteSpace(target) ? null : target.Trim().TrimStart(':'); diff --git a/src/Scenarios/NumericAssignmentCommandStrategy.cs b/src/Scenarios/NumericAssignmentCommandStrategy.cs new file mode 100644 index 0000000..a6a696b --- /dev/null +++ b/src/Scenarios/NumericAssignmentCommandStrategy.cs @@ -0,0 +1,48 @@ +namespace Cthangover.Mods.Dialog.Scenarios +{ + using System.Collections.Generic; + using Cthangover.Core.Localization; + using Cthangover.Mods.Stats.Formulas; + + /// Shared assignment syntax and validation; each command strategy chooses literal or calculated positional values. + public abstract class NumericAssignmentCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec + { + public abstract string Command { get; } + protected virtual bool CalculatePositional => true; + public int MinArguments => 1; + public int MaxPositional => 2; + public bool PreserveNamedArgumentCase => true; + public string ValidateArguments(List positional, Dictionary named, string arrowTarget) + { + if(!string.IsNullOrEmpty(arrowTarget))return "Assignments do not accept a jump target."; + if(!TryAssignment(positional,named,out var name,out var value))return "Use name value or name=expression, with one assignment per line."; + if(string.IsNullOrWhiteSpace(name))return "Assignment name is empty."; + if((CalculatePositional||named.Count>0)&&!Formula.TryParse(value,out _,out var error))return error; + return null; + } + public void Execute(DialogQueue dialog,List positional,Dictionary named,string arrowTarget,ILocalizationProvider locale) + { + var error=ValidateArguments(positional,named,arrowTarget); + TryAssignment(positional,named,out var name,out var value); + if(error!=null||CalculatePositional||named.Count>0)dialog.Calculate(name,value,error); + else dialog.Set(name,value); + } + public void NormalizeArguments(List positional, Dictionary named) + { + if (positional.Count == 0 && named.Count == 1 && TryAssignment(positional, named, out var name, out var value)) + { + positional.Add(name); + positional.Add(value); + named.Clear(); + } + } + private static bool TryAssignment(List positional,Dictionary named,out string name,out string value) + { + name=value=null; + if(positional.Count==2&&named.Count==0){name=positional[0];value=positional[1];return true;} + if(positional.Count==0&&named.Count==1) + foreach(var pair in named){name=pair.Key;value=pair.Value;return true;} + return false; + } + } +} diff --git a/src/Scenarios/ScenarioParser.cs b/src/Scenarios/ScenarioParser.cs index 0a419d7..14a7912 100644 --- a/src/Scenarios/ScenarioParser.cs +++ b/src/Scenarios/ScenarioParser.cs @@ -144,6 +144,7 @@ namespace Cthangover.Mods.Dialog.Scenarios return null; var cmd = tokens[0].ToLowerInvariant(); + var spec = ScenarioCommandStrategyFactory.Get(cmd) as IScenarioCommandSpec; for (var i = 1; i < tokens.Count; i++) { var t = tokens[i]; @@ -163,7 +164,7 @@ namespace Cthangover.Mods.Dialog.Scenarios var name = t.Substring(0, eqIdx); if (IsValidIdent(name)) { - named[name.ToLowerInvariant()] = t.Substring(eqIdx + 1); + named[spec?.PreserveNamedArgumentCase == true ? name : name.ToLowerInvariant()] = t.Substring(eqIdx + 1); continue; } } @@ -242,39 +243,34 @@ namespace Cthangover.Mods.Dialog.Scenarios private static List Tokenize(string line) { - var result = new List(); - var i = 0; - - while (i < line.Length) + var tokens=new List(); + var i=0; + while(iEvaluate once at the queue position; dependent actions are aborted on invalid input instead of seeing a stale result. + public class ActionCalculate : ActionCommand + { + public string Name {get;set;} + public string Expression {get;set;} + public string ArgumentError {get;set;} + public override WaitType WaitType {get;set;}=WaitType.NoWait; + public override void DoRun(DialogRuntime runtime) + { + try + { + if(ArgumentError!=null)throw new ArgumentException(ArgumentError); + var value=Formula.Parse(Expression).EvaluateStrict(new DialogExpressionScope(runtime)); + runtime.SetVariable(Name,value.ToString("G17",CultureInfo.InvariantCulture)); + } + catch(Exception ex){runtime.FailExpression($"Assignment '{Name}': {ex.Message}",SourceLine);} + } + } +} diff --git a/src/UI/Dialog/Action/Impls/ActionIf.cs b/src/UI/Dialog/Action/Impls/ActionIf.cs index 0ed9e1d..c3c37fb 100644 --- a/src/UI/Dialog/Action/Impls/ActionIf.cs +++ b/src/UI/Dialog/Action/Impls/ActionIf.cs @@ -23,6 +23,13 @@ namespace Cthangover.Mods.Dialog.Action.Impls { /// Delegate evaluated at runtime. Must return true/false to select the branch target. public Func Condition { get; set; } + + /// + /// Used instead of when set: evaluated with the instance scope of the + /// running dialog (), so when "self.open == 1" reads the flag of + /// the object that started the dialog, even when the line was parsed into another queue first. + /// + public Func ScopedCondition { get; set; } /// Target action ID if returns true. Empty or null causes fall-through. public string TrueGoTo { get; set; } /// Target action ID if returns false. Empty or null causes fall-through. @@ -34,13 +41,13 @@ namespace Cthangover.Mods.Dialog.Action.Impls /// Evaluates and jumps to or ; an empty target falls through to the next action. public override void DoRun(DialogRuntime runtime) { - if (Condition == null) + if (Condition == null && ScopedCondition == null) { GameLogger.Log("DIALOG", "ActionIf: Condition delegate is null", LogLevel.Error); return; } - var result = Condition(); + var result = ScopedCondition != null ? ScopedCondition(runtime?.Dialog?.Scope) : Condition(); var goTo = result ? TrueGoTo : FalseGoTo; if (!string.IsNullOrEmpty(goTo)) diff --git a/src/UI/Dialog/Action/Impls/ActionSelect.cs b/src/UI/Dialog/Action/Impls/ActionSelect.cs index a669055..a65a0c3 100644 --- a/src/UI/Dialog/Action/Impls/ActionSelect.cs +++ b/src/UI/Dialog/Action/Impls/ActionSelect.cs @@ -48,7 +48,8 @@ namespace Cthangover.Mods.Dialog.Action.Impls if (dialogBox == null) return; - var shown = Variants.Where(v => v.IsShown()).ToList(); + var scope = runtime.Dialog?.Scope; + var shown = Variants.Where(v => v.IsShown(scope)).ToList(); WaitType = shown.Count == 0 ? WaitType.NoWait : WaitType.WaitSelect; if (shown.Count == 0) { diff --git a/src/UI/Dialog/Action/Impls/ActionSet.cs b/src/UI/Dialog/Action/Impls/ActionSet.cs index ed0a5c6..3f43732 100644 --- a/src/UI/Dialog/Action/Impls/ActionSet.cs +++ b/src/UI/Dialog/Action/Impls/ActionSet.cs @@ -25,6 +25,8 @@ namespace Cthangover.Mods.Dialog.Action.Impls public string Value { get; set; } /// Lazy evaluation delegate. When non-null, invoked at runtime to compute the value dynamically. public Func Callback { get; set; } + /// Only action parameter templates expand references; ordinary set literals retain their historical behavior. + public bool UseProcessText { get; set; } /// Variable assignment is instant — the dialog continues without pause. public override WaitType WaitType { get; set; } = WaitType.NoWait; @@ -33,6 +35,12 @@ namespace Cthangover.Mods.Dialog.Action.Impls public override void DoRun(DialogRuntime runtime) { var val = Callback != null ? Callback() : Value; + if (UseProcessText) + { + try { val = runtime.ProcessParameter(val); } + catch (Cthangover.Mods.Expressions.ExpressionEvaluationException ex) + { runtime.FailExpression(ex.Message, SourceLine); return; } + } runtime.SetVariable(Name, val ?? string.Empty); } } diff --git a/src/UI/Dialog/Action/Impls/ActionSetFlag.cs b/src/UI/Dialog/Action/Impls/ActionSetFlag.cs index 1ae97fa..41198dd 100644 --- a/src/UI/Dialog/Action/Impls/ActionSetFlag.cs +++ b/src/UI/Dialog/Action/Impls/ActionSetFlag.cs @@ -8,6 +8,8 @@ namespace Cthangover.Mods.Dialog.Action.Impls /// flag is game state: it is saved, read by scenario conditions /// (condition: door == "open") and its change rescans the scene's /// event chain, so a scenario waiting for it can start right after. + /// flag self.open 1 writes the flag of the dialog's instance scope + /// (), e.g. rpg_village/supply_1:open. /// public class ActionSetFlag : ActionCommand { @@ -20,6 +22,11 @@ namespace Cthangover.Mods.Dialog.Action.Impls public override WaitType WaitType { get; set; } = WaitType.NoWait; /// - public override void DoRun(DialogRuntime runtime) => ScenarioCondition.SetFlag(Name, Value ?? string.Empty); + public override void DoRun(DialogRuntime runtime) + { + var key = ScenarioCondition.FlagKey(Name, runtime?.Dialog?.Scope); + if (key != null) + ScenarioCondition.SetFlag(key, Value ?? string.Empty); + } } } diff --git a/src/UI/Dialog/Action/Impls/SelectVariant.cs b/src/UI/Dialog/Action/Impls/SelectVariant.cs index ec7cbcc..c5870e8 100644 --- a/src/UI/Dialog/Action/Impls/SelectVariant.cs +++ b/src/UI/Dialog/Action/Impls/SelectVariant.cs @@ -30,8 +30,8 @@ namespace Cthangover.Mods.Dialog.Action.Impls /// public string Condition { get; set; } - /// Whether the option is offered now. - public bool IsShown() => string.IsNullOrWhiteSpace(Condition) || ScenarioCondition.Evaluate(Condition); + /// Whether the option is offered now; is the dialog's instance scope (, may be null). + public bool IsShown(string scope) => string.IsNullOrWhiteSpace(Condition) || ScenarioCondition.Evaluate(Condition, scope); /// Generates a new unique action ID for use as a GoTo target (e.g. with ). public static string Point => Guid.NewGuid().ToString(); diff --git a/src/UI/Dialog/DialogBox.cs b/src/UI/Dialog/DialogBox.cs index eeb9346..ad460c4 100644 --- a/src/UI/Dialog/DialogBox.cs +++ b/src/UI/Dialog/DialogBox.cs @@ -18,6 +18,7 @@ using Cthangover.Core.Interactive; namespace Cthangover.Mods.Dialog { + using Cthangover.Core.Events; /// /// The visible dialog UI: dual avatars with shader-based hide-color mode, /// answer box spawning, and click/keyboard advance. Validates that no existing @@ -47,6 +48,7 @@ namespace Cthangover.Mods.Dialog protected override void OnReady() { MouseFilter = MouseFilterEnum.Ignore; + ModEventBus.Subscribe(OnSceneExited); if (Body == null) { @@ -373,6 +375,26 @@ namespace Cthangover.Mods.Dialog Locker = null; } + /// Detaches the scene-exit hook when the box leaves the tree. + protected override void OnExitTree() + { + ModEventBus.Unsubscribe(OnSceneExited); + } + + /// + /// Leaving a logical scene ends a dialog that is still running in it. The box belongs to the shell and + /// survives scene switches, so nothing else would release its locker: a switch in the middle of a dialog + /// would keep the box busy, and the next scene's events and the player's walking would wait for it. The + /// dialog is aborted, not finished: its end actions and do not run. + /// + private void OnSceneExited(SceneExitedEvent evt) + { + if (!IsRunning) + return; + GameLogger.Log("DIALOG", $"scene '{evt.SceneName}' left during a dialog ('{Locker?.ID ?? "click dialog"}'): dialog aborted", LogLevel.Warning); + Abort(); + } + /// /// Whether a dialog occupies the box: one started by an event (Locker is not null) or a locker-less queue /// that has not ended yet (an interactive's click dialog runs without a locker). Event chains and the click diff --git a/src/UI/Dialog/DialogQueue.cs b/src/UI/Dialog/DialogQueue.cs index c69e02c..af47ed4 100644 --- a/src/UI/Dialog/DialogQueue.cs +++ b/src/UI/Dialog/DialogQueue.cs @@ -33,6 +33,15 @@ namespace Cthangover.Mods.Dialog /// The ordered sequence of dialog actions. Appended via fluent builder methods; consumed by . public List Queue { get; } = new(); + /// + /// The instance scope of this dialog — the placement id of the object whose interaction runs it — + /// or null. Inside it self.<name> in flag, when and option conditions means + /// the story flag <scope>:<name> (). Set by + /// whoever starts the dialog (the RPG runtime) before it runs; a queue with no scope rejects + /// self. with an ERROR instead of writing a global flag. + /// + public string Scope { get; set; } + /// Spawns a visual effect identified by through . Returns the action for further configuration. public ActionEffect Effect(string effectID) { @@ -130,6 +139,14 @@ namespace Cthangover.Mods.Dialog return action; } + /// Enqueues a strict numeric assignment; expressions and random functions are evaluated at execution. + public ActionCalculate Calculate(string name, string expression, string argumentError = null) + { + var action = new ActionCalculate { Name = name, Expression = expression, ArgumentError = argumentError }; + Queue.Add(action); + return action; + } + /// Sets a story flag of the game (saved, read by scenario conditions) when the queue reaches this point. public ActionSetFlag Flag(string name, string value) { diff --git a/src/UI/Dialog/DialogRuntime.cs b/src/UI/Dialog/DialogRuntime.cs index 0910c8f..42855d0 100644 --- a/src/UI/Dialog/DialogRuntime.cs +++ b/src/UI/Dialog/DialogRuntime.cs @@ -81,6 +81,35 @@ namespace Cthangover.Mods.Dialog /// Queue index of . public int Index => index; + /// Inject a seeded generator for reproducible scripts and tests; expressions never roll while parsing. + public System.Random ExpressionRandom { get; set; } = System.Random.Shared; + public string LastExpressionError { get; private set; } + + /// Abort a malformed calculation without completing a one-run scenario or running later reward actions. + public void FailExpression(string message, int sourceLine = 0) + { + var scenario = DialogBox?.Locker as Cthangover.Mods.Dialog.Executable.ScenarioEvent; + var origin = scenario?.SourcePath ?? DialogBox?.Locker?.ID ?? ""; + LastExpressionError = origin + ":" + sourceLine + ": " + message; + GameLogger.Log("DIALOG", LastExpressionError, LogLevel.Error); + if (DialogBox != null) DialogBox.Abort(); else Abort(); + } + + /// Resolve action parameters at execution, preserving string values and rejecting undefined references. + public string ProcessParameter(string text) + { + if (text == null) return null; + var names = text.GetIncludesInQuotes("${", "}"); + if (names != null) + foreach (var name in names) + { + if (!variables.TryGetValue(name, out var value)) + throw new Cthangover.Mods.Expressions.ExpressionEvaluationException("Undefined action parameter variable: " + name); + text = text.Replace("${" + name + "}", value); + } + return text; + } + /// Current ${name} variables of the dialog, for inspection. public IReadOnlyDictionary Variables => (IReadOnlyDictionary)variables; @@ -153,6 +182,7 @@ namespace Cthangover.Mods.Dialog generation++; variables = new Dictionary(); + LastExpressionError = null; DialogBox = dialogBox; IsHeld = false; isLastGoTo = false; diff --git a/test_suites/autoplay/manifest.json b/test_suites/autoplay/manifest.json index 220333f..a33f706 100644 --- a/test_suites/autoplay/manifest.json +++ b/test_suites/autoplay/manifest.json @@ -1,10 +1,8 @@ { "name": "Test Runner", - "description": "Test harness mod: ports the integration DialogAutoDriver into an engine mod. Activated by --choices=/--timeout= user args; auto-plays the active dialog, prints [TEST_RESULT] and quits. Depends on cthangover_game and dialog_base.", + "description": "Test harness mod: ports the integration DialogAutoDriver into an engine mod. Activated by --choices=/--timeout= user args; auto-plays the active dialog, prints [TEST_RESULT] and quits. Depends only on its host dialog_base, so a game run (--game= --timeout=) keeps that game's own mod set.", "sources": [ "src/*.cs" ], - "depends": [ - "cthangover_game>=1" - ] + "depends": [] } diff --git a/tests/DialogBoxTests.cs b/tests/DialogBoxTests.cs index 5dea2e6..ef13de5 100644 --- a/tests/DialogBoxTests.cs +++ b/tests/DialogBoxTests.cs @@ -2,6 +2,7 @@ namespace Cthangover.Mods.Dialog.Tests { using System.Collections.Generic; using System.Threading.Tasks; + using Cthangover.Core.Events; using Cthangover.Core.Mods.Testing; using Cthangover.Core.Settings; using Cthangover.Mods.Dialog.Action.Impls; @@ -49,6 +50,30 @@ namespace Cthangover.Mods.Dialog.Tests } } + [ModTest("box/scene_exit_ends_running_dialog", TimeoutSeconds = 10)] + static async Task SceneExitEndsRunningDialog(ModTestContext ctx) + { + var stage = await DialogStage.Create(); + try + { + var running = new DialogQueue(); + running.Queue.Add(new ProbeAction()); + ModAssert.True(stage.Box.SetDialogQueueAndRun(running, null, 0, null), "a dialog starts"); + ModAssert.True(stage.Box.IsRunning, "the dialog occupies the box"); + + ModEventBus.Publish(new SceneExitedEvent { SceneName = "dialog_base_test_scene" }); + ModAssert.False(stage.Box.IsRunning, "leaving the scene releases the box"); + + var next = new DialogQueue(); + next.Queue.Add(new ProbeAction()); + ModAssert.True(stage.Box.SetDialogQueueAndRun(next, null, 0, null), "the next scene's dialog starts on the freed box"); + } + finally + { + stage.Free(); + } + } + [ModTest("box/held_delay_is_not_skipped", TimeoutSeconds = 10)] static async Task HeldDelayIsNotSkipped(ModTestContext ctx) { diff --git a/tests/InstanceScopeTests.cs b/tests/InstanceScopeTests.cs new file mode 100644 index 0000000..c35826f --- /dev/null +++ b/tests/InstanceScopeTests.cs @@ -0,0 +1,107 @@ +namespace Cthangover.Mods.Dialog.Tests +{ + using System.Collections.Generic; + using System.Text.Json; + using System.Threading.Tasks; + using Cthangover.Core.Mods.Testing; + using Cthangover.Core.Scenes; + using Cthangover.Core.Settings; + using Cthangover.Core.Utils; + using Cthangover.Mods.Dialog.Scenarios; + + /// + /// self. in a dialog (contract rpg-model-v2.md 5.5): the queue's + /// — the placement that started it — turns self.open into the + /// story flag <placement>:open in flag, when and option conditions. Two + /// instances of one object keep their own state, which is an ordinary saved flag. + /// + public class InstanceScopeTests + { + private const string First = "test_scope_map/barrel_1"; + private const string Second = "test_scope_map/barrel_2"; + + /// The shared scenario of both barrels: open once, then say so. + private const string Barrel = "when \"self.open == 1\" -> :opened\nflag self.open 1\nset outcome opened_now\nend\n:opened\nset outcome already_open\nend\n"; + + [ModTest("scope/two_instances_are_independent_and_survive_a_save", TimeoutSeconds = 10)] + static async Task TwoInstancesAreIndependentAndSurviveASave(ModTestContext ctx) + { + var stage = await DialogStage.Create(); + var runtime = GameData.Instance.Runtime; + var savedFlags = runtime.Flags; + runtime.Flags = new Dictionary(); + try + { + ModAssert.Equal("opened_now", Play(stage, Barrel, First), "the first barrel opens"); + ModAssert.Equal("already_open", Play(stage, Barrel, First), "the first barrel remembers it"); + ModAssert.Equal("opened_now", Play(stage, Barrel, Second), "the second barrel is still closed"); + ModAssert.Equal("1", runtime.Flags.TryGetValue(First + ":open", out var value) ? value : null, "the flag has the placement's full name"); + ModAssert.Equal(2, runtime.Flags.Count, "no global 'open' flag is written"); + ModAssert.True(ScenarioCondition.Evaluate(First + ":open == 1"), "other scenarios read it by its full name"); + + var slot = RuntimeSerializer.BuildCoreSlot(runtime, "1.0.0", "test", new HashSet()); + var json = JsonSerializer.Serialize(slot, JsonOptionsProvider.WriteCompactOptions); + runtime.Flags = new Dictionary(); + ModAssert.False(ScenarioCondition.Evaluate("self.open == 1", First), "state cleared before the load"); + RuntimeSerializer.RestoreCoreState(JsonSerializer.Deserialize(json, JsonOptionsProvider.ReadOptions), runtime); + + ModAssert.True(ScenarioCondition.Evaluate("self.open == 1", First), "the first barrel is open after the load"); + ModAssert.True(ScenarioCondition.Evaluate("self.open == 1", Second), "the second barrel is open after the load"); + ModAssert.Equal("already_open", Play(stage, Barrel, Second), "the loaded state drives the dialog"); + } + finally + { + runtime.Flags = savedFlags; + stage.Free(); + } + } + + [ModTest("scope/option_conditions_read_the_instance", TimeoutSeconds = 10)] + static async Task OptionConditionsReadTheInstance(ModTestContext ctx) + { + var stage = await DialogStage.Create(); + var runtime = GameData.Instance.Runtime; + var savedFlags = runtime.Flags; + runtime.Flags = new Dictionary { [First + ":open"] = "1" }; + try + { + var dlg = ScenarioParser.Parse("select \"?\"\noption \"Close\" \"if=self.open == 1\" -> :a\noption \"Open\" \"if=self.open != 1\" -> :b\noption \"Leave\" -> :c\n:a\nend\n:b\nend\n:c\nend\n"); + dlg.Scope = First; + ModAssert.True(stage.Box.SetDialogQueueAndRun(dlg, null, 0, null), "dialog starts"); + ModAssert.Equal(2, stage.Box.AnswerVariantCount, "only the option of the open instance and the plain one"); + } + finally + { + runtime.Flags = savedFlags; + stage.Free(); + } + } + + [ModTest("scope/self_without_a_scope_writes_nothing", TimeoutSeconds = 10)] + static async Task SelfWithoutAScopeWritesNothing(ModTestContext ctx) + { + var stage = await DialogStage.Create(); + var runtime = GameData.Instance.Runtime; + var savedFlags = runtime.Flags; + runtime.Flags = new Dictionary(); + try + { + ModAssert.Equal("opened_now", Play(stage, Barrel, null), "the dialog still runs to its end"); + ModAssert.Equal(0, runtime.Flags.Count, "self. outside a scope is never a global flag"); + } + finally + { + runtime.Flags = savedFlags; + stage.Free(); + } + } + + private static string Play(DialogStage stage, string scenario, string scope) + { + var dlg = new ScenarioConverter().Convert(scenario); + dlg.Scope = scope; + ModAssert.True(stage.Box.SetDialogQueueAndRun(dlg, null, 0, null), "dialog starts"); + return stage.Box.Runtime.GetVariable("outcome"); + } + } +} diff --git a/tests/NumericAssignmentTests.cs b/tests/NumericAssignmentTests.cs new file mode 100644 index 0000000..898e423 --- /dev/null +++ b/tests/NumericAssignmentTests.cs @@ -0,0 +1,107 @@ +namespace Cthangover.Mods.Dialog.Tests +{ + using System.Collections.Generic; + using System.Globalization; + using System.Threading.Tasks; + using Cthangover.Core.Factories.Impls; + using Cthangover.Core.Mods.Testing; + using Cthangover.Mods.Dialog.Scenarios; + using Godot; + + /// Old string scripts, calculated assignments and action arguments share the existing DSL strategy pipeline. + public class NumericAssignmentTests + { + [ModTest("numeric_assignment/literals_case_and_assignment_syntax")] + static void Assignments(ModTestContext ctx) + { + var runtime=Run("set greeting Hello\nset literal 1+2\nset Value 7\ncset total \"1 + 2 + ${Value}\"\nset Money=rnd(3,3)\ncset quoted=\"${total} * 2\"\nend\n"); + ModAssert.Equal("Hello",runtime.GetVariable("greeting"),"old strings stay strings"); + ModAssert.Equal("1+2",runtime.GetVariable("literal"),"set does not guess expressions"); + ModAssert.Equal("10",runtime.GetVariable("total"),"earlier numeric string is read at runtime"); + ModAssert.Equal("3",runtime.GetVariable("Money"),"assigned variable keeps its case"); + ModAssert.Equal("20",runtime.GetVariable("quoted"),"quoted assignment expression retains spaces"); + ModAssert.Null(runtime.LastExpressionError,"valid assignments have no errors"); + } + [ModTest("numeric_assignment/errors_abort_dependents_without_overwriting")] + static void Errors(ModTestContext ctx) + { + foreach(var expression in new[]{"1/0","${missing}+1","rnd(5,3)","max()"}) + { + var runtime=Run("set amount 9\ncset amount \""+expression+"\"\nset after wrong\nend\n"); + ModAssert.Equal("9",runtime.GetVariable("amount"),"bad assignment preserves the previous value for inspection"); + ModAssert.Null(runtime.GetVariable("after"),"dependent actions never run after a bad calculation"); + ModAssert.True(runtime.LastExpressionError?.Contains(":2:")==true,"error reports the source line"); + } + var text=Run("set value abc\ncset result ${value}+1\nset after wrong\nend\n"); + ModAssert.True(text.LastExpressionError?.Contains("not numeric")==true,"non-numeric variables are diagnosed"); + } + [ModTest("numeric_assignment/invariant_format_and_json_tokens")] + static void Formatting(ModTestContext ctx) + { + var culture=CultureInfo.CurrentCulture; + try + { + // A hand-built comma culture: ru-RU/es-ES cannot be created where ICU is missing + // (invariant globalization), and the rule must hold for any culture anyway. + var comma=(CultureInfo)CultureInfo.InvariantCulture.Clone(); + comma.NumberFormat.NumberDecimalSeparator=","; + comma.NumberFormat.NumberGroupSeparator=" "; + CultureInfo.CurrentCulture=comma; + ModAssert.Equal("2.5",Run("cset amount 5/2\nend\n").GetVariable("amount"),"stored numeric syntax does not depend on UI culture"); + } + finally{CultureInfo.CurrentCulture=culture;} + var positional=new List();var named=new Dictionary(); + ScenarioParser.SplitLine("light_set [{\"x\":0.2,\"color\":\"#fff\"}]",positional,named,out _); + ModAssert.Equal("[{\"x\":0.2,\"color\":\"#fff\"}]",positional[0],"JSON quotes are not stripped by assignment quoting"); + ModAssert.Equal(0,named.Count,"JSON never becomes a named argument"); + } + [ModTest("numeric_assignment/command_owned_validation")] + static void Validation(ModTestContext ctx) + { + IScenarioCommandSpec spec=new CsetCommandStrategy(); + ModAssert.NotNull(spec.ValidateArguments(new(){"amount"},new(),""),"missing operand diagnosed"); + ModAssert.NotNull(spec.ValidateArguments(new(){"amount","rnd(1)"},new(),""),"function arity checked without rolling"); + ModAssert.Null(spec.ValidateArguments(new(),new(){["Money"]="rnd(3,5)"},""),"assignment form recognized by strategy contract"); + var positional=new List();var named=new Dictionary{["Money"]="rnd(3,5)"}; + spec.NormalizeArguments(positional,named); + ModAssert.Equal("Money",positional[0],"schema normalization preserves case"); + ModAssert.Equal("rnd(3,5)",positional[1],"schema receives the unevaluated expression"); + ModAssert.Equal(0,named.Count,"normalized arguments are canonical"); + } + [ModTest("numeric_assignment/localized_text_and_action_share_one_value",TimeoutSeconds=10)] + static async Task TextAndAction(ModTestContext ctx) + { + var stage=await DialogStage.Create();var capture=new NumericCaptureAction(); + ScenarioActionFactory.Instance.Register(capture); + try + { + var dialog=ScenarioParser.Parse("cset money=rnd(3,3)\naction dsl_test.capture id=money/coin count=${money}\nptext \"Fallback\" key=mod_dialog_base_name\nend\n",new NumericTextLocale()); + stage.Box.SetDialogQueueAndRun(dialog,null,0,null); + ModAssert.Equal("3",capture.Amount,"action receives a number, never the placeholder"); + ModAssert.Equal("money/coin",capture.Item,"ordinary string parameters still work"); + ModAssert.Equal("Received 3 coins.",stage.Box.GetNode