Compare commits

...
3 Commits
Author SHA1 Message Date
cthangover 4720506047 Support concurrent background scenario execution and debugging 2026-11-03 13:53:23 +03:00
cthangover 1894857eff Update dialog_base implementation, assets and documentation 2026-10-31 15:28:50 +03:00
cthangover 2c9e6805a9 Quit test drivers through ApplicationExit and free test resources
Adds tests for freeing the temporary ext_resource sub-scene instance; the
animated foreground tests dispose their frame textures.
2026-10-29 13:21:41 +01:00
52 changed files with 914 additions and 95 deletions

No files matched your search

+4
View File
@@ -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.
+4
View File
@@ -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).
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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 = 数式
+6
View File
@@ -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 = 수식
+6
View File
@@ -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 = Числовое выражение
+6
View File
@@ -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ố
+6
View File
@@ -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 = 数值表达式
+27 -4
View File
@@ -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"
]
}
]
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 207 KiB

+72
View File
@@ -18,6 +18,18 @@
"Key": "key",
"Type": "text",
"Name": "dialog/schema/field/locale_key"
},
{
"Key": "who",
"Type": "text"
},
{
"Key": "duration",
"Type": "number"
},
{
"Key": "cue",
"Type": "text"
}
]
},
@@ -39,6 +51,18 @@
"Key": "key",
"Type": "text",
"Name": "dialog/schema/field/locale_key"
},
{
"Key": "who",
"Type": "text"
},
{
"Key": "duration",
"Type": "number"
},
{
"Key": "cue",
"Type": "text"
}
]
},
@@ -109,6 +133,14 @@
"Key": "else",
"Type": "label",
"Name": "dialog/schema/field/else"
},
{
"Key": "wait",
"Type": "bool"
},
{
"Key": "timeout",
"Type": "number"
}
]
},
@@ -159,6 +191,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"
}
]
}
]
}
+9 -7
View File
@@ -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
@@ -28,10 +27,13 @@ namespace Cthangover.Mods.Dialog
/// it may not exist at the moment of context creation (e.g. before game data
/// is fully loaded).
/// </summary>
internal class ScenarioActionContext : IActionContext
internal class ScenarioActionContext : IActionContext, IHostedScenarioContext
{
private readonly DialogRuntime runtime;
/// <summary>Host services belong to the runtime that invoked this action.</summary>
public IScenarioExecutionHost Host => runtime.Host;
/// <summary>
/// Service accessor for quest operations. Resolved from the kernel
/// quest registry — the implementation (QuestServiceImpl) lives in
@@ -70,12 +72,12 @@ namespace Cthangover.Mods.Dialog
/// </summary>
public IInventory Inventory { get; }
/// <summary>
/// Map zone control. Stateless adapter over the
/// <c>MapZoneService</c> 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
/// <see cref="MapZoneServiceRegistry"/> that the map_base engine mod
/// fills (the dialog engine does not depend on the map). <c>null</c>
/// in games without the world map, where no <c>map.*</c> action exists.
/// </summary>
public IMapZoneService Map { get; } = new MapZoneServiceImpl();
public IMapZoneService Map => MapZoneServiceRegistry.Provider;
/// <summary>
/// Creates a context wired to the given DialogRuntime. The
+21
View File
@@ -0,0 +1,21 @@
namespace Cthangover.Mods.Dialog
{
using System.Collections.Generic;
/// <summary>A detached UI snapshot; no tool retains mutable actions or a gameplay session.</summary>
public sealed class BackgroundRunInfo
{
public string RunId { get; init; }
public string ScenarioId { get; init; }
public int Line { get; init; }
public string Label { get; init; }
public bool Paused { get; init; }
public string Wait { get; init; }
public double Remaining { get; init; }
public string Text { get; init; }
public string Speaker { get; init; }
public string Body { get; init; }
public string Details { get; init; }
public IReadOnlyDictionary<string, string> Variables { get; init; }
public IReadOnlyDictionary<string, string> Roles { get; init; }
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Cthangover.Mods.Dialog
{
/// <summary>Catalog source plus a current, human-inspectable admission decision.</summary>
public sealed class BackgroundScenarioInfo
{
public string Id { get; init; }
public string ModId { get; init; }
public string Path { get; init; }
public string Map { get; init; }
public string Source { get; init; }
public string Body { get; init; }
public string Status { get; init; }
public string Details { get; init; }
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Cthangover.Mods.Dialog
{
/// <summary>Optional background-host capability shared by authoring tools without a dependency on a particular gameplay mod.</summary>
public static class BackgroundScenarios
{
public static IBackgroundScenarioTools Provider { get; set; }
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Cthangover.Mods.Dialog
{
using System.Collections.Generic;
using Godot;
/// <summary>Live authoring seam. Runtime IDs select one execution, scenario IDs select source and breakpoints.</summary>
public interface IBackgroundScenarioTools
{
IReadOnlyList<BackgroundScenarioInfo> Catalog();
IReadOnlyList<BackgroundRunInfo> Executions();
IReadOnlyList<string> Events { get; }
bool WorldPaused { get; set; }
void Observe(Node owner, bool enabled);
bool Validate(string modId, string path, string text, out string error);
bool Reload(out string error);
bool Play(string modId, string path, string text, bool company, out string error);
bool Command(string runId, string command, out string error);
bool HasBreakpoint(string scenarioId, int line);
void ToggleBreakpoint(string scenarioId, int line);
}
}
+22
View File
@@ -0,0 +1,22 @@
namespace Cthangover.Mods.Dialog
{
using System;
using System.Globalization;
using Cthangover.Mods.Expressions;
using Cthangover.Mods.Stats.Formulas;
/// <summary>Numeric reads from the current dialog only; strings never become executable expression fragments.</summary>
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;
}
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Cthangover.Mods.Dialog
{
/// <summary>Optional action-context capability exposing the execution owner without a global current-scenario singleton.</summary>
public interface IHostedScenarioContext
{
IScenarioExecutionHost Host { get; }
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace Cthangover.Mods.Dialog
{
using Cthangover.Mods.Dialog.Action.Impls;
/// <summary>Per-execution presentation and wait services. A host never owns another runtime's mutable actions.</summary>
public interface IScenarioExecutionHost
{
/// <summary>Displays timed speech without occupying the modal DialogBox.</summary>
void ShowText(ActionText action, DialogRuntime runtime);
/// <summary>Starts a host-clock delay; completion advances only this execution.</summary>
void Delay(ActionDelay action, DialogRuntime runtime);
/// <summary>Waits for a live condition or its deadline.</summary>
void WaitCondition(ActionWaitCondition action, DialogRuntime runtime);
/// <summary>Evaluates against this execution's roles and scope.</summary>
bool EvaluateCondition(string condition);
/// <summary>Releases waits and presentation. Aborts do not count as completed scenarios.</summary>
void Finished(bool aborted);
}
}
+9
View File
@@ -24,6 +24,15 @@ namespace Cthangover.Mods.Dialog.Scenarios
/// </summary>
public interface IScenarioCommandSpec
{
/// <summary>Assignment variable names retain their case; ordinary named parameters remain case insensitive.</summary>
bool PreserveNamedArgumentCase => false;
/// <summary>Optional command-owned semantic validation, shared by runtime construction and static author tools.</summary>
string ValidateArguments(List<string> positional, Dictionary<string,string> named, string arrowTarget) => null;
/// <summary>Normalize a copy of parsed arguments to the canonical schema form; runtime syntax stays untouched.</summary>
void NormalizeArguments(List<string> positional, Dictionary<string, string> named) { }
/// <summary>Minimum number of positional arguments. Default: 0.</summary>
int MinPositional => 0;
+1 -1
View File
@@ -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);
@@ -0,0 +1,8 @@
namespace Cthangover.Mods.Dialog.Scenarios
{
/// <summary>Explicit numeric assignment; calculation happens when the queue reaches the action, never while parsing.</summary>
public class CsetCommandStrategy : NumericAssignmentCommandStrategy
{
public override string Command => "cset";
}
}
+12 -1
View File
@@ -29,7 +29,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait" };
public IReadOnlyList<string> AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait", "who", "duration", "cue" };
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
@@ -46,6 +46,17 @@ namespace Cthangover.Mods.Dialog.Scenarios
var hideColor = named.TryGetValue("hide_color", out var hc) && hc == "true";
var action = dlg.PText(textVal, first, second, hideColor);
action.FallbackText = positional.Count > 0 ? positional[0] : "";
action.LocaleKey = key;
action.Speaker = named.TryGetValue("who", out var who) ? who : null;
action.Cue = named.TryGetValue("cue", out var cue) ? cue : null;
if (named.TryGetValue("duration", out var duration))
{
if (!double.TryParse(duration, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds)
|| !double.IsFinite(seconds) || seconds <= 0)
throw new System.FormatException("duration must be a positive finite number");
action.Duration = seconds;
}
if (named.TryGetValue("wait", out var wait) && wait == "false")
action.WaitType = WaitType.NoWait;
+4 -34
View File
@@ -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
{
/// <summary>
/// Handles the <c>set</c> DSL command. Stores an arbitrary key-value pair
/// into the dialog session's variable state via <see cref="DialogQueue.Set"/>.
/// Values are read back later through <c>${var}</c> substitution in
/// <c>ptext</c> lines and via <see cref="Cthangover.Core.Actions.IActionContext.GetParam"/>
/// in scenario actions.
/// </summary>
public class SetCommandStrategy : IScenarioCommandStrategy, IScenarioCommandSpec
/// <summary>Legacy set name value remains literal. The previously unsupported name=expression form calculates a number.</summary>
public class SetCommandStrategy : NumericAssignmentCommandStrategy
{
/// <inheritdoc/>
public string Command => "set";
/// <inheritdoc/>
public int MinPositional => 2;
/// <inheritdoc/>
public int MaxPositional => 2;
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> 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;
}
}
+12 -1
View File
@@ -29,7 +29,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
/// <inheritdoc/>
public int MaxPositional => 1;
/// <inheritdoc/>
public IReadOnlyList<string> AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait" };
public IReadOnlyList<string> AllowedNamed => new[] { "key", "first", "second", "hide_color", "wait", "who", "duration", "cue" };
/// <inheritdoc/>
public void Execute(DialogQueue dlg, List<string> positional, Dictionary<string, string> named, string arrowTarget, ILocalizationProvider locale)
@@ -46,6 +46,17 @@ namespace Cthangover.Mods.Dialog.Scenarios
var hideColor = named.TryGetValue("hide_color", out var hc) && hc == "true";
var action = dlg.Text(textVal, first, second, hideColor);
action.FallbackText = positional.Count > 0 ? positional[0] : "";
action.LocaleKey = key;
action.Speaker = named.TryGetValue("who", out var who) ? who : null;
action.Cue = named.TryGetValue("cue", out var cue) ? cue : null;
if (named.TryGetValue("duration", out var duration))
{
if (!double.TryParse(duration, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds)
|| !double.IsFinite(seconds) || seconds <= 0)
throw new System.FormatException("duration must be a positive finite number");
action.Duration = seconds;
}
if (named.TryGetValue("wait", out var wait) && wait == "false")
action.WaitType = WaitType.NoWait;
+16 -2
View File
@@ -25,7 +25,7 @@ namespace Cthangover.Mods.Dialog.Scenarios
public int MinPositional => 1;
/// <inheritdoc />
public IReadOnlyList<string> AllowedNamed => new[] { "else" };
public IReadOnlyList<string> AllowedNamed => new[] { "else", "wait", "timeout" };
/// <inheritdoc />
public string JumpTargetNamedKey => "else";
@@ -35,7 +35,21 @@ 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));
if (named.TryGetValue("wait", out var wait) && wait == "true")
{
if (!named.TryGetValue("timeout", out var timeout) || !double.TryParse(timeout,
System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seconds)
|| !double.IsFinite(seconds) || seconds <= 0)
throw new System.FormatException("when wait=true requires a positive finite timeout");
dlg.Queue.Add(new Cthangover.Mods.Dialog.Action.Impls.ActionWaitCondition
{
ConditionText = condition, Timeout = seconds,
TrueTarget = Label(arrowTarget), TimeoutTarget = Label(otherwise),
});
return;
}
var branch = dlg.IfGoTo(null, Label(arrowTarget), Label(otherwise));
branch.ConditionText = condition;
}
private static string Label(string target) => string.IsNullOrWhiteSpace(target) ? null : target.Trim().TrimStart(':');
@@ -0,0 +1,48 @@
namespace Cthangover.Mods.Dialog.Scenarios
{
using System.Collections.Generic;
using Cthangover.Core.Localization;
using Cthangover.Mods.Stats.Formulas;
/// <summary>Shared assignment syntax and validation; each command strategy chooses literal or calculated positional values.</summary>
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<string> positional, Dictionary<string,string> 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<string> positional,Dictionary<string,string> 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<string> positional, Dictionary<string, string> 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<string> positional,Dictionary<string,string> 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;
}
}
}
+24 -28
View File
@@ -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<string> Tokenize(string line)
{
var result = new List<string>();
var i = 0;
while (i < line.Length)
var tokens=new List<string>();
var i=0;
while(i<line.Length)
{
if (char.IsWhiteSpace(line[i]))
if(char.IsWhiteSpace(line[i])){i++;continue;}
var token=new System.Text.StringBuilder();
while(i<line.Length&&!char.IsWhiteSpace(line[i]))
{
i++;
continue;
}
if (line[i] == '"')
{
i++;
var start = i;
while (i < line.Length && line[i] != '"')
// Preserve quotes embedded in JSON. Quotes at the start of a value, including name="...",
// delimit one DSL argument and allow spaces without changing existing JSON commands.
var quoted=false;
if(line[i]=='"')
{
if (line[i] == '\\' && i + 1 < line.Length)
i++;
i++;
var prefix=token.ToString();
quoted=token.Length==0||prefix.EndsWith("=")&&IsValidIdent(prefix.Substring(0,prefix.Length-1));
}
result.Add(line[start..i].Replace("\\\"", "\""));
if (i < line.Length) i++;
continue;
}
var tokenStart = i;
while (i < line.Length && !char.IsWhiteSpace(line[i]))
if(!quoted){token.Append(line[i++]);continue;}
i++;
result.Add(line[tokenStart..i]);
while(i<line.Length&&line[i]!='"')
{
if(line[i]==(char)92&&i+1<line.Length&&line[i+1]=='"'){token.Append('"');i+=2;}
else token.Append(line[i++]);
}
if(i<line.Length)i++;
}
tokens.Add(token.ToString());
}
return result;
return tokens;
}
private static bool IsValidIdent(string s)
@@ -0,0 +1,24 @@
namespace Cthangover.Mods.Dialog.Action.Impls
{
using System;
using System.Globalization;
using Cthangover.Mods.Stats.Formulas;
/// <summary>Evaluate once at the queue position; dependent actions are aborted on invalid input instead of seeing a stale result.</summary>
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);}
}
}
}
@@ -32,6 +32,11 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// <summary>Shows the optional <see cref="ShowText"/> and, in <see cref="HiddenMode"/>, hides the dialog body for the pause duration.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (runtime.Host != null)
{
runtime.Host.Delay(this, runtime);
return;
}
var dialogBox = runtime.DialogBox;
if (dialogBox == null)
return;
+12 -2
View File
@@ -23,6 +23,15 @@ namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>Delegate evaluated at runtime. Must return true/false to select the branch target.</summary>
public Func<bool> Condition { get; set; }
/// <summary>Source expression evaluated by the runtime's own context, not a captured global role.</summary>
public string ConditionText { get; set; }
/// <summary>
/// Used instead of <see cref="Condition"/> when set: evaluated with the instance scope of the
/// running dialog (<see cref="DialogQueue.Scope"/>), so <c>when "self.open == 1"</c> reads the flag of
/// the object that started the dialog, even when the line was parsed into another queue first.
/// </summary>
public Func<string, bool> ScopedCondition { get; set; }
/// <summary>Target action ID if <see cref="Condition"/> returns true. Empty or null causes fall-through.</summary>
public string TrueGoTo { get; set; }
/// <summary>Target action ID if <see cref="Condition"/> returns false. Empty or null causes fall-through.</summary>
@@ -34,13 +43,14 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// <summary>Evaluates <see cref="Condition"/> and jumps to <see cref="TrueGoTo"/> or <see cref="FalseGoTo"/>; an empty target falls through to the next action.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (Condition == null)
if (ConditionText == null && Condition == null && ScopedCondition == null)
{
GameLogger.Log("DIALOG", "ActionIf: Condition delegate is null", LogLevel.Error);
return;
}
var result = Condition();
var result = ConditionText != null ? runtime.EvaluateCondition(ConditionText)
: ScopedCondition != null ? ScopedCondition(runtime?.Dialog?.Scope) : Condition();
var goTo = result ? TrueGoTo : FalseGoTo;
if (!string.IsNullOrEmpty(goTo))
+2 -1
View File
@@ -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)
{
+8
View File
@@ -25,6 +25,8 @@ namespace Cthangover.Mods.Dialog.Action.Impls
public string Value { get; set; }
/// <summary>Lazy evaluation delegate. When non-null, invoked at runtime to compute the value dynamically.</summary>
public Func<string> Callback { get; set; }
/// <summary>Only action parameter templates expand references; ordinary set literals retain their historical behavior.</summary>
public bool UseProcessText { get; set; }
/// <summary>Variable assignment is instant — the dialog continues without pause.</summary>
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);
}
}
+8 -1
View File
@@ -8,6 +8,8 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// flag is game state: it is saved, read by scenario conditions
/// (<c>condition: door == "open"</c>) and its change rescans the scene's
/// event chain, so a scenario waiting for it can start right after.
/// <c>flag self.open 1</c> writes the flag of the dialog's instance scope
/// (<see cref="DialogQueue.Scope"/>), e.g. <c>rpg_village/supply_1:open</c>.
/// </summary>
public class ActionSetFlag : ActionCommand
{
@@ -20,6 +22,11 @@ namespace Cthangover.Mods.Dialog.Action.Impls
public override WaitType WaitType { get; set; } = WaitType.NoWait;
/// <inheritdoc/>
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);
}
}
}
+15
View File
@@ -21,6 +21,16 @@ namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>The dialog text to display. Supports ${var} substitution when <see cref="UseProcessText"/> is true.</summary>
public string Text { get; set; }
/// <summary>Untranslated source retained for live locale changes in hosted speech.</summary>
public string FallbackText { get; set; }
/// <summary>Stable translation key, independent of speech event cues.</summary>
public string LocaleKey { get; set; }
/// <summary>Host role naming the concrete speaker.</summary>
public string Speaker { get; set; }
/// <summary>Optional semantic event emitted when this utterance finishes normally.</summary>
public string Cue { get; set; }
/// <summary>Explicit host-clock duration; null uses translated text length.</summary>
public double? Duration { get; set; }
/// <summary>Left avatar sprite ID resolved through <see cref="IAvatarProvider"/>. Null or empty hides the slot.</summary>
public string FirstAvatar { get; set; }
/// <summary>Right avatar sprite ID resolved through <see cref="IAvatarProvider"/>. Null or empty hides the slot.</summary>
@@ -36,6 +46,11 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// <summary>Sets the dialog text (optionally variable-substituted) and both avatar slots, then waits for the player's click.</summary>
public override void DoRun(DialogRuntime runtime)
{
if (runtime.Host != null)
{
runtime.Host.ShowText(this, runtime);
return;
}
var dialogBox = runtime.DialogBox;
if (dialogBox == null)
return;
@@ -0,0 +1,22 @@
namespace Cthangover.Mods.Dialog.Action.Impls
{
/// <summary>An explicit asynchronous condition wait; ordinary when remains a one-shot branch.</summary>
public class ActionWaitCondition : ActionCommand
{
/// <summary>Evaluated against the execution's live context.</summary>
public string ConditionText { get; set; }
/// <summary>Maximum host-clock wait.</summary>
public double Timeout { get; set; }
/// <summary>Label selected when the condition becomes true.</summary>
public string TrueTarget { get; set; }
/// <summary>Label selected on timeout, or fall-through when absent.</summary>
public string TimeoutTarget { get; set; }
public override WaitType WaitType { get; set; } = WaitType.WaitEvent;
public override void DoRun(DialogRuntime runtime)
{
if (runtime.Host == null)
runtime.FailExpression("when wait=true requires an execution host", SourceLine);
else runtime.Host.WaitCondition(this, runtime);
}
}
}
+2 -2
View File
@@ -30,8 +30,8 @@ namespace Cthangover.Mods.Dialog.Action.Impls
/// </summary>
public string Condition { get; set; }
/// <summary>Whether the option is offered now.</summary>
public bool IsShown() => string.IsNullOrWhiteSpace(Condition) || ScenarioCondition.Evaluate(Condition);
/// <summary>Whether the option is offered now; <paramref name="scope"/> is the dialog's instance scope (<see cref="DialogQueue.Scope"/>, may be null).</summary>
public bool IsShown(string scope) => string.IsNullOrWhiteSpace(Condition) || ScenarioCondition.Evaluate(Condition, scope);
/// <summary>Generates a new unique action ID for use as a GoTo target (e.g. with <see cref="DialogQueue.Point"/>).</summary>
public static string Point => Guid.NewGuid().ToString();
+22
View File
@@ -18,6 +18,7 @@ using Cthangover.Core.Interactive;
namespace Cthangover.Mods.Dialog
{
using Cthangover.Core.Events;
/// <summary>
/// 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<SceneExitedEvent>(OnSceneExited);
if (Body == null)
{
@@ -373,6 +375,26 @@ namespace Cthangover.Mods.Dialog
Locker = null;
}
/// <summary>Detaches the scene-exit hook when the box leaves the tree.</summary>
protected override void OnExitTree()
{
ModEventBus.Unsubscribe<SceneExitedEvent>(OnSceneExited);
}
/// <summary>
/// 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 <see cref="DialogEndedEvent"/> do not run.
/// </summary>
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();
}
/// <summary>
/// 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
+17
View File
@@ -33,6 +33,15 @@ namespace Cthangover.Mods.Dialog
/// <summary>The ordered sequence of dialog actions. Appended via fluent builder methods; consumed by <see cref="DialogRuntime"/>.</summary>
public List<IActionCommand> Queue { get; } = new();
/// <summary>
/// The instance scope of this dialog — the placement id of the object whose interaction runs it —
/// or null. Inside it <c>self.&lt;name&gt;</c> in <c>flag</c>, <c>when</c> and option conditions means
/// the story flag <c>&lt;scope&gt;:&lt;name&gt;</c> (<see cref="ScenarioCondition.FlagKey"/>). Set by
/// whoever starts the dialog (the RPG runtime) before it runs; a queue with no scope rejects
/// <c>self.</c> with an ERROR instead of writing a global flag.
/// </summary>
public string Scope { get; set; }
/// <summary>Spawns a visual effect identified by <paramref name="effectID"/> through <see cref="EffectFactory"/>. Returns the action for further configuration.</summary>
public ActionEffect Effect(string effectID)
{
@@ -130,6 +139,14 @@ namespace Cthangover.Mods.Dialog
return action;
}
/// <summary>Enqueues a strict numeric assignment; expressions and random functions are evaluated at execution.</summary>
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;
}
/// <summary>Sets a story flag of the game (saved, read by scenario conditions) when the queue reaches this point.</summary>
public ActionSetFlag Flag(string name, string value)
{
+48 -1
View File
@@ -51,6 +51,13 @@ namespace Cthangover.Mods.Dialog
/// <summary>The currently active dialog queue. Set by <see cref="SetDialogQueueAndRun"/>.</summary>
public DialogQueue Dialog { get; set; }
/// <summary>Optional per-execution host; null preserves modal dialog behavior.</summary>
public IScenarioExecutionHost Host { get; set; }
/// <summary>Conditions use live host roles when present, otherwise the existing instance scope.</summary>
public bool EvaluateCondition(string text) => Host != null
? Host.EvaluateCondition(text) : ScenarioCondition.Evaluate(text, Dialog?.Scope);
/// <summary>The action at the current queue index. Automatically constructs it if <see cref="ConstructType"/> is OnStartAction and it hasn't been constructed yet.</summary>
public IActionCommand CurrentAction => GetByIndex(index);
@@ -71,6 +78,9 @@ namespace Cthangover.Mods.Dialog
/// </summary>
public static IDialogDebugger Debugger { get; set; }
/// <summary>A host may debug its own execution independently of the modal studio hook.</summary>
public IDialogDebugger ExecutionDebugger { get; set; }
/// <summary>
/// True while the debugger holds the runtime before <see cref="CurrentAction"/>:
/// that action has not run yet, so advancing must wait for
@@ -81,6 +91,35 @@ namespace Cthangover.Mods.Dialog
/// <summary>Queue index of <see cref="CurrentAction"/>.</summary>
public int Index => index;
/// <summary>Inject a seeded generator for reproducible scripts and tests; expressions never roll while parsing.</summary>
public System.Random ExpressionRandom { get; set; } = System.Random.Shared;
public string LastExpressionError { get; private set; }
/// <summary>Abort a malformed calculation without completing a one-run scenario or running later reward actions.</summary>
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 ?? "<inline>";
LastExpressionError = origin + ":" + sourceLine + ": " + message;
GameLogger.Log("DIALOG", LastExpressionError, LogLevel.Error);
if (DialogBox != null) DialogBox.Abort(); else Abort();
}
/// <summary>Resolve action parameters at execution, preserving string values and rejecting undefined references.</summary>
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;
}
/// <summary>Current <c>${name}</c> variables of the dialog, for inspection.</summary>
public IReadOnlyDictionary<string, string> Variables => (IReadOnlyDictionary<string, string>)variables;
@@ -153,6 +192,7 @@ namespace Cthangover.Mods.Dialog
generation++;
variables = new Dictionary<string, string>();
LastExpressionError = null;
DialogBox = dialogBox;
IsHeld = false;
isLastGoTo = false;
@@ -246,7 +286,7 @@ namespace Cthangover.Mods.Dialog
GameLogger.Log("DIALOG", $"Run: idx={index}/{dialogQueue.Count-1} type={action.GetType().Name} wait={action.WaitType}", LogLevel.Debug);
if (Debugger != null && Debugger.ShouldHold(this, action))
if ((ExecutionDebugger ?? Debugger)?.ShouldHold(this, action) == true)
{
IsHeld = true;
return;
@@ -256,6 +296,11 @@ namespace Cthangover.Mods.Dialog
if (runGeneration != generation)
return;
if (Host != null && iteration > 256)
{
FailExpression("Hosted scenario exceeded the immediate-action budget");
return;
}
if (iteration++ > 100000)
{
GameLogger.Log("DIALOG", "Run: infinite loop protection (100k iterations)", LogLevel.Warning);
@@ -368,6 +413,7 @@ namespace Cthangover.Mods.Dialog
dialogQueue = null;
endDialogQueue = null;
index = 0;
Host?.Finished(false);
if (DialogBox != null)
{
@@ -397,6 +443,7 @@ namespace Cthangover.Mods.Dialog
index = 0;
isLastGoTo = false;
IsHeld = false;
Host?.Finished(true);
GameLogger.Log("DIALOG", "Abort: queue dropped without ending");
}
+2 -4
View File
@@ -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=<id> --timeout=) keeps that game's own mod set.",
"sources": [
"src/*.cs"
],
"depends": [
"cthangover_game>=1"
]
"depends": []
}
@@ -193,7 +193,7 @@ namespace Mods.TestRunner
if (reason != null)
GameLogger.Log("TEST", $"DialogAutoDriverNode: {reason}");
GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");
GetTree().Quit(failed == 0 ? 0 : 1);
Cthangover.Core.Bootstrap.ApplicationExit.Quit(GetTree(), failed == 0 ? 0 : 1);
}
}
}
+1 -1
View File
@@ -409,7 +409,7 @@ namespace Cthangover.Mods.Dialog.DocShots
{
index = -1;
GameLogger.Log("TEST", $"[TEST_RESULT] name=doc_shots passed={passed} failed={failures.Count}");
GetTree().Quit(failures.Count == 0 ? 0 : 1);
Cthangover.Core.Bootstrap.ApplicationExit.Quit(GetTree(), failures.Count == 0 ? 0 : 1);
}
}
}
+1 -1
View File
@@ -123,7 +123,7 @@ namespace Mods.DialogBase.Tests
{
done = true;
GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}");
GetTree().Quit(failed == 0 ? 0 : 1);
Cthangover.Core.Bootstrap.ApplicationExit.Quit(GetTree(), failed == 0 ? 0 : 1);
}
}
}
+8 -3
View File
@@ -19,9 +19,9 @@ namespace Cthangover.Mods.Dialog.Tests
var stage = await DialogStage.Create();
var view = stage.AddViewBox();
await DialogStage.Frames(1);
var frames = Frames(3);
try
{
var frames = Frames(3);
var anim = new ActionAnimatedForeground { Textures = frames, Speed = 20f, NextFrameSpeed = 20f, IsLoop = false };
var dlg = new DialogQueue();
dlg.Queue.Add(anim);
@@ -38,6 +38,7 @@ namespace Cthangover.Mods.Dialog.Tests
finally
{
stage.Free();
foreach (var texture in frames) texture.Dispose();
}
}
@@ -47,9 +48,9 @@ namespace Cthangover.Mods.Dialog.Tests
var stage = await DialogStage.Create();
var view = stage.AddViewBox();
await DialogStage.Frames(1);
var frames = Frames(2);
try
{
var frames = Frames(2);
var anim = new ActionAnimatedForeground { Textures = frames, Speed = 20f, NextFrameSpeed = 20f, IsLoop = true };
var dlg = new DialogQueue();
dlg.Queue.Add(anim);
@@ -68,6 +69,7 @@ namespace Cthangover.Mods.Dialog.Tests
finally
{
stage.Free();
foreach (var texture in frames) texture.Dispose();
}
}
@@ -75,7 +77,10 @@ namespace Cthangover.Mods.Dialog.Tests
{
var list = new List<Texture2D>();
for (var i = 0; i < count; i++)
list.Add(ImageTexture.CreateFromImage(Image.CreateEmpty(2, 2, false, Image.Format.Rgba8)));
{
using var image = Image.CreateEmpty(2, 2, false, Image.Format.Rgba8);
list.Add(ImageTexture.CreateFromImage(image));
}
return list;
}
}
+25
View File
@@ -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)
{
+52
View File
@@ -0,0 +1,52 @@
namespace Cthangover.Mods.Dialog.Tests
{
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Scenes.TscnBuild;
using Cthangover.Core.Scenes.TscnBuild.PropertyResolvers;
using Cthangover.Core.Scenes.TscnParsing;
using Cthangover.Mods.Interface.View;
using Godot;
/// <summary>Packing a nested prefab must release the temporary tree while preserving its children in the packed result.</summary>
public class ExtResourcePropertyResolverTests
{
private static Node temporary;
[ModTest("prefabs/packing_releases_the_temporary_tree")]
static void PackingReleasesTemporaryTree(ModTestContext ctx)
{
var target = new DialogBox();
try
{
var scene = new ParsedScene();
scene.ExtResources.Add(new ExtResourceRef { Id = "nested", Type = "PackedScene", Path = "test_nested.tscn" });
var context = new PropertyContext(scene, CreateTemporary, null);
new ExtResourcePropertyResolver().Apply(target, "answerBoxScene", TscnPropertyValue.FromExtResource("nested"), context);
ModAssert.False(GodotObject.IsInstanceValid(temporary), "packing frees its input instance immediately");
using var packed = target.Get("answerBoxScene").AsGodotObject() as PackedScene;
ModAssert.NotNull(packed, "the target retains the packed prefab");
var instance = packed.Instantiate();
try
{
ModAssert.Equal("PackedRoot", instance.Name.ToString(), "packed root survives freeing the temporary tree");
ModAssert.Equal(1, instance.GetChildCount(), "packed child is retained");
ModAssert.Equal("PackedChild", instance.GetChild(0).Name.ToString(), "child owners were assigned before packing");
}
finally { instance.Free(); }
}
finally
{
target.Free();
if (GodotObject.IsInstanceValid(temporary)) temporary.Free();
temporary = null;
}
}
private static Node CreateTemporary(string path)
{
temporary = new Control { Name = "PackedRoot" };
temporary.AddChild(new Label { Name = "PackedChild", Text = "nested prefab" });
return temporary;
}
}
}
+107
View File
@@ -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;
/// <summary>
/// <c>self.</c> in a dialog (contract <c>rpg-model-v2.md</c> 5.5): the queue's
/// <see cref="DialogQueue.Scope"/> — the placement that started it — turns <c>self.open</c> into the
/// story flag <c>&lt;placement&gt;:open</c> in <c>flag</c>, <c>when</c> and option conditions. Two
/// instances of one object keep their own state, which is an ordinary saved flag.
/// </summary>
public class InstanceScopeTests
{
private const string First = "test_scope_map/barrel_1";
private const string Second = "test_scope_map/barrel_2";
/// <summary>The shared scenario of both barrels: open once, then say so.</summary>
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<string, string>();
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<string>());
var json = JsonSerializer.Serialize(slot, JsonOptionsProvider.WriteCompactOptions);
runtime.Flags = new Dictionary<string, string>();
ModAssert.False(ScenarioCondition.Evaluate("self.open == 1", First), "state cleared before the load");
RuntimeSerializer.RestoreCoreState(JsonSerializer.Deserialize<ModSlot>(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<string, string> { [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<string, string>();
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");
}
}
}
+107
View File
@@ -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;
/// <summary>Old string scripts, calculated assignments and action arguments share the existing DSL strategy pipeline.</summary>
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<string>();var named=new Dictionary<string,string>();
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<string>();var named=new Dictionary<string,string>{["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<Label>("DialogBody/TextField").Text,"localized ptext displays the same stored value");
stage.Box.NextAction();
ModAssert.Equal("3",stage.Box.Runtime.GetVariable("money"),"continuing the text does not reroll");
}
finally{ScenarioActionFactory.Instance.UnregisterAssembly(typeof(NumericCaptureAction).Assembly);stage.Free();}
}
[ModTest("numeric_assignment/undefined_action_parameter_stops_action")]
static void MissingParameter(ModTestContext ctx)
{
var capture=new NumericCaptureAction();ScenarioActionFactory.Instance.Register(capture);
try
{
var runtime=Run("set marker kept\naction dsl_test.capture count=${missing}\nset marker wrong\nend\n");
ModAssert.Null(capture.Amount,"invalid parameter is never delivered to the action");
ModAssert.Equal("kept",runtime.GetVariable("marker"),"later state changes are skipped");
ModAssert.True(runtime.LastExpressionError?.Contains("missing")==true,"undefined parameter is reported");
}
finally{ScenarioActionFactory.Instance.UnregisterAssembly(typeof(NumericCaptureAction).Assembly);}
}
private static DialogRuntime Run(string script)
{
var dialog=ScenarioParser.Parse(script);var runtime=new DialogRuntime();
runtime.SetDialogQueueAndRun(null,dialog.Queue,null);runtime.Run();return runtime;
}
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Cthangover.Mods.Dialog.Tests
{
using Cthangover.Core.Actions;
internal class NumericCaptureAction : IScenarioAction
{
public string Name=>"dsl_test.capture";
public string Amount {get;private set;}
public string Item {get;private set;}
public void Run(IActionContext context){Amount=context.GetParam("count");Item=context.GetParam("id");}
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Cthangover.Mods.Dialog.Tests
{
using Cthangover.Core.Localization;
internal class NumericTextLocale : ILocalizationProvider
{
public string Get(string key)=>key=="mod_dialog_base_name"?"Received ${money} coins.":null;
}
}