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