Update dialog_base implementation, assets and documentation

This commit is contained in:
cthangover committed 2026-10-31 15:28:50 +03:00
1 parent 2c9e6805a9
commit 1894857eff
36 files changed
+622 -84

No files matched your search

+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)
{
+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;
}
}