From 682f1e823fa43b1a1c6e3c3cfd7690977e008408 Mon Sep 17 00:00:00 2001 From: Mohamad Iraji <4851913+Enzx@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:06:22 +0200 Subject: [PATCH 1/3] Add data-built branching states, conditions, DSL, and diagnostics Renames the delegate-backed director states to the Relay* family and gives the freed ChoiceState / SwitchState names to data-built states whose decision is a list of ICondition objects or a blackboard key plus literal cases. Adds NxGraph/Conditions (ICondition, ConditionMatch, KeyEquals, IsTrue, Not), the .If(condition) / .Switch(key) DSL overloads, labelled Mermaid arms, and two branch lints. --- .../DungeonCrawler/DungeonCrawlerExample.cs | 14 +- .../DungeonCrawler/States/CombatState.cs | 2 +- NxGraph.Tests/GraphValidatorTests.cs | 2 +- NxGraph.Tests/MermaidGraphExporterTests.cs | 2 +- .../OpaqueDirectorValidationTests.cs | 2 +- NxGraph.Tests/PublicApi/NxGraph.approved.txt | 8 +- .../NxGraph.netstandard2.1.approved.txt | 8 +- ...StateTests.cs => RelayChoiceStateTests.cs} | 6 +- ...tCaseTests.cs => RelaySwitchStateTests.cs} | 4 +- NxGraph.Tests/TerminalOutcomeTests.cs | 2 +- NxGraph/Authoring/Dsl.AsyncSwitchBuilder.cs | 6 +- NxGraph/Authoring/Dsl.Conditions.cs | 77 +++++++ NxGraph/Authoring/Dsl.IfBuilder.cs | 35 ++- NxGraph/Authoring/Dsl.SwitchBuilder.cs | 94 +++++++- NxGraph/Conditions/ConditionMatch.cs | 15 ++ NxGraph/Conditions/ICondition.cs | 71 ++++++ NxGraph/Conditions/IsTrue.cs | 25 +++ NxGraph/Conditions/KeyEquals.cs | 77 +++++++ NxGraph/Conditions/Not.cs | 27 +++ .../Export/MermaidGraphExporter.cs | 74 ++++++- .../Diagnostics/Validations/GraphValidator.cs | 32 +++ ...hoiceState.cs => AsyncRelayChoiceState.cs} | 11 +- ...witchState.cs => AsyncRelaySwitchState.cs} | 11 +- NxGraph/Fsm/Async/AsyncStateMachine.cs | 2 +- NxGraph/Fsm/ChoiceState.cs | 129 ++++++++--- NxGraph/Fsm/IBranchNode.cs | 50 +++++ NxGraph/Fsm/IDirector.cs | 2 +- NxGraph/Fsm/RelayChoiceState.cs | 66 ++++++ NxGraph/Fsm/RelaySwitchState.cs | 86 ++++++++ NxGraph/Fsm/SwitchState.cs | 205 +++++++++++++----- README.md | 8 +- 31 files changed, 1011 insertions(+), 142 deletions(-) rename NxGraph.Tests/{ChoiceStateTests.cs => RelayChoiceStateTests.cs} (89%) rename NxGraph.Tests/{SwitchDefaultCaseTests.cs => RelaySwitchStateTests.cs} (94%) create mode 100644 NxGraph/Authoring/Dsl.Conditions.cs create mode 100644 NxGraph/Conditions/ConditionMatch.cs create mode 100644 NxGraph/Conditions/ICondition.cs create mode 100644 NxGraph/Conditions/IsTrue.cs create mode 100644 NxGraph/Conditions/KeyEquals.cs create mode 100644 NxGraph/Conditions/Not.cs rename NxGraph/Fsm/Async/{AsyncChoiceState.cs => AsyncRelayChoiceState.cs} (72%) rename NxGraph/Fsm/Async/{AsyncSwitchState.cs => AsyncRelaySwitchState.cs} (84%) create mode 100644 NxGraph/Fsm/IBranchNode.cs create mode 100644 NxGraph/Fsm/RelayChoiceState.cs create mode 100644 NxGraph/Fsm/RelaySwitchState.cs diff --git a/NxFSM.Examples/DungeonCrawler/DungeonCrawlerExample.cs b/NxFSM.Examples/DungeonCrawler/DungeonCrawlerExample.cs index 0d03db2..b2d677d 100644 --- a/NxFSM.Examples/DungeonCrawler/DungeonCrawlerExample.cs +++ b/NxFSM.Examples/DungeonCrawler/DungeonCrawlerExample.cs @@ -16,8 +16,8 @@ namespace NxFSM.Examples.DungeonCrawler; /// /// Custom classes with full lifecycle (OnEnter / OnRun / OnExit) /// Agent propagation via + WithAgent -/// branching (encounter routing) -/// branching (alive check, boss-defeated check) +/// branching (encounter routing) +/// branching (alive check, boss-defeated check) /// Graph cycles (loop back to Explore after each encounter) /// Hierarchical / nested FSM (boss fight is a child ) /// for full event tracing @@ -60,7 +60,7 @@ public static void Run(int seed = 42) // We must drop down to the builder to wire the converging paths (merges) // and loop backs, which the linear DSL does not support directly. - // Also, for 'SwitchState' to log target names correctly, we must supply + // Also, for 'RelaySwitchState' to log target names correctly, we must supply // the named NodeIds explicitly to its constructor. // Add Encounter Nodes and use the named ids directly @@ -70,8 +70,8 @@ public static void Run(int seed = 42) NodeId bossFightId = builder.AddNode(bossFight); builder.SetName(bossFightId, "BossFight"); bossFightId = bossFightId.WithName("BossFight"); NodeId emptyRoomId = builder.AddNode(emptyRoom); builder.SetName(emptyRoomId, "EmptyRoom"); emptyRoomId = emptyRoomId.WithName("EmptyRoom"); - // Manually build SwitchState with named nodes - SwitchState encounterSwitch = new( + // Manually build RelaySwitchState with named nodes + RelaySwitchState encounterSwitch = new( () => ctx.CurrentEncounter, new Dictionary { @@ -96,13 +96,13 @@ public static void Run(int seed = 42) // Build Director Logic (Choice) - resolving loops manually // Boss Defeated? -> Victory OR Explore (Loop) - ChoiceState bossCheck = new(() => ctx.BossDefeated, victoryId, exploreToken.Id); + RelayChoiceState bossCheck = new(() => ctx.BossDefeated, victoryId, exploreToken.Id); NodeId bossCheckId = builder.AddNode(bossCheck); builder.SetName(bossCheckId, "BossDefeatedCheck"); bossCheckId = bossCheckId.WithName("BossDefeatedCheck"); // Hero Alive? -> BossCheck OR Defeat - ChoiceState aliveCheck = new(() => ctx.HeroAlive, bossCheckId, defeatId); + RelayChoiceState aliveCheck = new(() => ctx.HeroAlive, bossCheckId, defeatId); NodeId aliveCheckId = builder.AddNode(aliveCheck); builder.SetName(aliveCheckId, "AliveCheck"); aliveCheckId = aliveCheckId.WithName("AliveCheck"); diff --git a/NxFSM.Examples/DungeonCrawler/States/CombatState.cs b/NxFSM.Examples/DungeonCrawler/States/CombatState.cs index 74d8967..d962288 100644 --- a/NxFSM.Examples/DungeonCrawler/States/CombatState.cs +++ b/NxFSM.Examples/DungeonCrawler/States/CombatState.cs @@ -7,7 +7,7 @@ namespace NxFSM.Examples.DungeonCrawler.States; /// The hero fights a random monster. /// Combat is a simple loop of trading blows until one side falls. /// Always returns — the alive-check is done by a -/// downstream director node. +/// downstream director node. /// public sealed class CombatState : State { diff --git a/NxGraph.Tests/GraphValidatorTests.cs b/NxGraph.Tests/GraphValidatorTests.cs index d2d7e72..689267d 100644 --- a/NxGraph.Tests/GraphValidatorTests.cs +++ b/NxGraph.Tests/GraphValidatorTests.cs @@ -216,7 +216,7 @@ public void Validator_follows_choice_director_branches_for_reachability() NodeId start = builder.AddNode(new AsyncRelayState(_ => ResultHelpers.Success), isStart: true); NodeId trueBranch = builder.AddNode(new AsyncRelayState(_ => ResultHelpers.Success)); NodeId falseBranch = builder.AddNode(new AsyncRelayState(_ => ResultHelpers.Success)); - NodeId choice = builder.AddNode(new ChoiceState(() => true, trueBranch, falseBranch)); + NodeId choice = builder.AddNode(new RelayChoiceState(() => true, trueBranch, falseBranch)); builder.AddTransition(start, choice); diff --git a/NxGraph.Tests/MermaidGraphExporterTests.cs b/NxGraph.Tests/MermaidGraphExporterTests.cs index 15e862d..9e7b30a 100644 --- a/NxGraph.Tests/MermaidGraphExporterTests.cs +++ b/NxGraph.Tests/MermaidGraphExporterTests.cs @@ -161,7 +161,7 @@ public void director_name_with_quotes_is_escaped_exactly_once() NodeId start = builder.AddNode(new RelayState(() => Result.Success), isStart: true); NodeId thenBranch = builder.AddNode(new RelayState(() => Result.Success)); // n1 NodeId elseBranch = builder.AddNode(new RelayState(() => Result.Success)); // n2 - NodeId choice = builder.AddNode(new ChoiceState(() => true, thenBranch, elseBranch)); // n3 + NodeId choice = builder.AddNode(new RelayChoiceState(() => true, thenBranch, elseBranch)); // n3 builder.SetName(choice, "say \"hi\""); builder.AddTransition(start, choice); Graph graph = builder.Build(throwOnError: false); diff --git a/NxGraph.Tests/OpaqueDirectorValidationTests.cs b/NxGraph.Tests/OpaqueDirectorValidationTests.cs index 11788c0..72dca19 100644 --- a/NxGraph.Tests/OpaqueDirectorValidationTests.cs +++ b/NxGraph.Tests/OpaqueDirectorValidationTests.cs @@ -75,6 +75,6 @@ public void builtin_choice_state_stays_clean() Assert.That(result.Diagnostics.Any(d => d.Message.Contains("no static targets", StringComparison.OrdinalIgnoreCase)), - Is.False, "Built-in ChoiceState surfaces its branches and must not be flagged."); + Is.False, "Built-in RelayChoiceState surfaces its branches and must not be flagged."); } } diff --git a/NxGraph.Tests/PublicApi/NxGraph.approved.txt b/NxGraph.Tests/PublicApi/NxGraph.approved.txt index 6ae3071..a80750d 100644 --- a/NxGraph.Tests/PublicApi/NxGraph.approved.txt +++ b/NxGraph.Tests/PublicApi/NxGraph.approved.txt @@ -552,7 +552,7 @@ sealed class NxGraph.Fsm.AllState : NxGraph.Fsm.State, NxGraph.Blackboards.IBlac sealed class NxGraph.Fsm.Async.AsyncAllState : NxGraph.Fsm.Async.AsyncState, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.IAsyncLogic ctor Void .ctor(System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]][]) method System.Threading.Tasks.ValueTask`1[NxGraph.Result] OnRunAsync(System.Threading.CancellationToken) -sealed class NxGraph.Fsm.Async.AsyncChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic +sealed class NxGraph.Fsm.Async.AsyncRelayChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic ctor Void .ctor(System.Func`1[System.Threading.Tasks.ValueTask`1[System.Boolean]], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Threading.Tasks.ValueTask`1[System.Boolean]], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() @@ -633,7 +633,7 @@ abstract class NxGraph.Fsm.Async.AsyncState`1 : NxGraph.Fsm.Async.AsyncState, IA ctor Void .ctor() field TAgent Agent method Void SetAgent(TAgent) -sealed class NxGraph.Fsm.Async.AsyncSwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic +sealed class NxGraph.Fsm.Async.AsyncRelaySwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic ctor Void .ctor(System.Func`1[System.Threading.Tasks.ValueTask`1[TKey]], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Threading.Tasks.ValueTask`1[TKey]], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() @@ -651,7 +651,7 @@ enum NxGraph.Fsm.BackoffKind : System.IComparable, System.IConvertible, System.I Exponential = 2 Fixed = 0 Linear = 1 -sealed class NxGraph.Fsm.ChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.RelayChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic ctor Void .ctor(System.Func`1[System.Boolean], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Boolean], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) method NxGraph.Graphs.NodeId SelectNext() @@ -875,7 +875,7 @@ abstract class NxGraph.Fsm.State`1 : NxGraph.Fsm.State, IAgentSettable`1, NxGrap ctor Void .ctor() field TAgent Agent method Void SetAgent(TAgent) -sealed class NxGraph.Fsm.SwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.RelaySwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic ctor Void .ctor(System.Func`1[TKey], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,TKey], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) method NxGraph.Graphs.NodeId SelectNext() diff --git a/NxGraph.Tests/PublicApi/NxGraph.netstandard2.1.approved.txt b/NxGraph.Tests/PublicApi/NxGraph.netstandard2.1.approved.txt index 7c71ee5..a59d785 100644 --- a/NxGraph.Tests/PublicApi/NxGraph.netstandard2.1.approved.txt +++ b/NxGraph.Tests/PublicApi/NxGraph.netstandard2.1.approved.txt @@ -552,7 +552,7 @@ sealed class NxGraph.Fsm.AllState : NxGraph.Fsm.State, NxGraph.Blackboards.IBlac sealed class NxGraph.Fsm.Async.AsyncAllState : NxGraph.Fsm.Async.AsyncState, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.IAsyncLogic ctor System.Void .ctor(System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]][]) method System.Threading.Tasks.ValueTask`1[NxGraph.Result] OnRunAsync(System.Threading.CancellationToken) -sealed class NxGraph.Fsm.Async.AsyncChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic +sealed class NxGraph.Fsm.Async.AsyncRelayChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic ctor System.Void .ctor(System.Func`1[System.Threading.Tasks.ValueTask`1[System.Boolean]], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Threading.Tasks.ValueTask`1[System.Boolean]], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() @@ -633,7 +633,7 @@ abstract class NxGraph.Fsm.Async.AsyncState`1 : NxGraph.Fsm.Async.AsyncState, IA ctor System.Void .ctor() field TAgent Agent method System.Void SetAgent(TAgent) -sealed class NxGraph.Fsm.Async.AsyncSwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic +sealed class NxGraph.Fsm.Async.AsyncRelaySwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic ctor System.Void .ctor(System.Func`1[System.Threading.Tasks.ValueTask`1[TKey]], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Threading.Tasks.ValueTask`1[TKey]], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() @@ -651,7 +651,7 @@ enum NxGraph.Fsm.BackoffKind : System.IComparable, System.IConvertible, System.I Exponential = 2 Fixed = 0 Linear = 1 -sealed class NxGraph.Fsm.ChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.RelayChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic ctor System.Void .ctor(System.Func`1[System.Boolean], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Boolean], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) method NxGraph.Graphs.NodeId SelectNext() @@ -875,7 +875,7 @@ abstract class NxGraph.Fsm.State`1 : NxGraph.Fsm.State, IAgentSettable`1, NxGrap ctor System.Void .ctor() field TAgent Agent method System.Void SetAgent(TAgent) -sealed class NxGraph.Fsm.SwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.RelaySwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic ctor System.Void .ctor(System.Func`1[TKey], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,TKey], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) method NxGraph.Graphs.NodeId SelectNext() diff --git a/NxGraph.Tests/ChoiceStateTests.cs b/NxGraph.Tests/RelayChoiceStateTests.cs similarity index 89% rename from NxGraph.Tests/ChoiceStateTests.cs rename to NxGraph.Tests/RelayChoiceStateTests.cs index a397815..81359d0 100644 --- a/NxGraph.Tests/ChoiceStateTests.cs +++ b/NxGraph.Tests/RelayChoiceStateTests.cs @@ -6,7 +6,7 @@ namespace NxGraph.Tests; [TestFixture] [Category("branching_choice")] -public class ChoiceStateTests +public class RelayChoiceStateTests { [Test] [CancelAfter(10_000)] @@ -44,8 +44,8 @@ public async Task choice_state_should_follow_false_branch() public void start_if_graph_is_executable_by_the_sync_runtime() { // Regression: Start().If(predicate) used to wrap the sync predicate in an - // AsyncChoiceState, making the start node unexecutable by the sync StateMachine - // while every sibling If overload produced a sync ChoiceState. + // AsyncRelayChoiceState, making the start node unexecutable by the sync StateMachine + // while every sibling If overload produced a sync RelayChoiceState. const bool flag = true; StateMachine fsm = GraphBuilder.Start() diff --git a/NxGraph.Tests/SwitchDefaultCaseTests.cs b/NxGraph.Tests/RelaySwitchStateTests.cs similarity index 94% rename from NxGraph.Tests/SwitchDefaultCaseTests.cs rename to NxGraph.Tests/RelaySwitchStateTests.cs index da034d5..019fb28 100644 --- a/NxGraph.Tests/SwitchDefaultCaseTests.cs +++ b/NxGraph.Tests/RelaySwitchStateTests.cs @@ -6,7 +6,7 @@ namespace NxGraph.Tests; [TestFixture] [Category("switch_default")] -public class SwitchDefaultCaseTests +public class RelaySwitchStateTests { [Test] public async Task switch_should_follow_default_when_no_case_matches() @@ -29,7 +29,7 @@ public async Task switch_should_follow_default_when_no_case_matches() [Test] public async Task async_switch_without_default_should_terminate_when_no_case_matches() { - // Regression: previously AsyncSwitchState defaulted _defaultNode to default(NodeId) + // Regression: previously AsyncRelaySwitchState defaulted _defaultNode to default(NodeId) // (index 0 = Start) so a no-match case silently looped to Start instead of // exiting cleanly. The fix routes the no-default case through NodeId.Default, // which the async runtime treats as terminal success. diff --git a/NxGraph.Tests/TerminalOutcomeTests.cs b/NxGraph.Tests/TerminalOutcomeTests.cs index d52b756..6ad5d6f 100644 --- a/NxGraph.Tests/TerminalOutcomeTests.cs +++ b/NxGraph.Tests/TerminalOutcomeTests.cs @@ -16,7 +16,7 @@ private static Graph ApprovalGraph(Func approve) GraphBuilder builder = new(); NodeId approved = builder.AddNode(new AsyncRelayState(_ => ResultHelpers.Success)); NodeId rejected = builder.AddNode(new AsyncRelayState(_ => ResultHelpers.Success)); - builder.AddNode(new AsyncChoiceState(() => new ValueTask(approve()), approved, rejected), + builder.AddNode(new AsyncRelayChoiceState(() => new ValueTask(approve()), approved, rejected), isStart: true); builder.SetName(approved, "Approved"); builder.SetName(rejected, "Rejected"); diff --git a/NxGraph/Authoring/Dsl.AsyncSwitchBuilder.cs b/NxGraph/Authoring/Dsl.AsyncSwitchBuilder.cs index 1ae766d..1e771fc 100644 --- a/NxGraph/Authoring/Dsl.AsyncSwitchBuilder.cs +++ b/NxGraph/Authoring/Dsl.AsyncSwitchBuilder.cs @@ -15,7 +15,7 @@ public static partial class Dsl private readonly GraphBuilder _builder; private readonly StateToken _prev; private readonly Dictionary _map = new(); - private readonly AsyncSwitchState _switchNode; + private readonly AsyncRelaySwitchState _switchNode; private readonly bool _isStart; internal AsyncSwitchBuilder(StateToken prev, Func> selector) @@ -23,7 +23,7 @@ internal AsyncSwitchBuilder(StateToken prev, Func> selector) _prev = prev; _builder = prev.Builder; _isStart = false; - _switchNode = new AsyncSwitchState(selector, _map); + _switchNode = new AsyncRelaySwitchState(selector, _map); } internal AsyncSwitchBuilder(StartToken start, Func> selector) @@ -31,7 +31,7 @@ internal AsyncSwitchBuilder(StartToken start, Func> selector) _prev = new StateToken(NodeId.Default, start.Builder); _builder = start.Builder; _isStart = true; - _switchNode = new AsyncSwitchState(selector, _map); + _switchNode = new AsyncRelaySwitchState(selector, _map); } /// diff --git a/NxGraph/Authoring/Dsl.Conditions.cs b/NxGraph/Authoring/Dsl.Conditions.cs new file mode 100644 index 0000000..2953d64 --- /dev/null +++ b/NxGraph/Authoring/Dsl.Conditions.cs @@ -0,0 +1,77 @@ +using NxGraph.Blackboards; +using NxGraph.Conditions; +using NxGraph.Fsm; + +namespace NxGraph.Authoring; + +/// +/// Data-built branching overloads (spec 023) — the serializable twins of the delegate +/// .If(predicate) / .Switch(selector) paths. The decision is a list of +/// objects or a blackboard key plus literal cases, so a graph built +/// with these round-trips through GraphSerializer with zero options and survives +/// suspend/resume — and its arms carry labels into the Mermaid export. +/// +/// The builders returned here are the same / +/// the delegate paths return, so +/// .Then(...)/.Else(...) and .Case(...)/.Default(...)/.End() are unchanged. +/// +/// +public static partial class Dsl +{ + /// + /// Branches on a single condition. Equivalent to .If(ConditionMatch.All, condition). + /// + public static IfBuilder If(this StateToken prev, ICondition condition) + { + return new IfBuilder(prev, Single(condition), ConditionMatch.All); + } + + /// + public static IfBuilder If(this StartToken root, ICondition condition) + { + return new IfBuilder(root, Single(condition), ConditionMatch.All); + } + + /// + /// Branches on a condition list combined by + /// ( = AND, = OR). + /// Evaluation short-circuits; conditions are side-effect free by contract. + /// + public static IfBuilder If(this StateToken prev, ConditionMatch match, params ICondition[] conditions) + { + return new IfBuilder(prev, conditions, match); + } + + /// + public static IfBuilder If(this StartToken root, ConditionMatch match, params ICondition[] conditions) + { + return new IfBuilder(root, conditions, match); + } + + /// + /// Switches on the value of a blackboard key, with literal cases — the serializable twin of + /// .Switch(selector). Chain .Case(value, logic) arms and an optional + /// .Default(logic), then .End(). A value cased twice is rejected at + /// .End(): a switch is a lookup, so at most one case may match. Ordered, + /// first-match-wins rules are a chain of .If(condition) branches. + /// + public static SwitchBuilder Switch(this StateToken prev, BlackboardKey key) + where TKey : notnull + { + return new SwitchBuilder(prev, key); + } + + /// + public static SwitchBuilder Switch(this StartToken root, BlackboardKey key) + where TKey : notnull + { + return new SwitchBuilder(root, key); + } + + private static ICondition[] Single(ICondition condition) + { + // Null is rejected by the ChoiceState constructor with the "conditions" parameter name, + // so a single-condition call reports the same way as the list overload. + return [condition]; + } +} diff --git a/NxGraph/Authoring/Dsl.IfBuilder.cs b/NxGraph/Authoring/Dsl.IfBuilder.cs index 9be4214..ba8071b 100644 --- a/NxGraph/Authoring/Dsl.IfBuilder.cs +++ b/NxGraph/Authoring/Dsl.IfBuilder.cs @@ -1,4 +1,5 @@ using NxGraph.Blackboards; +using NxGraph.Conditions; using NxGraph.Fsm; using NxGraph.Fsm.Async; using NxGraph.Graphs; @@ -21,7 +22,7 @@ internal IfBuilder(StateToken prev, Func predicate) _builder = prev.Builder; _truePad = _builder.AddNode(new EmptyLogic()); _falsePad = _builder.AddNode(new EmptyLogic()); - NodeId choiceId = _builder.AddNode(new ChoiceState(predicate, _truePad, _falsePad)); + NodeId choiceId = _builder.AddNode(new RelayChoiceState(predicate, _truePad, _falsePad)); _builder.AddTransition(prev.Id, choiceId); } @@ -30,10 +31,10 @@ internal IfBuilder(StartToken root, Func predicate) _builder = root.Builder; _truePad = _builder.AddNode(new EmptyLogic()); _falsePad = _builder.AddNode(new EmptyLogic()); - // Sync ChoiceState, matching every sibling overload: it runs on both runtimes - // (the async loop routes sync directors), whereas an AsyncChoiceState start node + // Sync RelayChoiceState, matching every sibling overload: it runs on both runtimes + // (the async loop routes sync directors), whereas an AsyncRelayChoiceState start node // would make the graph unexecutable by the sync StateMachine. - _builder.AddNode(new ChoiceState(predicate, _truePad, _falsePad), true); + _builder.AddNode(new RelayChoiceState(predicate, _truePad, _falsePad), true); } internal IfBuilder(StateToken prev, Func predicate) @@ -41,7 +42,7 @@ internal IfBuilder(StateToken prev, Func predicate) _builder = prev.Builder; _truePad = _builder.AddNode(new EmptyLogic()); _falsePad = _builder.AddNode(new EmptyLogic()); - NodeId choiceId = _builder.AddNode(new ChoiceState(predicate, _truePad, _falsePad)); + NodeId choiceId = _builder.AddNode(new RelayChoiceState(predicate, _truePad, _falsePad)); _builder.AddTransition(prev.Id, choiceId); } @@ -50,7 +51,29 @@ internal IfBuilder(StartToken root, Func predicate) _builder = root.Builder; _truePad = _builder.AddNode(new EmptyLogic()); _falsePad = _builder.AddNode(new EmptyLogic()); - _builder.AddNode(new ChoiceState(predicate, _truePad, _falsePad), true); + _builder.AddNode(new RelayChoiceState(predicate, _truePad, _falsePad), true); + } + + // Data-built branches (spec 023): the decision is a condition list, so the node + // serializes and its arms carry labels into Mermaid. Added through the IAsyncLogic + // overload — ChoiceState implements both logic slots, so the node exposes the same + // instance on Logic and AsyncLogic and runs under either runtime family. + internal IfBuilder(StateToken prev, IReadOnlyList conditions, ConditionMatch match) + { + _builder = prev.Builder; + _truePad = _builder.AddNode(new EmptyLogic()); + _falsePad = _builder.AddNode(new EmptyLogic()); + NodeId choiceId = _builder.AddNode( + (IAsyncLogic)new ChoiceState(conditions, match, _truePad, _falsePad)); + _builder.AddTransition(prev.Id, choiceId); + } + + internal IfBuilder(StartToken root, IReadOnlyList conditions, ConditionMatch match) + { + _builder = root.Builder; + _truePad = _builder.AddNode(new EmptyLogic()); + _falsePad = _builder.AddNode(new EmptyLogic()); + _builder.AddNode((IAsyncLogic)new ChoiceState(conditions, match, _truePad, _falsePad), true); } /// Adds an async "then" branch. diff --git a/NxGraph/Authoring/Dsl.SwitchBuilder.cs b/NxGraph/Authoring/Dsl.SwitchBuilder.cs index 588f1a1..87afa86 100644 --- a/NxGraph/Authoring/Dsl.SwitchBuilder.cs +++ b/NxGraph/Authoring/Dsl.SwitchBuilder.cs @@ -8,6 +8,13 @@ public static partial class Dsl { /// /// Represents a switch statement in the FSM graph, allowing for multiple branches based on a key. + /// + /// Two modes share this builder. The delegate mode (.Switch(selector)) builds a + /// ; the data mode (.Switch(blackboardKey), + /// spec 023) builds a serializable whose cases are literals. + /// Both take the same .Case(...) / .Default(...) / .End() chain; the data + /// mode additionally rejects a value cased twice, at .End(). + /// /// /// public readonly struct SwitchBuilder where TKey : notnull @@ -15,7 +22,14 @@ public static partial class Dsl private readonly GraphBuilder _builder; private readonly StateToken _prev; private readonly Dictionary _map = new(); - private readonly SwitchState _switchNode; + private readonly RelaySwitchState? _switchNode; + + // Data mode (spec 023): the state is immutable and built at End(), so the arms and the + // default accumulate in reference-typed cells — this builder is a readonly struct that + // every chaining call returns by value. + private readonly List>? _cases; + private readonly BlackboardKey _dataKey; + private readonly NodeId[]? _defaultCell; private readonly bool _isStart; internal SwitchBuilder(StateToken prev, Func selector) @@ -23,7 +37,7 @@ internal SwitchBuilder(StateToken prev, Func selector) _prev = prev; _builder = prev.Builder; _isStart = false; - _switchNode = new SwitchState(selector, _map); + _switchNode = new RelaySwitchState(selector, _map); } internal SwitchBuilder(StartToken start, Func selector) @@ -31,7 +45,7 @@ internal SwitchBuilder(StartToken start, Func selector) _prev = new StateToken(NodeId.Default, start.Builder); _builder = start.Builder; _isStart = true; - _switchNode = new SwitchState(selector, _map); + _switchNode = new RelaySwitchState(selector, _map); } internal SwitchBuilder(StateToken prev, Func selector) @@ -39,7 +53,7 @@ internal SwitchBuilder(StateToken prev, Func selector) _prev = prev; _builder = prev.Builder; _isStart = false; - _switchNode = new SwitchState(selector, _map); + _switchNode = new RelaySwitchState(selector, _map); } internal SwitchBuilder(StartToken start, Func selector) @@ -47,7 +61,62 @@ internal SwitchBuilder(StartToken start, Func selector) _prev = new StateToken(NodeId.Default, start.Builder); _builder = start.Builder; _isStart = true; - _switchNode = new SwitchState(selector, _map); + _switchNode = new RelaySwitchState(selector, _map); + } + + internal SwitchBuilder(StateToken prev, BlackboardKey key) + { + _prev = prev; + _builder = prev.Builder; + _isStart = false; + _switchNode = null; + _dataKey = ValidatedKey(key); + _cases = new List>(); + _defaultCell = [NodeId.Default]; + } + + internal SwitchBuilder(StartToken start, BlackboardKey key) + { + _prev = new StateToken(NodeId.Default, start.Builder); + _builder = start.Builder; + _isStart = true; + _switchNode = null; + _dataKey = ValidatedKey(key); + _cases = new List>(); + _defaultCell = [NodeId.Default]; + } + + private static BlackboardKey ValidatedKey(BlackboardKey key) + { + if (!key.IsValid) + { + throw new ArgumentException( + "Invalid blackboard key — obtain keys via BlackboardSchema.Register(...).", nameof(key)); + } + + return key; + } + + private void Record(TKey key, NodeId id) + { + if (_cases is not null) + { + _cases.Add(new SwitchCase(key, id)); + return; + } + + _map[key] = id; + } + + private void RecordDefault(NodeId id) + { + if (_defaultCell is not null) + { + _defaultCell[0] = id; + return; + } + + _switchNode!.SetDefault(id); } /// @@ -56,7 +125,7 @@ internal SwitchBuilder(StartToken start, Func selector) public SwitchBuilder CaseAsync(TKey key, IAsyncLogic asyncLogic) { NodeId id = _builder.AddNode(asyncLogic); - _map[key] = id; + Record(key, id); return this; } @@ -66,7 +135,7 @@ public SwitchBuilder CaseAsync(TKey key, IAsyncLogic asyncLogic) public SwitchBuilder Case(TKey key, ILogic syncLogic) { NodeId id = _builder.AddNode(syncLogic); - _map[key] = id; + Record(key, id); return this; } @@ -78,7 +147,7 @@ public SwitchBuilder Case(TKey key, ILogic syncLogic) public SwitchBuilder DefaultAsync(IAsyncLogic asyncLogic) { NodeId defaultNode = _builder.AddNode(asyncLogic); - _switchNode.SetDefault(defaultNode); + RecordDefault(defaultNode); return this; } @@ -90,7 +159,7 @@ public SwitchBuilder DefaultAsync(IAsyncLogic asyncLogic) public SwitchBuilder Default(ILogic syncLogic) { NodeId defaultNode = _builder.AddNode(syncLogic); - _switchNode.SetDefault(defaultNode); + RecordDefault(defaultNode); return this; } @@ -100,7 +169,12 @@ public SwitchBuilder Default(ILogic syncLogic) /// Returns a representing the switch state. public StateToken End() { - NodeId switchId = _builder.AddNode((ILogic)_switchNode, _isStart); + // Data mode adds the state through the IAsyncLogic overload: SwitchState implements + // both logic slots, so the node exposes the same instance on Logic and AsyncLogic and + // runs unchanged under either runtime family. + NodeId switchId = _switchNode is null + ? _builder.AddNode((IAsyncLogic)new SwitchState(_dataKey, _cases!, _defaultCell![0]), _isStart) + : _builder.AddNode((ILogic)_switchNode, _isStart); if (_prev.Id != NodeId.Default) { _builder.AddTransition(_prev.Id, switchId); diff --git a/NxGraph/Conditions/ConditionMatch.cs b/NxGraph/Conditions/ConditionMatch.cs new file mode 100644 index 0000000..4e96130 --- /dev/null +++ b/NxGraph/Conditions/ConditionMatch.cs @@ -0,0 +1,15 @@ +namespace NxGraph.Conditions; + +/// +/// How a ChoiceState combines its condition list. Evaluation short-circuits in both +/// modes — legal because conditions are side-effect free by contract +/// (see ). +/// +public enum ConditionMatch +{ + /// Logical AND: walks until the first . + All = 0, + + /// Logical OR: walks until the first . + Any = 1, +} diff --git a/NxGraph/Conditions/ICondition.cs b/NxGraph/Conditions/ICondition.cs new file mode 100644 index 0000000..99e2331 --- /dev/null +++ b/NxGraph/Conditions/ICondition.cs @@ -0,0 +1,71 @@ +using NxGraph.Behaviors; + +namespace NxGraph.Conditions; + +/// +/// A data-shaped decision (spec 023): the branching counterpart of +/// . A condition reads the machine-bound blackboards through the +/// behavior model's — routed Bb, typed +/// for literal-or-key operands — and answers +/// or . +/// +/// It deliberately reuses none of the fault model. returns a +/// whose Failure makes the owning node fault into retry and the +/// failure edge, and whose InProgress is meaningless to a decision; a condition that is +/// false is not a fault, and conflating the two spends the node fault model on ordinary +/// branching. A condition returns . +/// +/// +/// Contract — conditions are side-effect free. They read the boards and write nothing, +/// so the / short-circuit +/// walk is always safe and re-evaluation is always equivalent. A genuine wiring fault (an +/// unbound key, a key declared with a different value type) throws and propagates like +/// any node throw; it is never reported as . +/// +/// +/// Conditions are sync-only by design: they read boards, and a sync condition runs under both +/// runtimes exactly as sync Repeat bodies do. Implementations are shareable data +/// objects — never stamped with per-machine state — so one instance may appear in several +/// graphs. +/// +/// +public interface ICondition +{ + /// + /// Evaluates this condition against the machine-bound context. Must not write to the + /// boards; must throw (not return ) on a wiring fault. + /// + bool Evaluate(in BehaviorContext ctx); +} + +/// +/// Shared wiring-time validation for condition lists — the condition twin of +/// BehaviorComposition. +/// +internal static class ConditionComposition +{ + internal const string ParamName = "conditions"; + + /// + /// Copies the list into a dense array, rejecting a null/empty list and null entries. The + /// copy matters: the caller's list must not be able to mutate a built graph's decision. + /// + internal static ICondition[] ValidateEntries(IReadOnlyList conditions, string paramName) + { + if (conditions is null || conditions.Count == 0) + { + throw new ArgumentException( + "At least one condition is required — an empty condition list has no defensible reading.", + paramName); + } + + ICondition[] copy = new ICondition[conditions.Count]; + for (int i = 0; i < copy.Length; i++) + { + copy[i] = conditions[i] ?? throw new ArgumentException( + $"Condition at index {i} is null — conditions must not contain null entries.", paramName); + } + + return copy; + } +} diff --git a/NxGraph/Conditions/IsTrue.cs b/NxGraph/Conditions/IsTrue.cs new file mode 100644 index 0000000..c674e7f --- /dev/null +++ b/NxGraph/Conditions/IsTrue.cs @@ -0,0 +1,25 @@ +using NxGraph.Behaviors; + +namespace NxGraph.Conditions; + +/// +/// Standard condition: the plain guard — when the resolved +/// is . Takes a BlackboardValue<bool>, +/// so it reads either a bool slot (new IsTrue(doorOpenKey)) or a literal +/// (new IsTrue(true) — a constant arm, occasionally useful as a default). +/// This is the shape KeyEquals<bool> would otherwise be spelled awkwardly in. +/// +public sealed class IsTrue : ICondition +{ + /// Creates a truth test of (literal or key-bound). + public IsTrue(BlackboardValue value) + { + Value = value; + } + + /// The tested value — literal or key-bound. + public BlackboardValue Value { get; } + + /// + public bool Evaluate(in BehaviorContext ctx) => ctx.Resolve(Value); +} diff --git a/NxGraph/Conditions/KeyEquals.cs b/NxGraph/Conditions/KeyEquals.cs new file mode 100644 index 0000000..4e3ab44 --- /dev/null +++ b/NxGraph/Conditions/KeyEquals.cs @@ -0,0 +1,77 @@ +using NxGraph.Behaviors; +using NxGraph.Blackboards; + +namespace NxGraph.Conditions; + +/// +/// Standard condition: when the value in equals +/// , compared with . The +/// expected side is a binding, so a rule may compare a key +/// against a literal or against another key. +/// +/// Authored instances hold a live key; deserialized instances () hold +/// only the key name and resolve it per evaluation against the machine's bound boards' +/// schemas (Graph, then Global, then Node) — the same name-based rebind as behavior bindings, +/// with the same targeted miss/type-mismatch throws. +/// +/// +/// The slot's value type. +public sealed class KeyEquals : ICondition +{ + private readonly BlackboardKey _key; + private readonly string _keyName; + + /// Creates a comparison of against . + public KeyEquals(BlackboardKey key, BlackboardValue expected) + { + if (!key.IsValid) + { + throw new ArgumentException( + "Invalid blackboard key — obtain keys via BlackboardSchema.Register(...).", nameof(key)); + } + + _key = key; + _keyName = key.Name; + Expected = expected; + } + + private KeyEquals(string keyName, BlackboardValue expected) + { + _key = default; + _keyName = keyName; + Expected = expected; + } + + /// + /// Creates a name-bound comparison — the deserialization rebind form. The tested key + /// resolves per evaluation against the machine's bound boards' schemas. + /// + public static KeyEquals Unbound(string keyName, BlackboardValue expected) + { + if (string.IsNullOrEmpty(keyName)) + { + throw new ArgumentException("Key name cannot be null or empty.", nameof(keyName)); + } + + return new KeyEquals(keyName, expected); + } + + /// The live tested key; default (invalid) for name-bound instances. + public BlackboardKey Key => _key; + + /// The tested key's registered name — the serialization identity. + public string KeyName => _keyName; + + /// The expected value — literal or key-bound. + public BlackboardValue Expected { get; } + + /// + public bool Evaluate(in BehaviorContext ctx) + { + BlackboardContext bb = ctx.Bb; + T actual = _key.IsValid + ? bb.Get(_key) + : bb.Get(BehaviorKeyResolver.Resolve(in bb, _keyName)); + return EqualityComparer.Default.Equals(actual, ctx.Resolve(Expected)); + } +} diff --git a/NxGraph/Conditions/Not.cs b/NxGraph/Conditions/Not.cs new file mode 100644 index 0000000..69fe119 --- /dev/null +++ b/NxGraph/Conditions/Not.cs @@ -0,0 +1,27 @@ +using NxGraph.Behaviors; + +namespace NxGraph.Conditions; + +/// +/// Standard condition: negates exactly one inner condition. Without negation, +/// / cannot express "not +/// equal" — swapping the two arms only works for a single-condition choice. +/// +/// The one nesting shape in the condition model; it rides the payload through the neutral +/// field model's nested-entry slot, under the same read-side depth cap as nested behaviors. +/// +/// +public sealed class Not : ICondition +{ + /// Creates the negation of . + public Not(ICondition condition) + { + Inner = condition ?? throw new ArgumentNullException(nameof(condition)); + } + + /// The negated condition. + public ICondition Inner { get; } + + /// + public bool Evaluate(in BehaviorContext ctx) => !Inner.Evaluate(in ctx); +} diff --git a/NxGraph/Diagnostics/Export/MermaidGraphExporter.cs b/NxGraph/Diagnostics/Export/MermaidGraphExporter.cs index a74377d..d49f24f 100644 --- a/NxGraph/Diagnostics/Export/MermaidGraphExporter.cs +++ b/NxGraph/Diagnostics/Export/MermaidGraphExporter.cs @@ -1,4 +1,5 @@ -using System.Text; +using System.Globalization; +using System.Text; using NxGraph.Fsm; using NxGraph.Fsm.Async; using NxGraph.Graphs; @@ -13,6 +14,13 @@ namespace NxGraph.Diagnostics.Export; /// first-class: subroutine-bar shapes, solid fork-labeled branch edges (every branch /// runs — an AND-split, not a choice), the join's in its label, and /// no terminal edge from forks (a fork never ends a token). +/// +/// Data-built branches (spec 023) carry their arms' labels: a 's two +/// edges are labeled true and false, and a 's case +/// edges carry the case literal with the default edge labeled otherwise. The +/// delegate-backed Relay* states stay unlabeled — their decision is opaque by +/// construction. +/// /// public sealed class MermaidGraphExporter : IGraphExporter { @@ -210,6 +218,31 @@ public string Export(Graph graph, ExportOptions? options = null) continue; } + // Data-built branches (spec 023) are the one director family whose arms the exporter + // can name: the decision is data, so "which way did you go" is knowable statically. + // The Relay* (delegate-backed) states keep the unlabeled rendering below — drawing a + // label the exporter cannot know would be a lie. + if (ChoiceOf(dn) is { } choice) + { + string choiceVar = NodeVar(i); + AppendDirectorEdge(sb, choiceVar, choice.TrueTarget, "true", graph); + AppendDirectorEdge(sb, choiceVar, choice.FalseTarget, "false", graph); + continue; + } + + if (SwitchOf(dn) is { } switchNode) + { + string switchVar = NodeVar(i); + for (int c = 0; c < switchNode.CaseCount; c++) + { + AppendDirectorEdge(sb, switchVar, switchNode.CaseTargetAt(c), + FormatCaseLabel(switchNode.CaseValueAt(c)), graph); + } + + AppendDirectorEdge(sb, switchVar, switchNode.DefaultTarget, "otherwise", graph); + continue; + } + IEnumerable? targets = (dn.AsyncLogic as IDirector)?.EnumerateStaticTargets() ?? (dn.Logic as IDirector)?.EnumerateStaticTargets() @@ -244,6 +277,45 @@ public string Export(Graph graph, ExportOptions? options = null) private static EventEntryState? EventEntryOf(LogicNode node) => node.AsyncLogic as EventEntryState ?? node.Logic as EventEntryState; + private static IChoiceNode? ChoiceOf(LogicNode node) => + node.AsyncLogic as IChoiceNode ?? node.Logic as IChoiceNode; + + private static ISwitchNode? SwitchOf(LogicNode node) => + node.AsyncLogic as ISwitchNode ?? node.Logic as ISwitchNode; + + /// + /// Emits one labeled dashed director edge, skipping the + /// terminal sentinel and out-of-range destinations exactly as the unlabeled path does. + /// + private static void AppendDirectorEdge(StringBuilder sb, string from, NodeId target, string label, Graph graph) + { + if (target.Equals(NodeId.Default)) + { + return; + } + + int dstIdx = target.Index; + if ((uint)dstIdx >= (uint)graph.NodeCount) + { + return; + } + + sb.Append(" ").Append(from).Append(" -. ").Append(EscapeLabel(label)).Append(" .-> ") + .Append(NodeVar(dstIdx)).AppendLine(); + } + + /// + /// Renders a switch case literal for its edge label — culture-neutral, so exports are + /// byte-identical regardless of the exporting machine's locale. + /// + private static string FormatCaseLabel(object? value) => + value switch + { + null => "null", + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? "null", + }; + private static bool IsFork(Graph graph, int index) => graph.GetNodeByIndex(index) is LogicNode ln && ForkOf(ln) is not null; diff --git a/NxGraph/Diagnostics/Validations/GraphValidator.cs b/NxGraph/Diagnostics/Validations/GraphValidator.cs index a65a24a..40b625d 100644 --- a/NxGraph/Diagnostics/Validations/GraphValidator.cs +++ b/NxGraph/Diagnostics/Validations/GraphValidator.cs @@ -225,6 +225,11 @@ public static GraphValidationResult Validate(this Graph graph, GraphValidationOp // 4e) Event-entry lints (spec 013) — always on, mirroring the fork/join presence Info. ValidateEventEntries(graph, result); + // 4f) Data-built branch lints (spec 023) — both are about branches that cannot be what + // the author meant. Empty condition lists and duplicate case values are constructor + // rejections, not lints: they cannot reach a built graph. + ValidateBranches(graph, result); + // 5) Unreachable-node and duplicate-name checks. A supplied AllNodes wins (back-compat // for pre-build ID lists); otherwise the set is derived from the graph itself — a built // Graph holds every node with its display name applied at Build(), so standalone @@ -315,6 +320,33 @@ private static void ValidateUids(Graph graph, GraphValidationResult result) } } + private static void ValidateBranches(Graph graph, GraphValidationResult result) + { + for (int i = 0; i < graph.NodeCount; i++) + { + if (!graph.TryGetNodeByIndex(i, out INode? node) || node is not LogicNode logicNode) + { + continue; + } + + if ((logicNode.AsyncLogic as IChoiceNode ?? logicNode.Logic as IChoiceNode) is { } choice && + choice.TrueTarget.Equals(choice.FalseTarget)) + { + result.Add(Severity.Warning, + "Choice routes both arms to the same node — the branch decides nothing. Point the arms " + + "at different nodes, or drop the choice.", node.Id); + } + + if ((logicNode.AsyncLogic as ISwitchNode ?? logicNode.Logic as ISwitchNode) is { } switchNode && + switchNode.DefaultTarget.Equals(NodeId.Default)) + { + result.Add(Severity.Warning, + "Switch declares no default target — a value matching no case terminates the run " + + "silently. Declare an explicit Default(...) arm.", node.Id); + } + } + } + private static void ValidateEventEntries(Graph graph, GraphValidationResult result) { if (!graph.TryGetNodeByIndex(NodeId.Start.Index, out INode? startNode) || diff --git a/NxGraph/Fsm/Async/AsyncChoiceState.cs b/NxGraph/Fsm/Async/AsyncRelayChoiceState.cs similarity index 72% rename from NxGraph/Fsm/Async/AsyncChoiceState.cs rename to NxGraph/Fsm/Async/AsyncRelayChoiceState.cs index 44e64cb..d9d7934 100644 --- a/NxGraph/Fsm/Async/AsyncChoiceState.cs +++ b/NxGraph/Fsm/Async/AsyncRelayChoiceState.cs @@ -4,12 +4,15 @@ namespace NxGraph.Fsm.Async; /// -/// Async director that picks between two destinations via a predicate. The +/// Delegate-backed async director that picks between two destinations via a predicate +/// (the Relay* family — a state whose decision closes over code; it cannot ride a +/// serialization payload, so prefer the data-built ChoiceState when the decision is +/// data). The /// blackboard-context overload receives the machine-bound routed context (see /// ), so branching can read shared memory instead of /// closing over ad-hoc state. /// -public sealed class AsyncChoiceState : IAsyncLogic, IAsyncDirector, IBlackboardSettable +public sealed class AsyncRelayChoiceState : IAsyncLogic, IAsyncDirector, IBlackboardSettable { private readonly Func>? _predicate; private readonly Func>? _bbPredicate; @@ -17,14 +20,14 @@ public sealed class AsyncChoiceState : IAsyncLogic, IAsyncDirector, IBlackboardS private readonly NodeId _falseNode; private BlackboardContext _blackboards; - public AsyncChoiceState(Func> predicate, NodeId trueNode, NodeId falseNode) + public AsyncRelayChoiceState(Func> predicate, NodeId trueNode, NodeId falseNode) { _predicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); _trueNode = trueNode; _falseNode = falseNode; } - public AsyncChoiceState(Func> predicate, NodeId trueNode, NodeId falseNode) + public AsyncRelayChoiceState(Func> predicate, NodeId trueNode, NodeId falseNode) { _bbPredicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); _trueNode = trueNode; diff --git a/NxGraph/Fsm/Async/AsyncSwitchState.cs b/NxGraph/Fsm/Async/AsyncRelaySwitchState.cs similarity index 84% rename from NxGraph/Fsm/Async/AsyncSwitchState.cs rename to NxGraph/Fsm/Async/AsyncRelaySwitchState.cs index c4fee23..2ea795e 100644 --- a/NxGraph/Fsm/Async/AsyncSwitchState.cs +++ b/NxGraph/Fsm/Async/AsyncRelaySwitchState.cs @@ -4,11 +4,14 @@ namespace NxGraph.Fsm.Async; /// -/// Async switch/case director. The blackboard-context overload receives the machine-bound +/// Delegate-backed async switch/case director (the Relay* family — a state whose +/// decision closes over code; it cannot ride a serialization payload, so prefer the data-built +/// SwitchState<T> when the tested value is a blackboard slot). +/// The blackboard-context overload receives the machine-bound /// routed context (see ), so the selector can read shared /// memory instead of closing over ad-hoc state. /// -public sealed class AsyncSwitchState : IAsyncLogic, IAsyncDirector, IBlackboardSettable +public sealed class AsyncRelaySwitchState : IAsyncLogic, IAsyncDirector, IBlackboardSettable where TKey : notnull { private readonly Func>? _selector; @@ -20,7 +23,7 @@ public sealed class AsyncSwitchState : IAsyncLogic, IAsyncDirector, IBlack private NodeId _defaultNode; private BlackboardContext _blackboards; - public AsyncSwitchState( + public AsyncRelaySwitchState( Func> selector, IReadOnlyDictionary cases, NodeId defaultNode = default) @@ -30,7 +33,7 @@ public AsyncSwitchState( _defaultNode = defaultNode.Equals(default(NodeId)) ? NodeId.Default : defaultNode; } - public AsyncSwitchState( + public AsyncRelaySwitchState( Func> selector, IReadOnlyDictionary cases, NodeId defaultNode = default) diff --git a/NxGraph/Fsm/Async/AsyncStateMachine.cs b/NxGraph/Fsm/Async/AsyncStateMachine.cs index cdd172a..f729f0c 100644 --- a/NxGraph/Fsm/Async/AsyncStateMachine.cs +++ b/NxGraph/Fsm/Async/AsyncStateMachine.cs @@ -940,7 +940,7 @@ private async ValueTask StepCoreAsync(CancellationToken ct) next = CanonicalId(next); } - // Sync directors (ChoiceState/SwitchState behind a SyncLogicAdapter) route + // Sync directors (RelayChoiceState/RelaySwitchState behind a SyncLogicAdapter) route // here too — mirroring the sync runtime's `Logic is IDirector` check, and // matching the validator/exporter, which already probe both logic slots. else if (logic.Logic is IDirector syncDirector) diff --git a/NxGraph/Fsm/ChoiceState.cs b/NxGraph/Fsm/ChoiceState.cs index 91cc1ca..049bd44 100644 --- a/NxGraph/Fsm/ChoiceState.cs +++ b/NxGraph/Fsm/ChoiceState.cs @@ -1,58 +1,119 @@ +using NxGraph.Behaviors; using NxGraph.Blackboards; +using NxGraph.Conditions; using NxGraph.Graphs; namespace NxGraph.Fsm; /// -/// Executes a predicate and immediately returns ; the -/// destination is selected via . -/// The blackboard-context overload receives the machine-bound routed context (see -/// ), so branching can read shared memory instead of -/// closing over ad-hoc state. -/// Purely synchronous — the authoring layer wraps this in a -/// so that async runtimes can also execute it. +/// Data-built two-way branch (spec 023): the decision is a list of +/// data objects combined by a mode, not +/// a closure — so a branching graph rides the serialization payload with zero options and +/// therefore survives suspend/resume. For a decision that is genuinely code, the delegate-backed +/// stays fully supported. +/// +/// returns a decision never +/// faults. Selection evaluates the list against the machine-stamped blackboard context: +/// walks until the first , +/// until the first ; the outcome picks +/// or . Either arm may be +/// (terminal exit). +/// +/// +/// One class implements both logic slots and both director interfaces (the ForkState / +/// EventEntryState shape), so a single instance authors either runtime and one wire +/// marker covers both. yields the true arm then +/// the false arm, so reachability validation and Mermaid export need no special casing. +/// +/// +/// The condition list is evaluated through a whose report channel +/// is inert: a branch node's decision is side-effect free by contract, so nothing is routed to +/// the observer from here ( is +/// ). Selection is an array walk over one stack-allocated context — 0 B. +/// /// -public sealed class ChoiceState : ILogic, IDirector, IBlackboardSettable +public sealed class ChoiceState : ILogic, IAsyncLogic, IDirector, IAsyncDirector, IBlackboardSettable, IChoiceNode { - private readonly Func? _predicate; - private readonly Func? _bbPredicate; - private readonly NodeId _trueNode; - private readonly NodeId _falseNode; + private readonly ICondition[] _conditions; + private readonly ConditionMatch _match; + private readonly NodeId _trueTarget; + private readonly NodeId _falseTarget; + private readonly NodeId[] _staticTargets; private BlackboardContext _blackboards; - public ChoiceState(Func predicate, NodeId trueNode, NodeId falseNode) + /// The conditions to evaluate, in order. At least one is + /// required; null entries are rejected. + /// How the conditions combine. + /// The arm taken when the combined decision is true. + /// The arm taken when it is false. + public ChoiceState(IReadOnlyList conditions, ConditionMatch match, NodeId trueTarget, + NodeId falseTarget) { - _predicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); - _trueNode = trueNode; - _falseNode = falseNode; + _conditions = ConditionComposition.ValidateEntries(conditions, nameof(conditions)); + _match = match; + _trueTarget = trueTarget; + _falseTarget = falseTarget; + _staticTargets = [trueTarget, falseTarget]; } - public ChoiceState(Func predicate, NodeId trueNode, NodeId falseNode) + /// Creates a single-condition choice ( of one). + public ChoiceState(ICondition condition, NodeId trueTarget, NodeId falseTarget) + : this(new[] { condition }, ConditionMatch.All, trueTarget, falseTarget) { - _bbPredicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); - _trueNode = trueNode; - _falseNode = falseNode; } - void IBlackboardSettable.SetBlackboards(in BlackboardContext context) => _blackboards = context; + /// + public ConditionMatch Match => _match; /// - public Result Execute() => Result.Success; + public IReadOnlyList Conditions => _conditions; - /// - /// Selects the next node based on the predicate. - /// - /// The next node to run. - public NodeId SelectNext() - { - bool taken = _bbPredicate is not null ? _bbPredicate(_blackboards) : _predicate!(); - return taken ? _trueNode : _falseNode; - } + /// + public NodeId TrueTarget => _trueTarget; /// - public IEnumerable EnumerateStaticTargets() + public NodeId FalseTarget => _falseTarget; + + void IBlackboardSettable.SetBlackboards(in BlackboardContext context) => _blackboards = context; + + private NodeId SelectNextCore() { - yield return _trueNode; - yield return _falseNode; + BehaviorContext ctx = new(in _blackboards, null); + ICondition[] conditions = _conditions; + if (_match == ConditionMatch.All) + { + for (int i = 0; i < conditions.Length; i++) + { + if (!conditions[i].Evaluate(in ctx)) + { + return _falseTarget; + } + } + + return _trueTarget; + } + + for (int i = 0; i < conditions.Length; i++) + { + if (conditions[i].Evaluate(in ctx)) + { + return _trueTarget; + } + } + + return _falseTarget; } + + /// + public NodeId SelectNext() => SelectNextCore(); + + Result ILogic.Execute() => Result.Success; + + ValueTask IAsyncLogic.ExecuteAsync(CancellationToken ct) => ResultHelpers.Success; + + ValueTask IAsyncDirector.SelectNextAsync(CancellationToken ct) => new(SelectNextCore()); + + IEnumerable IDirector.EnumerateStaticTargets() => _staticTargets; + + IEnumerable IAsyncDirector.EnumerateStaticTargets() => _staticTargets; } diff --git a/NxGraph/Fsm/IBranchNode.cs b/NxGraph/Fsm/IBranchNode.cs new file mode 100644 index 0000000..e1380b4 --- /dev/null +++ b/NxGraph/Fsm/IBranchNode.cs @@ -0,0 +1,50 @@ +using NxGraph.Conditions; +using NxGraph.Graphs; + +namespace NxGraph.Fsm; + +/// +/// Non-generic serialization/diagnostics surface of the data-built +/// — the branch twin of IBehaviorComposite. Keeps the serializer and the Mermaid +/// exporter reflection-free at the detection point. +/// +public interface IChoiceNode +{ + /// How the condition list combines. + ConditionMatch Match { get; } + + /// The conditions, in evaluation order. + IReadOnlyList Conditions { get; } + + /// The arm taken when the combined decision is . + NodeId TrueTarget { get; } + + /// The arm taken when the combined decision is . + NodeId FalseTarget { get; } +} + +/// +/// Non-generic serialization/diagnostics surface of the data-built . +/// Case values are exposed boxed: the only consumers are cold paths (payload writing, Mermaid +/// labels). +/// +public interface ISwitchNode +{ + /// The tested key's registered name — the serialization identity. + string KeyName { get; } + + /// The tested key's value type. + Type ValueType { get; } + + /// The arm taken when no case matches ( = terminal). + NodeId DefaultTarget { get; } + + /// The number of cases. + int CaseCount { get; } + + /// The literal value of the case at (boxed). + object? CaseValueAt(int index); + + /// The target of the case at . + NodeId CaseTargetAt(int index); +} diff --git a/NxGraph/Fsm/IDirector.cs b/NxGraph/Fsm/IDirector.cs index c782e41..d62c101 100644 --- a/NxGraph/Fsm/IDirector.cs +++ b/NxGraph/Fsm/IDirector.cs @@ -22,7 +22,7 @@ public interface IDirector /// /// The default returns an empty sequence so existing user implementations compile /// unchanged — but those custom directors will be opaque to the validator and the - /// exporter. Built-in and + /// exporter. Built-in and /// override this to surface their known targets. /// IEnumerable EnumerateStaticTargets() => System.Array.Empty(); diff --git a/NxGraph/Fsm/RelayChoiceState.cs b/NxGraph/Fsm/RelayChoiceState.cs new file mode 100644 index 0000000..124bd5c --- /dev/null +++ b/NxGraph/Fsm/RelayChoiceState.cs @@ -0,0 +1,66 @@ +using NxGraph.Blackboards; +using NxGraph.Graphs; + +namespace NxGraph.Fsm; + +/// +/// Delegate-backed two-way branch (the Relay* family — a state whose decision +/// closes over code): executes a predicate and immediately returns +/// ; the destination is selected via +/// . +/// The blackboard-context overload receives the machine-bound routed context (see +/// ), so branching can read shared memory instead of +/// closing over ad-hoc state. +/// Purely synchronous — the authoring layer wraps this in a +/// so that async runtimes can also execute it. +/// +/// A closure cannot ride a serialization payload, so a graph branching through this state +/// cannot round-trip and therefore cannot survive suspend/resume. When the decision is data +/// — a comparison against a blackboard slot — use the data-built +/// instead, which serializes and renders labelled Mermaid arms. +/// +/// +public sealed class RelayChoiceState : ILogic, IDirector, IBlackboardSettable +{ + private readonly Func? _predicate; + private readonly Func? _bbPredicate; + private readonly NodeId _trueNode; + private readonly NodeId _falseNode; + private BlackboardContext _blackboards; + + public RelayChoiceState(Func predicate, NodeId trueNode, NodeId falseNode) + { + _predicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); + _trueNode = trueNode; + _falseNode = falseNode; + } + + public RelayChoiceState(Func predicate, NodeId trueNode, NodeId falseNode) + { + _bbPredicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); + _trueNode = trueNode; + _falseNode = falseNode; + } + + void IBlackboardSettable.SetBlackboards(in BlackboardContext context) => _blackboards = context; + + /// + public Result Execute() => Result.Success; + + /// + /// Selects the next node based on the predicate. + /// + /// The next node to run. + public NodeId SelectNext() + { + bool taken = _bbPredicate is not null ? _bbPredicate(_blackboards) : _predicate!(); + return taken ? _trueNode : _falseNode; + } + + /// + public IEnumerable EnumerateStaticTargets() + { + yield return _trueNode; + yield return _falseNode; + } +} diff --git a/NxGraph/Fsm/RelaySwitchState.cs b/NxGraph/Fsm/RelaySwitchState.cs new file mode 100644 index 0000000..6f2b212 --- /dev/null +++ b/NxGraph/Fsm/RelaySwitchState.cs @@ -0,0 +1,86 @@ +using NxGraph.Blackboards; +using NxGraph.Graphs; + +namespace NxGraph.Fsm; + +/// +/// Delegate-backed multi-way branch (the Relay* family — a state whose decision +/// closes over code). Branches like a switch/case. Finishes immediately with +/// ; the runtime asks for the next node. +/// +/// A closure cannot ride a serialization payload, so a graph branching through this state +/// cannot round-trip and therefore cannot survive suspend/resume. When the tested value is a +/// blackboard slot and the cases are literals, use the data-built +/// instead. +/// +/// The blackboard-context overload receives the machine-bound routed context (see +/// ), so the selector can read shared memory instead of +/// closing over ad-hoc state. +/// Purely synchronous — the authoring layer wraps this in a +/// so that async runtimes can also execute it. +/// +public sealed class RelaySwitchState : ILogic, IDirector, IBlackboardSettable + where TKey : notnull +{ + private readonly Func? _selector; + private readonly Func? _bbSelector; + private readonly IReadOnlyDictionary _cases; + // When no explicit default is supplied, fall back to NodeId.Default — both the sync and + // the async runtimes treat that as a terminal-success exit from the director. Defaulting + // to default(NodeId) would silently route to Start (index 0) instead. + private NodeId _defaultNode; + private BlackboardContext _blackboards; + + public RelaySwitchState( + Func selector, + IReadOnlyDictionary cases, + NodeId defaultNode = default) + { + _selector = selector ?? throw new ArgumentNullException(nameof(selector)); + _cases = cases ?? throw new ArgumentNullException(nameof(cases)); + _defaultNode = defaultNode.Equals(default(NodeId)) ? NodeId.Default : defaultNode; + } + + public RelaySwitchState( + Func selector, + IReadOnlyDictionary cases, + NodeId defaultNode = default) + { + _bbSelector = selector ?? throw new ArgumentNullException(nameof(selector)); + _cases = cases ?? throw new ArgumentNullException(nameof(cases)); + _defaultNode = defaultNode.Equals(default(NodeId)) ? NodeId.Default : defaultNode; + } + + void IBlackboardSettable.SetBlackboards(in BlackboardContext context) => _blackboards = context; + + /// + /// Selects the next node based on the selector function. + /// + /// The next node to run. + public NodeId SelectNext() + { + TKey key = _bbSelector is not null ? _bbSelector(_blackboards) : _selector!(); + return _cases.GetValueOrDefault(key, _defaultNode); + } + + /// + public IEnumerable EnumerateStaticTargets() + { + foreach (NodeId target in _cases.Values) + yield return target; + yield return _defaultNode; + } + + + /// + public Result Execute() => Result.Success; + + /// + /// Sets the default node to be used when no case matches the selector's key. + /// + /// The default node to set. + internal void SetDefault(NodeId defaultNode) + { + _defaultNode = defaultNode; + } +} diff --git a/NxGraph/Fsm/SwitchState.cs b/NxGraph/Fsm/SwitchState.cs index 99408ef..dee9444 100644 --- a/NxGraph/Fsm/SwitchState.cs +++ b/NxGraph/Fsm/SwitchState.cs @@ -1,79 +1,186 @@ +using NxGraph.Behaviors; using NxGraph.Blackboards; using NxGraph.Graphs; namespace NxGraph.Fsm; /// -/// Branches like a switch/case. Finishes immediately with ; the -/// runtime asks for the next node. -/// The blackboard-context overload receives the machine-bound routed context (see -/// ), so the selector can read shared memory instead of -/// closing over ad-hoc state. -/// Purely synchronous — the authoring layer wraps this in a -/// so that async runtimes can also execute it. +/// One arm of a data-built : a literal value and the node +/// it routes to. Case values are deliberately never bindings — a key-bound case value would +/// make distinctness undecidable at construction, and the switch's whole contract is that at +/// most one case can match. /// -public sealed class SwitchState : ILogic, IDirector, IBlackboardSettable - where TKey : notnull +public readonly record struct SwitchCase(T Value, NodeId Target); + +/// +/// Data-built multi-way branch (spec 023): reads one blackboard key and routes to the +/// case whose literal value it equals, else to . Nothing about the +/// decision is code, so a switching graph rides the serialization payload and survives +/// suspend/resume. For a selector that is genuinely code, use the delegate-backed +/// . +/// +/// Exactly one case can match, and the data enforces it: case values are literals and +/// the constructor rejects duplicates by , naming the +/// offending value. Deserialization reconstructs through the public constructor, so the same +/// guard covers rebuilt graphs — there is no second code path to keep in step. +/// +/// +/// A switch is a lookup and carries no order. Ordered, first-match-wins semantics — +/// where an earlier arm may shadow a later one, or where different arms test different keys — +/// are a chain of s, which is what if/else-if is. Lower to that +/// in the host; the library ships the two shapes every language has and does not grow a third +/// branching primitive to hold an ordered rule table. +/// +/// +/// One class implements both logic slots and both director interfaces, so a single instance +/// authors either runtime. returns — +/// a decision never faults. Selection is one typed Get plus a linear scan over one +/// stack-allocated context — 0 B. +/// +/// +/// The tested key's value type. +public sealed class SwitchState : ILogic, IAsyncLogic, IDirector, IAsyncDirector, IBlackboardSettable, ISwitchNode { - private readonly Func? _selector; - private readonly Func? _bbSelector; - private readonly IReadOnlyDictionary _cases; - // When no explicit default is supplied, fall back to NodeId.Default — both the sync and - // the async runtimes treat that as a terminal-success exit from the director. Defaulting - // to default(NodeId) would silently route to Start (index 0) instead. - private NodeId _defaultNode; + private readonly BlackboardKey _key; + private readonly string _keyName; + private readonly SwitchCase[] _cases; + private readonly NodeId _defaultTarget; + private readonly NodeId[] _staticTargets; private BlackboardContext _blackboards; - public SwitchState( - Func selector, - IReadOnlyDictionary cases, - NodeId defaultNode = default) + /// The blackboard key whose value the switch tests. + /// The arms, in authoring order (order is presentation only — at most + /// one can match). At least one is required; duplicate values are rejected. + /// The arm taken when no case matches; pass + /// for a terminal exit (the validator warns about it). + public SwitchState(BlackboardKey key, IReadOnlyList> cases, NodeId defaultTarget) + : this(ValidatedName(key), key, cases, defaultTarget) { - _selector = selector ?? throw new ArgumentNullException(nameof(selector)); - _cases = cases ?? throw new ArgumentNullException(nameof(cases)); - _defaultNode = defaultNode.Equals(default(NodeId)) ? NodeId.Default : defaultNode; } - public SwitchState( - Func selector, - IReadOnlyDictionary cases, - NodeId defaultNode = default) + private SwitchState(string keyName, BlackboardKey key, IReadOnlyList> cases, + NodeId defaultTarget) { - _bbSelector = selector ?? throw new ArgumentNullException(nameof(selector)); - _cases = cases ?? throw new ArgumentNullException(nameof(cases)); - _defaultNode = defaultNode.Equals(default(NodeId)) ? NodeId.Default : defaultNode; - } + _key = key; + _keyName = keyName; + _cases = ValidateCases(cases); + _defaultTarget = defaultTarget; - void IBlackboardSettable.SetBlackboards(in BlackboardContext context) => _blackboards = context; + NodeId[] targets = new NodeId[_cases.Length + 1]; + for (int i = 0; i < _cases.Length; i++) + { + targets[i] = _cases[i].Target; + } + + targets[_cases.Length] = defaultTarget; + _staticTargets = targets; + } /// - /// Selects the next node based on the selector function. + /// Creates a name-bound switch — the deserialization rebind form. The tested key resolves + /// per selection against the machine's bound boards' schemas (Graph, then Global, then + /// Node), with targeted miss/type-mismatch errors. /// - /// The next node to run. - public NodeId SelectNext() + public static SwitchState Unbound(string keyName, IReadOnlyList> cases, NodeId defaultTarget) { - TKey key = _bbSelector is not null ? _bbSelector(_blackboards) : _selector!(); - return _cases.GetValueOrDefault(key, _defaultNode); + if (string.IsNullOrEmpty(keyName)) + { + throw new ArgumentException("Key name cannot be null or empty.", nameof(keyName)); + } + + return new SwitchState(keyName, default, cases, defaultTarget); } + /// The live tested key; default (invalid) for name-bound instances. + public BlackboardKey Key => _key; + /// - public IEnumerable EnumerateStaticTargets() + public string KeyName => _keyName; + + /// The arms, in authoring order. + public IReadOnlyList> Cases => _cases; + + /// + public NodeId DefaultTarget => _defaultTarget; + + Type ISwitchNode.ValueType => typeof(T); + + int ISwitchNode.CaseCount => _cases.Length; + + object? ISwitchNode.CaseValueAt(int index) => _cases[index].Value; + + NodeId ISwitchNode.CaseTargetAt(int index) => _cases[index].Target; + + void IBlackboardSettable.SetBlackboards(in BlackboardContext context) => _blackboards = context; + + private static string ValidatedName(in BlackboardKey key) { - foreach (NodeId target in _cases.Values) - yield return target; - yield return _defaultNode; + if (!key.IsValid) + { + throw new ArgumentException( + "Invalid blackboard key — obtain keys via BlackboardSchema.Register(...).", nameof(key)); + } + + return key.Name; } + private static SwitchCase[] ValidateCases(IReadOnlyList> cases) + { + if (cases is null || cases.Count == 0) + { + throw new ArgumentException( + "A switch needs at least one case — route unmatched values through the default target.", + nameof(cases)); + } - /// - public Result Execute() => Result.Success; + SwitchCase[] copy = new SwitchCase[cases.Count]; + EqualityComparer comparer = EqualityComparer.Default; + for (int i = 0; i < copy.Length; i++) + { + copy[i] = cases[i]; + for (int j = 0; j < i; j++) + { + if (comparer.Equals(copy[j].Value, copy[i].Value)) + { + throw new ArgumentException( + $"Case value '{copy[i].Value?.ToString() ?? ""}' is declared twice (arms {j} and " + + $"{i}) — a switch is a lookup, so at most one case may match. Ordered, " + + "first-match-wins rules are a chain of ChoiceStates.", nameof(cases)); + } + } + } - /// - /// Sets the default node to be used when no case matches the selector's key. - /// - /// The default node to set. - internal void SetDefault(NodeId defaultNode) + return copy; + } + + private NodeId SelectNextCore() { - _defaultNode = defaultNode; + BlackboardContext bb = _blackboards; + T value = _key.IsValid ? bb.Get(_key) : bb.Get(BehaviorKeyResolver.Resolve(in bb, _keyName)); + + SwitchCase[] cases = _cases; + EqualityComparer comparer = EqualityComparer.Default; + for (int i = 0; i < cases.Length; i++) + { + if (comparer.Equals(cases[i].Value, value)) + { + return cases[i].Target; + } + } + + return _defaultTarget; } + + /// + public NodeId SelectNext() => SelectNextCore(); + + Result ILogic.Execute() => Result.Success; + + ValueTask IAsyncLogic.ExecuteAsync(CancellationToken ct) => ResultHelpers.Success; + + ValueTask IAsyncDirector.SelectNextAsync(CancellationToken ct) => new(SelectNextCore()); + + IEnumerable IDirector.EnumerateStaticTargets() => _staticTargets; + + IEnumerable IAsyncDirector.EnumerateStaticTargets() => _staticTargets; } diff --git a/README.md b/README.md index 21e3ff0..86ee9e6 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The core package targets `net8.0` and `netstandard2.1`. ## Why NxGraph - **Simple runtime model**: graphs are backed by dense node/transition arrays and each node has at most one success edge plus an optional failure edge. -- **Predictable branching**: run-one fan-out happens through director nodes such as `ChoiceState` and `SwitchState`; run-many fan-out through parallel composites — see [Fan-out at a glance](#fan-out-at-a-glance). +- **Predictable branching**: run-one fan-out happens through director nodes such as `RelayChoiceState` and `RelaySwitchState`; run-many fan-out through parallel composites — see [Fan-out at a glance](#fan-out-at-a-glance). - **Authoring ergonomics**: build flows with `StartWithAsync`, `.ToAsync(...)`, `.If(...)`, `.Switch(...)`, `.WaitForAsync(...)`/`.WaitFor(...)`, and `.ToWithTimeoutAsync(...)`/`.ToWithTimeout(...)` — every construct has twins in both runtimes. - **Unity-ready sync runtime**: `StateMachine.Execute()` advances exactly one node per call, drop it into `MonoBehaviour.Update()`. @@ -225,7 +225,7 @@ var graph = GraphBuilder ### Custom directors -`.If(...)` and `.Switch(...)` compile down to the built-in director nodes `ChoiceState` and `SwitchState`. A **director** is a node implementing `IDirector` (`IAsyncDirector` for the async runtime) whose `SelectNext()` picks the next node at runtime — implement it yourself when the routing decision doesn't fit a predicate or a key/case map. Override `EnumerateStaticTargets()` to surface the nodes you can route to: the validator and the Mermaid exporter walk it, and the validator warns when a custom director exposes none (its branches would be invisible to reachability analysis and diagrams). +`.If(...)` and `.Switch(...)` compile down to the built-in director nodes `RelayChoiceState` and `RelaySwitchState`. A **director** is a node implementing `IDirector` (`IAsyncDirector` for the async runtime) whose `SelectNext()` picks the next node at runtime — implement it yourself when the routing decision doesn't fit a predicate or a key/case map. Override `EnumerateStaticTargets()` to surface the nodes you can route to: the validator and the Mermaid exporter walk it, and the validator warns when a custom director exposes none (its branches would be invisible to reachability analysis and diagrams). ### Fan-out at a glance @@ -233,7 +233,7 @@ Every fan-out construct answers two questions: **how many successors run**, and | How many run | Chosen statically (declared in the graph) | Chosen dynamically (at runtime) | |---|---|---| -| **One of many** | Conditional — [`.If(...)`](#branching-with-if) / [`.Switch(...)`](#branching-with-switch) declare the branches and the routing rule | Director — [`IDirector`](#custom-directors) selects any node in code; `ChoiceState`/`SwitchState` are the built-ins | +| **One of many** | Conditional — [`.If(...)`](#branching-with-if) / [`.Switch(...)`](#branching-with-switch) declare the branches and the routing rule | Director — [`IDirector`](#custom-directors) selects any node in code; `RelayChoiceState`/`RelaySwitchState` are the built-ins | | **Many at once** | Parallel — [`.Parallel(regions...)`](#parallel-regions-and-states) runs **all** region graphs | Dynamic parallel — [`.Parallel(selector, ...)`](#dynamic-some-of-many-regions) runs the **subset** a blackboard selector picks | | **Many in one flat graph** | Token runtime — [`.ForkTo(...)` + `JoinState`](#token-runtime-fork-join-and-mid-graph-merge) fan tokens out and merge them mid-graph (all / any / M-of-N) | The same fork/join graph — which tokens reach a join, and when, is decided by each token's own path at runtime | @@ -1249,7 +1249,7 @@ The tests cover: ## FAQ **Why is there only one direct success transition per node?** -Branching is modeled explicitly through directors such as `ChoiceState` and `SwitchState`, which keeps execution simple and predictable. A node can additionally carry one failure edge (`.OnError`) for the fault path. When several paths must run at once, use the parallel composites instead of extra edges — see [Fan-out at a glance](#fan-out-at-a-glance); a token runner with free-form fan-out in one flat graph is a recorded, deliberately deferred design. +Branching is modeled explicitly through directors such as `RelayChoiceState` and `RelaySwitchState`, which keeps execution simple and predictable. A node can additionally carry one failure edge (`.OnError`) for the fault path. When several paths must run at once, use the parallel composites instead of extra edges — see [Fan-out at a glance](#fan-out-at-a-glance); a token runner with free-form fan-out in one flat graph is a recorded, deliberately deferred design. **Can I share a graph across machines?** Yes. `Graph` is immutable after build and can be reused across multiple state machine instances. From fb3a9273ed78983ed9a4f2e6bb57cade42a38188 Mon Sep 17 00:00:00 2001 From: Mohamad Iraji <4851913+Enzx@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:32:10 +0200 Subject: [PATCH 2/3] Serialize data-built branches, cover them, and document the feature Payload version 10 adds two sparse sections (choice and switch) plus an ISerializableCondition / ConditionRegistry pair mirroring the behavior registry, so a branching graph round-trips with zero serializer options and survives the full suspend/serialize/resume loop. Case literals and condition fields reuse the neutral field model; keys ride by name and rebind against the machine's bound schemas. Version 9 payloads read branch-free. Adds condition, choice, switch, DSL, validator, Mermaid, parity-conformance and allocation-gate coverage, refreshes the four public API baselines, and updates the README, AGENTS notes and package changelog. --- NxFSM.Examples/Program.cs | 2 + .../ReadmeExamples/DataBranchingExample.cs | 74 ++ .../BehaviorField.cs | 30 +- .../BehaviorFieldReader.cs | 40 + .../BehaviorFieldWriter.cs | 36 + .../IConditionEntryCodec.cs | 20 + .../IConditionRegistry.cs | 28 + .../ISerializableCondition.cs | 17 + .../NxGraph.Serialization.Abstraction.csproj | 7 +- .../ConditionSerializationTests.cs | 724 ++++++++++++++++++ .../DurableSuspendResumeTests.cs | 86 +++ .../GraphSerializerTestsTextCodec.cs | 24 +- .../RepeatSerializationTests.cs | 9 +- NxGraph.Serialization/BehaviorDto.cs | 114 ++- NxGraph.Serialization/ChoiceDto.cs | 84 ++ NxGraph.Serialization/ConditionRegistry.cs | 156 ++++ NxGraph.Serialization/GraphDto.cs | 7 +- NxGraph.Serialization/GraphDtoFormatter.cs | 29 +- .../GraphFormatterResolver.cs | 27 + NxGraph.Serialization/GraphSerializer.cs | 314 +++++++- .../GraphSerializerOptions.cs | 9 + NxGraph.Serialization/SerializationVersion.cs | 11 +- NxGraph.Serialization/SwitchDto.cs | 101 +++ NxGraph.Serialization/SwitchLiteral.cs | 70 ++ NxGraph.Tests/AllocationGateTests.cs | 83 ++ NxGraph.Tests/ChoiceStateTests.cs | 299 ++++++++ NxGraph.Tests/ConditionTests.cs | 305 ++++++++ NxGraph.Tests/DataBranchDslTests.cs | 284 +++++++ NxGraph.Tests/GraphValidatorTests.cs | 83 ++ NxGraph.Tests/MermaidGraphExporterTests.cs | 103 +++ .../Parity/ParityConformanceTests.cs | 85 ++ ...aph.Serialization.Abstraction.approved.txt | 15 +- .../NxGraph.Serialization.approved.txt | 6 + NxGraph.Tests/PublicApi/NxGraph.approved.txt | 113 ++- .../NxGraph.netstandard2.1.approved.txt | 113 ++- NxGraph.Tests/RelaySwitchStateTests.cs | 96 +++ NxGraph.Tests/SwitchDefaultCaseTests.cs | 145 ++++ NxGraph.Tests/SwitchStateTests.cs | 340 ++++++-- NxGraph/Fsm/IDirector.cs | 5 +- NxGraph/Graphs/LogicNode.cs | 16 + NxGraph/Graphs/NodeId.cs | 6 + README.md | 43 +- upm/com.enzx.nxgraph/CHANGELOG.md | 10 + 43 files changed, 3994 insertions(+), 175 deletions(-) create mode 100644 NxFSM.Examples/ReadmeExamples/DataBranchingExample.cs create mode 100644 NxGraph.Serialization.Abstraction/IConditionEntryCodec.cs create mode 100644 NxGraph.Serialization.Abstraction/IConditionRegistry.cs create mode 100644 NxGraph.Serialization.Abstraction/ISerializableCondition.cs create mode 100644 NxGraph.Serialization.Tests/ConditionSerializationTests.cs create mode 100644 NxGraph.Serialization/ChoiceDto.cs create mode 100644 NxGraph.Serialization/ConditionRegistry.cs create mode 100644 NxGraph.Serialization/SwitchDto.cs create mode 100644 NxGraph.Serialization/SwitchLiteral.cs create mode 100644 NxGraph.Tests/ChoiceStateTests.cs create mode 100644 NxGraph.Tests/ConditionTests.cs create mode 100644 NxGraph.Tests/DataBranchDslTests.cs create mode 100644 NxGraph.Tests/SwitchDefaultCaseTests.cs diff --git a/NxFSM.Examples/Program.cs b/NxFSM.Examples/Program.cs index a3c976c..7d39168 100644 --- a/NxFSM.Examples/Program.cs +++ b/NxFSM.Examples/Program.cs @@ -85,6 +85,8 @@ Console.WriteLine(); BehaviorsExample.Run(); Console.WriteLine(); +await DataBranchingExample.RunAsync(); +Console.WriteLine(); ObserverExample.Run(); Console.WriteLine(); await FeatureExamples.RunAsync(); diff --git a/NxFSM.Examples/ReadmeExamples/DataBranchingExample.cs b/NxFSM.Examples/ReadmeExamples/DataBranchingExample.cs new file mode 100644 index 0000000..ff5399c --- /dev/null +++ b/NxFSM.Examples/ReadmeExamples/DataBranchingExample.cs @@ -0,0 +1,74 @@ +using NxGraph; +using NxGraph.Authoring; +using NxGraph.Blackboards; +using NxGraph.Conditions; +using NxGraph.Fsm.Async; +using NxGraph.Graphs; + +namespace NxFSM.Examples.ReadmeExamples; + +/// +/// Data-built branching (README "Data-built branching (serializable)"): the decision is a list +/// of objects or a blackboard key plus literal cases, not a closure — +/// so the branch rides the graph payload, survives suspend/resume, and renders labelled arms in +/// the Mermaid export. A condition that is false is a decision, never a node failure. +/// +public static class DataBranchingExample +{ + public static async ValueTask RunAsync() + { + Console.WriteLine("=== Data-built branching (serializable) ==="); + + BlackboardSchema world = new("world"); + BlackboardKey alarmRaised = world.Register("alarmRaised", false); + BlackboardKey tier = world.Register("tier", 0); + + Graph guarded = GraphBuilder + .StartWithAsync(_ => ResultHelpers.Success).SetName("Entry") + .If(new IsTrue(alarmRaised)) + .ThenAsync(_ => + { + Console.WriteLine(" Taking Evacuate branch"); + return ResultHelpers.Success; + }).SetName("Evacuate") + .ElseAsync(_ => + { + Console.WriteLine(" Taking Patrol branch"); + return ResultHelpers.Success; + }).SetName("Patrol") + .WithSchema(world) + .Build(); + + Blackboard board = new(world); + board.Set(alarmRaised, true); + Result guardedResult = await guarded.ToAsyncStateMachine().WithBlackboard(board).ExecuteAsync(); + Console.WriteLine($"Result: {guardedResult}"); + + Graph routed = GraphBuilder + .StartWithAsync(_ => ResultHelpers.Success).SetName("Entry") + .Switch(tier) + .CaseAsync(1, _ => + { + Console.WriteLine(" Route 1"); + return ResultHelpers.Success; + }) + .CaseAsync(2, _ => + { + Console.WriteLine(" Route 2"); + return ResultHelpers.Success; + }) + .DefaultAsync(_ => + { + Console.WriteLine(" Route default"); + return ResultHelpers.Success; + }) + .End().SetName("Router") + .WithSchema(world) + .Build(); + + Blackboard routing = new(world); + routing.Set(tier, 2); + Result routedResult = await routed.ToAsyncStateMachine().WithBlackboard(routing).ExecuteAsync(); + Console.WriteLine($"Result: {routedResult}"); + } +} diff --git a/NxGraph.Serialization.Abstraction/BehaviorField.cs b/NxGraph.Serialization.Abstraction/BehaviorField.cs index 607b443..707b178 100644 --- a/NxGraph.Serialization.Abstraction/BehaviorField.cs +++ b/NxGraph.Serialization.Abstraction/BehaviorField.cs @@ -37,6 +37,13 @@ public enum BehaviorFieldKind : byte /// composition, carrying Repeat/AsyncRepeat bodies (payload version 9). /// Behaviors = 8, + + /// + /// A nested condition entry list () — the + /// condition model's one nesting shape, carrying Not's inner condition + /// (payload version 10). + /// + Conditions = 9, } /// @@ -52,7 +59,8 @@ public sealed class BehaviorFieldValue( long integer = 0, double number = 0, BehaviorBinding? binding = null, - BehaviorEntry[]? entries = null) + BehaviorEntry[]? entries = null, + ConditionEntry[]? conditions = null) { /// The value kind, deciding which payload slot is meaningful. public BehaviorFieldKind Kind { get; } = kind; @@ -74,6 +82,9 @@ public sealed class BehaviorFieldValue( /// Nested behavior entries payload (payload version 9); null for every other kind. public BehaviorEntry[]? Entries { get; } = entries; + + /// Nested condition entries payload (payload version 10); null for every other kind. + public ConditionEntry[]? Conditions { get; } = conditions; } /// @@ -92,6 +103,23 @@ public sealed class BehaviorEntry(string behaviorTypeName, BehaviorField[] field public BehaviorField[] Fields { get; } = fields; } +/// +/// One serialized condition entry (payload version 10) — the exact twin of +/// , deliberately a separate type so a condition list can never be +/// read as a behavior list: the condition's runtime-stable CLR type name plus its fields. The +/// recursion closure is the same one behaviors have: a +/// field carries entries, and each entry carries +/// fields. +/// +public sealed class ConditionEntry(string conditionTypeName, BehaviorField[] fields) +{ + /// The condition's runtime-stable CLR type name — the registry's lookup identity. + public string ConditionTypeName { get; } = conditionTypeName; + + /// The entry's fields in write order. + public BehaviorField[] Fields { get; } = fields; +} + /// /// A serialized blackboard binding: the key form carries only the key's registered /// (rebound by name against the machine's bound boards at execution); diff --git a/NxGraph.Serialization.Abstraction/BehaviorFieldReader.cs b/NxGraph.Serialization.Abstraction/BehaviorFieldReader.cs index d4abb30..a414049 100644 --- a/NxGraph.Serialization.Abstraction/BehaviorFieldReader.cs +++ b/NxGraph.Serialization.Abstraction/BehaviorFieldReader.cs @@ -13,6 +13,7 @@ public sealed class BehaviorFieldReader { private readonly IReadOnlyList _fields; private readonly IBehaviorEntryCodec? _entryCodec; + private readonly IConditionEntryCodec? _conditionCodec; /// Wraps a field list (in write order) — standalone, no nested-entry support (see ). public BehaviorFieldReader(IReadOnlyList fields) @@ -27,6 +28,14 @@ internal BehaviorFieldReader(IReadOnlyList fields, IBehaviorEntry _entryCodec = entryCodec; } + internal BehaviorFieldReader(IReadOnlyList fields, IBehaviorEntryCodec? entryCodec, + IConditionEntryCodec? conditionCodec) + : this(fields) + { + _entryCodec = entryCodec; + _conditionCodec = conditionCodec; + } + /// when a field named exists. public bool Has(string name) => Find(name) is not null; @@ -121,6 +130,37 @@ public object[] ReadBehaviors(string name) return live; } + /// + /// Reads a nested condition entry list (payload version 10) back as live instances + /// — Not's inner condition. Each entry is reconstructed recursively through the + /// serializer's registry dispatch, with the usual targeted error for unregistered names. + /// Only operates inside a GraphSerializer payload session — the serializer wires + /// the entry codec into the readers it creates; a standalone reader throws a targeted + /// error. + /// + public object[] ReadConditions(string name) + { + if (_conditionCodec is null) + { + throw new InvalidOperationException( + "ReadConditions only operates inside a GraphSerializer payload session — nested condition " + + "entries are reconstructed by the serializer's entry codec, which is not wired on a " + + "standalone BehaviorFieldReader."); + } + + BehaviorFieldValue value = Require(name, BehaviorFieldKind.Conditions); + ConditionEntry[] entries = value.Conditions ?? throw new InvalidOperationException( + $"Behavior field '{name}' is a condition list but carries no conditions payload."); + + object[] live = new object[entries.Length]; + for (int i = 0; i < entries.Length; i++) + { + live[i] = _conditionCodec.ReadEntry(entries[i]); + } + + return live; + } + private static BlackboardValue LiteralOf(string name, BehaviorFieldValue literal) { if (typeof(T).IsEnum) diff --git a/NxGraph.Serialization.Abstraction/BehaviorFieldWriter.cs b/NxGraph.Serialization.Abstraction/BehaviorFieldWriter.cs index 29d90d2..7b9fb2c 100644 --- a/NxGraph.Serialization.Abstraction/BehaviorFieldWriter.cs +++ b/NxGraph.Serialization.Abstraction/BehaviorFieldWriter.cs @@ -11,6 +11,7 @@ public sealed class BehaviorFieldWriter { private readonly List _fields = []; private readonly IBehaviorEntryCodec? _entryCodec; + private readonly IConditionEntryCodec? _conditionCodec; /// Creates a standalone writer (no nested-entry support — see ). public BehaviorFieldWriter() @@ -22,6 +23,12 @@ internal BehaviorFieldWriter(IBehaviorEntryCodec? entryCodec) _entryCodec = entryCodec; } + internal BehaviorFieldWriter(IBehaviorEntryCodec? entryCodec, IConditionEntryCodec? conditionCodec) + { + _entryCodec = entryCodec; + _conditionCodec = conditionCodec; + } + /// Writes a string field (null allowed). public void WriteString(string name, string? value) => Add(name, new BehaviorFieldValue(BehaviorFieldKind.String, text: value)); @@ -98,6 +105,35 @@ public void WriteBehaviors(string name, IReadOnlyList entries) Add(name, new BehaviorFieldValue(BehaviorFieldKind.Behaviors, entries: encoded)); } + /// + /// Writes a nested condition entry list (payload version 10) — Not's inner + /// condition. Each entry is encoded recursively by the serializer's per-entry dispatch + /// ( else the condition registry), so nested + /// user conditions serialize under exactly the top-level rules. Only operates inside a + /// GraphSerializer payload session — the serializer wires the entry codec into the + /// writers it creates; a standalone writer throws a targeted error. + /// + public void WriteConditions(string name, IReadOnlyList conditions) + { + ArgumentNullException.ThrowIfNull(conditions); + + if (_conditionCodec is null) + { + throw new InvalidOperationException( + "WriteConditions only operates inside a GraphSerializer payload session — nested condition " + + "entries are encoded by the serializer's entry codec, which is not wired on a standalone " + + "BehaviorFieldWriter."); + } + + ConditionEntry[] encoded = new ConditionEntry[conditions.Count]; + for (int i = 0; i < conditions.Count; i++) + { + encoded[i] = _conditionCodec.WriteEntry(conditions[i]); + } + + Add(name, new BehaviorFieldValue(BehaviorFieldKind.Conditions, conditions: encoded)); + } + /// Drains the collected fields in write order. public BehaviorField[] ToFields() => _fields.ToArray(); diff --git a/NxGraph.Serialization.Abstraction/IConditionEntryCodec.cs b/NxGraph.Serialization.Abstraction/IConditionEntryCodec.cs new file mode 100644 index 0000000..b2726aa --- /dev/null +++ b/NxGraph.Serialization.Abstraction/IConditionEntryCodec.cs @@ -0,0 +1,20 @@ +namespace NxGraph.Serialization.Abstraction; + +/// +/// Internal recursion hook behind / +/// — the exact twin of +/// : the graph serializer wires its per-entry condition +/// dispatch (write: else +/// ; read: ) +/// into every writer/reader it creates for a payload session, so nested entry lists +/// () encode under exactly the top-level rules. +/// Standalone writers/readers carry no codec — the two methods throw a targeted error there. +/// +internal interface IConditionEntryCodec +{ + /// Encodes one live condition into a payload entry. + ConditionEntry WriteEntry(object condition); + + /// Reconstructs one live condition from a payload entry. + object ReadEntry(ConditionEntry entry); +} diff --git a/NxGraph.Serialization.Abstraction/IConditionRegistry.cs b/NxGraph.Serialization.Abstraction/IConditionRegistry.cs new file mode 100644 index 0000000..f477f9c --- /dev/null +++ b/NxGraph.Serialization.Abstraction/IConditionRegistry.cs @@ -0,0 +1,28 @@ +namespace NxGraph.Serialization.Abstraction; + +/// +/// Resolves condition payload identities (payload version 10) — the branch twin of +/// . The read side maps a condition's runtime-stable type name +/// plus its fields back to a live instance; the write side covers conditions that carry no +/// implementation of their own — the shipped default +/// registry (NxGraph.Serialization.ConditionRegistry) handles the standard set +/// (IsTrue, Not, closed KeyEquals<T>) built in, so branching graphs +/// round-trip with zero options configured. Same posture as : +/// the registry restores a condition for the name; whether it decides like the authored +/// one is the user's contract. +/// +public interface IConditionRegistry +{ + /// + /// Reconstructs a condition from its runtime-stable type name and serialized fields. + /// Returns when the name is not known to this registry. + /// + bool TryRead(string conditionTypeName, BehaviorFieldReader fields, out object? condition); + + /// + /// Writes the fields of a condition that does not implement + /// itself (the standard set). Returns + /// when the instance is not recognized. + /// + bool TryWrite(object condition, BehaviorFieldWriter fields); +} diff --git a/NxGraph.Serialization.Abstraction/ISerializableCondition.cs b/NxGraph.Serialization.Abstraction/ISerializableCondition.cs new file mode 100644 index 0000000..91e741e --- /dev/null +++ b/NxGraph.Serialization.Abstraction/ISerializableCondition.cs @@ -0,0 +1,17 @@ +namespace NxGraph.Serialization.Abstraction; + +/// +/// Opt-in serialization contract for conditions (payload version 10) — the branch twin of +/// , reusing the same neutral field model +/// () so a decision rides the wire under exactly the rules a +/// behavior does. Reconstruction is registry-based: register a factory under the condition's +/// runtime-stable type name on the GraphSerializerOptions.ConditionRegistry, and it +/// rebuilds the instance from a on read. The standard set +/// (IsTrue, Not, KeyEquals<T>) needs neither — the default registry +/// carries it built in, so a branching graph round-trips with zero options. +/// +public interface ISerializableCondition +{ + /// Writes this condition's fields to the payload. + void Write(BehaviorFieldWriter writer); +} diff --git a/NxGraph.Serialization.Abstraction/NxGraph.Serialization.Abstraction.csproj b/NxGraph.Serialization.Abstraction/NxGraph.Serialization.Abstraction.csproj index 367d1ee..616d802 100644 --- a/NxGraph.Serialization.Abstraction/NxGraph.Serialization.Abstraction.csproj +++ b/NxGraph.Serialization.Abstraction/NxGraph.Serialization.Abstraction.csproj @@ -26,9 +26,10 @@ - + diff --git a/NxGraph.Serialization.Tests/ConditionSerializationTests.cs b/NxGraph.Serialization.Tests/ConditionSerializationTests.cs new file mode 100644 index 0000000..601099a --- /dev/null +++ b/NxGraph.Serialization.Tests/ConditionSerializationTests.cs @@ -0,0 +1,724 @@ +using System.Text; +using NxGraph.Authoring; +using NxGraph.Behaviors; +using NxGraph.Blackboards; +using NxGraph.Conditions; +using NxGraph.Fsm; +using NxGraph.Fsm.Async; +using NxGraph.Graphs; +using NxGraph.Serialization.Abstraction; + +namespace NxGraph.Serialization.Tests; + +/// +/// Payload version 10: data-built branching on the wire. The standard condition set +/// (IsTrue, Not, closed KeyEquals<T>) rides with zero options via +/// the default registry; conditions serialize into the same neutral field model behaviors use; +/// the tested keys ride by name and rebind against the machine's bound boards at evaluation — +/// so a branching graph survives the trip and still decides the same way. +/// +[TestFixture] +[Category("serialization")] +public class ConditionSerializationTests +{ + // ── Test conditions ────────────────────────────────────────────────── + + /// + /// Custom condition: true when its bound operand is at least 3. Serializable on its own + /// (), reconstructed through a registered factory. + /// + private sealed class AtLeastThree(BlackboardValue operand) : ICondition, ISerializableCondition + { + public BlackboardValue Operand { get; } = operand; + + public bool Evaluate(in BehaviorContext ctx) => ctx.Resolve(Operand) >= 3; + + public void Write(BehaviorFieldWriter writer) => writer.WriteBinding("operand", Operand); + } + + /// Not ISerializableCondition and unknown to the registry — must fail loud on write. + private sealed class OpaqueCondition : ICondition + { + public bool Evaluate(in BehaviorContext ctx) => true; + } + + private sealed class DummyCodec : ILogicTextCodec + { + public string Serialize(IAsyncLogic data) => "noop"; + + public IAsyncLogic Deserialize(string s) => new EmptyAsyncLogic(); + } + + /// A codec that legitimately emits the branch marker strings for ordinary logic. + private sealed class MarkerEmittingCodec : ILogicTextCodec + { + public string Serialize(IAsyncLogic data) => "noop"; + + public IAsyncLogic Deserialize(string s) => s is "noop" or "ChoiceState" or "SwitchState" + ? new EmptyAsyncLogic() + : throw new InvalidOperationException($"Unknown logic key '{s}'."); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private static async Task RoundTrip(GraphSerializer serializer, Graph graph, bool binary) + { + await using MemoryStream stream = new(); + if (binary) + { + await serializer.ToBinaryAsync(graph, stream); + stream.Position = 0; + return await serializer.FromBinaryAsync(stream); + } + + await serializer.ToJsonAsync(graph, stream); + stream.Position = 0; + return await serializer.FromJsonAsync(stream); + } + + private static async Task ToJson(GraphSerializer serializer, Graph graph) + { + await using MemoryStream stream = new(); + await serializer.ToJsonAsync(graph, stream); + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static async Task FromJson(GraphSerializer serializer, string json) + { + using MemoryStream source = new(Encoding.UTF8.GetBytes(json)); + return await serializer.FromJsonAsync(source); + } + + private static IChoiceNode ChoiceAt(Graph graph, int index) + { + LogicNode node = (LogicNode)graph.GetNodeByIndex(index); + return (node.Logic as IChoiceNode ?? node.AsyncLogic as IChoiceNode)!; + } + + private static ISwitchNode SwitchAt(Graph graph, int index) + { + LogicNode node = (LogicNode)graph.GetNodeByIndex(index); + return (node.Logic as ISwitchNode ?? node.AsyncLogic as ISwitchNode)!; + } + + /// The gate schema every branch fixture below reads and writes. + private sealed class Gate + { + public BlackboardSchema Schema { get; } = new("gate"); + public BlackboardKey Open { get; } + public BlackboardKey Level { get; } + public BlackboardKey Result { get; } + + public Gate() + { + Open = Schema.Register("open", false); + Level = Schema.Register("level", 0); + Result = Schema.Register("result", 0); + } + + /// Runs over a freshly seeded board and reports the arm taken. + public async Task Decide(Graph graph, bool open, int level) + { + Blackboard board = new(Schema); + board.Set(Open, open); + board.Set(Level, level); + Result result = await graph.ToAsyncStateMachine().WithBlackboard(board).ExecuteAsync(); + Assert.That(result, Is.EqualTo(NxGraph.Result.Success)); + return board.Get(Result); + } + } + + /// An arm that records which way the branch went, and serializes with zero options. + private static BehaviorState Arm(BlackboardKey result, int marker) => + new(new SetValue(result, marker)); + + // ── Choice round trips (zero options) ──────────────────────────────── + + [Test] + public async Task Choice_graph_roundtrips_and_decides_the_same_way([Values] bool binary) + { + Gate gate = new(); + Graph graph = GraphBuilder.Start() + .If(ConditionMatch.All, new IsTrue(gate.Open), new KeyEquals(gate.Level, 3)) + .Then(Arm(gate.Result, 1)) + .Else(Arm(gate.Result, 2)) + .WithSchema(gate.Schema) + .Build(); + + Graph rebuilt = await RoundTrip(new GraphSerializer(new DummyCodec()), graph, binary); + IChoiceNode choice = ChoiceAt(rebuilt, 0); + + Assert.Multiple(() => + { + Assert.That(choice, Is.Not.Null.And.InstanceOf()); + Assert.That(choice.Match, Is.EqualTo(ConditionMatch.All)); + Assert.That(choice.Conditions, Has.Count.EqualTo(2)); + Assert.That(choice.Conditions[0], Is.InstanceOf()); + Assert.That(choice.Conditions[1], Is.InstanceOf>()); + Assert.That(choice.TrueTarget.Index, Is.EqualTo(1), "The true arm's pad index rides as structure."); + Assert.That(choice.FalseTarget.Index, Is.EqualTo(2)); + }); + + Assert.Multiple(async () => + { + // All: both conditions must hold. + Assert.That(await gate.Decide(rebuilt, open: true, level: 3), Is.EqualTo(1)); + Assert.That(await gate.Decide(rebuilt, open: true, level: 4), Is.EqualTo(2)); + Assert.That(await gate.Decide(rebuilt, open: false, level: 3), Is.EqualTo(2)); + // The pre-trip graph agrees, arm for arm. + Assert.That(await gate.Decide(graph, open: true, level: 3), Is.EqualTo(1)); + Assert.That(await gate.Decide(graph, open: false, level: 3), Is.EqualTo(2)); + }); + } + + [Test] + public async Task Any_match_roundtrips_and_decides_the_same_way([Values] bool binary) + { + Gate gate = new(); + Graph graph = GraphBuilder.Start() + .If(ConditionMatch.Any, new IsTrue(gate.Open), new KeyEquals(gate.Level, 3)) + .Then(Arm(gate.Result, 1)) + .Else(Arm(gate.Result, 2)) + .WithSchema(gate.Schema) + .Build(); + + Graph rebuilt = await RoundTrip(new GraphSerializer(new DummyCodec()), graph, binary); + + Assert.That(ChoiceAt(rebuilt, 0).Match, Is.EqualTo(ConditionMatch.Any)); + Assert.Multiple(async () => + { + Assert.That(await gate.Decide(rebuilt, open: true, level: 9), Is.EqualTo(1)); + Assert.That(await gate.Decide(rebuilt, open: false, level: 3), Is.EqualTo(1)); + Assert.That(await gate.Decide(rebuilt, open: false, level: 9), Is.EqualTo(2)); + }); + } + + [Test] + public async Task Nested_not_roundtrips([Values] bool binary) + { + Gate gate = new(); + Graph graph = GraphBuilder.Start() + .If(new Not(new IsTrue(gate.Open))) + .Then(Arm(gate.Result, 1)) + .Else(Arm(gate.Result, 2)) + .WithSchema(gate.Schema) + .Build(); + + Graph rebuilt = await RoundTrip(new GraphSerializer(new DummyCodec()), graph, binary); + IChoiceNode choice = ChoiceAt(rebuilt, 0); + + Assert.Multiple(() => + { + Assert.That(choice.Conditions, Has.Count.EqualTo(1)); + Not not = (Not)choice.Conditions[0]; + Assert.That(not.Inner, Is.InstanceOf(), + "The nested condition rides through the field model's Conditions slot."); + }); + + Assert.Multiple(async () => + { + Assert.That(await gate.Decide(rebuilt, open: false, level: 0), Is.EqualTo(1)); + Assert.That(await gate.Decide(rebuilt, open: true, level: 0), Is.EqualTo(2)); + }); + } + + [Test] + public async Task Payload_carries_markers_sections_and_current_version_stamp() + { + Gate gate = new(); + Graph graph = GraphBuilder.Start() + .If(new KeyEquals(gate.Level, 3)) + .Then(Arm(gate.Result, 1)) + .Else(Arm(gate.Result, 2)) + .WithSchema(gate.Schema) + .Build(); + + string json = await ToJson(new GraphSerializer(new DummyCodec()), graph); + + Assert.Multiple(() => + { + Assert.That(json, Does.Contain($"\"version\": {SerializationVersion.Version}")); + Assert.That(json, Does.Contain("\"ChoiceState\"")); + Assert.That(json, Does.Contain("\"choices\"")); + Assert.That(json, Does.Contain("\"switches\"")); + // The JSON writer escapes the generic-arity backtick as ` (default encoder); + // unescape it before the ordinal containment check. + Assert.That(json.Replace("\\u0060", "`") + .Contains("NxGraph.Conditions.KeyEquals`1[System.Int32]", StringComparison.Ordinal), + Is.True, "KeyEquals rides under its runtime-stable closed-generic name."); + }); + } + + // ── KeyEquals rebinding on a deserialized graph ────────────────────── + + [Test] + public async Task Key_equals_rebinds_by_name_and_decides_on_the_rebuilt_graph([Values] bool binary) + { + Gate gate = new(); + Graph graph = GraphBuilder.Start() + .If(new KeyEquals(gate.Level, 3)) + .Then(Arm(gate.Result, 1)) + .Else(Arm(gate.Result, 2)) + .WithSchema(gate.Schema) + .Build(); + + Graph rebuilt = await RoundTrip(new GraphSerializer(new DummyCodec()), graph, binary); + KeyEquals condition = (KeyEquals)ChoiceAt(rebuilt, 0).Conditions[0]; + + Assert.Multiple(() => + { + Assert.That(condition.KeyName, Is.EqualTo("level"), "The key rides by name only."); + Assert.That(condition.Key.IsValid, Is.False, "Deserialized conditions are name-bound."); + Assert.That(condition.Expected.IsBound, Is.False); + Assert.That(condition.Expected.Literal, Is.EqualTo(3)); + }); + + Assert.Multiple(async () => + { + Assert.That(await gate.Decide(rebuilt, open: false, level: 3), Is.EqualTo(1)); + Assert.That(await gate.Decide(rebuilt, open: false, level: 4), Is.EqualTo(2)); + }); + } + + [Test] + public async Task Key_equals_expected_side_may_be_another_key([Values] bool binary) + { + Gate gate = new(); + BlackboardKey expected = gate.Schema.Register("expected", 0); + Graph graph = GraphBuilder.Start() + .If(new KeyEquals(gate.Level, expected)) + .Then(Arm(gate.Result, 1)) + .Else(Arm(gate.Result, 2)) + .WithSchema(gate.Schema) + .Build(); + + Graph rebuilt = await RoundTrip(new GraphSerializer(new DummyCodec()), graph, binary); + KeyEquals condition = (KeyEquals)ChoiceAt(rebuilt, 0).Conditions[0]; + + Assert.Multiple(() => + { + Assert.That(condition.Expected.IsBound, Is.True); + Assert.That(condition.Expected.KeyName, Is.EqualTo("expected")); + }); + + Blackboard board = new(gate.Schema); + board.Set(gate.Level, 7); + board.Set(expected, 7); + Result result = await rebuilt.ToAsyncStateMachine().WithBlackboard(board).ExecuteAsync(); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(board.Get(gate.Result), Is.EqualTo(1), "Key-against-key comparison survived the trip."); + }); + } + + [Test] + public async Task Rebind_against_a_schema_missing_the_key_throws_targeted() + { + Gate gate = new(); + Graph graph = GraphBuilder.Start() + .If(new KeyEquals(gate.Level, 3)) + .Then(Arm(gate.Result, 1)) + .Else(Arm(gate.Result, 2)) + .WithSchema(gate.Schema) + .Build(); + + Graph rebuilt = await RoundTrip(new GraphSerializer(new DummyCodec()), graph, binary: false); + + BlackboardSchema other = new("other"); + other.Register("differentName"); + AsyncStateMachine machine = rebuilt.ToAsyncStateMachine().WithBlackboard(new Blackboard(other)); + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await machine.ExecuteAsync()); + Assert.That(ex!.Message, Does.Contain("'level'").And.Contain("does not exist")); + } + + [Test] + public async Task Rebind_against_a_mismatched_value_type_throws_targeted() + { + Gate gate = new(); + Graph graph = GraphBuilder.Start() + .If(new KeyEquals(gate.Level, 3)) + .Then(Arm(gate.Result, 1)) + .Else(Arm(gate.Result, 2)) + .WithSchema(gate.Schema) + .Build(); + + Graph rebuilt = await RoundTrip(new GraphSerializer(new DummyCodec()), graph, binary: false); + + BlackboardSchema other = new("other"); + other.Register("level"); // same name, different value type + AsyncStateMachine machine = rebuilt.ToAsyncStateMachine().WithBlackboard(new Blackboard(other)); + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await machine.ExecuteAsync()); + Assert.That(ex!.Message, Does.Contain("'level'").And.Contain("declared as")); + } + + // ── Switch round trips ─────────────────────────────────────────────── + + private sealed class Router + { + public BlackboardSchema Schema { get; } = new("router"); + public BlackboardKey Mode { get; } + public BlackboardKey Result { get; } + + public Router() + { + Mode = Schema.Register("mode", "a"); + Result = Schema.Register("result", 0); + } + + public Graph Build() => + GraphBuilder.Start() + .Switch(Mode) + .Case("a", Arm(Result, 1)) + .Case("b", Arm(Result, 2)) + .Default(Arm(Result, 9)) + .End() + .SetName("mode-switch") + .WithSchema(Schema) + .Build(); + + public async Task Route(Graph graph, string mode) + { + Blackboard board = new(Schema); + board.Set(Mode, mode); + Result result = await graph.ToAsyncStateMachine().WithBlackboard(board).ExecuteAsync(); + Assert.That(result, Is.EqualTo(NxGraph.Result.Success)); + return board.Get(Result); + } + } + + [Test] + public async Task Switch_graph_roundtrips_with_its_cases_and_default([Values] bool binary) + { + Router router = new(); + Graph rebuilt = await RoundTrip(new GraphSerializer(new DummyCodec()), router.Build(), binary); + ISwitchNode switchNode = SwitchAt(rebuilt, 0); + + Assert.Multiple(() => + { + Assert.That(switchNode, Is.Not.Null.And.InstanceOf>()); + Assert.That(switchNode.KeyName, Is.EqualTo("mode")); + Assert.That(switchNode.ValueType, Is.EqualTo(typeof(string))); + Assert.That(switchNode.CaseCount, Is.EqualTo(2)); + Assert.That(switchNode.CaseValueAt(0), Is.EqualTo("a")); + Assert.That(switchNode.CaseTargetAt(0).Index, Is.EqualTo(1)); + Assert.That(switchNode.CaseValueAt(1), Is.EqualTo("b")); + Assert.That(switchNode.CaseTargetAt(1).Index, Is.EqualTo(2)); + Assert.That(switchNode.DefaultTarget.Index, Is.EqualTo(3)); + Assert.That(((SwitchState)switchNode).Key.IsValid, Is.False, + "Deserialized switches are name-bound."); + }); + + Assert.Multiple(async () => + { + Assert.That(await router.Route(rebuilt, "a"), Is.EqualTo(1)); + Assert.That(await router.Route(rebuilt, "b"), Is.EqualTo(2)); + Assert.That(await router.Route(rebuilt, "zzz"), Is.EqualTo(9)); + }); + } + + [Test] + public async Task Switch_rebind_against_a_schema_missing_the_key_throws_targeted() + { + Router router = new(); + Graph rebuilt = await RoundTrip(new GraphSerializer(new DummyCodec()), router.Build(), binary: false); + + BlackboardSchema other = new("other"); + other.Register("differentName", "a"); + AsyncStateMachine machine = rebuilt.ToAsyncStateMachine().WithBlackboard(new Blackboard(other)); + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await machine.ExecuteAsync()); + Assert.That(ex!.Message, Does.Contain("'mode'").And.Contain("does not exist")); + } + + [Test] + public async Task Switch_rebind_against_a_mismatched_value_type_throws_targeted() + { + Router router = new(); + Graph rebuilt = await RoundTrip(new GraphSerializer(new DummyCodec()), router.Build(), binary: false); + + BlackboardSchema other = new("other"); + other.Register("mode", 0); // same name, different value type + AsyncStateMachine machine = rebuilt.ToAsyncStateMachine().WithBlackboard(new Blackboard(other)); + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await machine.ExecuteAsync()); + Assert.That(ex!.Message, Does.Contain("'mode'").And.Contain("declared as")); + } + + [Test] + public async Task Enum_cases_roundtrip([Values] bool binary) + { + BlackboardSchema schema = new("severity"); + BlackboardKey key = schema.Register("severity", LogSeverity.Info); + BlackboardKey result = schema.Register("result", 0); + + Graph graph = GraphBuilder.Start() + .Switch(key) + .Case(LogSeverity.Warning, Arm(result, 1)) + .Default(Arm(result, 9)) + .End() + .WithSchema(schema) + .Build(); + + Graph rebuilt = await RoundTrip(new GraphSerializer(new DummyCodec()), graph, binary); + ISwitchNode switchNode = SwitchAt(rebuilt, 0); + + Assert.Multiple(() => + { + Assert.That(switchNode.ValueType, Is.EqualTo(typeof(LogSeverity))); + Assert.That(switchNode.CaseValueAt(0), Is.EqualTo(LogSeverity.Warning), + "Enum case literals ride as member names, like every enum in the field model."); + }); + } + + [Test] + public void Switch_over_a_type_outside_the_field_model_fails_naming_the_node() + { + BlackboardSchema schema = new("ids"); + BlackboardKey key = schema.Register("id", Guid.Empty); + + Graph graph = GraphBuilder.Start() + .Switch(key) + .Case(Guid.Parse("11111111-1111-1111-1111-111111111111"), new EmptyLogic()) + .Default(new EmptyLogic()) + .End() + .SetName("id-switch") + .WithSchema(schema) + .Build(); + + NotSupportedException? ex = Assert.ThrowsAsync(async () => + await ToJson(new GraphSerializer(new DummyCodec()), graph)); + Assert.That(ex!.Message, Does.Contain("id-switch").And.Contain("outside the behavior field model")); + } + + // ── Custom conditions ──────────────────────────────────────────────── + + [Test] + public async Task Custom_condition_roundtrips_via_registered_factory([Values] bool binary) + { + Gate gate = new(); + ConditionRegistry registry = new(); + registry.Register(typeof(AtLeastThree).FullName!, + fields => new AtLeastThree(fields.ReadBinding("operand"))); + + GraphSerializer serializer = new(new DummyCodec(), + new GraphSerializerOptions { ConditionRegistry = registry }); + + Graph graph = GraphBuilder.Start() + .If(new AtLeastThree(gate.Level)) + .Then(Arm(gate.Result, 1)) + .Else(Arm(gate.Result, 2)) + .WithSchema(gate.Schema) + .Build(); + + Graph rebuilt = await RoundTrip(serializer, graph, binary); + AtLeastThree condition = (AtLeastThree)ChoiceAt(rebuilt, 0).Conditions[0]; + + Assert.That(condition.Operand.KeyName, Is.EqualTo("level")); + Assert.Multiple(async () => + { + Assert.That(await gate.Decide(rebuilt, open: false, level: 5), Is.EqualTo(1)); + Assert.That(await gate.Decide(rebuilt, open: false, level: 1), Is.EqualTo(2)); + }); + } + + [Test] + public void Unserializable_condition_fails_write_naming_the_registry() + { + Gate gate = new(); + Graph graph = GraphBuilder.Start() + .If(new OpaqueCondition()) + .Then(Arm(gate.Result, 1)) + .Else(Arm(gate.Result, 2)) + .Build(); + + NotSupportedException? ex = Assert.ThrowsAsync(async () => + await ToJson(new GraphSerializer(new DummyCodec()), graph)); + Assert.That(ex!.Message, Does.Contain("OpaqueCondition").And.Contain("ConditionRegistry")); + } + + [Test] + public async Task Unregistered_condition_name_fails_read_naming_the_registry() + { + Gate gate = new(); + Graph graph = GraphBuilder.Start() + .If(new AtLeastThree(gate.Level)) + .Then(Arm(gate.Result, 1)) + .Else(Arm(gate.Result, 2)) + .WithSchema(gate.Schema) + .Build(); + + // AtLeastThree is ISerializableCondition — the write needs no factory; the read does. + string json = await ToJson(new GraphSerializer(new DummyCodec()), graph); + + NotSupportedException? ex = Assert.ThrowsAsync(async () => + await FromJson(new GraphSerializer(new DummyCodec()), json)); + Assert.That(ex!.Message, Does.Contain("AtLeastThree").And.Contain("ConditionRegistry")); + } + + // ── Version stamps, back compatibility, spoof defense ──────────────── + + [Test] + public async Task Version_nine_payload_reads_branch_free() + { + string json = """ + { + "version": 9, + "nodes": [ { "$type": "txt", "index": 0, "name": "a", "logic": "noop" } ], + "transitions": [ { "destination": -1 } ], + "name": null, "index": -1 + } + """; + + Graph rebuilt = await FromJson(new GraphSerializer(new DummyCodec()), json); + + Assert.That(((LogicNode)rebuilt.StartNode).AsyncLogic, Is.Not.InstanceOf(), + "A pre-v10 payload rebuilds as an ordinary graph with no branch surface."); + } + + [Test] + public async Task Branch_marker_strings_in_ordinary_logic_are_not_honored() + { + // Without a ChoiceDto/SwitchDto claiming the index, the marker string must fall + // through to the ordinary logic codec. + Graph graph = GraphBuilder.Start().ToAsync(new EmptyAsyncLogic()).Build(); + GraphSerializer serializer = new(new MarkerEmittingCodec()); + + string choiceJson = (await ToJson(serializer, graph)) + .Replace("\"logic\": \"noop\"", "\"logic\": \"ChoiceState\""); + string switchJson = (await ToJson(serializer, graph)) + .Replace("\"logic\": \"noop\"", "\"logic\": \"SwitchState\""); + + Graph rebuiltChoice = await FromJson(serializer, choiceJson); + Graph rebuiltSwitch = await FromJson(serializer, switchJson); + + Assert.Multiple(() => + { + Assert.That(((LogicNode)rebuiltChoice.StartNode).AsyncLogic, Is.InstanceOf()); + Assert.That(((LogicNode)rebuiltSwitch.StartNode).AsyncLogic, Is.InstanceOf()); + }); + } + + [Test] + public void Choice_claim_on_a_non_marker_node_throws() + { + string json = $$""" + { + "version": {{SerializationVersion.Version}}, + "nodes": [ { "$type": "txt", "index": 0, "name": "a", "logic": "noop" } ], + "transitions": [ { "destination": -1 } ], + "choices": [ + { + "ownerIndex": 0, "match": 0, "trueTarget": -1, "falseTarget": -1, + "conditions": [ { "conditionTypeName": "NxGraph.Conditions.IsTrue", "fields": [] } ] + } + ], + "name": null, "index": -1 + } + """; + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await FromJson(new GraphSerializer(new DummyCodec()), json)); + Assert.That(ex!.Message, Does.Contain("does not reference a choice marker")); + } + + [Test] + public void Cross_section_claim_overlap_with_forks_throws() + { + string json = $$""" + { + "version": {{SerializationVersion.Version}}, + "nodes": [ + { "$type": "txt", "index": 0, "name": "a", "logic": "ChoiceState" }, + { "$type": "txt", "index": 1, "name": "b", "logic": "noop" } + ], + "transitions": [ { "destination": -1 }, { "destination": -1 } ], + "forks": [ { "ownerIndex": 0, "branches": [ 1 ] } ], + "choices": [ + { "ownerIndex": 0, "match": 0, "trueTarget": 1, "falseTarget": -1, "conditions": [] } + ], + "name": null, "index": -1 + } + """; + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await FromJson(new GraphSerializer(new DummyCodec()), json)); + Assert.That(ex!.Message, Does.Contain("claimed by both")); + } + + [Test] + public void Empty_condition_array_throws() + { + string json = $$""" + { + "version": {{SerializationVersion.Version}}, + "nodes": [ { "$type": "txt", "index": 0, "name": "a", "logic": "ChoiceState" } ], + "transitions": [ { "destination": -1 } ], + "choices": [ + { "ownerIndex": 0, "match": 0, "trueTarget": -1, "falseTarget": -1, "conditions": [] } + ], + "name": null, "index": -1 + } + """; + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await FromJson(new GraphSerializer(new DummyCodec()), json)); + Assert.That(ex!.Message, Does.Contain("at least one condition")); + } + + [Test] + public void Empty_case_array_throws() + { + string json = $$""" + { + "version": {{SerializationVersion.Version}}, + "nodes": [ { "$type": "txt", "index": 0, "name": "a", "logic": "SwitchState" } ], + "transitions": [ { "destination": -1 } ], + "switches": [ + { + "ownerIndex": 0, "keyName": "mode", "valueTypeName": "System.String", + "cases": [], "defaultTarget": -1 + } + ], + "name": null, "index": -1 + } + """; + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await FromJson(new GraphSerializer(new DummyCodec()), json)); + Assert.That(ex!.Message, Does.Contain("at least one case")); + } + + [Test] + public void Unresolvable_switch_value_type_throws_naming_it() + { + string json = $$""" + { + "version": {{SerializationVersion.Version}}, + "nodes": [ { "$type": "txt", "index": 0, "name": "a", "logic": "SwitchState" } ], + "transitions": [ { "destination": -1 } ], + "switches": [ + { + "ownerIndex": 0, "keyName": "mode", "valueTypeName": "Nowhere.Missing", + "cases": [ + { "targetIndex": -1, "literal": { "kind": 7, "binding": { "literal": { "kind": 0, "text": "a" } } } } + ], + "defaultTarget": -1 + } + ], + "name": null, "index": -1 + } + """; + + InvalidOperationException? ex = Assert.ThrowsAsync( + async () => await FromJson(new GraphSerializer(new DummyCodec()), json)); + Assert.That(ex!.Message, Does.Contain("Nowhere.Missing").And.Contain("cannot be resolved")); + } +} diff --git a/NxGraph.Serialization.Tests/DurableSuspendResumeTests.cs b/NxGraph.Serialization.Tests/DurableSuspendResumeTests.cs index d4cde8b..b8541ba 100644 --- a/NxGraph.Serialization.Tests/DurableSuspendResumeTests.cs +++ b/NxGraph.Serialization.Tests/DurableSuspendResumeTests.cs @@ -1,5 +1,7 @@ using System.Text.Json; using NxGraph.Authoring; +using NxGraph.Blackboards; +using NxGraph.Conditions; using NxGraph.Fsm; using NxGraph.Fsm.Async; using NxGraph.Graphs; @@ -17,6 +19,25 @@ public class DurableSuspendResumeTests { private readonly GraphSerializer _serializer = new(new DummyLogicTextCodec()); + /// + /// plus the branch pads: .If(...) wires its two + /// arms through empty pad nodes, which are ordinary logic and therefore the codec's problem, + /// not the branch section's. + /// + private sealed class PadTolerantCodec : ILogicTextCodec + { + private const string Pad = "pad"; + + public string Serialize(IAsyncLogic asyncLogic) => asyncLogic is DummyState dummy + ? JsonSerializer.Serialize(dummy) + : Pad; + + public IAsyncLogic Deserialize(string s) => s == Pad + ? new EmptyAsyncLogic() + : JsonSerializer.Deserialize(s) + ?? throw new InvalidOperationException("Failed to deserialize DummyState from text."); + } + private sealed class RecordingObserver : IAsyncStateMachineObserver { public readonly List Events = []; @@ -90,4 +111,69 @@ public async Task suspend_serialize_deserialize_resume_completes_the_flow() Assert.That(observer.Events, Does.Contain("exited:2")); }); } + + /// + /// The capstone of spec 023: a graph that branches survives the durability loop. + /// Before data-built branching this was impossible — the decision was a closure, so the + /// graph could not ride the payload at all. All three artifacts travel: the graph payload, + /// the machine snapshot, and the blackboard the decision reads. + /// + [Test] + public async Task a_branching_graph_survives_the_full_durability_loop() + { + GraphSerializer serializer = new(new PadTolerantCodec()); + BlackboardSchema schema = new("routing"); + BlackboardKey tier = schema.Register("tier", "standard"); + + Graph original = GraphBuilder + .StartWithAsync(new DummyState { Data = "intake" }) + .If(new KeyEquals(tier, "premium")) + .ThenAsync(new DummyState { Data = "premium" }) + .ElseAsync(new DummyState { Data = "standard" }) + .WithSchema(schema) + .Build(); + + Blackboard board = new(schema); + board.Set(tier, "premium"); + + // Run the intake node, then suspend before the branch has been taken. + AsyncStateMachine running = original.ToAsyncStateMachine().WithBlackboard(board); + Assert.That(await running.StepAsync(), Is.EqualTo(Result.InProgress)); + StateMachineSnapshot snapshot = running.Suspend(); + + // Ship all three artifacts, as a durable store would. + await using MemoryStream graphStream = new(); + await serializer.ToJsonAsync(original, graphStream); + string snapshotJson = JsonSerializer.Serialize(snapshot); + await using MemoryStream boardStream = new(); + BlackboardSerializer boardSerializer = new(); + await boardSerializer.ToJsonAsync(board, boardStream); + + // Rebuild everything on the "other side" — the choice reconstructs from the payload's + // condition list, and its key rebinds by name against the restored board. + graphStream.Position = 0; + Graph rebuilt = await serializer.FromJsonAsync(graphStream); + StateMachineSnapshot restored = JsonSerializer.Deserialize(snapshotJson)!; + Blackboard restoredBoard = new(schema); + boardStream.Position = 0; + await boardSerializer.RestoreFromJsonAsync(restoredBoard, boardStream); + + RecordingObserver observer = new(); + AsyncStateMachine resumed = rebuilt.ToAsyncStateMachine(observer).WithBlackboard(restoredBoard); + resumed.Resume(restored); + + Result result = Result.InProgress; + while (result == Result.InProgress) + { + result = await resumed.StepAsync(); + } + + // Node layout: 0 intake, 1 truePad, 2 falsePad, 3 choice, 4 premium, 5 standard. + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(observer.Events, Does.Contain("exited:4"), "the premium arm must run"); + Assert.That(observer.Events, Does.Not.Contain("entered:5"), "the standard arm must not run"); + }); + } } diff --git a/NxGraph.Serialization.Tests/GraphSerializerTestsTextCodec.cs b/NxGraph.Serialization.Tests/GraphSerializerTestsTextCodec.cs index 782d635..1e83bb9 100644 --- a/NxGraph.Serialization.Tests/GraphSerializerTestsTextCodec.cs +++ b/NxGraph.Serialization.Tests/GraphSerializerTestsTextCodec.cs @@ -446,7 +446,7 @@ public void Serialization_version_only_moves_with_a_deliberate_format_addition() // drains) must never move the payload version — the project bumps it only for // structural format additions. Update this pin consciously, together with the // changelog comment in SerializationVersion.cs, when such an addition ships. - Assert.That(SerializationVersion.Version, Is.EqualTo(9)); + Assert.That(SerializationVersion.Version, Is.EqualTo(10)); } // ── Crafted MessagePack: inflated header counts must drain, not desync ── @@ -469,7 +469,7 @@ private static void WriteTransition(ref MessagePackWriter writer, int destinatio /// /// A parent graph whose nested subgraph's GraphDto array header declares one element more - /// than the current 16-element shape (the 16 known fields plus one trailing nil). The + /// than the current 18-element shape (the 18 known fields plus one trailing nil). The /// parent carries a retry policy after the subgraph section, so its reads only stay /// aligned if the child read drains the extra element instead of leaving it in the stream. /// @@ -478,8 +478,8 @@ private static byte[] CraftBinaryPayloadWithInflatedChildHeader() ArrayBufferWriter buffer = new(); MessagePackWriter writer = new(buffer); - // Parent GraphDto: the regular 16-element v9 shape. - writer.WriteArrayHeader(16); + // Parent GraphDto: the regular 18-element v10 shape. + writer.WriteArrayHeader(18); writer.Write(SerializationVersion.Version); // 0. version writer.Write(-1); // 1. index writer.WriteNil(); // 2. name @@ -493,8 +493,8 @@ private static byte[] CraftBinaryPayloadWithInflatedChildHeader() writer.WriteArrayHeader(2); // SubGraphDto: [OwnerIndex, GraphDto] writer.Write(1); - // Child GraphDto with an inflated header: 17 declared elements. - writer.WriteArrayHeader(17); + // Child GraphDto with an inflated header: 19 declared elements. + writer.WriteArrayHeader(19); writer.Write(SerializationVersion.Version); writer.Write(-1); writer.WriteNil(); @@ -502,14 +502,14 @@ private static byte[] CraftBinaryPayloadWithInflatedChildHeader() WriteTextNode(ref writer, 0, "c", "{\"Data\":\"c\"}"); writer.WriteArrayHeader(1); // transitions WriteTransition(ref writer, -1); - for (int section = 0; section < 11; section++) + for (int section = 0; section < 13; section++) { - writer.WriteArrayHeader(0); // subGraphs .. behaviors, all empty + writer.WriteArrayHeader(0); // subGraphs .. switches, all empty } - writer.WriteNil(); // the unknown 17th element the reader must drain + writer.WriteNil(); // the unknown 19th element the reader must drain - // Parent sections 6..15: a real retry policy first, then the rest empty. Without the + // Parent sections 6..17: a real retry policy first, then the rest empty. Without the // child drain, this policy would be read one slot late and misparse. writer.WriteArrayHeader(1); // 6. retryPolicies writer.WriteArrayHeader(4); // [Index, MaxAttempts, BackoffTicks, BackoffKind] @@ -517,9 +517,9 @@ private static byte[] CraftBinaryPayloadWithInflatedChildHeader() writer.Write(2); writer.Write(0L); writer.Write(0); - for (int section = 0; section < 9; section++) + for (int section = 0; section < 11; section++) { - writer.WriteArrayHeader(0); // 7. outcomeCodes .. 15. behaviors, all empty + writer.WriteArrayHeader(0); // 7. outcomeCodes .. 17. switches, all empty } writer.Flush(); diff --git a/NxGraph.Serialization.Tests/RepeatSerializationTests.cs b/NxGraph.Serialization.Tests/RepeatSerializationTests.cs index c6a8f2c..6c67bf8 100644 --- a/NxGraph.Serialization.Tests/RepeatSerializationTests.cs +++ b/NxGraph.Serialization.Tests/RepeatSerializationTests.cs @@ -385,7 +385,7 @@ public void Wrong_family_body_entry_fails_with_the_targeted_error() // ── Version stamps and back compatibility ──────────────────────────── [Test] - public async Task Payload_carries_the_version_nine_stamp_and_stable_repeat_names() + public async Task Payload_carries_the_current_version_stamp_and_stable_repeat_names() { (BlackboardSchema schema, BlackboardKey trips, BlackboardKey index, _) = LoopSchema(); Graph graph = GraphBuilder.Start() @@ -397,8 +397,11 @@ public async Task Payload_carries_the_version_nine_stamp_and_stable_repeat_names Assert.Multiple(() => { - Assert.That(SerializationVersion.Version, Is.EqualTo(9)); - Assert.That(json, Does.Contain("\"version\": 9")); + // Nested repeat bodies shipped with payload version 9, so the stamp can only move + // forward from there; the single deliberate version pin lives in + // GraphSerializerTestsTextCodec. + Assert.That(SerializationVersion.Version, Is.GreaterThanOrEqualTo(9)); + Assert.That(json, Does.Contain($"\"version\": {SerializationVersion.Version}")); Assert.That(json, Does.Contain("NxGraph.Behaviors.Repeat")); Assert.That(json, Does.Contain("NxGraph.Behaviors.Log"), "The nested body entry rides under its own stable name."); diff --git a/NxGraph.Serialization/BehaviorDto.cs b/NxGraph.Serialization/BehaviorDto.cs index f8d7def..1e1022e 100644 --- a/NxGraph.Serialization/BehaviorDto.cs +++ b/NxGraph.Serialization/BehaviorDto.cs @@ -22,7 +22,8 @@ internal sealed class BehaviorDtoFormatter : GraphEntityFormatter // Read-side cap on Behaviors-field recursion (payload version 9): a deeper nesting is a // crafted or corrupt payload, not a real graph — without the cap an attacker can // stack-overflow the reader (the deep-suspend depth-cap precedent). Bindings keep their - // own nest-one rule. + // own nest-one rule. Conditions-field recursion (payload version 10) carries its own + // counter under the same cap: the two nesting axes must not be able to fund each other. internal const int MaxBehaviorNestingDepth = 32; public override void Serialize(ref MessagePackWriter writer, BehaviorDto value, @@ -42,12 +43,29 @@ public override void Serialize(ref MessagePackWriter writer, BehaviorDto value, } } - private static void WriteEntry(ref MessagePackWriter writer, BehaviorEntry entry) + internal static void WriteEntry(ref MessagePackWriter writer, BehaviorEntry entry) { writer.WriteArrayHeader(2); writer.Write(entry.BehaviorTypeName); - writer.WriteArrayHeader(entry.Fields.Length); - foreach (BehaviorField field in entry.Fields) + WriteFields(ref writer, entry.Fields); + } + + /// + /// Writes one condition entry (payload version 10) — the same + /// [typeName, fields[]] shape behavior entries use, so both nesting axes share one + /// field-value encoding. + /// + internal static void WriteConditionEntry(ref MessagePackWriter writer, ConditionEntry entry) + { + writer.WriteArrayHeader(2); + writer.Write(entry.ConditionTypeName); + WriteFields(ref writer, entry.Fields); + } + + private static void WriteFields(ref MessagePackWriter writer, BehaviorField[] fields) + { + writer.WriteArrayHeader(fields.Length); + foreach (BehaviorField field in fields) { writer.WriteArrayHeader(2); writer.Write(field.Name); @@ -55,11 +73,12 @@ private static void WriteEntry(ref MessagePackWriter writer, BehaviorEntry entry } } - private static void WriteValue(ref MessagePackWriter writer, BehaviorFieldValue value) + internal static void WriteValue(ref MessagePackWriter writer, BehaviorFieldValue value) { - // [Kind, Text?, Flag, Integer, Number, Binding?, Entries?] — the Entries slot arrived - // with payload version 9 (pre-v9 payloads wrote 6 elements; both shapes read). - writer.WriteArrayHeader(7); + // [Kind, Text?, Flag, Integer, Number, Binding?, Entries?, Conditions?] — the Entries + // slot arrived with payload version 9 and the Conditions slot with version 10 (pre-v9 + // payloads wrote 6 elements, v9 wrote 7; all three shapes read). + writer.WriteArrayHeader(8); writer.Write((byte)value.Kind); writer.Write(value.Text); writer.Write(value.Flag); @@ -96,6 +115,19 @@ private static void WriteValue(ref MessagePackWriter writer, BehaviorFieldValue { writer.WriteNil(); } + + if (value.Conditions is { } conditions) + { + writer.WriteArrayHeader(conditions.Length); + foreach (ConditionEntry entry in conditions) + { + WriteConditionEntry(ref writer, entry); + } + } + else + { + writer.WriteNil(); + } } public override BehaviorDto Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) @@ -110,13 +142,33 @@ public override BehaviorDto Deserialize(ref MessagePackReader reader, MessagePac BehaviorEntry[] entries = new BehaviorEntry[entryCount]; for (int i = 0; i < entryCount; i++) { - entries[i] = ReadEntry(ref reader, behaviorDepth: 0); + entries[i] = ReadEntry(ref reader, behaviorDepth: 0, conditionDepth: 0); } return new BehaviorDto(owner, isSync, agentTypeName, entries); } - private static BehaviorEntry ReadEntry(ref MessagePackReader reader, int behaviorDepth) + internal static BehaviorEntry ReadEntry(ref MessagePackReader reader, int behaviorDepth, int conditionDepth) + { + (string typeName, BehaviorField[] fields) = ReadEntryCore(ref reader, behaviorDepth, conditionDepth, + "behavior"); + return new BehaviorEntry(typeName, fields); + } + + /// + /// Reads one condition entry (payload version 10) — the behavior-entry shape with its own + /// nesting counter, so a Not chain cannot exceed the shared depth cap. + /// + internal static ConditionEntry ReadConditionEntry(ref MessagePackReader reader, int behaviorDepth, + int conditionDepth) + { + (string typeName, BehaviorField[] fields) = ReadEntryCore(ref reader, behaviorDepth, conditionDepth, + "condition"); + return new ConditionEntry(typeName, fields); + } + + private static (string TypeName, BehaviorField[] Fields) ReadEntryCore(ref MessagePackReader reader, + int behaviorDepth, int conditionDepth, string what) { int entryLength = reader.ReadArrayHeader(); if (entryLength != 2) @@ -125,7 +177,7 @@ private static BehaviorEntry ReadEntry(ref MessagePackReader reader, int behavio string typeName = reader.ReadString() ?? throw new InvalidOperationException( - "BehaviorDto: behavior type name cannot be null."); + $"BehaviorDto: {what} type name cannot be null."); int fieldCount = reader.ReadArrayHeader(); BehaviorField[] fields = new BehaviorField[fieldCount]; for (int f = 0; f < fieldCount; f++) @@ -137,25 +189,27 @@ private static BehaviorEntry ReadEntry(ref MessagePackReader reader, int behavio string name = reader.ReadString() ?? throw new InvalidOperationException("BehaviorDto: field name cannot be null."); - fields[f] = new BehaviorField(name, ReadValue(ref reader, bindingDepth: 0, behaviorDepth)); + fields[f] = new BehaviorField(name, + ReadValue(ref reader, bindingDepth: 0, behaviorDepth, conditionDepth)); } - return new BehaviorEntry(typeName, fields); + return (typeName, fields); } - private static BehaviorFieldValue ReadValue(ref MessagePackReader reader, int bindingDepth, int behaviorDepth) + internal static BehaviorFieldValue ReadValue(ref MessagePackReader reader, int bindingDepth, int behaviorDepth, + int conditionDepth) { // Bindings nest exactly one literal value; anything deeper is a crafted payload. if (bindingDepth > 1) throw new InvalidOperationException("BehaviorDto: field value nesting exceeds the binding model."); int length = reader.ReadArrayHeader(); - if (length != 6 && length != 7) + if (length is < 6 or > 8) throw new InvalidOperationException( - $"BehaviorDto: field value has {length} elements, expected 6 (pre-v9) or 7"); + $"BehaviorDto: field value has {length} elements, expected 6 (pre-v9), 7 (v9) or 8"); byte kind = reader.ReadByte(); - if (kind > (byte)BehaviorFieldKind.Behaviors) + if (kind > (byte)BehaviorFieldKind.Conditions) throw new InvalidOperationException($"BehaviorDto: unknown field kind {kind}."); string? text = reader.ReadString(); @@ -179,7 +233,7 @@ private static BehaviorFieldValue ReadValue(ref MessagePackReader reader, int bi BehaviorFieldValue? literal = null; if (!reader.TryReadNil()) { - literal = ReadValue(ref reader, bindingDepth + 1, behaviorDepth); + literal = ReadValue(ref reader, bindingDepth + 1, behaviorDepth, conditionDepth); } binding = new BehaviorBinding(keyName, literal); @@ -187,7 +241,7 @@ private static BehaviorFieldValue ReadValue(ref MessagePackReader reader, int bi // The Entries slot (payload version 9): pre-v9 values end after the binding. BehaviorEntry[]? entries = null; - if (length == 7 && !reader.TryReadNil()) + if (length >= 7 && !reader.TryReadNil()) { if (behaviorDepth >= MaxBehaviorNestingDepth) throw new InvalidOperationException( @@ -198,11 +252,29 @@ private static BehaviorFieldValue ReadValue(ref MessagePackReader reader, int bi entries = new BehaviorEntry[nestedCount]; for (int i = 0; i < nestedCount; i++) { - entries[i] = ReadEntry(ref reader, behaviorDepth + 1); + entries[i] = ReadEntry(ref reader, behaviorDepth + 1, conditionDepth); + } + } + + // The Conditions slot (payload version 10): pre-v10 values end after the entries. + ConditionEntry[]? conditions = null; + if (length >= 8 && !reader.TryReadNil()) + { + if (conditionDepth >= MaxBehaviorNestingDepth) + throw new InvalidOperationException( + $"BehaviorDto: nested condition entries exceed the maximum nesting depth " + + $"({MaxBehaviorNestingDepth})."); + + int nestedCount = reader.ReadArrayHeader(); + conditions = new ConditionEntry[nestedCount]; + for (int i = 0; i < nestedCount; i++) + { + conditions[i] = ReadConditionEntry(ref reader, behaviorDepth, conditionDepth + 1); } } - return new BehaviorFieldValue((BehaviorFieldKind)kind, text, flag, integer, number, binding, entries); + return new BehaviorFieldValue((BehaviorFieldKind)kind, text, flag, integer, number, binding, entries, + conditions); } } diff --git a/NxGraph.Serialization/ChoiceDto.cs b/NxGraph.Serialization/ChoiceDto.cs new file mode 100644 index 0000000..4d66d30 --- /dev/null +++ b/NxGraph.Serialization/ChoiceDto.cs @@ -0,0 +1,84 @@ +using MessagePack; +using NxGraph.Serialization.Abstraction; + +namespace NxGraph.Serialization; + +/// +/// Payload entry for a data-built ChoiceState node (payload version 10): +/// is the ConditionMatch mode (All/Any), +/// the decision itself in evaluation order, and the two targets +/// the arms — -1 encodes NodeId.Default (a terminal arm), exactly as +/// does. Conditions ride the neutral field model, so +/// the standard set round-trips with zero options through the default +/// . Reserved marker: "ChoiceState" (one for both runtimes — +/// the data-built branch is a single class implementing both logic and both director +/// interfaces). +/// +internal sealed record ChoiceDto(int OwnerIndex, byte Match, ConditionEntry[] Conditions, int TrueTarget, + int FalseTarget); + +internal sealed class ChoiceDtoFormatter : GraphEntityFormatter +{ + public static readonly ChoiceDtoFormatter Instance = new(); + + public override void Serialize(ref MessagePackWriter writer, ChoiceDto value, + MessagePackSerializerOptions options) + { + // [OwnerIndex, Match, [[typeName, [[name, value], ...]], ...], TrueTarget, FalseTarget] + // — hand-rolled to pin the payload shape; condition entries reuse the behavior field + // model's encoding and nest recursively (write side; the read side caps). + writer.WriteArrayHeader(5); + writer.Write(value.OwnerIndex); + writer.Write(value.Match); + writer.WriteArrayHeader(value.Conditions.Length); + foreach (ConditionEntry entry in value.Conditions) + { + BehaviorDtoFormatter.WriteConditionEntry(ref writer, entry); + } + + writer.Write(value.TrueTarget); + writer.Write(value.FalseTarget); + } + + public override ChoiceDto Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + int count = reader.ReadArrayHeader(); + if (count != 5) throw new InvalidOperationException($"ChoiceDto: expected 5 elements, got {count}"); + + int owner = reader.ReadInt32(); + byte match = reader.ReadByte(); + int conditionCount = reader.ReadArrayHeader(); + ConditionEntry[] conditions = new ConditionEntry[conditionCount]; + for (int i = 0; i < conditionCount; i++) + { + conditions[i] = BehaviorDtoFormatter.ReadConditionEntry(ref reader, behaviorDepth: 0, + conditionDepth: 0); + } + + int trueTarget = reader.ReadInt32(); + int falseTarget = reader.ReadInt32(); + return new ChoiceDto(owner, match, conditions, trueTarget, falseTarget); + } +} + +internal sealed class ChoiceArrayDtoFormatter : GraphEntityFormatter +{ + public static readonly ChoiceArrayDtoFormatter Instance = new(); + + public override void Serialize(ref MessagePackWriter writer, ChoiceDto[] value, + MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(value.Length); + for (int i = 0; i < value.Length; i++) + ChoiceDtoFormatter.Instance.Serialize(ref writer, value[i], options); + } + + public override ChoiceDto[] Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + int count = reader.ReadArrayHeader(); + ChoiceDto[] arr = new ChoiceDto[count]; + for (int i = 0; i < count; i++) + arr[i] = ChoiceDtoFormatter.Instance.Deserialize(ref reader, options); + return arr; + } +} diff --git a/NxGraph.Serialization/ConditionRegistry.cs b/NxGraph.Serialization/ConditionRegistry.cs new file mode 100644 index 0000000..4404a0f --- /dev/null +++ b/NxGraph.Serialization/ConditionRegistry.cs @@ -0,0 +1,156 @@ +using System.Reflection; +using NxGraph.Behaviors; +using NxGraph.Conditions; +using NxGraph.Serialization.Abstraction; + +namespace NxGraph.Serialization; + +/// +/// Default : user factories keyed by runtime-stable condition +/// type name, with the standard set (IsTrue, Not, every closed +/// KeyEquals<T>) built in — so branching graphs round-trip with zero options +/// configured ( falls back to a fresh instance of this class +/// when no is given). The branch twin of +/// , down to the mechanics: generic forms close on read via +/// cold-path reflection over the stable type name, KeyEquals<T> rebuilds through +/// its Unbound form (the key rides as a name and resolves against the machine's bound +/// schemas per evaluation), and Not's inner condition rides as a nested entry list +/// encoded through the serializer's entry codec. User factories are consulted first, so a +/// factory registered under a standard name overrides the built-in handling. +/// +public sealed class ConditionRegistry : IConditionRegistry +{ + private static readonly string IsTrueTypeName = BlackboardSerializer.StableTypeName(typeof(IsTrue)); + private static readonly string NotTypeName = BlackboardSerializer.StableTypeName(typeof(Not)); + private static readonly string KeyEqualsPrefix = typeof(KeyEquals<>).FullName + "["; + + private readonly Dictionary> _factories = new(StringComparer.Ordinal); + + /// + /// Registers a reconstruction factory under — the + /// condition's runtime-stable type name, the identity its payload entries carry. The + /// factory receives the entry's fields and returns the live condition instance. Duplicate + /// names fail here, at setup, rather than at load time. + /// + public void Register(string conditionTypeName, Func factory) + { + ArgumentException.ThrowIfNullOrEmpty(conditionTypeName); + ArgumentNullException.ThrowIfNull(factory); + if (!_factories.TryAdd(conditionTypeName, factory)) + { + throw new ArgumentException( + $"A condition factory is already registered under '{conditionTypeName}'.", + nameof(conditionTypeName)); + } + } + + /// + public bool TryRead(string conditionTypeName, BehaviorFieldReader fields, out object? condition) + { + if (_factories.TryGetValue(conditionTypeName, out Func? factory)) + { + condition = factory(fields) ?? throw new InvalidOperationException( + $"The condition factory registered under '{conditionTypeName}' returned null."); + return true; + } + + if (string.Equals(conditionTypeName, IsTrueTypeName, StringComparison.Ordinal)) + { + condition = new IsTrue(fields.ReadBinding("value")); + return true; + } + + if (string.Equals(conditionTypeName, NotTypeName, StringComparison.Ordinal)) + { + condition = new Not(SingleInner(fields)); + return true; + } + + if (conditionTypeName.StartsWith(KeyEqualsPrefix, StringComparison.Ordinal) && + conditionTypeName.EndsWith(']')) + { + condition = ReadKeyEquals(conditionTypeName, fields); + return true; + } + + condition = null; + return false; + } + + /// + public bool TryWrite(object condition, BehaviorFieldWriter fields) + { + if (condition is IsTrue isTrue) + { + fields.WriteBinding("value", isTrue.Value); + return true; + } + + if (condition is Not not) + { + // The condition model's one nesting shape: the inner condition rides as a + // one-element entry list, encoded by the same per-entry dispatch as the top level. + fields.WriteConditions("inner", [not.Inner]); + return true; + } + + Type type = condition.GetType(); + if (!type.IsConstructedGenericType || type.GetGenericTypeDefinition() != typeof(KeyEquals<>)) + { + return false; + } + + MethodInfo writer = typeof(ConditionRegistry) + .GetMethod(nameof(WriteKeyEquals), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(type.GetGenericArguments()[0]); + writer.Invoke(null, [condition, fields]); + return true; + } + + /// + /// Reads Not's body, which must hold exactly one condition — the arity is the + /// type's contract, so a payload carrying anything else is crafted or corrupt. + /// + private static ICondition SingleInner(BehaviorFieldReader fields) + { + object[] inner = fields.ReadConditions("inner"); + if (inner.Length != 1) + { + throw new InvalidOperationException( + $"Not payload carries {inner.Length} inner conditions, expected exactly 1."); + } + + return inner[0] as ICondition ?? throw new InvalidOperationException( + $"Not payload's inner entry ('{inner[0].GetType().Name}') does not implement ICondition."); + } + + private static object ReadKeyEquals(string conditionTypeName, BehaviorFieldReader fields) + { + string valueTypeName = conditionTypeName.Substring(KeyEqualsPrefix.Length, + conditionTypeName.Length - KeyEqualsPrefix.Length - 1); + if (!StableTypeResolver.TryResolve(valueTypeName, out Type valueType)) + { + throw new InvalidOperationException( + $"Condition payload names '{conditionTypeName}', but value type '{valueTypeName}' cannot be " + + "resolved — ensure the assembly declaring it is loaded."); + } + + MethodInfo reader = typeof(ConditionRegistry) + .GetMethod(nameof(ReadKeyEqualsGeneric), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(valueType); + return reader.Invoke(null, [fields])!; + } + + private static KeyEquals ReadKeyEqualsGeneric(BehaviorFieldReader fields) + { + string keyName = fields.ReadString("key") ?? throw new InvalidOperationException( + "KeyEquals payload carries a null key name."); + return KeyEquals.Unbound(keyName, fields.ReadBinding("expected")); + } + + private static void WriteKeyEquals(KeyEquals condition, BehaviorFieldWriter fields) + { + fields.WriteString("key", condition.KeyName); + fields.WriteBinding("expected", condition.Expected); + } +} diff --git a/NxGraph.Serialization/GraphDto.cs b/NxGraph.Serialization/GraphDto.cs index 4e11929..cce9d78 100644 --- a/NxGraph.Serialization/GraphDto.cs +++ b/NxGraph.Serialization/GraphDto.cs @@ -14,7 +14,8 @@ public GraphDto(INodeDto[] nodes, TransitionDto[] transitions, SubGraphDto[]? su string? name = null, RetryPolicyDto[]? retryPolicies = null, OutcomeCodeDto[]? outcomeCodes = null, OutcomeNameDto[]? outcomeNames = null, CompositeDto[]? composites = null, UidDto[]? uids = null, ForkDto[]? forks = null, JoinDto[]? joins = null, ContainerDto[]? containers = null, - EventEntryDto[]? eventEntries = null, BehaviorDto[]? behaviors = null) + EventEntryDto[]? eventEntries = null, BehaviorDto[]? behaviors = null, ChoiceDto[]? choices = null, + SwitchDto[]? switches = null) { if (nodes.Length != transitions.Length) throw new ArgumentException("Nodes and transitions must have the same length.", nameof(transitions)); @@ -33,6 +34,8 @@ public GraphDto(INodeDto[] nodes, TransitionDto[] transitions, SubGraphDto[]? su Containers = containers ?? []; EventEntries = eventEntries ?? []; Behaviors = behaviors ?? []; + Choices = choices ?? []; + Switches = switches ?? []; } /// @@ -60,5 +63,7 @@ public GraphDto(INodeDto[] nodes, TransitionDto[] transitions, SubGraphDto[]? su public ContainerDto[] Containers { get; set; } public EventEntryDto[] EventEntries { get; set; } public BehaviorDto[] Behaviors { get; set; } + public ChoiceDto[] Choices { get; set; } + public SwitchDto[] Switches { get; set; } } \ No newline at end of file diff --git a/NxGraph.Serialization/GraphDtoFormatter.cs b/NxGraph.Serialization/GraphDtoFormatter.cs index 7209f9d..5bf6c3b 100644 --- a/NxGraph.Serialization/GraphDtoFormatter.cs +++ b/NxGraph.Serialization/GraphDtoFormatter.cs @@ -14,13 +14,15 @@ internal sealed class GraphDtoFormatter : GraphEntityFormatter private const int VersionSixHeaderCount = 14; private const int VersionSevenHeaderCount = 15; private const int VersionEightHeaderCount = 16; + private const int VersionTenHeaderCount = 18; public override void Serialize(ref MessagePackWriter writer, GraphDto value, MessagePackSerializerOptions options) { // [0.Version 1.Index, 2.Name, 3.Nodes[], 4.Transitions[], 5.SubGraphs[], // 6.RetryPolicies[], 7.OutcomeCodes[], 8.OutcomeNames[], 9.Composites[], 10.Uids[], - // 11.Forks[], 12.Joins[], 13.Containers[], 14.EventEntries[], 15.Behaviors[]] - writer.WriteArrayHeader(VersionEightHeaderCount); + // 11.Forks[], 12.Joins[], 13.Containers[], 14.EventEntries[], 15.Behaviors[], + // 16.Choices[], 17.Switches[]] + writer.WriteArrayHeader(VersionTenHeaderCount); writer.Write(value.Version); writer.Write(value.Index); writer.Write(value.Name); @@ -47,6 +49,10 @@ public override void Serialize(ref MessagePackWriter writer, GraphDto value, Mes .Serialize(ref writer, value.EventEntries, options); options.Resolver.GetFormatterWithVerify() .Serialize(ref writer, value.Behaviors, options); + options.Resolver.GetFormatterWithVerify() + .Serialize(ref writer, value.Choices, options); + options.Resolver.GetFormatterWithVerify() + .Serialize(ref writer, value.Switches, options); } public override GraphDto Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) @@ -93,6 +99,9 @@ public override GraphDto Deserialize(ref MessagePackReader reader, MessagePackSe case 8 or 9 when count < VersionEightHeaderCount: throw new InvalidOperationException( $"GraphDto: expected at least {VersionEightHeaderCount} elements, got {count}"); + case 10 when count < VersionTenHeaderCount: + throw new InvalidOperationException( + $"GraphDto: expected at least {VersionTenHeaderCount} elements, got {count}"); } int index = reader.ReadInt32(); @@ -173,6 +182,19 @@ public override GraphDto Deserialize(ref MessagePackReader reader, MessagePackSe consumed = VersionEightHeaderCount; } + // Pre-v10 payloads end after Behaviors; the data-built branch sections arrived with + // version 10 and read branch-free before it. + ChoiceDto[] choices = []; + SwitchDto[] switches = []; + if (count >= VersionTenHeaderCount) + { + choices = options.Resolver.GetFormatterWithVerify() + .Deserialize(ref reader, options); + switches = options.Resolver.GetFormatterWithVerify() + .Deserialize(ref reader, options); + consumed = VersionTenHeaderCount; + } + // Drain any trailing elements beyond the known shape so the reader always ends // positioned after this array — a nested read that under-consumes desyncs every // subsequent read of its parent. The version gate rejects newer payloads, so extras @@ -187,6 +209,7 @@ public override GraphDto Deserialize(ref MessagePackReader reader, MessagePackSe reader.Depth--; return new GraphDto(nodes, transitions, subGraphs, index, name, retryPolicies, outcomeCodes, outcomeNames, - composites, uids, forks, joins, containers, eventEntries, behaviors) { Version = version }; + composites, uids, forks, joins, containers, eventEntries, behaviors, choices, switches) + { Version = version }; } } \ No newline at end of file diff --git a/NxGraph.Serialization/GraphFormatterResolver.cs b/NxGraph.Serialization/GraphFormatterResolver.cs index 939dde7..73810eb 100644 --- a/NxGraph.Serialization/GraphFormatterResolver.cs +++ b/NxGraph.Serialization/GraphFormatterResolver.cs @@ -181,6 +181,33 @@ static Cache() return; } + if (typeof(T) == typeof(ChoiceDto)) + { + Formatter = (IMessagePackFormatter)(object)ChoiceDtoFormatter.Instance; + return; + } + + if (typeof(T) == typeof(ChoiceDto[])) + { + Formatter = (IMessagePackFormatter)(object)ChoiceArrayDtoFormatter.Instance; + return; + } + + if (typeof(T) == typeof(SwitchDto)) + { + Formatter = (IMessagePackFormatter)(object)SwitchDtoFormatter.Instance; + return; + } + + if (typeof(T) == typeof(SwitchDto[])) + { + Formatter = (IMessagePackFormatter)(object)SwitchArrayDtoFormatter.Instance; + return; + } + + // SwitchCaseDto needs no entry: its literal and target ride inline in + // SwitchDtoFormatter, which owns the whole case-array shape. + Formatter = null; } } diff --git a/NxGraph.Serialization/GraphSerializer.cs b/NxGraph.Serialization/GraphSerializer.cs index 82ef29a..2733c72 100644 --- a/NxGraph.Serialization/GraphSerializer.cs +++ b/NxGraph.Serialization/GraphSerializer.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Reflection; +using System.Runtime.ExceptionServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization.Metadata; @@ -6,6 +8,7 @@ using MessagePack.Resolvers; using NxGraph.Behaviors; using NxGraph.Blackboards; +using NxGraph.Conditions; using NxGraph.Fsm; using NxGraph.Fsm.Async; using NxGraph.Graphs; @@ -29,6 +32,7 @@ public sealed class GraphSerializer : IGraphJsonSerializer, IGraphBinarySerializ private readonly IRegionSelectorRegistry? _selectorRegistry; private readonly IContainerCodec? _containerCodec; private readonly IBehaviorRegistry _behaviorRegistry; + private readonly IConditionRegistry _conditionRegistry; private readonly MessagePackSerializerOptions _options; private readonly JsonSerializerOptions _jsonOptions; @@ -46,6 +50,9 @@ public GraphSerializer(ILogicCodec codec, GraphSerializerOptions? options) // The default registry carries the standard behavior set built in, so standard-set // graphs round-trip with zero options configured (payload version 8). _behaviorRegistry = options?.BehaviorRegistry ?? new BehaviorRegistry(); + // Same posture for the condition standard set (payload version 10): a data-built + // branching graph round-trips with zero options configured. + _conditionRegistry = options?.ConditionRegistry ?? new ConditionRegistry(); // Wire-type coherence: the container codec's payload rides in the same node DTO slot // as the logic codec's, so their wire types must match. Fail at setup, not save time. @@ -208,6 +215,8 @@ private GraphDto ToDto(Graph graph, int depth) List containers = []; List eventEntries = []; List behaviors = []; + List choices = []; + List switches = []; INodeDto[] nodes = new INodeDto[nodeCount]; for (int index = 0; index < nodeCount; index++) { @@ -263,6 +272,29 @@ private GraphDto ToDto(Graph graph, int depth) break; } + // Data-built branches (payload version 10): the decision is data, so it + // rides — a condition list plus its match mode for a choice, a key name + // plus literal cases for a switch. Detected via the non-generic + // IChoiceNode/ISwitchNode surfaces (the IBehaviorComposite precedent), so + // closed SwitchState types need no reflection here. The delegate-backed + // Relay* twins implement neither and fall through to the logic codec, which + // is exactly right: their decision is a closure. + if ((logicNode.Logic as IChoiceNode ?? logicNode.AsyncLogic as IChoiceNode) is { } choice) + { + choices.Add(BuildChoiceDto(index, node.Id, choice)); + nodes[index] = new NodeTextDto(index, node.Id.Name, + LogicNode.ChoiceStateMarker.Id.Name); + break; + } + + if ((logicNode.Logic as ISwitchNode ?? logicNode.AsyncLogic as ISwitchNode) is { } switchNode) + { + switches.Add(BuildSwitchDto(index, node.Id, switchNode)); + nodes[index] = new NodeTextDto(index, node.Id.Name, + LogicNode.SwitchStateMarker.Id.Name); + break; + } + // Behavior composites (payload version 8): entries serialize into the // neutral field model — through their own ISerializableBehavior.Write or // the registry's built-in standard-set handling — and the composite's @@ -513,7 +545,8 @@ private GraphDto ToDto(Graph graph, int depth) return new GraphDto(nodes, transitions, subGraphs.ToArray(), graph.Id.Index, graph.Id.Name, retryPolicies, outcomeCodes, outcomeNames, composites.ToArray(), uids, forks.ToArray(), joins.ToArray(), - containers.ToArray(), eventEntries.ToArray(), behaviors.ToArray()) + containers.ToArray(), eventEntries.ToArray(), behaviors.ToArray(), choices.ToArray(), + switches.ToArray()) { // The writer stamps the version explicitly: GraphDto.Version defaults to 0 // ("no version seen") so that version-stripped payloads are detectable on read. @@ -545,6 +578,110 @@ private BehaviorDto BuildBehaviorDto(int index, NodeId nodeId, IBehaviorComposit return new BehaviorDto(index, composite.IsSync, agentTypeName, entryDtos); } + /// + /// Serializes one data-built choice node's decision (payload version 10): the match mode + /// and the condition list, through the per-session condition entry codec. The two arms ride + /// as node indexes, with -1 for a terminal arm — the + /// encoding. + /// + private ChoiceDto BuildChoiceDto(int index, NodeId nodeId, IChoiceNode choice) + { + IReadOnlyList conditions = choice.Conditions; + ConditionEntryCodec codec = new(_conditionRegistry, $"Node '{nodeId}'"); + ConditionEntry[] entries = new ConditionEntry[conditions.Count]; + for (int i = 0; i < entries.Length; i++) + { + entries[i] = codec.WriteEntry(conditions[i]); + } + + return new ChoiceDto(index, (byte)choice.Match, entries, TargetIndex(choice.TrueTarget), + TargetIndex(choice.FalseTarget)); + } + + /// + /// Serializes one data-built switch node (payload version 10). The typed key rides as its + /// registered name plus the runtime-stable value type name; case values ride as field-model + /// literals (see ), which is where a T outside the field + /// model fails, naming this node. + /// + private static SwitchDto BuildSwitchDto(int index, NodeId nodeId, ISwitchNode switchNode) + { + Type valueType = switchNode.ValueType; + SwitchCaseDto[] cases = new SwitchCaseDto[switchNode.CaseCount]; + for (int i = 0; i < cases.Length; i++) + { + cases[i] = new SwitchCaseDto(SwitchLiteral.Write(valueType, switchNode.CaseValueAt(i), nodeId), + TargetIndex(switchNode.CaseTargetAt(i))); + } + + return new SwitchDto(index, switchNode.KeyName, BlackboardSerializer.StableTypeName(valueType), cases, + TargetIndex(switchNode.DefaultTarget)); + } + + /// A branch arm's wire index: -1 encodes (terminal). + private static int TargetIndex(NodeId target) => target == NodeId.Default ? -1 : target.Index; + + /// + /// Per-payload-session condition entry codec (payload version 10) — the exact twin of + /// : write dispatch is + /// first and + /// second, read dispatch is the registry only, + /// and the codec is wired into every writer/reader it creates so nested entry lists + /// (Not) encode under exactly the top-level rules. The read side caps entry nesting + /// at the same depth as behaviors — the codec-neutral backstop for the JSON path. + /// + private sealed class ConditionEntryCodec(IConditionRegistry registry, string owner) : IConditionEntryCodec + { + private int _readDepth; + + public ConditionEntry WriteEntry(object condition) + { + BehaviorFieldWriter writer = new(entryCodec: null, conditionCodec: this); + if (condition is ISerializableCondition serializable) + { + serializable.Write(writer); + } + else if (!registry.TryWrite(condition, writer)) + { + throw new NotSupportedException( + $"{owner} contains condition '{condition.GetType().Name}', which is neither an " + + "ISerializableCondition nor known to the condition registry. Implement " + + "ISerializableCondition on it and register a reconstruction factory on " + + "GraphSerializerOptions.ConditionRegistry."); + } + + return new ConditionEntry(BlackboardSerializer.StableTypeName(condition.GetType()), writer.ToFields()); + } + + public object ReadEntry(ConditionEntry entry) + { + if (string.IsNullOrEmpty(entry.ConditionTypeName)) + throw new InvalidOperationException( + $"{owner} has an entry with a missing condition type name."); + if (_readDepth >= BehaviorDtoFormatter.MaxBehaviorNestingDepth) + throw new InvalidOperationException( + $"{owner}: nested condition entries exceed the maximum nesting depth " + + $"({BehaviorDtoFormatter.MaxBehaviorNestingDepth})."); + + _readDepth++; + try + { + BehaviorFieldReader reader = new(entry.Fields, entryCodec: null, conditionCodec: this); + if (!registry.TryRead(entry.ConditionTypeName, reader, out object? condition) || condition is null) + throw new NotSupportedException( + $"{owner} names condition type '{entry.ConditionTypeName}', which the condition " + + "registry cannot reconstruct. Register a factory for it on " + + "GraphSerializerOptions.ConditionRegistry."); + + return condition; + } + finally + { + _readDepth--; + } + } + } + /// /// Per-payload-session behavior entry codec (payload version 9): the serializer's /// per-entry dispatch — write: else @@ -743,6 +880,26 @@ private Graph FromDto(GraphDto dto, int depth) $"Behavior DTO owner index {behaviorDto.OwnerIndex} is duplicated in the payload."); } + // Claim-first for the v10 branch sections: the "ChoiceState"/"SwitchState" markers are + // only honored when the matching section claims the node index. + Dictionary? choiceOwners = null; + foreach (ChoiceDto choiceDto in dto.Choices) + { + choiceOwners ??= new Dictionary(); + if (!choiceOwners.TryAdd(choiceDto.OwnerIndex, choiceDto)) + throw new InvalidOperationException( + $"Choice DTO owner index {choiceDto.OwnerIndex} is duplicated in the payload."); + } + + Dictionary? switchOwners = null; + foreach (SwitchDto switchDto in dto.Switches) + { + switchOwners ??= new Dictionary(); + if (!switchOwners.TryAdd(switchDto.OwnerIndex, switchDto)) + throw new InvalidOperationException( + $"Switch DTO owner index {switchDto.OwnerIndex} is duplicated in the payload."); + } + // A node index may be claimed by at most one section. Before markerless container // claims this was implicit via marker matching; now it must be explicit — an // overlapping claim is a corrupt or crafted payload, not something to route by luck. @@ -755,6 +912,8 @@ private Graph FromDto(GraphDto dto, int depth) AddClaims(ref claimedBy, containerOwners?.Keys, "Containers"); AddClaims(ref claimedBy, eventEntryOwners?.Keys, "EventEntries"); AddClaims(ref claimedBy, behaviorOwners?.Keys, "Behaviors"); + AddClaims(ref claimedBy, choiceOwners?.Keys, "Choices"); + AddClaims(ref claimedBy, switchOwners?.Keys, "Switches"); } INode[] nodes = new INode[nodesLength]; @@ -860,6 +1019,23 @@ behaviorOwners is not null && break; } + // Branch markers (v10): honored only when the matching branch section + // claims this node index — an unclaimed marker string is ordinary codec + // payload, exactly as for every sibling section. + if (textDto.Logic == LogicNode.ChoiceStateMarker.Id.Name && + choiceOwners is not null && choiceOwners.ContainsKey(nodeDto.Index)) + { + nodes[nodeDto.Index] = LogicNode.ChoiceStateMarker; + break; + } + + if (textDto.Logic == LogicNode.SwitchStateMarker.Id.Name && + switchOwners is not null && switchOwners.ContainsKey(nodeDto.Index)) + { + nodes[nodeDto.Index] = LogicNode.SwitchStateMarker; + break; + } + // Container claims (v6) are markerless — the claim routes this node's // payload to the container codec. Decode is deferred until the child // graphs are rebuilt; the placeholder sentinel never rides the wire. @@ -1221,6 +1397,76 @@ behaviorOwners is not null && new NodeId(behaviorDto.OwnerIndex, behaviorNodeName), BuildBehaviorComposite(behaviorDto, entries)); } + // Rebuild data-built branches (v10) through the public constructors so their validation + // (non-empty condition list, duplicate case values) re-runs on load. Conditions + // reconstruct through the condition registry; a KeyEquals key and the switch's tested + // key rebuild name-bound and resolve against the machine's bound schemas at evaluation. + // Arm ids rebuild as bare indexes, like fork branches and event entry targets. + foreach (ChoiceDto choiceDto in dto.Choices) + { + if (choiceDto.OwnerIndex < 0 || choiceDto.OwnerIndex >= nodesLength) + throw new InvalidOperationException( + $"Choice DTO owner index {choiceDto.OwnerIndex} is out of range (0..{nodesLength - 1})."); + if (!ReferenceEquals(nodes[choiceDto.OwnerIndex], LogicNode.ChoiceStateMarker)) + throw new InvalidOperationException( + $"Choice DTO owner index {choiceDto.OwnerIndex} does not reference a choice marker node."); + if (choiceDto.Conditions.Length == 0) + throw new InvalidOperationException( + $"Choice DTO for node {choiceDto.OwnerIndex} must carry at least one condition."); + if (choiceDto.Match > (byte)ConditionMatch.Any) + throw new InvalidOperationException( + $"Choice DTO for node {choiceDto.OwnerIndex} has unknown match mode {choiceDto.Match}."); + + NodeId trueTarget = RebuildTarget(choiceDto.TrueTarget, nodesLength, + $"Choice DTO for node {choiceDto.OwnerIndex}", "true target"); + NodeId falseTarget = RebuildTarget(choiceDto.FalseTarget, nodesLength, + $"Choice DTO for node {choiceDto.OwnerIndex}", "false target"); + + ConditionEntryCodec conditionCodec = new(_conditionRegistry, + $"Choice DTO for node {choiceDto.OwnerIndex}"); + ICondition[] conditions = new ICondition[choiceDto.Conditions.Length]; + for (int c = 0; c < conditions.Length; c++) + { + object entry = conditionCodec.ReadEntry(choiceDto.Conditions[c]); + conditions[c] = entry as ICondition ?? throw new InvalidOperationException( + $"Choice DTO for node {choiceDto.OwnerIndex}: reconstructed condition " + + $"'{entry.GetType().Name}' does not implement ICondition."); + } + + nodes[choiceDto.OwnerIndex] = new LogicNode( + new NodeId(choiceDto.OwnerIndex, NodeName(dto, choiceDto.OwnerIndex)), + (IAsyncLogic)new ChoiceState(conditions, (ConditionMatch)choiceDto.Match, trueTarget, falseTarget)); + } + + foreach (SwitchDto switchDto in dto.Switches) + { + if (switchDto.OwnerIndex < 0 || switchDto.OwnerIndex >= nodesLength) + throw new InvalidOperationException( + $"Switch DTO owner index {switchDto.OwnerIndex} is out of range (0..{nodesLength - 1})."); + if (!ReferenceEquals(nodes[switchDto.OwnerIndex], LogicNode.SwitchStateMarker)) + throw new InvalidOperationException( + $"Switch DTO owner index {switchDto.OwnerIndex} does not reference a switch marker node."); + if (switchDto.Cases.Length == 0) + throw new InvalidOperationException( + $"Switch DTO for node {switchDto.OwnerIndex} must carry at least one case."); + if (string.IsNullOrEmpty(switchDto.KeyName)) + throw new InvalidOperationException( + $"Switch DTO for node {switchDto.OwnerIndex} carries no key name."); + + NodeId switchDefault = RebuildTarget(switchDto.DefaultTarget, nodesLength, + $"Switch DTO for node {switchDto.OwnerIndex}", "default target"); + NodeId[] caseTargets = new NodeId[switchDto.Cases.Length]; + for (int c = 0; c < caseTargets.Length; c++) + { + caseTargets[c] = RebuildTarget(switchDto.Cases[c].TargetIndex, nodesLength, + $"Switch DTO for node {switchDto.OwnerIndex}", $"case {c} target"); + } + + nodes[switchDto.OwnerIndex] = new LogicNode( + new NodeId(switchDto.OwnerIndex, NodeName(dto, switchDto.OwnerIndex)), + BuildSwitchState(switchDto, caseTargets, switchDefault)); + } + // Rebuild container-codec nodes (v6): children first (in wire order = SubGraphs // enumeration order), then the node's ordinary logic payload routes to the container // codec, which owns the reconstruction recipe. @@ -1471,6 +1717,72 @@ private static TEntry[] CastBehaviorEntries(BehaviorDto behaviorDto, obj return typed; } + /// + /// Rebuilds one branch arm's node id from its wire index, with -1 decoding back to + /// (a terminal arm). Ids rebuild as bare indexes — equality is + /// index-only and names resolve through the graph at runtime, so reading + /// nodes[target].Id here would only risk capturing a not-yet-rebuilt sentinel. + /// + private static NodeId RebuildTarget(int target, int nodesLength, string owner, string what) + { + if (target < -1 || target >= nodesLength) + throw new InvalidOperationException( + $"{owner} has {what} {target} out of range (-1..{nodesLength - 1})."); + + return target == -1 ? NodeId.Default : new NodeId(target); + } + + /// The display name a payload carries for one node index. + private static string NodeName(GraphDto dto, int index) => dto.Nodes[index] switch + { + NodeTextDto nt => nt.Name, + NodeBinaryDto nb => nb.Name, + _ => $"Node_{index}" + }; + + /// + /// Rebuilds one data-built switch, closing SwitchState<T> over the payload's + /// runtime-stable value type name and reconstructing through the public Unbound + /// factory, so duplicate-case validation re-runs on load (the + /// BehaviorRegistry.ReadSetValue recipe). + /// + private static IAsyncLogic BuildSwitchState(SwitchDto switchDto, NodeId[] caseTargets, NodeId defaultTarget) + { + if (!StableTypeResolver.TryResolve(switchDto.ValueTypeName, out Type valueType)) + throw new InvalidOperationException( + $"Switch DTO for node {switchDto.OwnerIndex} names value type " + + $"'{switchDto.ValueTypeName}', which cannot be resolved — ensure the assembly declaring it " + + "is loaded."); + + MethodInfo builder = typeof(GraphSerializer) + .GetMethod(nameof(BuildSwitchStateGeneric), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(valueType); + try + { + return (IAsyncLogic)builder.Invoke(null, [switchDto, caseTargets, defaultTarget])!; + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + // Surface the state's own construction error (duplicate case values, an empty + // list) rather than the reflection wrapper, stack trace intact. + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; // unreachable — Throw() always rethrows + } + } + + private static SwitchState BuildSwitchStateGeneric(SwitchDto switchDto, NodeId[] caseTargets, + NodeId defaultTarget) + { + string owner = $"Switch DTO for node {switchDto.OwnerIndex}"; + SwitchCase[] cases = new SwitchCase[switchDto.Cases.Length]; + for (int i = 0; i < cases.Length; i++) + { + cases[i] = new SwitchCase(SwitchLiteral.Read(switchDto.Cases[i].Literal, owner), caseTargets[i]); + } + + return SwitchState.Unbound(switchDto.KeyName, cases, defaultTarget); + } + /// /// Folds one section's claimed owner indexes into the shared claim map, throwing on the /// first index already claimed by another section. diff --git a/NxGraph.Serialization/GraphSerializerOptions.cs b/NxGraph.Serialization/GraphSerializerOptions.cs index c625622..82761fe 100644 --- a/NxGraph.Serialization/GraphSerializerOptions.cs +++ b/NxGraph.Serialization/GraphSerializerOptions.cs @@ -33,4 +33,13 @@ public sealed class GraphSerializerOptions /// factories for user ISerializableBehavior implementations. /// public IBehaviorRegistry? BehaviorRegistry { get; init; } + + /// + /// Resolves condition payload identities (payload version 10). When left null the + /// serializer uses a fresh default , which carries the + /// standard set (IsTrue, Not, closed KeyEquals<T>) built in — + /// data-built branching graphs round-trip with zero options. Configure one to register + /// reconstruction factories for user ISerializableCondition implementations. + /// + public IConditionRegistry? ConditionRegistry { get; init; } } diff --git a/NxGraph.Serialization/SerializationVersion.cs b/NxGraph.Serialization/SerializationVersion.cs index af93ef5..bcd41b0 100644 --- a/NxGraph.Serialization/SerializationVersion.cs +++ b/NxGraph.Serialization/SerializationVersion.cs @@ -29,6 +29,15 @@ public static class SerializationVersion // inside them) ride under the top-level entry rules; read-side nesting is capped at // 32. No new section — the change lives entirely inside the field model, and pre-v9 // payloads never contain the new kind, so they read unchanged. - public const int Version = 9; + // v10: data-built branching sections (sparse ChoiceDto/SwitchDto beside the other + // sections, markers "ChoiceState"/"SwitchState", one per state for both runtimes) — + // a choice rides its ConditionMatch mode plus its condition list in the neutral field + // model (nested Not bodies via the new BehaviorFieldKind.Conditions, read-side + // recursion capped at 32); a switch rides its key name, runtime-stable value type + // name, literal cases and default target, rebuilding unbound so the key resolves by + // name against the machine's bound schemas. The standard condition set (IsTrue, Not, + // KeyEquals) rides with zero options via the default ConditionRegistry, closing + // the last relay-lambda hole in graph payloads. Pre-v10 payloads read branch-free. + public const int Version = 10; } diff --git a/NxGraph.Serialization/SwitchDto.cs b/NxGraph.Serialization/SwitchDto.cs new file mode 100644 index 0000000..b93ec9f --- /dev/null +++ b/NxGraph.Serialization/SwitchDto.cs @@ -0,0 +1,101 @@ +using MessagePack; +using NxGraph.Serialization.Abstraction; + +namespace NxGraph.Serialization; + +/// +/// One arm of a data-built switch on the wire (payload version 10): the case's +/// literal value plus the arm's head node index. The literal rides as an ordinary +/// of kind — see +/// — so the case values inherit the field model's literal +/// validation and need no wire vocabulary of their own. +/// +internal sealed record SwitchCaseDto(BehaviorFieldValue Literal, int TargetIndex); + +/// +/// Payload entry for a data-built SwitchState<T> node (payload version 10). The +/// typed key never rides typed: plus the runtime-stable +/// rebuild an unbound switch that resolves its key by name +/// against the machine's bound schemas at selection (the recipe). +/// is -1 for NodeId.Default (a terminal +/// no-match exit). Reserved marker: "SwitchState" — one for both runtimes and every closed +/// T. +/// +internal sealed record SwitchDto(int OwnerIndex, string KeyName, string ValueTypeName, SwitchCaseDto[] Cases, + int DefaultTarget); + +internal sealed class SwitchDtoFormatter : GraphEntityFormatter +{ + public static readonly SwitchDtoFormatter Instance = new(); + + public override void Serialize(ref MessagePackWriter writer, SwitchDto value, + MessagePackSerializerOptions options) + { + // [OwnerIndex, KeyName, ValueTypeName, [[literal, targetIndex], ...], DefaultTarget] — + // hand-rolled to pin the payload shape; literals reuse the behavior field model's + // value encoding. + writer.WriteArrayHeader(5); + writer.Write(value.OwnerIndex); + writer.Write(value.KeyName); + writer.Write(value.ValueTypeName); + writer.WriteArrayHeader(value.Cases.Length); + foreach (SwitchCaseDto caseDto in value.Cases) + { + writer.WriteArrayHeader(2); + BehaviorDtoFormatter.WriteValue(ref writer, caseDto.Literal); + writer.Write(caseDto.TargetIndex); + } + + writer.Write(value.DefaultTarget); + } + + public override SwitchDto Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + int count = reader.ReadArrayHeader(); + if (count != 5) throw new InvalidOperationException($"SwitchDto: expected 5 elements, got {count}"); + + int owner = reader.ReadInt32(); + string keyName = reader.ReadString() ?? + throw new InvalidOperationException("SwitchDto: key name cannot be null."); + string valueTypeName = reader.ReadString() ?? + throw new InvalidOperationException("SwitchDto: value type name cannot be null."); + int caseCount = reader.ReadArrayHeader(); + SwitchCaseDto[] cases = new SwitchCaseDto[caseCount]; + for (int i = 0; i < caseCount; i++) + { + int caseLength = reader.ReadArrayHeader(); + if (caseLength != 2) + throw new InvalidOperationException( + $"SwitchDto: case {i} has {caseLength} elements, expected 2"); + + BehaviorFieldValue literal = BehaviorDtoFormatter.ReadValue(ref reader, bindingDepth: 0, + behaviorDepth: 0, conditionDepth: 0); + cases[i] = new SwitchCaseDto(literal, reader.ReadInt32()); + } + + int defaultTarget = reader.ReadInt32(); + return new SwitchDto(owner, keyName, valueTypeName, cases, defaultTarget); + } +} + +internal sealed class SwitchArrayDtoFormatter : GraphEntityFormatter +{ + public static readonly SwitchArrayDtoFormatter Instance = new(); + + public override void Serialize(ref MessagePackWriter writer, SwitchDto[] value, + MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(value.Length); + for (int i = 0; i < value.Length; i++) + SwitchDtoFormatter.Instance.Serialize(ref writer, value[i], options); + } + + public override SwitchDto[] Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + int count = reader.ReadArrayHeader(); + SwitchDto[] arr = new SwitchDto[count]; + for (int i = 0; i < count; i++) + arr[i] = SwitchDtoFormatter.Instance.Deserialize(ref reader, options); + return arr; + } +} diff --git a/NxGraph.Serialization/SwitchLiteral.cs b/NxGraph.Serialization/SwitchLiteral.cs new file mode 100644 index 0000000..92b86ff --- /dev/null +++ b/NxGraph.Serialization/SwitchLiteral.cs @@ -0,0 +1,70 @@ +using System.Reflection; +using NxGraph.Behaviors; +using NxGraph.Graphs; +using NxGraph.Serialization.Abstraction; + +namespace NxGraph.Serialization; + +/// +/// Encodes a data-built switch's case literals (payload version 10) through the neutral +/// field model, so the branch sections need no wire vocabulary of their own: a case value is +/// written as a one-field payload and read +/// back through . Two consequences are +/// deliberate — the model's literal validation applies verbatim (a SwitchState<T> +/// whose T is not string/bool/int/long/float/double/enum fails at save time with the +/// field model's own targeted error, here re-thrown naming the node), and a crafted payload +/// cannot smuggle a key binding into a case value: a switch matches literals only. +/// +internal static class SwitchLiteral +{ + // The field model is name-addressed; a switch case carries exactly one anonymous value, + // so the name is a constant that never reaches a user. + private const string FieldName = "v"; + + /// + /// Encodes one boxed case value of runtime type . Cold path: + /// the closed T comes from ISwitchNode.ValueType, so the generic writer is + /// reached by reflection (the BehaviorRegistry.ReadSetValue recipe). + /// + internal static BehaviorFieldValue Write(Type valueType, object? value, NodeId nodeId) + { + MethodInfo writer = typeof(SwitchLiteral) + .GetMethod(nameof(WriteGeneric), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(valueType); + try + { + return (BehaviorFieldValue)writer.Invoke(null, [value])!; + } + catch (TargetInvocationException ex) when (ex.InnerException is NotSupportedException inner) + { + // Reflection hides the field model's targeted literal error behind a + // TargetInvocationException; rethrow naming the offending node, the way every + // other unsupported-node error in this serializer reads. + throw new NotSupportedException( + $"Node '{nodeId}' is a data-built switch whose case values cannot ride the payload. " + + inner.Message, inner); + } + } + + /// Decodes one case literal, rejecting the key-binding form. + internal static T Read(BehaviorFieldValue literal, string owner) + { + BehaviorFieldReader reader = new([new BehaviorField(FieldName, literal)]); + BlackboardValue value = reader.ReadBinding(FieldName); + if (value.IsBound) + { + throw new InvalidOperationException( + $"{owner} carries a case value bound to key '{value.KeyName}'. Switch cases are literals — a " + + "key-bound case value would make case distinctness undecidable."); + } + + return value.Literal; + } + + private static BehaviorFieldValue WriteGeneric(object? value) + { + BehaviorFieldWriter writer = new(); + writer.WriteBinding(FieldName, (T)value!); + return writer.ToFields()[0].Value; + } +} diff --git a/NxGraph.Tests/AllocationGateTests.cs b/NxGraph.Tests/AllocationGateTests.cs index e168a29..0dcdc7b 100644 --- a/NxGraph.Tests/AllocationGateTests.cs +++ b/NxGraph.Tests/AllocationGateTests.cs @@ -1,5 +1,6 @@ using NxGraph.Authoring; using NxGraph.Blackboards; +using NxGraph.Conditions; using NxGraph.Fsm; using NxGraph.Fsm.Async; using NxGraph.Graphs; @@ -594,6 +595,88 @@ public void sync_choice_without_blackboard_is_allocation_free() AssertZeroAlloc(graph.ToStateMachine()); } + // ── Data-built branching (spec 023): condition walk + case scan ───── + // + // Selection is an array walk over one stack-allocated context (plus one typed Get for the + // switch), so both new states must dispatch at 0 B under both runtimes. The tested key is + // Node-scoped on purpose: the board is machine-owned, so the gate measures the branch + // rather than board-binding plumbing. + + private static (BlackboardSchema schema, BlackboardKey tier) BranchFixture(string name) + { + BlackboardSchema schema = new(name, BlackboardScope.Node); + BlackboardKey tier = schema.Register("tier", 2); + return (schema, tier); + } + + [Test] + public async Task async_data_choice_branch_is_allocation_free() + { + (BlackboardSchema schema, BlackboardKey tier) = BranchFixture("gate-choice"); + + Graph graph = GraphBuilder + .Start() + .If(ConditionMatch.All, new KeyEquals(tier, 2), new Not(new IsTrue(false))) + .ThenAsync(_ => ResultHelpers.Success) + .ElseAsync(_ => ResultHelpers.Success) + .WithSchema(schema) + .Build(); + + await AssertZeroAllocAsync(graph.ToAsyncStateMachine()); + } + + [Test] + public void sync_data_choice_branch_is_allocation_free() + { + (BlackboardSchema schema, BlackboardKey tier) = BranchFixture("gate-choice"); + + Graph graph = GraphBuilder + .Start() + .If(ConditionMatch.All, new KeyEquals(tier, 2), new Not(new IsTrue(false))) + .Then(() => Result.Success) + .Else(() => Result.Success) + .WithSchema(schema) + .Build(); + + AssertZeroAlloc(graph.ToStateMachine()); + } + + [Test] + public async Task async_data_switch_dispatch_is_allocation_free() + { + (BlackboardSchema schema, BlackboardKey tier) = BranchFixture("gate-switch"); + + Graph graph = GraphBuilder + .Start() + .Switch(tier) + .CaseAsync(1, _ => ResultHelpers.Success) + .CaseAsync(2, _ => ResultHelpers.Success) + .DefaultAsync(_ => ResultHelpers.Success) + .End() + .WithSchema(schema) + .Build(); + + await AssertZeroAllocAsync(graph.ToAsyncStateMachine()); + } + + [Test] + public void sync_data_switch_dispatch_is_allocation_free() + { + (BlackboardSchema schema, BlackboardKey tier) = BranchFixture("gate-switch"); + + Graph graph = GraphBuilder + .Start() + .Switch(tier) + .Case(1, () => Result.Success) + .Case(2, () => Result.Success) + .Default(() => Result.Success) + .End() + .WithSchema(schema) + .Build(); + + AssertZeroAlloc(graph.ToStateMachine()); + } + // ── Blackboards: Node scope (per-transition reset included) ───────── private static Graph NodeScopeChain(bool sync) diff --git a/NxGraph.Tests/ChoiceStateTests.cs b/NxGraph.Tests/ChoiceStateTests.cs new file mode 100644 index 0000000..769832b --- /dev/null +++ b/NxGraph.Tests/ChoiceStateTests.cs @@ -0,0 +1,299 @@ +using NxGraph.Authoring; +using NxGraph.Behaviors; +using NxGraph.Conditions; +using NxGraph.Fsm; +using NxGraph.Graphs; + +namespace NxGraph.Tests; + +/// +/// The data-built (spec 023): a condition list, a match mode, two +/// arms. Pins the combination semantics (including short-circuit evaluation, which the +/// side-effect-free contract makes legal), routing of both arms under +/// both runtimes, the terminal arm, and the construction-time +/// rejections. The delegate-backed twin is covered by RelayChoiceStateTests. +/// +[TestFixture] +[Category("branching_choice")] +public class ChoiceStateTests +{ + private const string TrueArm = "true-arm"; + private const string FalseArm = "false-arm"; + + // ── Test doubles ───────────────────────────────────────────────────── + + /// Returns a fixed answer and counts how often it was asked. + private sealed class CountingCondition(bool answer) : ICondition + { + public int Evaluations { get; private set; } + + public bool Evaluate(in BehaviorContext ctx) + { + Evaluations++; + return answer; + } + } + + /// Throws if evaluated — the short-circuit tripwire. + private sealed class ExplodingCondition : ICondition + { + public bool Evaluate(in BehaviorContext ctx) => + throw new InvalidOperationException("evaluated past the short-circuit point"); + } + + // ── Fixtures ───────────────────────────────────────────────────────── + + private static RelayState Probe(string name, List trace) => new(() => + { + trace.Add(name); + return Result.Success; + }); + + /// + /// A choice as the start node, each arm a probe state. An arm marked terminal is wired to + /// instead — the director's terminal exit. + /// + private static Graph ChoiceGraph(IReadOnlyList conditions, ConditionMatch match, + List trace, bool trueArmTerminal = false, bool falseArmTerminal = false) + { + GraphBuilder builder = new(); + NodeId yes = trueArmTerminal ? NodeId.Default : builder.AddNode(Probe(TrueArm, trace)); + NodeId no = falseArmTerminal ? NodeId.Default : builder.AddNode(Probe(FalseArm, trace)); + builder.AddNode((IAsyncLogic)new ChoiceState(conditions, match, yes, no), isStart: true); + return builder.Build(throwOnError: false); + } + + private static Result RunToEnd(StateMachine machine) + { + Result result = machine.Execute(); + while (result == Result.InProgress) + { + result = machine.Execute(); + } + + return result; + } + + private static async Task RunAsync(Graph graph, bool sync) + { + return sync + ? RunToEnd(graph.ToStateMachine()) + : await graph.ToAsyncStateMachine().ExecuteAsync(); + } + + // ── Combination semantics ──────────────────────────────────────────── + + [Test] + public async Task All_short_circuits_at_the_first_false([Values] bool sync) + { + CountingCondition first = new(false); + List trace = []; + Graph graph = ChoiceGraph([first, new ExplodingCondition()], ConditionMatch.All, trace); + + Result result = await RunAsync(graph, sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { FalseArm })); + Assert.That(first.Evaluations, Is.EqualTo(1), + "All stops walking at the first false — the tripwire condition must never run."); + }); + } + + [Test] + public async Task Any_short_circuits_at_the_first_true([Values] bool sync) + { + CountingCondition first = new(true); + List trace = []; + Graph graph = ChoiceGraph([first, new ExplodingCondition()], ConditionMatch.Any, trace); + + Result result = await RunAsync(graph, sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { TrueArm })); + Assert.That(first.Evaluations, Is.EqualTo(1), + "Any stops walking at the first true — the tripwire condition must never run."); + }); + } + + [Test] + public async Task All_takes_the_true_arm_only_when_every_condition_holds([Values] bool sync) + { + CountingCondition a = new(true); + CountingCondition b = new(true); + List trace = []; + + Result result = await RunAsync(ChoiceGraph([a, b], ConditionMatch.All, trace), sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { TrueArm })); + Assert.That(a.Evaluations, Is.EqualTo(1)); + Assert.That(b.Evaluations, Is.EqualTo(1), "All walks the whole list when nothing is false."); + }); + } + + [Test] + public async Task Any_takes_the_false_arm_only_when_every_condition_fails([Values] bool sync) + { + CountingCondition a = new(false); + CountingCondition b = new(false); + List trace = []; + + Result result = await RunAsync(ChoiceGraph([a, b], ConditionMatch.Any, trace), sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { FalseArm })); + Assert.That(b.Evaluations, Is.EqualTo(1), "Any walks the whole list when nothing is true."); + }); + } + + // ── Arm routing ────────────────────────────────────────────────────── + + [Test] + public async Task Both_arms_route_to_their_own_node([Values] bool sync) + { + List trueTrace = []; + List falseTrace = []; + + Result trueRun = await RunAsync(ChoiceGraph([new IsTrue(true)], ConditionMatch.All, trueTrace), sync); + Result falseRun = await RunAsync(ChoiceGraph([new IsTrue(false)], ConditionMatch.All, falseTrace), sync); + + Assert.Multiple(() => + { + Assert.That(trueRun, Is.EqualTo(Result.Success)); + Assert.That(falseRun, Is.EqualTo(Result.Success)); + Assert.That(trueTrace, Is.EqualTo(new[] { TrueArm })); + Assert.That(falseTrace, Is.EqualTo(new[] { FalseArm })); + }); + } + + [Test] + public async Task A_default_true_arm_terminates_the_run([Values] bool sync) + { + List trace = []; + Graph graph = ChoiceGraph([new IsTrue(true)], ConditionMatch.All, trace, trueArmTerminal: true); + + Result result = await RunAsync(graph, sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success), "NodeId.Default is the director's terminal exit."); + Assert.That(trace, Is.Empty, "The false arm's probe must not run."); + }); + } + + [Test] + public async Task A_default_false_arm_terminates_the_run([Values] bool sync) + { + List trace = []; + Graph graph = ChoiceGraph([new IsTrue(false)], ConditionMatch.All, trace, falseArmTerminal: true); + + Result result = await RunAsync(graph, sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.Empty, "The true arm's probe must not run."); + }); + } + + // ── Node surface ───────────────────────────────────────────────────── + + [Test] + public void The_single_condition_constructor_is_an_All_of_one() + { + ChoiceState choice = new(new IsTrue(true), new NodeId(1), new NodeId(2)); + + Assert.Multiple(() => + { + Assert.That(choice.Match, Is.EqualTo(ConditionMatch.All)); + Assert.That(choice.Conditions, Has.Count.EqualTo(1)); + Assert.That(choice.SelectNext(), Is.EqualTo(new NodeId(1))); + }); + } + + [Test] + public void Execute_always_succeeds_because_a_decision_never_faults() + { + ChoiceState choice = new(new IsTrue(false), new NodeId(1), new NodeId(2)); + + Assert.That(((ILogic)choice).Execute(), Is.EqualTo(Result.Success)); + } + + [Test] + public void Static_targets_yield_the_true_arm_then_the_false_arm() + { + // Reachability validation and the Mermaid exporter walk this — order is the contract. + ChoiceState choice = new([new IsTrue(true)], ConditionMatch.All, new NodeId(7), new NodeId(9)); + + Assert.That(((IDirector)choice).EnumerateStaticTargets().ToArray(), + Is.EqualTo(new[] { new NodeId(7), new NodeId(9) })); + } + + [Test] + public void The_condition_list_is_copied_so_a_later_mutation_cannot_change_the_decision() + { + List conditions = [new IsTrue(true)]; + ChoiceState choice = new(conditions, ConditionMatch.All, new NodeId(1), new NodeId(2)); + + conditions[0] = new IsTrue(false); + conditions.Add(new IsTrue(false)); + + Assert.That(choice.SelectNext(), Is.EqualTo(new NodeId(1)), + "The built graph's decision must not be reachable through the caller's list."); + } + + // ── Construction-time rejections ───────────────────────────────────── + + [Test] + public void An_empty_condition_list_is_rejected() + { + ArgumentException? ex = Assert.Throws( + () => _ = new ChoiceState([], ConditionMatch.All, new NodeId(1), new NodeId(2))); + + Assert.Multiple(() => + { + Assert.That(ex!.ParamName, Is.EqualTo("conditions")); + Assert.That(ex.Message, Does.Contain("At least one condition")); + }); + } + + [Test] + public void A_null_condition_list_is_rejected() + { + ArgumentException? ex = Assert.Throws( + () => _ = new ChoiceState(null!, ConditionMatch.All, new NodeId(1), new NodeId(2))); + + Assert.That(ex!.ParamName, Is.EqualTo("conditions")); + } + + [Test] + public void A_null_condition_entry_is_rejected_naming_its_index() + { + ArgumentException? ex = Assert.Throws( + () => _ = new ChoiceState([new IsTrue(true), null!], ConditionMatch.All, + new NodeId(1), new NodeId(2))); + + Assert.Multiple(() => + { + Assert.That(ex!.ParamName, Is.EqualTo("conditions")); + Assert.That(ex.Message, Does.Contain("index 1")); + }); + } + + [Test] + public void A_null_single_condition_is_rejected_through_the_same_parameter_name() + { + ArgumentException? ex = Assert.Throws( + () => _ = new ChoiceState((ICondition)null!, new NodeId(1), new NodeId(2))); + + Assert.That(ex!.ParamName, Is.EqualTo("conditions")); + } +} diff --git a/NxGraph.Tests/ConditionTests.cs b/NxGraph.Tests/ConditionTests.cs new file mode 100644 index 0000000..2b6a94d --- /dev/null +++ b/NxGraph.Tests/ConditionTests.cs @@ -0,0 +1,305 @@ +using NxGraph.Authoring; +using NxGraph.Blackboards; +using NxGraph.Conditions; +using NxGraph.Fsm; +using NxGraph.Fsm.Async; +using NxGraph.Graphs; + +namespace NxGraph.Tests; + +/// +/// Condition semantics (spec 023): the standard set evaluated against +/// a machine-stamped context. BehaviorContext's constructor is internal, so every case +/// drives a real run over a one-node graph and reads back which arm +/// the decision took — the same way the library will be used. +/// +/// The load-bearing contract pinned here: a condition that is false is not a fault (the +/// run still ends Success, down the false arm), while a genuine wiring fault — a +/// name-bound key missing from every bound schema, or declared with a different value type — +/// throws and is never reported as false. +/// +/// +[TestFixture] +[Category("conditions")] +public class ConditionTests +{ + private const string TrueArm = "true-arm"; + private const string FalseArm = "false-arm"; + + // ── Fixtures ───────────────────────────────────────────────────────── + + /// + /// A three-node graph: the start node is a data-built choice whose arms are two probe + /// states appending their name to . + /// + private static Graph ChoiceGraph(IReadOnlyList conditions, ConditionMatch match, + List trace, BlackboardSchema? schema) + { + GraphBuilder builder = new(); + NodeId yes = builder.AddNode(new RelayState(() => + { + trace.Add(TrueArm); + return Result.Success; + })); + NodeId no = builder.AddNode(new RelayState(() => + { + trace.Add(FalseArm); + return Result.Success; + })); + builder.AddNode((IAsyncLogic)new ChoiceState(conditions, match, yes, no), isStart: true); + + if (schema is not null) + { + builder.WithSchema(schema); + } + + return builder.Build(throwOnError: false); + } + + private static Result RunToEnd(StateMachine machine) + { + Result result = machine.Execute(); + while (result == Result.InProgress) + { + result = machine.Execute(); + } + + return result; + } + + /// Runs one condition through both runtimes' shared graph shape and returns the arm taken. + private static async Task ArmAsync(ICondition condition, bool sync, + BlackboardSchema? schema = null, Blackboard? board = null) + { + List trace = []; + Graph graph = ChoiceGraph([condition], ConditionMatch.All, trace, schema); + + Result result; + if (sync) + { + StateMachine machine = graph.ToStateMachine(); + if (board is not null) + { + machine = machine.WithBlackboard(board); + } + + result = RunToEnd(machine); + } + else + { + AsyncStateMachine machine = graph.ToAsyncStateMachine(); + if (board is not null) + { + machine = machine.WithBlackboard(board); + } + + result = await machine.ExecuteAsync(); + } + + Assert.That(result, Is.EqualTo(Result.Success), + "A decision never faults — a false condition routes, it does not fail the node."); + return trace.Single(); + } + + private static (BlackboardSchema schema, Blackboard board) Boards(out BlackboardKey mode, + out BlackboardKey expected, out BlackboardKey armed) + { + BlackboardSchema schema = new("conditions"); + mode = schema.Register("mode", "patrol"); + expected = schema.Register("expected", "patrol"); + armed = schema.Register("armed", false); + return (schema, new Blackboard(schema)); + } + + // ── KeyEquals ──────────────────────────────────────────────────────── + + [Test] + public async Task KeyEquals_takes_the_true_arm_when_the_slot_matches_the_literal([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board) = Boards(out BlackboardKey mode, out _, out _); + board.Set(mode, "chase"); + + string arm = await ArmAsync(new KeyEquals(mode, "chase"), sync, schema, board); + + Assert.That(arm, Is.EqualTo(TrueArm)); + } + + [Test] + public async Task KeyEquals_takes_the_false_arm_when_the_slot_differs([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board) = Boards(out BlackboardKey mode, out _, out _); + board.Set(mode, "patrol"); + + string arm = await ArmAsync(new KeyEquals(mode, "chase"), sync, schema, board); + + Assert.That(arm, Is.EqualTo(FalseArm)); + } + + [Test] + public async Task KeyEquals_compares_a_key_against_another_key([Values] bool sync) + { + // The expected side is a BlackboardValue binding, so a rule may compare two slots. + (BlackboardSchema schema, Blackboard board) = + Boards(out BlackboardKey mode, out BlackboardKey expected, out _); + board.Set(mode, "chase"); + board.Set(expected, "chase"); + + string equalArm = await ArmAsync(new KeyEquals(mode, expected), sync, schema, board); + + board.Set(expected, "flee"); + string differingArm = await ArmAsync(new KeyEquals(mode, expected), sync, schema, board); + + Assert.Multiple(() => + { + Assert.That(equalArm, Is.EqualTo(TrueArm)); + Assert.That(differingArm, Is.EqualTo(FalseArm)); + }); + } + + [Test] + public void KeyEquals_rejects_an_invalid_key() + { + ArgumentException? ex = Assert.Throws( + () => _ = new KeyEquals(default, 1)); + + Assert.That(ex!.ParamName, Is.EqualTo("key")); + } + + [Test] + public void KeyEquals_unbound_rejects_an_empty_key_name() + { + ArgumentException? ex = Assert.Throws( + () => _ = KeyEquals.Unbound(string.Empty, 1)); + + Assert.That(ex!.ParamName, Is.EqualTo("keyName")); + } + + // ── IsTrue ─────────────────────────────────────────────────────────── + + [Test] + public async Task IsTrue_reads_a_literal_without_touching_any_board([Values] bool sync) + { + // No schema, no bound board: a literal binding resolves without blackboard access. + string trueArm = await ArmAsync(new IsTrue(true), sync); + string falseArm = await ArmAsync(new IsTrue(false), sync); + + Assert.Multiple(() => + { + Assert.That(trueArm, Is.EqualTo(TrueArm)); + Assert.That(falseArm, Is.EqualTo(FalseArm)); + }); + } + + [Test] + public async Task IsTrue_reads_a_bool_key([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board) = Boards(out _, out _, out BlackboardKey armed); + board.Set(armed, true); + + string arm = await ArmAsync(new IsTrue(armed), sync, schema, board); + + Assert.That(arm, Is.EqualTo(TrueArm)); + } + + // ── Not ────────────────────────────────────────────────────────────── + + [Test] + public async Task Not_inverts_the_inner_condition([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board) = Boards(out BlackboardKey mode, out _, out _); + board.Set(mode, "patrol"); + + string arm = await ArmAsync(new Not(new KeyEquals(mode, "chase")), sync, schema, board); + + Assert.That(arm, Is.EqualTo(TrueArm), "'not equal' is expressible only through Not."); + } + + [Test] + public async Task Not_nests_over_another_negation([Values] bool sync) + { + string arm = await ArmAsync(new Not(new Not(new IsTrue(true))), sync); + + Assert.That(arm, Is.EqualTo(TrueArm)); + } + + [Test] + public void Not_rejects_a_null_inner_condition() + { + ArgumentNullException? ex = Assert.Throws(() => _ = new Not(null!)); + + Assert.That(ex!.ParamName, Is.EqualTo("condition")); + } + + // ── Name-bound (deserialized) keys ─────────────────────────────────── + + [Test] + public async Task Unbound_key_resolves_by_name_against_the_bound_schema([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board) = Boards(out BlackboardKey mode, out _, out _); + board.Set(mode, "chase"); + + string arm = await ArmAsync(KeyEquals.Unbound("mode", "chase"), sync, schema, board); + + Assert.That(arm, Is.EqualTo(TrueArm), + "A rebuilt condition resolves its key by name against the machine's bound boards."); + } + + [Test] + public void Unbound_key_missing_from_every_bound_schema_throws_instead_of_reporting_false([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board) = Boards(out _, out _, out _); + List trace = []; + Graph graph = ChoiceGraph([KeyEquals.Unbound("ghost", "chase")], ConditionMatch.All, trace, schema); + + InvalidOperationException? ex = sync + ? Assert.Throws( + () => RunToEnd(graph.ToStateMachine().WithBlackboard(board))) + : Assert.ThrowsAsync( + async () => await graph.ToAsyncStateMachine().WithBlackboard(board).ExecuteAsync()); + + Assert.Multiple(() => + { + Assert.That(ex!.Message, Does.Contain("ghost")); + Assert.That(trace, Is.Empty, "A wiring fault throws — it must never be reported as a false arm."); + }); + } + + [Test] + public void Unbound_key_declared_with_a_different_value_type_throws([Values] bool sync) + { + BlackboardSchema schema = new("mismatched"); + BlackboardKey tier = schema.Register("mode", 2); + Blackboard board = new(schema); + board.Set(tier, 2); + + List trace = []; + Graph graph = ChoiceGraph([KeyEquals.Unbound("mode", "chase")], ConditionMatch.All, trace, schema); + + InvalidOperationException? ex = sync + ? Assert.Throws( + () => RunToEnd(graph.ToStateMachine().WithBlackboard(board))) + : Assert.ThrowsAsync( + async () => await graph.ToAsyncStateMachine().WithBlackboard(board).ExecuteAsync()); + + Assert.Multiple(() => + { + Assert.That(ex!.Message, Does.Contain("mode").And.Contain("System.Int32")); + Assert.That(trace, Is.Empty, "A type mismatch throws — it must never be reported as a false arm."); + }); + } + + // ── Scope reach ────────────────────────────────────────────────────── + + [Test] + public async Task Conditions_read_node_scoped_scratch_defaults([Values] bool sync) + { + // Unlike ports (spec 010), conditions accept any key scope — they resolve within one + // visit. A machine auto-creates its Node board, so no binding is involved. + BlackboardSchema scratch = new("scratch", BlackboardScope.Node); + BlackboardKey tier = scratch.Register("tier", 2); + + string arm = await ArmAsync(new KeyEquals(tier, 2), sync, scratch); + + Assert.That(arm, Is.EqualTo(TrueArm)); + } +} diff --git a/NxGraph.Tests/DataBranchDslTests.cs b/NxGraph.Tests/DataBranchDslTests.cs new file mode 100644 index 0000000..74b4379 --- /dev/null +++ b/NxGraph.Tests/DataBranchDslTests.cs @@ -0,0 +1,284 @@ +using NxGraph.Authoring; +using NxGraph.Blackboards; +using NxGraph.Conditions; +using NxGraph.Fsm; +using NxGraph.Graphs; + +namespace NxGraph.Tests; + +/// +/// The data-built authoring surface (spec 023, Authoring/Dsl.Conditions.cs): +/// .If(condition), .If(match, conditions…) and .Switch(blackboardKey) on +/// both StartToken (the branch as the graph's first node) and StateToken. The +/// builders are the same IfBuilder / SwitchBuilder the delegate paths return, so +/// these tests pin that the chain shape is unchanged and that the data mode really builds the +/// serializable states rather than a Relay* one. +/// +[TestFixture] +[Category("branching_dsl")] +public class DataBranchDslTests +{ + private static RelayState Probe(string name, List trace) => new(() => + { + trace.Add(name); + return Result.Success; + }); + + private static (BlackboardSchema schema, Blackboard board, BlackboardKey armed, + BlackboardKey mode) Boards() + { + BlackboardSchema schema = new("dsl-branching"); + BlackboardKey armed = schema.Register("armed", false); + BlackboardKey mode = schema.Register("mode", "alpha"); + return (schema, new Blackboard(schema), armed, mode); + } + + private static Result RunToEnd(StateMachine machine) + { + Result result = machine.Execute(); + while (result == Result.InProgress) + { + result = machine.Execute(); + } + + return result; + } + + private static async Task RunAsync(Graph graph, Blackboard board, bool sync) + { + return sync + ? RunToEnd(graph.ToStateMachine().WithBlackboard(board)) + : await graph.ToAsyncStateMachine().WithBlackboard(board).ExecuteAsync(); + } + + // ── .If(condition) ─────────────────────────────────────────────────── + + [Test] + public async Task If_condition_chains_from_a_state_token([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board, BlackboardKey armed, _) = Boards(); + List trace = []; + + Graph graph = GraphBuilder + .StartWith(() => Result.Success) + .If(new IsTrue(armed)) + .Then(Probe("then", trace)) + .Else(Probe("else", trace)) + .WithSchema(schema) + .Build(); + + board.Set(armed, true); + Result thenRun = await RunAsync(graph, board, sync); + board.Set(armed, false); + Result elseRun = await RunAsync(graph, board, sync); + + Assert.Multiple(() => + { + Assert.That(thenRun, Is.EqualTo(Result.Success)); + Assert.That(elseRun, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { "then", "else" })); + }); + } + + [Test] + public async Task If_condition_starts_the_graph([Values] bool sync) + { + // The branch is the start node: it must run under both runtimes, exactly like the + // delegate overloads (one class implements both logic slots and both director slots). + (BlackboardSchema schema, Blackboard board, BlackboardKey armed, _) = Boards(); + board.Set(armed, true); + List trace = []; + + Graph graph = GraphBuilder.Start() + .If(new IsTrue(armed)) + .Then(Probe("then", trace)) + .Else(Probe("else", trace)) + .WithSchema(schema) + .Build(); + + Result result = await RunAsync(graph, board, sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { "then" })); + Assert.That(graph.StartNode.Id.Index, Is.Zero); + }); + } + + [Test] + public async Task If_with_match_any_takes_the_true_arm_when_one_condition_holds([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board, BlackboardKey armed, _) = Boards(); + board.Set(armed, true); + List trace = []; + + Graph graph = GraphBuilder + .StartWith(() => Result.Success) + .If(ConditionMatch.Any, new IsTrue(false), new IsTrue(armed)) + .Then(Probe("then", trace)) + .Else(Probe("else", trace)) + .WithSchema(schema) + .Build(); + + Result result = await RunAsync(graph, board, sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { "then" })); + }); + } + + [Test] + public async Task If_with_match_all_takes_the_false_arm_when_one_condition_fails([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board, BlackboardKey armed, _) = Boards(); + board.Set(armed, true); + List trace = []; + + Graph graph = GraphBuilder + .StartWith(() => Result.Success) + .If(ConditionMatch.All, new IsTrue(armed), new Not(new IsTrue(armed))) + .Then(Probe("then", trace)) + .Else(Probe("else", trace)) + .WithSchema(schema) + .Build(); + + Result result = await RunAsync(graph, board, sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { "else" })); + }); + } + + [Test] + public void If_condition_builds_a_data_choice_state_not_a_relay() + { + (BlackboardSchema schema, _, BlackboardKey armed, _) = Boards(); + + Graph graph = GraphBuilder.Start() + .If(new IsTrue(armed)) + .Then(new EmptyLogic()) + .Else(new EmptyLogic()) + .WithSchema(schema) + .Build(); + + LogicNode start = (LogicNode)graph.GetNodeByIndex(0); + + Assert.Multiple(() => + { + Assert.That(start.AsyncLogic, Is.InstanceOf(), + "The data path must build the serializable state, not a RelayChoiceState."); + Assert.That(ReferenceEquals(start.Logic, start.AsyncLogic), Is.True, + "One instance fills both logic slots, so either runtime family can run it."); + }); + } + + // ── .Switch(key) ───────────────────────────────────────────────────── + + [Test] + public async Task Switch_key_chains_from_a_state_token([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board, _, BlackboardKey mode) = Boards(); + List trace = []; + + Graph graph = GraphBuilder + .StartWith(() => Result.Success) + .Switch(mode) + .Case("alpha", Probe("case:alpha", trace)) + .Case("beta", Probe("case:beta", trace)) + .Default(Probe("default", trace)) + .End() + .WithSchema(schema) + .Build(); + + board.Set(mode, "beta"); + Result matched = await RunAsync(graph, board, sync); + board.Set(mode, "omega"); + Result unmatched = await RunAsync(graph, board, sync); + + Assert.Multiple(() => + { + Assert.That(matched, Is.EqualTo(Result.Success)); + Assert.That(unmatched, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { "case:beta", "default" })); + }); + } + + [Test] + public async Task Switch_key_starts_the_graph([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board, _, BlackboardKey mode) = Boards(); + board.Set(mode, "alpha"); + List trace = []; + + Graph graph = GraphBuilder.Start() + .Switch(mode) + .Case("alpha", Probe("case:alpha", trace)) + .Default(Probe("default", trace)) + .End() + .WithSchema(schema) + .Build(); + + Result result = await RunAsync(graph, board, sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { "case:alpha" })); + Assert.That(graph.StartNode.Id.Index, Is.Zero); + }); + } + + [Test] + public void Switch_key_builds_a_data_switch_state_not_a_relay() + { + (BlackboardSchema schema, _, _, BlackboardKey mode) = Boards(); + + Graph graph = GraphBuilder.Start() + .Switch(mode) + .Case("alpha", new EmptyLogic()) + .Default(new EmptyLogic()) + .End() + .WithSchema(schema) + .Build(); + + LogicNode start = (LogicNode)graph.GetNodeByIndex(0); + + Assert.Multiple(() => + { + Assert.That(start.AsyncLogic, Is.InstanceOf>()); + Assert.That(ReferenceEquals(start.Logic, start.AsyncLogic), Is.True); + }); + } + + [Test] + public void Switch_data_mode_rejects_a_value_cased_twice_at_End() + { + (_, _, _, BlackboardKey mode) = Boards(); + + ArgumentException? ex = Assert.Throws(() => _ = GraphBuilder.Start() + .Switch(mode) + .Case("alpha", new EmptyLogic()) + .Case("alpha", new EmptyLogic()) + .End()); + + Assert.Multiple(() => + { + Assert.That(ex!.ParamName, Is.EqualTo("cases")); + Assert.That(ex.Message, Does.Contain("alpha")); + }); + } + + [Test] + public void Switch_data_mode_rejects_an_invalid_key() + { + ArgumentException? ex = Assert.Throws( + () => _ = GraphBuilder.Start().Switch(default(BlackboardKey))); + + Assert.That(ex!.ParamName, Is.EqualTo("key")); + } +} diff --git a/NxGraph.Tests/GraphValidatorTests.cs b/NxGraph.Tests/GraphValidatorTests.cs index 689267d..21ba758 100644 --- a/NxGraph.Tests/GraphValidatorTests.cs +++ b/NxGraph.Tests/GraphValidatorTests.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using NxGraph.Authoring; +using NxGraph.Blackboards; +using NxGraph.Conditions; using NxGraph.Diagnostics.Validations; using NxGraph.Fsm; using NxGraph.Fsm.Async; @@ -266,6 +268,87 @@ public void DuplicateUid_ShouldBeError() }); } + // ── Data-built branch lints (spec 023) ─────────────────────────────── + + private const string DecidesNothing = "decides nothing"; + private const string NoDefaultTarget = "no default target"; + + [Test] + public void ChoiceWithBothArmsOnTheSameNode_ShouldBeWarning() + { + GraphBuilder builder = new(); + NodeId start = builder.AddNode(new AsyncRelayState(_ => ResultHelpers.Success), isStart: true); + NodeId only = builder.AddNode(new AsyncRelayState(_ => ResultHelpers.Success)); + NodeId choice = builder.AddNode((IAsyncLogic)new ChoiceState(new IsTrue(true), only, only)); + builder.AddTransition(start, choice); + + Graph graph = builder.Build(throwOnError: false); + GraphValidationResult res = graph.Validate(); + + Assert.That( + res.Diagnostics.Any(d => + d.Severity == Severity.Warning && d.Node.Index == choice.Index && + d.Message.Contains(DecidesNothing, StringComparison.OrdinalIgnoreCase)), Is.True, + "A choice routing both arms to the same node decides nothing and must be flagged."); + } + + [Test] + public void SwitchWithoutADefaultTarget_ShouldBeWarning() + { + BlackboardSchema schema = new("validation"); + BlackboardKey mode = schema.Register("mode", "alpha"); + + GraphBuilder builder = new(); + NodeId start = builder.AddNode(new AsyncRelayState(_ => ResultHelpers.Success), isStart: true); + NodeId alpha = builder.AddNode(new AsyncRelayState(_ => ResultHelpers.Success)); + NodeId switchNode = builder.AddNode((IAsyncLogic)new SwitchState(mode, + [new SwitchCase("alpha", alpha)], NodeId.Default)); + builder.AddTransition(start, switchNode); + builder.WithSchema(schema); + + Graph graph = builder.Build(throwOnError: false); + GraphValidationResult res = graph.Validate(); + + Assert.That( + res.Diagnostics.Any(d => + d.Severity == Severity.Warning && d.Node.Index == switchNode.Index && + d.Message.Contains(NoDefaultTarget, StringComparison.OrdinalIgnoreCase)), Is.True, + "An unmatched value terminating the run silently must be flagged."); + } + + [Test] + public void WellFormedDataBranchGraph_ProducesNoBranchWarnings() + { + BlackboardSchema schema = new("validation"); + BlackboardKey mode = schema.Register("mode", "alpha"); + + GraphBuilder builder = new(); + NodeId start = builder.AddNode(new AsyncRelayState(_ => ResultHelpers.Success), isStart: true); + NodeId yes = builder.AddNode(new AsyncRelayState(_ => ResultHelpers.Success)); + NodeId no = builder.AddNode(new AsyncRelayState(_ => ResultHelpers.Success)); + NodeId fallback = builder.AddNode(new AsyncRelayState(_ => ResultHelpers.Success)); + NodeId choice = builder.AddNode((IAsyncLogic)new ChoiceState(new IsTrue(true), yes, no)); + NodeId switchNode = builder.AddNode((IAsyncLogic)new SwitchState(mode, + [new SwitchCase("alpha", yes), new SwitchCase("beta", no)], fallback)); + builder.AddTransition(start, choice); + builder.AddTransition(yes, switchNode); + builder.WithSchema(schema); + + Graph graph = builder.Build(throwOnError: false); + GraphValidationResult res = graph.Validate(); + + Assert.Multiple(() => + { + Assert.That(res.HasErrors, Is.False); + Assert.That( + res.Diagnostics.Any(d => d.Message.Contains(DecidesNothing, StringComparison.OrdinalIgnoreCase)), + Is.False, "Distinct arms must not be flagged."); + Assert.That( + res.Diagnostics.Any(d => d.Message.Contains(NoDefaultTarget, StringComparison.OrdinalIgnoreCase)), + Is.False, "An explicit default target must not be flagged."); + }); + } + [Test] public void DistinctUids_ProduceNoUidDiagnostics() { diff --git a/NxGraph.Tests/MermaidGraphExporterTests.cs b/NxGraph.Tests/MermaidGraphExporterTests.cs index 9e7b30a..36b2403 100644 --- a/NxGraph.Tests/MermaidGraphExporterTests.cs +++ b/NxGraph.Tests/MermaidGraphExporterTests.cs @@ -1,4 +1,6 @@ using NxGraph.Authoring; +using NxGraph.Blackboards; +using NxGraph.Conditions; using NxGraph.Diagnostics.Export; using NxGraph.Fsm; using NxGraph.Graphs; @@ -202,4 +204,105 @@ public void should_render_director_nodes_with_curly_braces_and_no_space() Assert.That(mmd, Does.Contain("\n n0([")); Assert.That(mmd, Does.Not.Contain("n0 (")); } + + // ── Data-built branches (spec 023): the arms carry their labels ────── + + [Test] + public void data_choice_arms_are_labeled_true_and_false() + { + // n0 start → n1 true pad, n2 false pad, n3 choice, n4 then, n5 else. + Graph graph = GraphBuilder + .StartWith(() => Result.Success) + .If(new IsTrue(true)) + .Then(() => Result.Success) + .Else(() => Result.Success) + .Build(); + + string mmd = new MermaidGraphExporter().Export(graph); + + Assert.Multiple(() => + { + Assert.That(mmd, Does.Contain("n3 -. true .-> n1")); + Assert.That(mmd, Does.Contain("n3 -. false .-> n2")); + Assert.That(mmd, Does.Contain("\n n3{\""), "A choice still renders as a decision rhombus."); + }); + } + + [Test] + public void relay_choice_arms_stay_unlabeled() + { + // Regression guard for the deliberate asymmetry: a delegate-backed decision is opaque, + // so the exporter must not invent a label it cannot know. + const bool flag = true; + Graph graph = GraphBuilder + .StartWith(() => Result.Success) + .If(() => flag) + .Then(() => Result.Success) + .Else(() => Result.Success) + .Build(); + + string mmd = new MermaidGraphExporter().Export(graph); + + Assert.Multiple(() => + { + Assert.That(mmd, Does.Contain("n3 -.-> n1")); + Assert.That(mmd, Does.Contain("n3 -.-> n2")); + Assert.That(mmd, Does.Not.Contain("-. true .->")); + Assert.That(mmd, Does.Not.Contain("-. false .->")); + }); + } + + [Test] + public void data_switch_case_edges_carry_the_literal_and_the_default_says_otherwise() + { + BlackboardSchema schema = new("export"); + BlackboardKey mode = schema.Register("mode", "idle"); + + // n0 start → n1 "armed", n2 "idle", n3 default, n4 switch. + Graph graph = GraphBuilder + .StartWith(() => Result.Success) + .Switch(mode) + .Case("armed", () => Result.Success) + .Case("idle", () => Result.Success) + .Default(() => Result.Success) + .End() + .WithSchema(schema) + .Build(); + + string mmd = new MermaidGraphExporter().Export(graph); + + Assert.Multiple(() => + { + Assert.That(mmd, Does.Contain("n4 -. armed .-> n1")); + Assert.That(mmd, Does.Contain("n4 -. idle .-> n2")); + Assert.That(mmd, Does.Contain("n4 -. otherwise .-> n3")); + }); + } + + [Test] + public void data_switch_renders_numeric_case_literals_culture_neutrally() + { + BlackboardSchema schema = new("export"); + BlackboardKey tier = schema.Register("tier", 1); + + // n0 start → n1 case 1, n2 case 2, n3 switch (no default arm declared). + Graph graph = GraphBuilder + .StartWith(() => Result.Success) + .Switch(tier) + .Case(1, () => Result.Success) + .Case(2, () => Result.Success) + .End() + .WithSchema(schema) + .Build(); + + string mmd = new MermaidGraphExporter().Export(graph); + + Assert.Multiple(() => + { + Assert.That(mmd, Does.Contain("n3 -. 1 .-> n1")); + Assert.That(mmd, Does.Contain("n3 -. 2 .-> n2")); + Assert.That(mmd, Does.Not.Contain("otherwise"), + "A NodeId.Default default target is a terminal exit — there is no edge to draw."); + }); + } } diff --git a/NxGraph.Tests/Parity/ParityConformanceTests.cs b/NxGraph.Tests/Parity/ParityConformanceTests.cs index d59e708..188b02f 100644 --- a/NxGraph.Tests/Parity/ParityConformanceTests.cs +++ b/NxGraph.Tests/Parity/ParityConformanceTests.cs @@ -1,4 +1,6 @@ using NxGraph.Authoring; +using NxGraph.Blackboards; +using NxGraph.Conditions; using NxGraph.Fsm; using NxGraph.Graphs; @@ -122,6 +124,51 @@ private static ParityScenario SwitchRouting() return new ParityScenario { Graph = graph }; } + // Data-built branching (spec 023). The adapters build machines straight from the Graph + // with no board binding, so these recipes stay board-free: literal conditions bind + // nothing, and the Node-scoped key resolves against the board each machine auto-creates + // from the graph's Node schema — its registered default makes routing deterministic. + private static ParityScenario DataIfBranch(bool takeThen) + { + Graph graph = GraphBuilder + .StartWith(() => Result.Success).SetName("ask") + .If(new IsTrue(takeThen)) + .Then(() => Result.Success).SetName("yes") + .Else(() => Result.Success).SetName("no") + .Build(); + return new ParityScenario { Graph = graph }; + } + + private static ParityScenario DataChoiceOverNodeScratch() + { + BlackboardSchema scratch = new("parity-choice", BlackboardScope.Node); + BlackboardKey tier = scratch.Register("tier", 2); + Graph graph = GraphBuilder + .StartWith(() => Result.Success).SetName("ask") + .If(ConditionMatch.All, new KeyEquals(tier, 2), new Not(new KeyEquals(tier, 3))) + .Then(() => Result.Success).SetName("yes") + .Else(() => Result.Success).SetName("no") + .WithSchema(scratch) + .Build(); + return new ParityScenario { Graph = graph }; + } + + private static ParityScenario DataSwitchRouting() + { + BlackboardSchema scratch = new("parity-switch", BlackboardScope.Node); + BlackboardKey tier = scratch.Register("tier", 2); + Graph graph = GraphBuilder + .StartWith(() => Result.Success).SetName("pick") + .Switch(tier) + .Case(1, () => Result.Failure) + .Case(2, () => Result.Success) + .Default(() => Result.Failure) + .End().SetName("switch") + .WithSchema(scratch) + .Build(); + return new ParityScenario { Graph = graph }; + } + private static ParityScenario OutcomeCodes() { Graph graph = GraphBuilder @@ -273,6 +320,44 @@ public async Task switch_routing_runs_identically() }); } + [TestCase(true)] + [TestCase(false)] + public async Task data_if_branching_runs_identically(bool takeThen) + { + // A data-built ChoiceState is one class implementing both logic slots and both + // director slots, so the same node must route identically on all four surfaces. + List baseline = + await ParityRunner.AssertFsmParityAsync(() => DataIfBranch(takeThen), ParityDrives.OneRunAsync); + Assert.That(baseline, Does.Contain(takeThen ? "entered yes" : "entered no")); + } + + [Test] + public async Task data_choice_over_node_scratch_runs_identically() + { + // Condition evaluation reads the machine-owned Node board; both runtimes must resolve + // the same value and take the same arm. + List baseline = + await ParityRunner.AssertFsmParityAsync(DataChoiceOverNodeScratch, ParityDrives.OneRunAsync); + Assert.Multiple(() => + { + Assert.That(baseline, Does.Contain("entered yes")); + Assert.That(baseline, Does.Contain("run-result Success")); + }); + } + + [Test] + public async Task data_switch_routing_runs_identically() + { + List baseline = + await ParityRunner.AssertFsmParityAsync(DataSwitchRouting, ParityDrives.OneRunAsync); + Assert.Multiple(() => + { + Assert.That(baseline, Does.Contain("transition switch->#2"), + "The case-2 node (index 2) is selected by the director."); + Assert.That(baseline, Does.Contain("run-result Success")); + }); + } + [Test] public async Task outcome_codes_and_last_outcome_run_identically() { diff --git a/NxGraph.Tests/PublicApi/NxGraph.Serialization.Abstraction.approved.txt b/NxGraph.Tests/PublicApi/NxGraph.Serialization.Abstraction.approved.txt index db68f05..fa91982 100644 --- a/NxGraph.Tests/PublicApi/NxGraph.Serialization.Abstraction.approved.txt +++ b/NxGraph.Tests/PublicApi/NxGraph.Serialization.Abstraction.approved.txt @@ -14,6 +14,7 @@ enum NxGraph.Serialization.Abstraction.BehaviorFieldKind : System.IComparable, S Behaviors = 8 Binding = 7 Bool = 1 + Conditions = 9 Double = 5 Enum = 6 Int32 = 2 @@ -30,16 +31,18 @@ sealed class NxGraph.Serialization.Abstraction.BehaviorFieldReader method NxGraph.Behaviors.BlackboardValue`1[T] ReadBinding[T](System.String) method Single ReadSingle(System.String) method System.Object[] ReadBehaviors(System.String) + method System.Object[] ReadConditions(System.String) method System.String ReadString(System.String) method TEnum ReadEnum[TEnum](System.String) sealed class NxGraph.Serialization.Abstraction.BehaviorFieldValue - ctor Void .ctor(NxGraph.Serialization.Abstraction.BehaviorFieldKind, System.String, Boolean, Int64, Double, NxGraph.Serialization.Abstraction.BehaviorBinding, NxGraph.Serialization.Abstraction.BehaviorEntry[]) + ctor Void .ctor(NxGraph.Serialization.Abstraction.BehaviorFieldKind, System.String, Boolean, Int64, Double, NxGraph.Serialization.Abstraction.BehaviorBinding, NxGraph.Serialization.Abstraction.BehaviorEntry[], NxGraph.Serialization.Abstraction.ConditionEntry[]) property Boolean Flag { get; } property Double Number { get; } property Int64 Integer { get; } property NxGraph.Serialization.Abstraction.BehaviorBinding Binding { get; } property NxGraph.Serialization.Abstraction.BehaviorEntry[] Entries { get; } property NxGraph.Serialization.Abstraction.BehaviorFieldKind Kind { get; } + property NxGraph.Serialization.Abstraction.ConditionEntry[] Conditions { get; } property System.String Text { get; } sealed class NxGraph.Serialization.Abstraction.BehaviorFieldWriter ctor Void .ctor() @@ -47,6 +50,7 @@ sealed class NxGraph.Serialization.Abstraction.BehaviorFieldWriter method Void WriteBehaviors(System.String, System.Collections.Generic.IReadOnlyList`1[System.Object]) method Void WriteBinding[T](System.String, NxGraph.Behaviors.BlackboardValue`1[T] ByRef) method Void WriteBool(System.String, Boolean) + method Void WriteConditions(System.String, System.Collections.Generic.IReadOnlyList`1[System.Object]) method Void WriteDouble(System.String, Double) method Void WriteEnum[TEnum](System.String, TEnum) method Void WriteInt32(System.String, Int32) @@ -56,6 +60,10 @@ sealed class NxGraph.Serialization.Abstraction.BehaviorFieldWriter enum NxGraph.Serialization.Abstraction.BlackboardMismatchPolicy : System.IComparable, System.IConvertible, System.IFormattable, System.ISpanFormattable Skip = 1 Strict = 0 +sealed class NxGraph.Serialization.Abstraction.ConditionEntry + ctor Void .ctor(System.String, NxGraph.Serialization.Abstraction.BehaviorField[]) + property NxGraph.Serialization.Abstraction.BehaviorField[] Fields { get; } + property System.String ConditionTypeName { get; } interface NxGraph.Serialization.Abstraction.IBehaviorRegistry method Boolean TryRead(System.String, NxGraph.Serialization.Abstraction.BehaviorFieldReader, System.Object ByRef) method Boolean TryWrite(System.Object, NxGraph.Serialization.Abstraction.BehaviorFieldWriter) @@ -65,6 +73,9 @@ interface NxGraph.Serialization.Abstraction.IBlackboardBinarySerializer interface NxGraph.Serialization.Abstraction.IBlackboardJsonSerializer method System.Threading.Tasks.ValueTask RestoreFromJsonAsync(NxGraph.Blackboards.Blackboard, System.IO.Stream, NxGraph.Serialization.Abstraction.BlackboardMismatchPolicy, System.Threading.CancellationToken) method System.Threading.Tasks.ValueTask ToJsonAsync(NxGraph.Blackboards.Blackboard, System.IO.Stream, System.Threading.CancellationToken) +interface NxGraph.Serialization.Abstraction.IConditionRegistry + method Boolean TryRead(System.String, NxGraph.Serialization.Abstraction.BehaviorFieldReader, System.Object ByRef) + method Boolean TryWrite(System.Object, NxGraph.Serialization.Abstraction.BehaviorFieldWriter) interface NxGraph.Serialization.Abstraction.IContainerCodec interface NxGraph.Serialization.Abstraction.IContainerCodec`1 : NxGraph.Serialization.Abstraction.IContainerCodec method NxGraph.Graphs.IAsyncLogic Deserialize(TWire, System.Collections.Generic.IReadOnlyList`1[NxGraph.Graphs.Graph]) @@ -85,3 +96,5 @@ interface NxGraph.Serialization.Abstraction.IRegionSelectorRegistry method Boolean TryGetSelector(System.String, System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Fsm.RegionMask] ByRef) interface NxGraph.Serialization.Abstraction.ISerializableBehavior method Void Write(NxGraph.Serialization.Abstraction.BehaviorFieldWriter) +interface NxGraph.Serialization.Abstraction.ISerializableCondition + method Void Write(NxGraph.Serialization.Abstraction.BehaviorFieldWriter) diff --git a/NxGraph.Tests/PublicApi/NxGraph.Serialization.approved.txt b/NxGraph.Tests/PublicApi/NxGraph.Serialization.approved.txt index ece4e5f..192c9de 100644 --- a/NxGraph.Tests/PublicApi/NxGraph.Serialization.approved.txt +++ b/NxGraph.Tests/PublicApi/NxGraph.Serialization.approved.txt @@ -9,6 +9,11 @@ sealed class NxGraph.Serialization.BlackboardSerializer : NxGraph.Serialization. method System.Threading.Tasks.ValueTask RestoreFromJsonAsync(NxGraph.Blackboards.Blackboard, System.IO.Stream, NxGraph.Serialization.Abstraction.BlackboardMismatchPolicy, System.Threading.CancellationToken) method System.Threading.Tasks.ValueTask ToBinaryAsync(NxGraph.Blackboards.Blackboard, System.IO.Stream, System.Threading.CancellationToken) method System.Threading.Tasks.ValueTask ToJsonAsync(NxGraph.Blackboards.Blackboard, System.IO.Stream, System.Threading.CancellationToken) +sealed class NxGraph.Serialization.ConditionRegistry : NxGraph.Serialization.Abstraction.IConditionRegistry + ctor Void .ctor() + method Boolean TryRead(System.String, NxGraph.Serialization.Abstraction.BehaviorFieldReader, System.Object ByRef) + method Boolean TryWrite(System.Object, NxGraph.Serialization.Abstraction.BehaviorFieldWriter) + method Void Register(System.String, System.Func`2[NxGraph.Serialization.Abstraction.BehaviorFieldReader,System.Object]) sealed class NxGraph.Serialization.GraphSerializer : NxGraph.Serialization.Abstraction.IGraphBinarySerializer, NxGraph.Serialization.Abstraction.IGraphJsonSerializer, NxGraph.Serialization.Abstraction.IGraphSerializer ctor Void .ctor(NxGraph.Serialization.Abstraction.ILogicCodec) ctor Void .ctor(NxGraph.Serialization.Abstraction.ILogicCodec, NxGraph.Serialization.GraphSerializerOptions) @@ -21,6 +26,7 @@ sealed class NxGraph.Serialization.GraphSerializer : NxGraph.Serialization.Abstr sealed class NxGraph.Serialization.GraphSerializerOptions ctor Void .ctor() property NxGraph.Serialization.Abstraction.IBehaviorRegistry BehaviorRegistry { get; set; } + property NxGraph.Serialization.Abstraction.IConditionRegistry ConditionRegistry { get; set; } property NxGraph.Serialization.Abstraction.IContainerCodec ContainerCodec { get; set; } property NxGraph.Serialization.Abstraction.IRegionSelectorRegistry SelectorRegistry { get; set; } interface NxGraph.Serialization.IContainerBinaryCodec : NxGraph.Serialization.Abstraction.IContainerCodec, NxGraph.Serialization.Abstraction.IContainerCodec`1[[System.ReadOnlyMemory`1[[System.Byte]]]] diff --git a/NxGraph.Tests/PublicApi/NxGraph.approved.txt b/NxGraph.Tests/PublicApi/NxGraph.approved.txt index a80750d..72c995a 100644 --- a/NxGraph.Tests/PublicApi/NxGraph.approved.txt +++ b/NxGraph.Tests/PublicApi/NxGraph.approved.txt @@ -32,7 +32,11 @@ static class NxGraph.Authoring.Dsl method BranchEnd ElseAsync(BranchBuilder, System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) method BranchEnd SetName(BranchEnd, System.String) method BranchEnd WithSchema(BranchEnd, NxGraph.Blackboards.BlackboardSchema) + method IfBuilder If(NxGraph.Authoring.StartToken, NxGraph.Conditions.ConditionMatch, NxGraph.Conditions.ICondition[]) + method IfBuilder If(NxGraph.Authoring.StartToken, NxGraph.Conditions.ICondition) method IfBuilder If(NxGraph.Authoring.StartToken, System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Boolean]) + method IfBuilder If(NxGraph.Authoring.StateToken, NxGraph.Conditions.ConditionMatch, NxGraph.Conditions.ICondition[]) + method IfBuilder If(NxGraph.Authoring.StateToken, NxGraph.Conditions.ICondition) method IfBuilder If(NxGraph.Authoring.StateToken, System.Func`1[System.Boolean]) method IfBuilder If(NxGraph.Authoring.StateToken, System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Boolean]) method NxGraph.Authoring.StartToken WithSchema(NxGraph.Authoring.StartToken, NxGraph.Blackboards.BlackboardSchema) @@ -144,7 +148,9 @@ static class NxGraph.Authoring.Dsl method SwitchBuilder`1 DefaultAsync[TKey](SwitchBuilder`1, System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) method SwitchBuilder`1 Default[TKey](SwitchBuilder`1, System.Func`1[NxGraph.Result]) method SwitchBuilder`1 Default[TKey](SwitchBuilder`1, System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Result]) + method SwitchBuilder`1 Switch[TKey](NxGraph.Authoring.StartToken, NxGraph.Blackboards.BlackboardKey`1[TKey]) method SwitchBuilder`1 Switch[TKey](NxGraph.Authoring.StartToken, System.Func`2[NxGraph.Blackboards.BlackboardContext,TKey]) + method SwitchBuilder`1 Switch[TKey](NxGraph.Authoring.StateToken, NxGraph.Blackboards.BlackboardKey`1[TKey]) method SwitchBuilder`1 Switch[TKey](NxGraph.Authoring.StateToken, System.Func`1[TKey]) method SwitchBuilder`1 Switch[TKey](NxGraph.Authoring.StateToken, System.Func`2[NxGraph.Blackboards.BlackboardContext,TKey]) method System.TimeSpan Days(Double) @@ -447,6 +453,26 @@ interface NxGraph.Blackboards.IBlackboardBindable method Void SetBlackboard(NxGraph.Blackboards.Blackboard) interface NxGraph.Blackboards.IBlackboardSettable method Void SetBlackboards(NxGraph.Blackboards.BlackboardContext ByRef) +enum NxGraph.Conditions.ConditionMatch : System.IComparable, System.IConvertible, System.IFormattable, System.ISpanFormattable + All = 0 + Any = 1 +interface NxGraph.Conditions.ICondition + method Boolean Evaluate(NxGraph.Behaviors.BehaviorContext ByRef) +sealed class NxGraph.Conditions.IsTrue : NxGraph.Conditions.ICondition + ctor Void .ctor(NxGraph.Behaviors.BlackboardValue`1[System.Boolean]) + method Boolean Evaluate(NxGraph.Behaviors.BehaviorContext ByRef) + property NxGraph.Behaviors.BlackboardValue`1[System.Boolean] Value { get; } +sealed class NxGraph.Conditions.KeyEquals`1 : NxGraph.Conditions.ICondition + ctor Void .ctor(NxGraph.Blackboards.BlackboardKey`1[T], NxGraph.Behaviors.BlackboardValue`1[T]) + method Boolean Evaluate(NxGraph.Behaviors.BehaviorContext ByRef) + method NxGraph.Conditions.KeyEquals`1[T] Unbound(System.String, NxGraph.Behaviors.BlackboardValue`1[T]) + property NxGraph.Behaviors.BlackboardValue`1[T] Expected { get; } + property NxGraph.Blackboards.BlackboardKey`1[T] Key { get; } + property System.String KeyName { get; } +sealed class NxGraph.Conditions.Not : NxGraph.Conditions.ICondition + ctor Void .ctor(NxGraph.Conditions.ICondition) + method Boolean Evaluate(NxGraph.Behaviors.BehaviorContext ByRef) + property NxGraph.Conditions.ICondition Inner { get; } class NxGraph.Diagnostics.Export.ExportOptions ctor Void .ctor() enum NxGraph.Diagnostics.Export.FlowDirection : System.IComparable, System.IConvertible, System.IFormattable, System.ISpanFormattable @@ -552,12 +578,6 @@ sealed class NxGraph.Fsm.AllState : NxGraph.Fsm.State, NxGraph.Blackboards.IBlac sealed class NxGraph.Fsm.Async.AsyncAllState : NxGraph.Fsm.Async.AsyncState, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.IAsyncLogic ctor Void .ctor(System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]][]) method System.Threading.Tasks.ValueTask`1[NxGraph.Result] OnRunAsync(System.Threading.CancellationToken) -sealed class NxGraph.Fsm.Async.AsyncRelayChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic - ctor Void .ctor(System.Func`1[System.Threading.Tasks.ValueTask`1[System.Boolean]], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) - ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Threading.Tasks.ValueTask`1[System.Boolean]], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) - method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() - method System.Threading.Tasks.ValueTask`1[NxGraph.Graphs.NodeId] SelectNextAsync(System.Threading.CancellationToken) - method System.Threading.Tasks.ValueTask`1[NxGraph.Result] ExecuteAsync(System.Threading.CancellationToken) class NxGraph.Fsm.Async.AsyncCompositeState : NxGraph.Fsm.Async.AsyncState, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.IAsyncLogic, NxGraph.Graphs.ISubGraphProvider ctor Void .ctor(NxGraph.Graphs.IAsyncLogic) method System.Threading.Tasks.ValueTask`1[NxGraph.Result] OnRunAsync(System.Threading.CancellationToken) @@ -584,6 +604,12 @@ sealed class NxGraph.Fsm.Async.AsyncPortPipeRelayState`2 : NxGraph.Fsm.Async.Asy sealed class NxGraph.Fsm.Async.AsyncPortProducerRelayState`1 : NxGraph.Fsm.Async.AsyncState, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.IAsyncLogic ctor Void .ctor(NxGraph.Blackboards.BlackboardKey`1[TOut], System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[TOut]]) method System.Threading.Tasks.ValueTask`1[NxGraph.Result] OnRunAsync(System.Threading.CancellationToken) +sealed class NxGraph.Fsm.Async.AsyncRelayChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic + ctor Void .ctor(System.Func`1[System.Threading.Tasks.ValueTask`1[System.Boolean]], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) + ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Threading.Tasks.ValueTask`1[System.Boolean]], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) + method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() + method System.Threading.Tasks.ValueTask`1[NxGraph.Graphs.NodeId] SelectNextAsync(System.Threading.CancellationToken) + method System.Threading.Tasks.ValueTask`1[NxGraph.Result] ExecuteAsync(System.Threading.CancellationToken) sealed class NxGraph.Fsm.Async.AsyncRelayState : NxGraph.Fsm.Async.AsyncState, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.IAsyncLogic ctor Void .ctor(System.Func`2[System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]], System.Func`2[System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]], System.Func`2[System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) ctor Void .ctor(System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]], System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]], System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) @@ -596,6 +622,12 @@ sealed class NxGraph.Fsm.Async.AsyncRelayState`1 : AsyncState`1, IAgentSettable` method System.Threading.Tasks.ValueTask`1[NxGraph.Result] OnEnterAsync(System.Threading.CancellationToken) method System.Threading.Tasks.ValueTask`1[NxGraph.Result] OnExitAsync(System.Threading.CancellationToken) method System.Threading.Tasks.ValueTask`1[NxGraph.Result] OnRunAsync(System.Threading.CancellationToken) +sealed class NxGraph.Fsm.Async.AsyncRelaySwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic + ctor Void .ctor(System.Func`1[System.Threading.Tasks.ValueTask`1[TKey]], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) + ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Threading.Tasks.ValueTask`1[TKey]], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) + method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() + method System.Threading.Tasks.ValueTask`1[NxGraph.Graphs.NodeId] SelectNextAsync(System.Threading.CancellationToken) + method System.Threading.Tasks.ValueTask`1[NxGraph.Result] ExecuteAsync(System.Threading.CancellationToken) abstract class NxGraph.Fsm.Async.AsyncState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.IAsyncLogic ctor Void .ctor() method System.Threading.Tasks.ValueTask LogAsync(System.String, System.Threading.CancellationToken) @@ -633,12 +665,6 @@ abstract class NxGraph.Fsm.Async.AsyncState`1 : NxGraph.Fsm.Async.AsyncState, IA ctor Void .ctor() field TAgent Agent method Void SetAgent(TAgent) -sealed class NxGraph.Fsm.Async.AsyncRelaySwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic - ctor Void .ctor(System.Func`1[System.Threading.Tasks.ValueTask`1[TKey]], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) - ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Threading.Tasks.ValueTask`1[TKey]], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) - method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() - method System.Threading.Tasks.ValueTask`1[NxGraph.Graphs.NodeId] SelectNextAsync(System.Threading.CancellationToken) - method System.Threading.Tasks.ValueTask`1[NxGraph.Result] ExecuteAsync(System.Threading.CancellationToken) class NxGraph.Fsm.Async.AsyncTimeoutState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Graphs.IAsyncLogic, NxGraph.Graphs.ILogicWrapper ctor Void .ctor(NxGraph.Graphs.IAsyncLogic, System.TimeSpan, NxGraph.Fsm.TimeoutBehavior) method System.Threading.Tasks.ValueTask`1[NxGraph.Result] ExecuteAsync(System.Threading.CancellationToken) @@ -651,12 +677,14 @@ enum NxGraph.Fsm.BackoffKind : System.IComparable, System.IConvertible, System.I Exponential = 2 Fixed = 0 Linear = 1 -sealed class NxGraph.Fsm.RelayChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic - ctor Void .ctor(System.Func`1[System.Boolean], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) - ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Boolean], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) +sealed class NxGraph.Fsm.ChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Fsm.IChoiceNode, NxGraph.Fsm.IDirector, NxGraph.Graphs.IAsyncLogic, NxGraph.Graphs.ILogic + ctor Void .ctor(NxGraph.Conditions.ICondition, NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) + ctor Void .ctor(System.Collections.Generic.IReadOnlyList`1[NxGraph.Conditions.ICondition], NxGraph.Conditions.ConditionMatch, NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) method NxGraph.Graphs.NodeId SelectNext() - method NxGraph.Result Execute() - method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() + property NxGraph.Conditions.ConditionMatch Match { get; } + property NxGraph.Graphs.NodeId FalseTarget { get; } + property NxGraph.Graphs.NodeId TrueTarget { get; } + property System.Collections.Generic.IReadOnlyList`1[NxGraph.Conditions.ICondition] Conditions { get; } sealed class NxGraph.Fsm.CompositeSnapshot : System.IEquatable`1[[NxGraph.Fsm.CompositeSnapshot]] ctor Void .ctor(Int32, Boolean, Boolean[], NxGraph.Fsm.StateMachineDeepSnapshot[]) method Boolean Equals(NxGraph.Fsm.CompositeSnapshot) @@ -721,6 +749,11 @@ interface NxGraph.Fsm.IAsyncStateMachineObserver method System.Threading.Tasks.ValueTask OnStateMachineStarted(NxGraph.Graphs.NodeId, System.Threading.CancellationToken) method System.Threading.Tasks.ValueTask OnTransition(NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId, System.Threading.CancellationToken) method System.Threading.Tasks.ValueTask StateMachineStatusChanged(NxGraph.Graphs.NodeId, NxGraph.Fsm.ExecutionStatus, NxGraph.Fsm.ExecutionStatus, System.Threading.CancellationToken) +interface NxGraph.Fsm.IChoiceNode + property NxGraph.Conditions.ConditionMatch Match { get; } + property NxGraph.Graphs.NodeId FalseTarget { get; } + property NxGraph.Graphs.NodeId TrueTarget { get; } + property System.Collections.Generic.IReadOnlyList`1[NxGraph.Conditions.ICondition] Conditions { get; } interface NxGraph.Fsm.IDirector method NxGraph.Graphs.NodeId SelectNext() method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() @@ -737,6 +770,13 @@ interface NxGraph.Fsm.IStateMachineObserver interface NxGraph.Fsm.ISuspendableComposite method NxGraph.Fsm.CompositeSnapshot SuspendComposite(Int32) method Void ResumeComposite(NxGraph.Fsm.CompositeSnapshot) +interface NxGraph.Fsm.ISwitchNode + method NxGraph.Graphs.NodeId CaseTargetAt(Int32) + method System.Object CaseValueAt(Int32) + property Int32 CaseCount { get; } + property NxGraph.Graphs.NodeId DefaultTarget { get; } + property System.String KeyName { get; } + property System.Type ValueType { get; } sealed class NxGraph.Fsm.NodeStatusTracker ctor Void .ctor() method NxGraph.Fsm.ExecutionStatus Get(NxGraph.Graphs.NodeId) @@ -787,6 +827,12 @@ struct NxGraph.Fsm.RegionMask : System.IEquatable`1[[NxGraph.Fsm.RegionMask]] property Boolean IsEmpty { get; } property Int32 Count { get; } property NxGraph.Fsm.RegionMask None { get; } +sealed class NxGraph.Fsm.RelayChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic + ctor Void .ctor(System.Func`1[System.Boolean], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) + ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Boolean], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) + method NxGraph.Graphs.NodeId SelectNext() + method NxGraph.Result Execute() + method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() sealed class NxGraph.Fsm.RelayState : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic ctor Void .ctor(System.Func`1[NxGraph.Result], System.Action, System.Action) ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Result], System.Action`1[NxGraph.Blackboards.BlackboardContext], System.Action`1[NxGraph.Blackboards.BlackboardContext]) @@ -799,6 +845,12 @@ sealed class NxGraph.Fsm.RelayState`1 : State`1, IAgentSettable`1, NxGraph.Black method NxGraph.Result OnRun() method Void OnEnter() method Void OnExit() +sealed class NxGraph.Fsm.RelaySwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic + ctor Void .ctor(System.Func`1[TKey], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) + ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,TKey], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) + method NxGraph.Graphs.NodeId SelectNext() + method NxGraph.Result Execute() + method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() enum NxGraph.Fsm.RestartPolicy : System.IComparable, System.IConvertible, System.IFormattable, System.ISpanFormattable Auto = 0 Ignore = 2 @@ -875,12 +927,25 @@ abstract class NxGraph.Fsm.State`1 : NxGraph.Fsm.State, IAgentSettable`1, NxGrap ctor Void .ctor() field TAgent Agent method Void SetAgent(TAgent) -sealed class NxGraph.Fsm.RelaySwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic - ctor Void .ctor(System.Func`1[TKey], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) - ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,TKey], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) +struct NxGraph.Fsm.SwitchCase`1 : IEquatable`1 + ctor Void .ctor(T, NxGraph.Graphs.NodeId) + method Boolean Equals(NxGraph.Fsm.SwitchCase`1[T]) + method Boolean Equals(System.Object) + method Int32 GetHashCode() + method System.String ToString() + method Void Deconstruct(T ByRef, NxGraph.Graphs.NodeId ByRef) + operator Boolean op_Equality(NxGraph.Fsm.SwitchCase`1[T], NxGraph.Fsm.SwitchCase`1[T]) + operator Boolean op_Inequality(NxGraph.Fsm.SwitchCase`1[T], NxGraph.Fsm.SwitchCase`1[T]) + property NxGraph.Graphs.NodeId Target { get; set; } + property T Value { get; set; } +sealed class NxGraph.Fsm.SwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Fsm.IDirector, NxGraph.Fsm.ISwitchNode, NxGraph.Graphs.IAsyncLogic, NxGraph.Graphs.ILogic + ctor Void .ctor(NxGraph.Blackboards.BlackboardKey`1[T], System.Collections.Generic.IReadOnlyList`1[NxGraph.Fsm.SwitchCase`1[T]], NxGraph.Graphs.NodeId) + method NxGraph.Fsm.SwitchState`1[T] Unbound(System.String, System.Collections.Generic.IReadOnlyList`1[NxGraph.Fsm.SwitchCase`1[T]], NxGraph.Graphs.NodeId) method NxGraph.Graphs.NodeId SelectNext() - method NxGraph.Result Execute() - method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() + property NxGraph.Blackboards.BlackboardKey`1[T] Key { get; } + property NxGraph.Graphs.NodeId DefaultTarget { get; } + property System.Collections.Generic.IReadOnlyList`1[NxGraph.Fsm.SwitchCase`1[T]] Cases { get; } + property System.String KeyName { get; } enum NxGraph.Fsm.TimeoutBehavior : System.IComparable, System.IConvertible, System.IFormattable, System.ISpanFormattable Fail = 0 Throw = 1 @@ -956,6 +1021,7 @@ class NxGraph.Graphs.LogicNode : NxGraph.Graphs.INode ctor Void .ctor(NxGraph.Graphs.NodeId, NxGraph.Graphs.IAsyncLogic, System.Action, System.Action) field static readonly NxGraph.Graphs.LogicNode AsyncBehaviorStateMarker field static readonly NxGraph.Graphs.LogicNode BehaviorStateMarker + field static readonly NxGraph.Graphs.LogicNode ChoiceStateMarker field static readonly NxGraph.Graphs.LogicNode DynamicParallelStateMarker field static readonly NxGraph.Graphs.LogicNode Empty field static readonly NxGraph.Graphs.LogicNode EventEntryStateMarker @@ -964,6 +1030,7 @@ class NxGraph.Graphs.LogicNode : NxGraph.Graphs.INode field static readonly NxGraph.Graphs.LogicNode JoinStateMarker field static readonly NxGraph.Graphs.LogicNode ParallelStateMarker field static readonly NxGraph.Graphs.LogicNode StateMachineMarker + field static readonly NxGraph.Graphs.LogicNode SwitchStateMarker field static readonly NxGraph.Graphs.LogicNode SyncDynamicParallelStateMarker field static readonly NxGraph.Graphs.LogicNode SyncHistoryStateMarker field static readonly NxGraph.Graphs.LogicNode SyncParallelStateMarker @@ -988,6 +1055,7 @@ struct NxGraph.Graphs.NodeId : System.IEquatable`1[[NxGraph.Graphs.NodeId]] property Int32 Index { get; } property NxGraph.Graphs.NodeId AsyncBehaviorStateMarker { get; } property NxGraph.Graphs.NodeId BehaviorStateMarker { get; } + property NxGraph.Graphs.NodeId ChoiceStateMarker { get; } property NxGraph.Graphs.NodeId Default { get; } property NxGraph.Graphs.NodeId DynamicParallelStateMarker { get; } property NxGraph.Graphs.NodeId EventEntryStateMarker { get; } @@ -997,6 +1065,7 @@ struct NxGraph.Graphs.NodeId : System.IEquatable`1[[NxGraph.Graphs.NodeId]] property NxGraph.Graphs.NodeId ParallelStateMarker { get; } property NxGraph.Graphs.NodeId Start { get; } property NxGraph.Graphs.NodeId StateMachineMarker { get; } + property NxGraph.Graphs.NodeId SwitchStateMarker { get; } property NxGraph.Graphs.NodeId SyncDynamicParallelStateMarker { get; } property NxGraph.Graphs.NodeId SyncHistoryStateMarker { get; } property NxGraph.Graphs.NodeId SyncParallelStateMarker { get; } diff --git a/NxGraph.Tests/PublicApi/NxGraph.netstandard2.1.approved.txt b/NxGraph.Tests/PublicApi/NxGraph.netstandard2.1.approved.txt index a59d785..c98d242 100644 --- a/NxGraph.Tests/PublicApi/NxGraph.netstandard2.1.approved.txt +++ b/NxGraph.Tests/PublicApi/NxGraph.netstandard2.1.approved.txt @@ -32,7 +32,11 @@ static class NxGraph.Authoring.Dsl method NxGraph.Authoring.Dsl+BranchEnd ElseAsync(NxGraph.Authoring.Dsl+BranchBuilder, System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) method NxGraph.Authoring.Dsl+BranchEnd SetName(NxGraph.Authoring.Dsl+BranchEnd, System.String) method NxGraph.Authoring.Dsl+BranchEnd WithSchema(NxGraph.Authoring.Dsl+BranchEnd, NxGraph.Blackboards.BlackboardSchema) + method NxGraph.Authoring.Dsl+IfBuilder If(NxGraph.Authoring.StartToken, NxGraph.Conditions.ConditionMatch, NxGraph.Conditions.ICondition[]) + method NxGraph.Authoring.Dsl+IfBuilder If(NxGraph.Authoring.StartToken, NxGraph.Conditions.ICondition) method NxGraph.Authoring.Dsl+IfBuilder If(NxGraph.Authoring.StartToken, System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Boolean]) + method NxGraph.Authoring.Dsl+IfBuilder If(NxGraph.Authoring.StateToken, NxGraph.Conditions.ConditionMatch, NxGraph.Conditions.ICondition[]) + method NxGraph.Authoring.Dsl+IfBuilder If(NxGraph.Authoring.StateToken, NxGraph.Conditions.ICondition) method NxGraph.Authoring.Dsl+IfBuilder If(NxGraph.Authoring.StateToken, System.Func`1[System.Boolean]) method NxGraph.Authoring.Dsl+IfBuilder If(NxGraph.Authoring.StateToken, System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Boolean]) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] CaseAsync[TKey](NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey], TKey, System.Func`2[System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) @@ -43,7 +47,9 @@ static class NxGraph.Authoring.Dsl method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] DefaultAsync[TKey](NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey], System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Default[TKey](NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey], System.Func`1[NxGraph.Result]) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Default[TKey](NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey], System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Result]) + method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Switch[TKey](NxGraph.Authoring.StartToken, NxGraph.Blackboards.BlackboardKey`1[TKey]) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Switch[TKey](NxGraph.Authoring.StartToken, System.Func`2[NxGraph.Blackboards.BlackboardContext,TKey]) + method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Switch[TKey](NxGraph.Authoring.StateToken, NxGraph.Blackboards.BlackboardKey`1[TKey]) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Switch[TKey](NxGraph.Authoring.StateToken, System.Func`1[TKey]) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Switch[TKey](NxGraph.Authoring.StateToken, System.Func`2[NxGraph.Blackboards.BlackboardContext,TKey]) method NxGraph.Authoring.StartToken WithSchema(NxGraph.Authoring.StartToken, NxGraph.Blackboards.BlackboardSchema) @@ -447,6 +453,26 @@ interface NxGraph.Blackboards.IBlackboardBindable method System.Void SetBlackboard(NxGraph.Blackboards.Blackboard) interface NxGraph.Blackboards.IBlackboardSettable method System.Void SetBlackboards(NxGraph.Blackboards.BlackboardContext&) +enum NxGraph.Conditions.ConditionMatch : System.IComparable, System.IConvertible, System.IFormattable + All = 0 + Any = 1 +interface NxGraph.Conditions.ICondition + method System.Boolean Evaluate(NxGraph.Behaviors.BehaviorContext&) +sealed class NxGraph.Conditions.IsTrue : NxGraph.Conditions.ICondition + ctor System.Void .ctor(NxGraph.Behaviors.BlackboardValue`1[System.Boolean]) + method System.Boolean Evaluate(NxGraph.Behaviors.BehaviorContext&) + property NxGraph.Behaviors.BlackboardValue`1[System.Boolean] Value { get; } +sealed class NxGraph.Conditions.KeyEquals`1 : NxGraph.Conditions.ICondition + ctor System.Void .ctor(NxGraph.Blackboards.BlackboardKey`1[T], NxGraph.Behaviors.BlackboardValue`1[T]) + method NxGraph.Conditions.KeyEquals`1[T] Unbound(System.String, NxGraph.Behaviors.BlackboardValue`1[T]) + method System.Boolean Evaluate(NxGraph.Behaviors.BehaviorContext&) + property NxGraph.Behaviors.BlackboardValue`1[T] Expected { get; } + property NxGraph.Blackboards.BlackboardKey`1[T] Key { get; } + property System.String KeyName { get; } +sealed class NxGraph.Conditions.Not : NxGraph.Conditions.ICondition + ctor System.Void .ctor(NxGraph.Conditions.ICondition) + method System.Boolean Evaluate(NxGraph.Behaviors.BehaviorContext&) + property NxGraph.Conditions.ICondition Inner { get; } class NxGraph.Diagnostics.Export.ExportOptions ctor System.Void .ctor() enum NxGraph.Diagnostics.Export.FlowDirection : System.IComparable, System.IConvertible, System.IFormattable @@ -552,12 +578,6 @@ sealed class NxGraph.Fsm.AllState : NxGraph.Fsm.State, NxGraph.Blackboards.IBlac sealed class NxGraph.Fsm.Async.AsyncAllState : NxGraph.Fsm.Async.AsyncState, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.IAsyncLogic ctor System.Void .ctor(System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]][]) method System.Threading.Tasks.ValueTask`1[NxGraph.Result] OnRunAsync(System.Threading.CancellationToken) -sealed class NxGraph.Fsm.Async.AsyncRelayChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic - ctor System.Void .ctor(System.Func`1[System.Threading.Tasks.ValueTask`1[System.Boolean]], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) - ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Threading.Tasks.ValueTask`1[System.Boolean]], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) - method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() - method System.Threading.Tasks.ValueTask`1[NxGraph.Graphs.NodeId] SelectNextAsync(System.Threading.CancellationToken) - method System.Threading.Tasks.ValueTask`1[NxGraph.Result] ExecuteAsync(System.Threading.CancellationToken) class NxGraph.Fsm.Async.AsyncCompositeState : NxGraph.Fsm.Async.AsyncState, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.IAsyncLogic, NxGraph.Graphs.ISubGraphProvider ctor System.Void .ctor(NxGraph.Graphs.IAsyncLogic) method System.Threading.Tasks.ValueTask`1[NxGraph.Result] OnRunAsync(System.Threading.CancellationToken) @@ -584,6 +604,12 @@ sealed class NxGraph.Fsm.Async.AsyncPortPipeRelayState`2 : NxGraph.Fsm.Async.Asy sealed class NxGraph.Fsm.Async.AsyncPortProducerRelayState`1 : NxGraph.Fsm.Async.AsyncState, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.IAsyncLogic ctor System.Void .ctor(NxGraph.Blackboards.BlackboardKey`1[TOut], System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[TOut]]) method System.Threading.Tasks.ValueTask`1[NxGraph.Result] OnRunAsync(System.Threading.CancellationToken) +sealed class NxGraph.Fsm.Async.AsyncRelayChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic + ctor System.Void .ctor(System.Func`1[System.Threading.Tasks.ValueTask`1[System.Boolean]], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) + ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Threading.Tasks.ValueTask`1[System.Boolean]], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) + method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() + method System.Threading.Tasks.ValueTask`1[NxGraph.Graphs.NodeId] SelectNextAsync(System.Threading.CancellationToken) + method System.Threading.Tasks.ValueTask`1[NxGraph.Result] ExecuteAsync(System.Threading.CancellationToken) sealed class NxGraph.Fsm.Async.AsyncRelayState : NxGraph.Fsm.Async.AsyncState, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.IAsyncLogic ctor System.Void .ctor(System.Func`2[System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]], System.Func`2[System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]], System.Func`2[System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) ctor System.Void .ctor(System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]], System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]], System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) @@ -596,6 +622,12 @@ sealed class NxGraph.Fsm.Async.AsyncRelayState`1 : AsyncState`1, IAgentSettable` method System.Threading.Tasks.ValueTask`1[NxGraph.Result] OnEnterAsync(System.Threading.CancellationToken) method System.Threading.Tasks.ValueTask`1[NxGraph.Result] OnExitAsync(System.Threading.CancellationToken) method System.Threading.Tasks.ValueTask`1[NxGraph.Result] OnRunAsync(System.Threading.CancellationToken) +sealed class NxGraph.Fsm.Async.AsyncRelaySwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic + ctor System.Void .ctor(System.Func`1[System.Threading.Tasks.ValueTask`1[TKey]], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) + ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Threading.Tasks.ValueTask`1[TKey]], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) + method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() + method System.Threading.Tasks.ValueTask`1[NxGraph.Graphs.NodeId] SelectNextAsync(System.Threading.CancellationToken) + method System.Threading.Tasks.ValueTask`1[NxGraph.Result] ExecuteAsync(System.Threading.CancellationToken) abstract class NxGraph.Fsm.Async.AsyncState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.IAsyncLogic ctor System.Void .ctor() method System.Threading.Tasks.ValueTask LogAsync(System.String, System.Threading.CancellationToken) @@ -633,12 +665,6 @@ abstract class NxGraph.Fsm.Async.AsyncState`1 : NxGraph.Fsm.Async.AsyncState, IA ctor System.Void .ctor() field TAgent Agent method System.Void SetAgent(TAgent) -sealed class NxGraph.Fsm.Async.AsyncRelaySwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Graphs.IAsyncLogic - ctor System.Void .ctor(System.Func`1[System.Threading.Tasks.ValueTask`1[TKey]], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) - ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Threading.Tasks.ValueTask`1[TKey]], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) - method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() - method System.Threading.Tasks.ValueTask`1[NxGraph.Graphs.NodeId] SelectNextAsync(System.Threading.CancellationToken) - method System.Threading.Tasks.ValueTask`1[NxGraph.Result] ExecuteAsync(System.Threading.CancellationToken) class NxGraph.Fsm.Async.AsyncTimeoutState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Graphs.IAsyncLogic, NxGraph.Graphs.ILogicWrapper ctor System.Void .ctor(NxGraph.Graphs.IAsyncLogic, System.TimeSpan, NxGraph.Fsm.TimeoutBehavior) method System.Threading.Tasks.ValueTask`1[NxGraph.Result] ExecuteAsync(System.Threading.CancellationToken) @@ -651,12 +677,14 @@ enum NxGraph.Fsm.BackoffKind : System.IComparable, System.IConvertible, System.I Exponential = 2 Fixed = 0 Linear = 1 -sealed class NxGraph.Fsm.RelayChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic - ctor System.Void .ctor(System.Func`1[System.Boolean], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) - ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Boolean], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) +sealed class NxGraph.Fsm.ChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Fsm.IChoiceNode, NxGraph.Fsm.IDirector, NxGraph.Graphs.IAsyncLogic, NxGraph.Graphs.ILogic + ctor System.Void .ctor(NxGraph.Conditions.ICondition, NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) + ctor System.Void .ctor(System.Collections.Generic.IReadOnlyList`1[NxGraph.Conditions.ICondition], NxGraph.Conditions.ConditionMatch, NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) method NxGraph.Graphs.NodeId SelectNext() - method NxGraph.Result Execute() - method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() + property NxGraph.Conditions.ConditionMatch Match { get; } + property NxGraph.Graphs.NodeId FalseTarget { get; } + property NxGraph.Graphs.NodeId TrueTarget { get; } + property System.Collections.Generic.IReadOnlyList`1[NxGraph.Conditions.ICondition] Conditions { get; } sealed class NxGraph.Fsm.CompositeSnapshot : System.IEquatable`1[[NxGraph.Fsm.CompositeSnapshot]] ctor System.Void .ctor(System.Int32, System.Boolean, System.Boolean[], NxGraph.Fsm.StateMachineDeepSnapshot[]) method NxGraph.Fsm.CompositeSnapshot $() @@ -721,6 +749,11 @@ interface NxGraph.Fsm.IAsyncStateMachineObserver method System.Threading.Tasks.ValueTask OnStateMachineStarted(NxGraph.Graphs.NodeId, System.Threading.CancellationToken) method System.Threading.Tasks.ValueTask OnTransition(NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId, System.Threading.CancellationToken) method System.Threading.Tasks.ValueTask StateMachineStatusChanged(NxGraph.Graphs.NodeId, NxGraph.Fsm.ExecutionStatus, NxGraph.Fsm.ExecutionStatus, System.Threading.CancellationToken) +interface NxGraph.Fsm.IChoiceNode + property NxGraph.Conditions.ConditionMatch Match { get; } + property NxGraph.Graphs.NodeId FalseTarget { get; } + property NxGraph.Graphs.NodeId TrueTarget { get; } + property System.Collections.Generic.IReadOnlyList`1[NxGraph.Conditions.ICondition] Conditions { get; } interface NxGraph.Fsm.IDirector method NxGraph.Graphs.NodeId SelectNext() method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() @@ -737,6 +770,13 @@ interface NxGraph.Fsm.IStateMachineObserver interface NxGraph.Fsm.ISuspendableComposite method NxGraph.Fsm.CompositeSnapshot SuspendComposite(System.Int32) method System.Void ResumeComposite(NxGraph.Fsm.CompositeSnapshot) +interface NxGraph.Fsm.ISwitchNode + method NxGraph.Graphs.NodeId CaseTargetAt(System.Int32) + method System.Object CaseValueAt(System.Int32) + property NxGraph.Graphs.NodeId DefaultTarget { get; } + property System.Int32 CaseCount { get; } + property System.String KeyName { get; } + property System.Type ValueType { get; } sealed class NxGraph.Fsm.NodeStatusTracker ctor System.Void .ctor() method NxGraph.Fsm.ExecutionStatus Get(NxGraph.Graphs.NodeId) @@ -787,6 +827,12 @@ struct NxGraph.Fsm.RegionMask : System.IEquatable`1[[NxGraph.Fsm.RegionMask]] property NxGraph.Fsm.RegionMask None { get; } property System.Boolean IsEmpty { get; } property System.Int32 Count { get; } +sealed class NxGraph.Fsm.RelayChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic + ctor System.Void .ctor(System.Func`1[System.Boolean], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) + ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Boolean], NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) + method NxGraph.Graphs.NodeId SelectNext() + method NxGraph.Result Execute() + method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() sealed class NxGraph.Fsm.RelayState : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic ctor System.Void .ctor(System.Func`1[NxGraph.Result], System.Action, System.Action) ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Result], System.Action`1[NxGraph.Blackboards.BlackboardContext], System.Action`1[NxGraph.Blackboards.BlackboardContext]) @@ -799,6 +845,12 @@ sealed class NxGraph.Fsm.RelayState`1 : State`1, IAgentSettable`1, NxGraph.Black method NxGraph.Result OnRun() method System.Void OnEnter() method System.Void OnExit() +sealed class NxGraph.Fsm.RelaySwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic + ctor System.Void .ctor(System.Func`1[TKey], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) + ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,TKey], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) + method NxGraph.Graphs.NodeId SelectNext() + method NxGraph.Result Execute() + method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() enum NxGraph.Fsm.RestartPolicy : System.IComparable, System.IConvertible, System.IFormattable Auto = 0 Ignore = 2 @@ -875,12 +927,25 @@ abstract class NxGraph.Fsm.State`1 : NxGraph.Fsm.State, IAgentSettable`1, NxGrap ctor System.Void .ctor() field TAgent Agent method System.Void SetAgent(TAgent) -sealed class NxGraph.Fsm.RelaySwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IDirector, NxGraph.Graphs.ILogic - ctor System.Void .ctor(System.Func`1[TKey], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) - ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,TKey], System.Collections.Generic.IReadOnlyDictionary`2[TKey,NxGraph.Graphs.NodeId], NxGraph.Graphs.NodeId) +struct NxGraph.Fsm.SwitchCase`1 : IEquatable`1 + ctor System.Void .ctor(T, NxGraph.Graphs.NodeId) + method System.Boolean Equals(NxGraph.Fsm.SwitchCase`1[T]) + method System.Boolean Equals(System.Object) + method System.Int32 GetHashCode() + method System.String ToString() + method System.Void Deconstruct(T&, NxGraph.Graphs.NodeId&) + operator System.Boolean op_Equality(NxGraph.Fsm.SwitchCase`1[T], NxGraph.Fsm.SwitchCase`1[T]) + operator System.Boolean op_Inequality(NxGraph.Fsm.SwitchCase`1[T], NxGraph.Fsm.SwitchCase`1[T]) + property NxGraph.Graphs.NodeId Target { get; set; } + property T Value { get; set; } +sealed class NxGraph.Fsm.SwitchState`1 : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Fsm.IDirector, NxGraph.Fsm.ISwitchNode, NxGraph.Graphs.IAsyncLogic, NxGraph.Graphs.ILogic + ctor System.Void .ctor(NxGraph.Blackboards.BlackboardKey`1[T], System.Collections.Generic.IReadOnlyList`1[NxGraph.Fsm.SwitchCase`1[T]], NxGraph.Graphs.NodeId) + method NxGraph.Fsm.SwitchState`1[T] Unbound(System.String, System.Collections.Generic.IReadOnlyList`1[NxGraph.Fsm.SwitchCase`1[T]], NxGraph.Graphs.NodeId) method NxGraph.Graphs.NodeId SelectNext() - method NxGraph.Result Execute() - method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() + property NxGraph.Blackboards.BlackboardKey`1[T] Key { get; } + property NxGraph.Graphs.NodeId DefaultTarget { get; } + property System.Collections.Generic.IReadOnlyList`1[NxGraph.Fsm.SwitchCase`1[T]] Cases { get; } + property System.String KeyName { get; } enum NxGraph.Fsm.TimeoutBehavior : System.IComparable, System.IConvertible, System.IFormattable Fail = 0 Throw = 1 @@ -945,6 +1010,7 @@ class NxGraph.Graphs.LogicNode : NxGraph.Graphs.INode ctor System.Void .ctor(NxGraph.Graphs.NodeId, NxGraph.Graphs.IAsyncLogic, System.Action, System.Action) field static readonly NxGraph.Graphs.LogicNode AsyncBehaviorStateMarker field static readonly NxGraph.Graphs.LogicNode BehaviorStateMarker + field static readonly NxGraph.Graphs.LogicNode ChoiceStateMarker field static readonly NxGraph.Graphs.LogicNode DynamicParallelStateMarker field static readonly NxGraph.Graphs.LogicNode Empty field static readonly NxGraph.Graphs.LogicNode EventEntryStateMarker @@ -953,6 +1019,7 @@ class NxGraph.Graphs.LogicNode : NxGraph.Graphs.INode field static readonly NxGraph.Graphs.LogicNode JoinStateMarker field static readonly NxGraph.Graphs.LogicNode ParallelStateMarker field static readonly NxGraph.Graphs.LogicNode StateMachineMarker + field static readonly NxGraph.Graphs.LogicNode SwitchStateMarker field static readonly NxGraph.Graphs.LogicNode SyncDynamicParallelStateMarker field static readonly NxGraph.Graphs.LogicNode SyncHistoryStateMarker field static readonly NxGraph.Graphs.LogicNode SyncParallelStateMarker @@ -976,6 +1043,7 @@ struct NxGraph.Graphs.NodeId : System.IEquatable`1[[NxGraph.Graphs.NodeId]] operator System.Boolean op_Inequality(NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) property NxGraph.Graphs.NodeId AsyncBehaviorStateMarker { get; } property NxGraph.Graphs.NodeId BehaviorStateMarker { get; } + property NxGraph.Graphs.NodeId ChoiceStateMarker { get; } property NxGraph.Graphs.NodeId Default { get; } property NxGraph.Graphs.NodeId DynamicParallelStateMarker { get; } property NxGraph.Graphs.NodeId EventEntryStateMarker { get; } @@ -985,6 +1053,7 @@ struct NxGraph.Graphs.NodeId : System.IEquatable`1[[NxGraph.Graphs.NodeId]] property NxGraph.Graphs.NodeId ParallelStateMarker { get; } property NxGraph.Graphs.NodeId Start { get; } property NxGraph.Graphs.NodeId StateMachineMarker { get; } + property NxGraph.Graphs.NodeId SwitchStateMarker { get; } property NxGraph.Graphs.NodeId SyncDynamicParallelStateMarker { get; } property NxGraph.Graphs.NodeId SyncHistoryStateMarker { get; } property NxGraph.Graphs.NodeId SyncParallelStateMarker { get; } diff --git a/NxGraph.Tests/RelaySwitchStateTests.cs b/NxGraph.Tests/RelaySwitchStateTests.cs index 019fb28..e31ef55 100644 --- a/NxGraph.Tests/RelaySwitchStateTests.cs +++ b/NxGraph.Tests/RelaySwitchStateTests.cs @@ -4,10 +4,106 @@ namespace NxGraph.Tests; +/// +/// The delegate-backed — the .Switch(selector) +/// path. The data-built (spec 023) is covered by +/// SwitchStateTests / SwitchDefaultCaseTests. +/// [TestFixture] [Category("switch_default")] public class RelaySwitchStateTests { + private enum Mode + { + A, + B, + C + } + + [Test] + public async Task relay_switch_state_should_follow_matching_case() + { + const Mode mode = Mode.B; + + AsyncStateMachine fsm = GraphBuilder + .Start() + .Switch(() => mode) + .CaseAsync(Mode.A, new AsyncRelayState(_ => ResultHelpers.Failure)) + .CaseAsync(Mode.B, new AsyncRelayState(_ => ResultHelpers.Success)) + .CaseAsync(Mode.C, new AsyncRelayState(_ => ResultHelpers.Failure)) + .DefaultAsync(new AsyncRelayState(_ => ResultHelpers.Failure)) + .End() + .Build().ToAsyncStateMachine(); + + Result result = await fsm.ExecuteAsync(); + Assert.That(result, Is.EqualTo(Result.Success)); + } + + [Test] + public void sync_relay_switch_state_should_follow_matching_case() + { + const Mode mode = Mode.B; + bool matchedCaseRan = false; + + StateMachine fsm = GraphBuilder + .Start() + .Switch(() => mode) + .Case(Mode.A, () => Result.Failure) + .Case(Mode.B, () => + { + matchedCaseRan = true; + return Result.Success; + }) + .Case(Mode.C, () => Result.Failure) + .Default(() => Result.Failure) + .End() + .ToStateMachine(); + + Result result = Result.InProgress; + while (result == Result.InProgress) + { + result = fsm.Execute(); + } + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(matchedCaseRan, Is.True, "The sync runtime must route through the matching case body."); + }); + } + + [Test] + public void sync_relay_switch_state_should_use_default_when_no_match() + { + const int selector = 99; // no matching case + bool defaultRan = false; + + StateMachine fsm = GraphBuilder + .Start() + .Switch(() => selector) + .Case(0, () => Result.Failure) + .Case(1, () => Result.Failure) + .Default(() => + { + defaultRan = true; + return Result.Success; + }) + .End() + .ToStateMachine(); + + Result result = Result.InProgress; + while (result == Result.InProgress) + { + result = fsm.Execute(); + } + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(defaultRan, Is.True, "The sync runtime must route through the explicit default body."); + }); + } + [Test] public async Task switch_should_follow_default_when_no_case_matches() { diff --git a/NxGraph.Tests/SwitchDefaultCaseTests.cs b/NxGraph.Tests/SwitchDefaultCaseTests.cs new file mode 100644 index 0000000..365361b --- /dev/null +++ b/NxGraph.Tests/SwitchDefaultCaseTests.cs @@ -0,0 +1,145 @@ +using NxGraph.Authoring; +using NxGraph.Blackboards; +using NxGraph.Fsm; +using NxGraph.Graphs; + +namespace NxGraph.Tests; + +/// +/// The default arm of the data-built (spec 023): where an +/// unmatched value goes. An explicit .Default(...) arm runs; a switch built without one +/// carries and therefore terminates the run — silently +/// enough that the validator warns about it (see GraphValidatorTests). +/// +[TestFixture] +[Category("switch_default")] +public class SwitchDefaultCaseTests +{ + private static RelayState Probe(string name, List trace) => new(() => + { + trace.Add(name); + return Result.Success; + }); + + private static (BlackboardSchema schema, Blackboard board, BlackboardKey mode) Boards() + { + BlackboardSchema schema = new("switch-default"); + BlackboardKey mode = schema.Register("mode", "alpha"); + return (schema, new Blackboard(schema), mode); + } + + private static Result RunToEnd(StateMachine machine) + { + Result result = machine.Execute(); + while (result == Result.InProgress) + { + result = machine.Execute(); + } + + return result; + } + + private static async Task RunAsync(Graph graph, Blackboard board, bool sync) + { + return sync + ? RunToEnd(graph.ToStateMachine().WithBlackboard(board)) + : await graph.ToAsyncStateMachine().WithBlackboard(board).ExecuteAsync(); + } + + [Test] + public async Task An_explicit_default_arm_runs_when_no_case_matches([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board, BlackboardKey mode) = Boards(); + board.Set(mode, "omega"); + List trace = []; + + Graph graph = GraphBuilder.Start() + .Switch(mode) + .Case("alpha", Probe("case:alpha", trace)) + .Case("beta", Probe("case:beta", trace)) + .Default(Probe("default", trace)) + .End() + .WithSchema(schema) + .Build(); + + Result result = await RunAsync(graph, board, sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { "default" })); + }); + } + + [Test] + public async Task A_switch_without_a_default_terminates_the_run_when_no_case_matches([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board, BlackboardKey mode) = Boards(); + board.Set(mode, "omega"); + List trace = []; + + Graph graph = GraphBuilder.Start() + .Switch(mode) + .Case("alpha", Probe("case:alpha", trace)) + .Case("beta", Probe("case:beta", trace)) + .End() + .WithSchema(schema) + .Build(); + + Result result = await RunAsync(graph, board, sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success), + "An unmatched value with no default target exits cleanly through NodeId.Default."); + Assert.That(trace, Is.Empty, "No case arm may run when nothing matched."); + }); + } + + [Test] + public async Task A_switch_without_a_default_still_routes_a_matching_case([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board, BlackboardKey mode) = Boards(); + board.Set(mode, "beta"); + List trace = []; + + Graph graph = GraphBuilder.Start() + .Switch(mode) + .Case("alpha", Probe("case:alpha", trace)) + .Case("beta", Probe("case:beta", trace)) + .End() + .WithSchema(schema) + .Build(); + + Result result = await RunAsync(graph, board, sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { "case:beta" })); + }); + } + + [Test] + public async Task A_default_target_of_NodeId_Default_terminates_the_run([Values] bool sync) + { + // The same contract at the state level: the DSL is not the only way to reach it. + (BlackboardSchema schema, Blackboard board, BlackboardKey mode) = Boards(); + board.Set(mode, "omega"); + List trace = []; + + GraphBuilder builder = new(); + SwitchCase[] cases = [new("alpha", builder.AddNode(Probe("case:alpha", trace)))]; + builder.AddNode((IAsyncLogic)new SwitchState(mode, cases, NodeId.Default), isStart: true); + builder.WithSchema(schema); + Graph graph = builder.Build(throwOnError: false); + + Result result = await RunAsync(graph, board, sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.Empty); + }); + } +} diff --git a/NxGraph.Tests/SwitchStateTests.cs b/NxGraph.Tests/SwitchStateTests.cs index 3c0f09f..c9ae10e 100644 --- a/NxGraph.Tests/SwitchStateTests.cs +++ b/NxGraph.Tests/SwitchStateTests.cs @@ -1,121 +1,305 @@ using NxGraph.Authoring; +using NxGraph.Blackboards; using NxGraph.Fsm; using NxGraph.Fsm.Async; +using NxGraph.Graphs; namespace NxGraph.Tests; +/// +/// The data-built (spec 023): one tested blackboard key, literal +/// cases, at most one match. Pins case routing under both runtimes, the construction-time +/// distinctness guard (a switch is a lookup — ordered, first-match-wins rules are a chain of +/// choices), and the name-bound rebind form. The delegate-backed twin is covered by +/// RelaySwitchStateTests; the default arm has its own fixture in +/// SwitchDefaultCaseTests. +/// [TestFixture] [Category("branching_switch")] public class SwitchStateTests { private enum Mode { - A, - B, - C + Patrol, + Chase, + Flee, } - [Test] - public async Task switch_state_should_follow_matching_case() + // ── Fixtures ───────────────────────────────────────────────────────── + + private static RelayState Probe(string name, List trace) => new(() => + { + trace.Add(name); + return Result.Success; + }); + + private static (BlackboardSchema schema, Blackboard board, BlackboardKey mode) Boards() + { + BlackboardSchema schema = new("switching"); + BlackboardKey mode = schema.Register("mode", "alpha"); + return (schema, new Blackboard(schema), mode); + } + + /// + /// A switch as the start node with one probe per case value, and either a probe default + /// arm or the terminal . + /// + private static Graph SwitchGraph(BlackboardSchema schema, BlackboardKey key, List trace, + bool withDefault, params string[] caseValues) + { + GraphBuilder builder = new(); + SwitchCase[] cases = new SwitchCase[caseValues.Length]; + for (int i = 0; i < caseValues.Length; i++) + { + cases[i] = new SwitchCase(caseValues[i], builder.AddNode(Probe($"case:{caseValues[i]}", trace))); + } + + NodeId defaultTarget = withDefault ? builder.AddNode(Probe("default", trace)) : NodeId.Default; + builder.AddNode((IAsyncLogic)new SwitchState(key, cases, defaultTarget), isStart: true); + builder.WithSchema(schema); + return builder.Build(throwOnError: false); + } + + private static Result RunToEnd(StateMachine machine) { - const Mode mode = Mode.B; + Result result = machine.Execute(); + while (result == Result.InProgress) + { + result = machine.Execute(); + } - AsyncStateMachine fsm = GraphBuilder - .Start() - .Switch(() => mode) - .CaseAsync(Mode.A, new AsyncRelayState(_ => ResultHelpers.Failure)) - .CaseAsync(Mode.B, new AsyncRelayState(_ => ResultHelpers.Success)) - .CaseAsync(Mode.C, new AsyncRelayState(_ => ResultHelpers.Failure)) - .DefaultAsync(new AsyncRelayState(_ => ResultHelpers.Failure)) - .End() - .Build().ToAsyncStateMachine(); + return result; + } - Result result = await fsm.ExecuteAsync(); - Assert.That(result, Is.EqualTo(Result.Success)); + private static async Task RunAsync(Graph graph, Blackboard board, bool sync) + { + return sync + ? RunToEnd(graph.ToStateMachine().WithBlackboard(board)) + : await graph.ToAsyncStateMachine().WithBlackboard(board).ExecuteAsync(); } + // ── Routing ────────────────────────────────────────────────────────── + [Test] - public async Task switch_state_should_use_default_when_no_match() + public async Task Switch_routes_to_the_matching_case([Values] bool sync) { - const int selector = 99; // no matching case + (BlackboardSchema schema, Blackboard board, BlackboardKey mode) = Boards(); + board.Set(mode, "beta"); + List trace = []; + Graph graph = SwitchGraph(schema, mode, trace, withDefault: true, "alpha", "beta", "gamma"); + + Result result = await RunAsync(graph, board, sync); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { "case:beta" })); + }); + } - AsyncStateMachine fsm = GraphBuilder.Start() - .Switch(() => selector) - .CaseAsync(0, _ => ResultHelpers.Failure) - .CaseAsync(1, _ => ResultHelpers.Failure) - .DefaultAsync(_ => ResultHelpers.Success) - .End() - .ToAsyncStateMachine(); + [Test] + public async Task Switch_routes_to_the_default_when_no_case_matches([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board, BlackboardKey mode) = Boards(); + board.Set(mode, "omega"); + List trace = []; + Graph graph = SwitchGraph(schema, mode, trace, withDefault: true, "alpha", "beta"); + Result result = await RunAsync(graph, board, sync); - Result result = await fsm.ExecuteAsync(); - Assert.That(result, Is.EqualTo(Result.Success)); + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { "default" })); + }); } - // ── Sync-runtime twins: dispatch driven by StateMachine.Execute() ──── + [Test] + public async Task Switch_reads_the_key_at_selection_time_so_the_same_graph_reroutes([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board, BlackboardKey mode) = Boards(); + List trace = []; + Graph graph = SwitchGraph(schema, mode, trace, withDefault: true, "alpha", "beta"); + + board.Set(mode, "alpha"); + await RunAsync(graph, board, sync); + board.Set(mode, "beta"); + await RunAsync(graph, board, sync); + + Assert.That(trace, Is.EqualTo(new[] { "case:alpha", "case:beta" })); + } [Test] - public void sync_switch_state_should_follow_matching_case() - { - const Mode mode = Mode.B; - bool matchedCaseRan = false; - - StateMachine fsm = GraphBuilder - .Start() - .Switch(() => mode) - .Case(Mode.A, () => Result.Failure) - .Case(Mode.B, () => - { - matchedCaseRan = true; - return Result.Success; - }) - .Case(Mode.C, () => Result.Failure) - .Default(() => Result.Failure) - .End() - .ToStateMachine(); - - Result result = Result.InProgress; - while (result == Result.InProgress) + public async Task Switch_routes_enum_keys([Values] bool sync) + { + BlackboardSchema schema = new("modes"); + BlackboardKey key = schema.Register("mode", Mode.Patrol); + Blackboard board = new(schema); + board.Set(key, Mode.Flee); + + List trace = []; + GraphBuilder builder = new(); + SwitchCase[] cases = + [ + new(Mode.Chase, builder.AddNode(Probe("chase", trace))), + new(Mode.Flee, builder.AddNode(Probe("flee", trace))), + ]; + NodeId fallback = builder.AddNode(Probe("default", trace)); + builder.AddNode((IAsyncLogic)new SwitchState(key, cases, fallback), isStart: true); + builder.WithSchema(schema); + Graph graph = builder.Build(throwOnError: false); + + Result result = await RunAsync(graph, board, sync); + + Assert.Multiple(() => { - result = fsm.Execute(); - } + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(trace, Is.EqualTo(new[] { "flee" })); + }); + } + + // ── Name-bound (deserialized) form ─────────────────────────────────── + + [Test] + public async Task Unbound_switch_resolves_its_key_by_name_against_the_bound_schema([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board, BlackboardKey mode) = Boards(); + board.Set(mode, "beta"); + + List trace = []; + GraphBuilder builder = new(); + SwitchCase[] cases = + [ + new("alpha", builder.AddNode(Probe("case:alpha", trace))), + new("beta", builder.AddNode(Probe("case:beta", trace))), + ]; + NodeId fallback = builder.AddNode(Probe("default", trace)); + builder.AddNode((IAsyncLogic)SwitchState.Unbound("mode", cases, fallback), isStart: true); + builder.WithSchema(schema); + Graph graph = builder.Build(throwOnError: false); + + Result result = await RunAsync(graph, board, sync); Assert.Multiple(() => { Assert.That(result, Is.EqualTo(Result.Success)); - Assert.That(matchedCaseRan, Is.True, "The sync runtime must route through the matching case body."); + Assert.That(trace, Is.EqualTo(new[] { "case:beta" })); }); } [Test] - public void sync_switch_state_should_use_default_when_no_match() - { - const int selector = 99; // no matching case - bool defaultRan = false; - - StateMachine fsm = GraphBuilder - .Start() - .Switch(() => selector) - .Case(0, () => Result.Failure) - .Case(1, () => Result.Failure) - .Default(() => - { - defaultRan = true; - return Result.Success; - }) - .End() - .ToStateMachine(); - - Result result = Result.InProgress; - while (result == Result.InProgress) + public void Unbound_switch_whose_key_name_is_missing_throws([Values] bool sync) + { + (BlackboardSchema schema, Blackboard board, _) = Boards(); + + List trace = []; + GraphBuilder builder = new(); + SwitchCase[] cases = [new("alpha", builder.AddNode(Probe("case:alpha", trace)))]; + NodeId fallback = builder.AddNode(Probe("default", trace)); + builder.AddNode((IAsyncLogic)SwitchState.Unbound("ghost", cases, fallback), isStart: true); + builder.WithSchema(schema); + Graph graph = builder.Build(throwOnError: false); + + InvalidOperationException? ex = sync + ? Assert.Throws( + () => RunToEnd(graph.ToStateMachine().WithBlackboard(board))) + : Assert.ThrowsAsync( + async () => await graph.ToAsyncStateMachine().WithBlackboard(board).ExecuteAsync()); + + Assert.Multiple(() => { - result = fsm.Execute(); - } + Assert.That(ex!.Message, Does.Contain("ghost")); + Assert.That(trace, Is.Empty, "An unresolvable key throws — it never silently falls to the default."); + }); + } + + // ── Node surface ───────────────────────────────────────────────────── + + [Test] + public void Execute_always_succeeds_because_a_decision_never_faults() + { + (_, _, BlackboardKey mode) = Boards(); + SwitchState switchState = new(mode, [new SwitchCase("alpha", new NodeId(1))], new NodeId(2)); + + Assert.That(((ILogic)switchState).Execute(), Is.EqualTo(Result.Success)); + } + + [Test] + public void Static_targets_yield_the_case_arms_then_the_default() + { + (_, _, BlackboardKey mode) = Boards(); + SwitchState switchState = new(mode, + [new SwitchCase("alpha", new NodeId(1)), new SwitchCase("beta", new NodeId(2))], + new NodeId(3)); + + Assert.That(((IDirector)switchState).EnumerateStaticTargets().ToArray(), + Is.EqualTo(new[] { new NodeId(1), new NodeId(2), new NodeId(3) })); + } + + // ── Construction-time rejections ───────────────────────────────────── + + [Test] + public void A_value_cased_twice_is_rejected_naming_the_offending_value() + { + (_, _, BlackboardKey mode) = Boards(); + + ArgumentException? ex = Assert.Throws(() => _ = new SwitchState(mode, + [ + new SwitchCase("alpha", new NodeId(1)), + new SwitchCase("beta", new NodeId(2)), + new SwitchCase("beta", new NodeId(3)), + ], + new NodeId(4))); Assert.Multiple(() => { - Assert.That(result, Is.EqualTo(Result.Success)); - Assert.That(defaultRan, Is.True, "The sync runtime must route through the explicit default body."); + Assert.That(ex!.ParamName, Is.EqualTo("cases")); + Assert.That(ex.Message, Does.Contain("beta"), + "The rejection must name the value the author cased twice."); }); } -} \ No newline at end of file + + [Test] + public void An_empty_case_list_is_rejected() + { + (_, _, BlackboardKey mode) = Boards(); + + ArgumentException? ex = Assert.Throws( + () => _ = new SwitchState(mode, [], new NodeId(1))); + + Assert.Multiple(() => + { + Assert.That(ex!.ParamName, Is.EqualTo("cases")); + Assert.That(ex.Message, Does.Contain("at least one case")); + }); + } + + [Test] + public void A_null_case_list_is_rejected() + { + (_, _, BlackboardKey mode) = Boards(); + + ArgumentException? ex = Assert.Throws( + () => _ = new SwitchState(mode, null!, new NodeId(1))); + + Assert.That(ex!.ParamName, Is.EqualTo("cases")); + } + + [Test] + public void An_invalid_key_is_rejected() + { + ArgumentException? ex = Assert.Throws(() => _ = new SwitchState( + default, [new SwitchCase("alpha", new NodeId(1))], new NodeId(2))); + + Assert.That(ex!.ParamName, Is.EqualTo("key")); + } + + [Test] + public void Unbound_rejects_an_empty_key_name() + { + ArgumentException? ex = Assert.Throws(() => _ = SwitchState.Unbound( + string.Empty, [new SwitchCase("alpha", new NodeId(1))], new NodeId(2))); + + Assert.That(ex!.ParamName, Is.EqualTo("keyName")); + } +} diff --git a/NxGraph/Fsm/IDirector.cs b/NxGraph/Fsm/IDirector.cs index d62c101..c5020b5 100644 --- a/NxGraph/Fsm/IDirector.cs +++ b/NxGraph/Fsm/IDirector.cs @@ -22,8 +22,9 @@ public interface IDirector /// /// The default returns an empty sequence so existing user implementations compile /// unchanged — but those custom directors will be opaque to the validator and the - /// exporter. Built-in and - /// override this to surface their known targets. + /// exporter. The built-in branch states — data-built / + /// and delegate-backed / + /// — override this to surface their known targets. /// IEnumerable EnumerateStaticTargets() => System.Array.Empty(); } diff --git a/NxGraph/Graphs/LogicNode.cs b/NxGraph/Graphs/LogicNode.cs index 723cd1d..b820fe5 100644 --- a/NxGraph/Graphs/LogicNode.cs +++ b/NxGraph/Graphs/LogicNode.cs @@ -143,6 +143,22 @@ public LogicNode(NodeId id, IAsyncLogic asyncLogic, Action? enterAction = null, /// public static readonly LogicNode AsyncBehaviorStateMarker = new(NodeId.AsyncBehaviorStateMarker, new EmptyAsyncLogic()); + + /// + /// Sentinel for data-built ChoiceState owner nodes during (de)serialization (wire + /// marker string "ChoiceState", payload version 10). One marker for both runtimes — the + /// data-built branch is one class implementing both logic and both director interfaces. + /// + public static readonly LogicNode ChoiceStateMarker = + new(NodeId.ChoiceStateMarker, new EmptyAsyncLogic()); + + /// + /// Sentinel for data-built SwitchState<T> owner nodes during + /// (de)serialization (wire marker string "SwitchState", payload version 10). One marker + /// for both runtimes and every closed T — the DTO's value type name discriminates. + /// + public static readonly LogicNode SwitchStateMarker = + new(NodeId.SwitchStateMarker, new EmptyAsyncLogic()); } /// diff --git a/NxGraph/Graphs/NodeId.cs b/NxGraph/Graphs/NodeId.cs index 36c3b80..6b048be 100644 --- a/NxGraph/Graphs/NodeId.cs +++ b/NxGraph/Graphs/NodeId.cs @@ -79,6 +79,12 @@ public override int GetHashCode() public static NodeId BehaviorStateMarker => new(-13) { Name = "BehaviorState" }; public static NodeId AsyncBehaviorStateMarker => new(-14) { Name = "AsyncBehaviorState" }; + // -15 is taken: NxGraph.Serialization's internal ContainerPlaceholderMarker reserves it for + // the markerless container claim (an in-memory-only sentinel that never rides the wire). + // The sequence skips it so the sentinel space stays collision-free. + public static NodeId ChoiceStateMarker => new(-16) { Name = "ChoiceState" }; + public static NodeId SwitchStateMarker => new(-17) { Name = "SwitchState" }; + /// /// Represents the start NodeId with an index of 0 and an empty name /// ( prints (0)). Display names are presentation data diff --git a/README.md b/README.md index 86ee9e6..2f08462 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The core package targets `net8.0` and `netstandard2.1`. ## Why NxGraph - **Simple runtime model**: graphs are backed by dense node/transition arrays and each node has at most one success edge plus an optional failure edge. -- **Predictable branching**: run-one fan-out happens through director nodes such as `RelayChoiceState` and `RelaySwitchState`; run-many fan-out through parallel composites — see [Fan-out at a glance](#fan-out-at-a-glance). +- **Predictable branching**: run-one fan-out happens through director nodes — the data-built `ChoiceState`/`SwitchState`, which [serialize](#data-built-branching-serializable), or their delegate-backed `RelayChoiceState`/`RelaySwitchState` twins; run-many fan-out through parallel composites — see [Fan-out at a glance](#fan-out-at-a-glance). - **Authoring ergonomics**: build flows with `StartWithAsync`, `.ToAsync(...)`, `.If(...)`, `.Switch(...)`, `.WaitForAsync(...)`/`.WaitFor(...)`, and `.ToWithTimeoutAsync(...)`/`.ToWithTimeout(...)` — every construct has twins in both runtimes. - **Unity-ready sync runtime**: `StateMachine.Execute()` advances exactly one node per call, drop it into `MonoBehaviour.Update()`. @@ -223,9 +223,43 @@ var graph = GraphBuilder .Build(); ``` +### Data-built branching (serializable) + +The `.If(predicate)` / `.Switch(selector)` overloads above take **delegates**, and a closure cannot ride a serialization payload — a graph that branches through them cannot round-trip, and therefore cannot survive suspend and resume. When the decision is *data* — a comparison against a blackboard slot — pass a condition or a key instead, and the branch becomes an ordinary part of the payload: + +```csharp +var world = new BlackboardSchema("world"); +BlackboardKey alarmRaised = world.Register("alarmRaised", false); +BlackboardKey tier = world.Register("tier", 0); + +var graph = GraphBuilder + .StartWithAsync(_ => ResultHelpers.Success).SetName("Entry") + .If(new IsTrue(alarmRaised)) + .ThenAsync(_ => ResultHelpers.Success).SetName("Evacuate") + .ElseAsync(_ => ResultHelpers.Success).SetName("Patrol") + .WithSchema(world) + .Build(); + +var routed = GraphBuilder + .StartWithAsync(_ => ResultHelpers.Success).SetName("Entry") + .Switch(tier) // the key is the tested value + .CaseAsync(1, _ => ResultHelpers.Success) // cases are literals + .CaseAsync(2, _ => ResultHelpers.Success) + .DefaultAsync(_ => ResultHelpers.Success) + .End().SetName("Router") + .WithSchema(world) + .Build(); +``` + +`.If(...)` with conditions builds a `ChoiceState`, `.Switch(key)` builds a `SwitchState`; both serialize with **zero serializer options** and both render labelled arms in the Mermaid export (`true` / `false`, the case literal, `otherwise`). + +A **condition** implements `ICondition` — `bool Evaluate(in BehaviorContext ctx)`, reading the machine-bound blackboards through the same context [behaviors](#behaviors-declarative-state-composition) use. It reuses none of the fault model: a condition that is false is *not* a failure, so branching never spends the node's retry/failure edge. Conditions are side-effect free by contract, which is what makes short-circuit evaluation safe; a genuine wiring fault (an unbound key, a key declared with another type) throws rather than answering `false`. The standard set is deliberately tiny — `KeyEquals` (whose expected side is a literal *or* another key), `IsTrue`, and `Not` — and `.If(ConditionMatch.All, …)` / `.If(ConditionMatch.Any, …)` combine several. + +Two shapes, two jobs: a **switch is a lookup**, so its case values are literals and a value cased twice is rejected at build time — at most one arm can match, and the arms carry no order. Ordered, first-match-wins rules, where an earlier arm may shadow a later one or different arms test different keys, are a **chain of choices**, which is what `if`/`else if` is; lower to that rather than reaching for an ordered rule table. + ### Custom directors -`.If(...)` and `.Switch(...)` compile down to the built-in director nodes `RelayChoiceState` and `RelaySwitchState`. A **director** is a node implementing `IDirector` (`IAsyncDirector` for the async runtime) whose `SelectNext()` picks the next node at runtime — implement it yourself when the routing decision doesn't fit a predicate or a key/case map. Override `EnumerateStaticTargets()` to surface the nodes you can route to: the validator and the Mermaid exporter walk it, and the validator warns when a custom director exposes none (its branches would be invisible to reachability analysis and diagrams). +`.If(predicate)` and `.Switch(selector)` compile down to the delegate-backed director nodes `RelayChoiceState` and `RelaySwitchState`; their data-built twins are `ChoiceState` and `SwitchState` (above). A **director** is a node implementing `IDirector` (`IAsyncDirector` for the async runtime) whose `SelectNext()` picks the next node at runtime — implement it yourself when the routing decision doesn't fit a predicate or a key/case map. Override `EnumerateStaticTargets()` to surface the nodes you can route to: the validator and the Mermaid exporter walk it, and the validator warns when a custom director exposes none (its branches would be invisible to reachability analysis and diagrams). ### Fan-out at a glance @@ -233,7 +267,7 @@ Every fan-out construct answers two questions: **how many successors run**, and | How many run | Chosen statically (declared in the graph) | Chosen dynamically (at runtime) | |---|---|---| -| **One of many** | Conditional — [`.If(...)`](#branching-with-if) / [`.Switch(...)`](#branching-with-switch) declare the branches and the routing rule | Director — [`IDirector`](#custom-directors) selects any node in code; `RelayChoiceState`/`RelaySwitchState` are the built-ins | +| **One of many** | Conditional — [`.If(...)`](#branching-with-if) / [`.Switch(...)`](#branching-with-switch) declare the branches and the routing rule; the [data-built forms](#data-built-branching-serializable) additionally serialize | Director — [`IDirector`](#custom-directors) selects any node in code; `RelayChoiceState`/`RelaySwitchState` are the built-ins | | **Many at once** | Parallel — [`.Parallel(regions...)`](#parallel-regions-and-states) runs **all** region graphs | Dynamic parallel — [`.Parallel(selector, ...)`](#dynamic-some-of-many-regions) runs the **subset** a blackboard selector picks | | **Many in one flat graph** | Token runtime — [`.ForkTo(...)` + `JoinState`](#token-runtime-fork-join-and-mid-graph-merge) fan tokens out and merge them mid-graph (all / any / M-of-N) | The same fork/join graph — which tokens reach a join, and when, is decided by each token's own path at runtime | @@ -1150,6 +1184,7 @@ Notes: - event entry dispatchers serialize out of the box (payload version 7): the dispatch table — key names, runtime-stable event type names, targets, and the `Otherwise` target — is plain structure. Blackboard keys never ride the graph payload (schemas are code), so a deserialized graph raises by resolving the event's type name and the delivery key by name against the machine's bound Graph board, with targeted errors on a missing name or a changed value type - behavior composites serialize out of the box for the standard set (payload version 8): `Log` and `SetValue` ride as self-describing field lists with **zero options configured** — the default `BehaviorRegistry` reconstructs them, closing `SetValue` (and `BehaviorState`'s agent type) from runtime-stable type names. Key bindings ride by name and rebind against the machine's bound boards at execution. Custom behaviors implement `ISerializableBehavior` (writing through the small neutral field model: strings, bools, numerics, enums, bindings) and register a reconstruction factory on `GraphSerializerOptions.BehaviorRegistry`; a behavior that does neither fails with a targeted error naming that option. The agent never rides — re-attach it via `SetAgent`/`WithAgent` - `Repeat` bodies ride as nested behavior entry lists (payload version 9): all four repeat forms serialize with zero options via the default registry, bodies encode recursively under exactly the top-level entry rules (user behaviors nested in a body follow the same `ISerializableBehavior` + factory contract), the count binding and index key rebind by name, and read-side nesting is capped at 32 as a crafted-payload guard. Pre-v9 payloads read unchanged +- [data-built branches](#data-built-branching-serializable) serialize out of the box (payload version 10): a `ChoiceState`'s condition list and a `SwitchState`'s key, literal cases and default target ride as two sparse sections, and the standard conditions (`KeyEquals`, `IsTrue`, `Not`) reconstruct with **zero options** through the default `ConditionRegistry` — nested `Not` conditions encode recursively under the same rules and the same read-side depth cap. Custom conditions implement `ISerializableCondition` and register a factory on `GraphSerializerOptions.ConditionRegistry`, exactly as custom behaviors do. Keys never ride typed: the switch's key and `KeyEquals`'s key rebind by name against the machine's bound boards at execution. Pre-v10 payloads read branch-free — which is the point of the whole feature: a graph that branches can now be suspended, stored and resumed --- @@ -1249,7 +1284,7 @@ The tests cover: ## FAQ **Why is there only one direct success transition per node?** -Branching is modeled explicitly through directors such as `RelayChoiceState` and `RelaySwitchState`, which keeps execution simple and predictable. A node can additionally carry one failure edge (`.OnError`) for the fault path. When several paths must run at once, use the parallel composites instead of extra edges — see [Fan-out at a glance](#fan-out-at-a-glance); a token runner with free-form fan-out in one flat graph is a recorded, deliberately deferred design. +Branching is modeled explicitly through directors — `ChoiceState`/`SwitchState` when the decision is data, `RelayChoiceState`/`RelaySwitchState` when it is code — which keeps execution simple and predictable. A node can additionally carry one failure edge (`.OnError`) for the fault path. When several paths must run at once, use the parallel composites instead of extra edges — see [Fan-out at a glance](#fan-out-at-a-glance); a token runner with free-form fan-out in one flat graph is a recorded, deliberately deferred design. **Can I share a graph across machines?** Yes. `Graph` is immutable after build and can be reused across multiple state machine instances. diff --git a/upm/com.enzx.nxgraph/CHANGELOG.md b/upm/com.enzx.nxgraph/CHANGELOG.md index 15eb3ed..d7d629f 100644 --- a/upm/com.enzx.nxgraph/CHANGELOG.md +++ b/upm/com.enzx.nxgraph/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this package will be documented in this file. +## [Unreleased] + +### Runtime (staged NxGraph core) +- **Data-built branching**: `ChoiceState(conditions, ConditionMatch.All|Any, trueTarget, falseTarget)` and `SwitchState(key, literal cases, defaultTarget)` decide from data instead of a closure, so a branching graph rides the serialization payload with zero serializer options and survives suspend/resume. Conditions live in `NxGraph.Conditions` (`ICondition`, `KeyEquals`, `IsTrue`, `Not`), reuse the behavior model's `BehaviorContext`, and reuse none of the fault model — a false condition is a decision, never a node failure. Author with `.If(condition)`, `.If(ConditionMatch.Any, …)`, and `.Switch(blackboardKey).Case(value, …).Default(…).End()`. +- Mermaid export labels data-built arms (`true` / `false`, the case literal, `otherwise`), and the validator warns on a choice whose arms are the same node and on a switch with no default target. +- Serialization payload version 10: sparse choice and switch sections, with an `ISerializableCondition` / `ConditionRegistry` pair mirroring the behavior registry. Version 9 payloads read branch-free. + +### Breaking +- The delegate-backed director states were renamed: `ChoiceState` → `RelayChoiceState`, `SwitchState` → `RelaySwitchState`, `AsyncChoiceState` → `AsyncRelayChoiceState`, `AsyncSwitchState` → `AsyncRelaySwitchState`. Behavior and constructors are unchanged, and the `.If(predicate)` / `.Switch(selector)` DSL paths still build them. **No obsolete forwarding aliases exist**: the old names are reused in the same release for the new data-built states, so an alias would silently compile old code into a state that means something else. Update the type names; the compiler error is the migration instruction. + ## [2.1.0-alpha] ### Runtime (staged NxGraph core) From fb02b68c2e4359b237a23bdcb0e5d3619512819c Mon Sep 17 00:00:00 2001 From: Mohamad Iraji <4851913+Enzx@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:25:13 +0200 Subject: [PATCH 3/3] Make the branch report channel live and split the data-switch builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data-built ChoiceState evaluated its conditions through a BehaviorContext with a null report sink, so a condition calling Report() went nowhere. The sync machines' report tables were typed to the State base class, which a branch state is not. They now target the capability instead — a new internal ISyncLogReporter beside the public ILogReporter — so any node owning a report channel is wired, and ChoiceState carries both machine-owned slots itself. The per-visit contract is unchanged: each family wires its own slot, clears the other's, and wires null when the machine has no observer. SwitchState runs no user code and deliberately gains no channel. The data switch also no longer shares SwitchBuilder with the delegate path. .Switch(blackboardKey) returns its own KeySwitchBuilder, which accumulates literal cases and builds the immutable state at End(), and SwitchBuilder is back to its single-purpose relay shape. Both mirror the same Case/Default/End surface, lambda overloads included, so swapping a selector for a key changes one call. --- NxGraph.Tests/BranchReportChannelTests.cs | 379 ++++++++++++++++++ NxGraph.Tests/DataBranchDslTests.cs | 95 ++++- NxGraph.Tests/PublicApi/NxGraph.approved.txt | 48 ++- .../NxGraph.netstandard2.1.approved.txt | 48 ++- NxGraph/Authoring/Dsl.Blackboard.cs | 32 ++ NxGraph/Authoring/Dsl.Conditions.cs | 17 +- NxGraph/Authoring/Dsl.KeySwitchBuilder.cs | 133 ++++++ NxGraph/Authoring/Dsl.SwitchBuilder.cs | 93 +---- NxGraph/Authoring/Dsl.Sync.cs | 22 + NxGraph/Authoring/Dsl.cs | 33 ++ NxGraph/Behaviors/BehaviorState.cs | 22 +- NxGraph/Diagnostics/Replay/ILogReporter.cs | 47 ++- NxGraph/Fsm/Async/AsyncStateMachine.cs | 23 +- NxGraph/Fsm/ChoiceState.cs | 45 ++- NxGraph/Fsm/State.cs | 5 +- NxGraph/Fsm/StateMachine.cs | 52 +-- NxGraph/Fsm/SwitchState.cs | 9 +- NxGraph/Tokens/AsyncTokenMachine.cs | 11 +- NxGraph/Tokens/TokenMachine.cs | 33 +- 19 files changed, 951 insertions(+), 196 deletions(-) create mode 100644 NxGraph.Tests/BranchReportChannelTests.cs create mode 100644 NxGraph/Authoring/Dsl.KeySwitchBuilder.cs diff --git a/NxGraph.Tests/BranchReportChannelTests.cs b/NxGraph.Tests/BranchReportChannelTests.cs new file mode 100644 index 0000000..7c41941 --- /dev/null +++ b/NxGraph.Tests/BranchReportChannelTests.cs @@ -0,0 +1,379 @@ +using NxGraph.Authoring; +using NxGraph.Behaviors; +using NxGraph.Conditions; +using NxGraph.Fsm; +using NxGraph.Fsm.Async; +using NxGraph.Graphs; +using NxGraph.Tokens; + +namespace NxGraph.Tests; + +/// +/// The data-built branch states' report channel: a condition inside a +/// calls and the message must reach the running machine's +/// observer (OnLogReport), attributed to the branch node — the same contract +/// State.Log and the behavior composites have (see LogReportBridgeTests). +/// +/// A branch state is not a State subclass, so this only works because the machines' sync +/// report tables target the report capability (ISyncLogReporter) rather than the +/// base class. The fixture therefore pins the same invariants the base-class channel has: both +/// slots are machine-owned and reassigned per visit, an observer-less machine wires +/// (so HasReporter is false and gated conditions pay nothing), and +/// the channel is live before the director selects — the report is raised from inside +/// selection, so its arrival is that ordering. +/// +/// +[TestFixture] +[Category("branching_choice")] +public class BranchReportChannelTests +{ + private const string BranchNode = "branch"; + private const string ArmNode = "arm"; + private const string Message = "deciding"; + + // ── Fixtures ───────────────────────────────────────────────────────── + + /// + /// Reports through the context (gated on HasReporter, as report-formatting + /// conditions should be) and records what it saw on every evaluation. + /// + private sealed class ReportingCondition(bool answer = true) : ICondition + { + public readonly List ReporterSeen = []; + + public bool Evaluate(in BehaviorContext ctx) + { + ReporterSeen.Add(ctx.HasReporter); + if (ctx.HasReporter) + { + ctx.Report(Message); + } + + return answer; + } + } + + /// + /// A choice as the start node: the true arm is a probe node, the false arm is the + /// director's terminal exit. Both nodes are named so reports can be attributed. + /// + private static Graph BranchGraph(ICondition condition) + { + GraphBuilder builder = new(); + NodeId arm = builder.AddNode(new RelayState(() => Result.Success)); + builder.SetName(arm, ArmNode); + NodeId branch = builder.AddNode((IAsyncLogic)new ChoiceState(condition, arm, NodeId.Default), isStart: true); + builder.SetName(branch, BranchNode); + return builder.Build(throwOnError: false); + } + + private static Result RunToEnd(StateMachine machine) + { + Result result = machine.Execute(); + while (result == Result.InProgress) + { + result = machine.Execute(); + } + + return result; + } + + // ── Delivery under all four machines ──────────────────────────────── + + [Test] + public void condition_report_reaches_the_sync_machine_observer() + { + ReportingCondition condition = new(); + RecordingSyncObserver observer = new(); + StateMachine machine = BranchGraph(condition).ToStateMachine(observer); + + Result result = RunToEnd(machine); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(observer.Messages, Is.EqualTo(new[] { Message })); + Assert.That(observer.NodeNames, Is.EqualTo(new[] { BranchNode }), + "The report is attributed to the branch node whose decision raised it."); + Assert.That(condition.ReporterSeen, Is.EqualTo(new[] { true })); + }); + } + + [Test] + public async Task condition_report_reaches_the_async_machine_observer() + { + ReportingCondition condition = new(); + RecordingAsyncObserver observer = new(); + AsyncStateMachine machine = BranchGraph(condition).ToAsyncStateMachine(observer); + + Result result = await machine.ExecuteAsync(); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(observer.Messages, Is.EqualTo(new[] { Message })); + Assert.That(observer.NodeNames, Is.EqualTo(new[] { BranchNode })); + Assert.That(condition.ReporterSeen, Is.EqualTo(new[] { true })); + }); + } + + [Test] + public void condition_report_reaches_the_token_machine_observer() + { + ReportingCondition condition = new(); + RecordingTokenObserver observer = new(); + TokenMachine machine = BranchGraph(condition).ToTokenMachine(observer); + machine.SetStepMode(ParallelStepMode.RunToJoin); + + Result result = machine.Execute(); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(observer.Messages, Is.EqualTo(new[] { Message })); + Assert.That(observer.NodeNames, Is.EqualTo(new[] { BranchNode })); + Assert.That(observer.TokenIds, Is.EqualTo(new[] { 0 })); + }); + } + + [Test] + public async Task condition_report_reaches_the_async_token_machine_observer() + { + ReportingCondition condition = new(); + RecordingAsyncTokenObserver observer = new(); + AsyncTokenMachine machine = BranchGraph(condition).ToAsyncTokenMachine(observer); + + Result result = await machine.ExecuteAsync(); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(observer.Messages, Is.EqualTo(new[] { Message })); + Assert.That(observer.NodeNames, Is.EqualTo(new[] { BranchNode })); + Assert.That(observer.TokenIds, Is.EqualTo(new[] { 0 })); + }); + } + + // ── Ordering: wired before the director selects ───────────────────── + + [Test] + public async Task the_report_channel_is_live_before_the_director_selects([Values] bool sync) + { + // The report is raised from inside SelectNext, so its arrival already proves the + // channel was wired before selection ran. The ordered trace pins the second half: + // it lands while the machine is still on the branch node, ahead of the transition + // this very decision produced. + ReportingCondition condition = new(); + Graph graph = BranchGraph(condition); + List trace; + + if (sync) + { + RecordingSyncObserver observer = new(); + RunToEnd(graph.ToStateMachine(observer)); + trace = observer.Trace; + } + else + { + RecordingAsyncObserver observer = new(); + await graph.ToAsyncStateMachine(observer).ExecuteAsync(); + trace = observer.Trace; + } + + Assert.That(trace, Is.EqualTo(new[] { $"report:{BranchNode}", $"transition:{BranchNode}->{ArmNode}" })); + } + + // ── Observer-less machines: the channel is inert and free ─────────── + + [Test] + public async Task has_reporter_is_false_on_an_observer_less_machine([Values] bool sync) + { + ReportingCondition condition = new(); + Graph graph = BranchGraph(condition); + + Result result = sync + ? RunToEnd(graph.ToStateMachine()) + : await graph.ToAsyncStateMachine().ExecuteAsync(); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(Result.Success)); + Assert.That(condition.ReporterSeen, Is.EqualTo(new[] { false }), + "An observer-less machine wires null into both slots, so report-formatting " + + "conditions pay nothing."); + }); + } + + // ── Machines sharing one graph: per-visit reassignment ────────────── + + [Test] + public async Task two_machines_over_one_shared_graph_each_receive_only_their_own_condition_reports( + [Values] bool sync) + { + Graph shared = BranchGraph(new ReportingCondition()); + + if (sync) + { + RecordingSyncObserver first = new(); + RecordingSyncObserver second = new(); + StateMachine machineA = shared.ToStateMachine(first); + StateMachine machineB = shared.ToStateMachine(second); + + RunToEnd(machineA); + RunToEnd(machineB); + RunToEnd(machineA); + + Assert.Multiple(() => + { + Assert.That(first.Messages, Has.Count.EqualTo(2), "Two runs, one decision each."); + Assert.That(second.Messages, Has.Count.EqualTo(1)); + }); + } + else + { + RecordingAsyncObserver first = new(); + RecordingAsyncObserver second = new(); + AsyncStateMachine machineA = shared.ToAsyncStateMachine(first); + AsyncStateMachine machineB = shared.ToAsyncStateMachine(second); + + await machineA.ExecuteAsync(); + await machineB.ExecuteAsync(); + await machineA.ExecuteAsync(); + + Assert.Multiple(() => + { + Assert.That(first.Messages, Has.Count.EqualTo(2)); + Assert.That(second.Messages, Has.Count.EqualTo(1)); + }); + } + } + + [Test] + public async Task an_observer_less_sync_run_does_not_leak_condition_reports_to_a_previous_async_observer() + { + // Without the per-visit clearing of the async slot, the observer-less sync machine + // would null only the sync callback and the report bridge would fall back to the async + // callback the async machine left on the branch node — stale attribution. + ReportingCondition condition = new(); + Graph shared = BranchGraph(condition); + RecordingAsyncObserver asyncObserver = new(); + + await shared.ToAsyncStateMachine(asyncObserver).ExecuteAsync(); + RunToEnd(shared.ToStateMachine()); + + Assert.Multiple(() => + { + Assert.That(asyncObserver.Messages, Has.Count.EqualTo(1), + "The observer-less sync run must not deliver through the stale async callback."); + Assert.That(condition.ReporterSeen, Is.EqualTo(new[] { true, false })); + }); + } + + [Test] + public async Task an_observer_less_async_run_does_not_leak_condition_reports_to_a_previous_sync_observer() + { + ReportingCondition condition = new(); + Graph shared = BranchGraph(condition); + RecordingSyncObserver syncObserver = new(); + + RunToEnd(shared.ToStateMachine(syncObserver)); + await shared.ToAsyncStateMachine().ExecuteAsync(); + + Assert.Multiple(() => + { + Assert.That(syncObserver.Messages, Has.Count.EqualTo(1), + "The observer-less async run must not deliver through the stale sync callback."); + Assert.That(condition.ReporterSeen, Is.EqualTo(new[] { true, false })); + }); + } + + [Test] + public async Task a_sync_run_after_an_async_run_reports_only_to_its_own_observer() + { + // Cross-family staleness with an observer on both sides: each family wires its own + // slot and clears the other's, so neither observer sees the other's run. + ReportingCondition condition = new(); + Graph shared = BranchGraph(condition); + RecordingAsyncObserver asyncObserver = new(); + RecordingSyncObserver syncObserver = new(); + + await shared.ToAsyncStateMachine(asyncObserver).ExecuteAsync(); + RunToEnd(shared.ToStateMachine(syncObserver)); + + Assert.Multiple(() => + { + Assert.That(asyncObserver.Messages, Has.Count.EqualTo(1)); + Assert.That(syncObserver.Messages, Has.Count.EqualTo(1)); + Assert.That(condition.ReporterSeen, Is.EqualTo(new[] { true, true })); + }); + } + + // ── Observers ─────────────────────────────────────────────────────── + + private sealed class RecordingSyncObserver : IStateMachineObserver + { + public readonly List Messages = []; + public readonly List NodeNames = []; + public readonly List Trace = []; + + void IStateMachineObserver.OnLogReport(NodeId nodeId, string message) + { + Messages.Add(message); + NodeNames.Add(nodeId.Name); + Trace.Add($"report:{nodeId.Name}"); + } + + void IStateMachineObserver.OnTransition(NodeId from, NodeId to) => + Trace.Add($"transition:{from.Name}->{to.Name}"); + } + + private sealed class RecordingAsyncObserver : IAsyncStateMachineObserver + { + public readonly List Messages = []; + public readonly List NodeNames = []; + public readonly List Trace = []; + + public ValueTask OnLogReport(NodeId nodeId, string message, CancellationToken ct = default) + { + Messages.Add(message); + NodeNames.Add(nodeId.Name); + Trace.Add($"report:{nodeId.Name}"); + return default; + } + + public ValueTask OnTransition(NodeId from, NodeId to, CancellationToken ct = default) + { + Trace.Add($"transition:{from.Name}->{to.Name}"); + return default; + } + } + + private sealed class RecordingTokenObserver : ITokenMachineObserver + { + public readonly List Messages = []; + public readonly List NodeNames = []; + public readonly List TokenIds = []; + + void ITokenMachineObserver.OnLogReport(int tokenId, NodeId nodeId, string message) + { + Messages.Add(message); + NodeNames.Add(nodeId.Name); + TokenIds.Add(tokenId); + } + } + + private sealed class RecordingAsyncTokenObserver : IAsyncTokenMachineObserver + { + public readonly List Messages = []; + public readonly List NodeNames = []; + public readonly List TokenIds = []; + + public ValueTask OnLogReport(int tokenId, NodeId nodeId, string message, CancellationToken ct = default) + { + Messages.Add(message); + NodeNames.Add(nodeId.Name); + TokenIds.Add(tokenId); + return default; + } + } +} diff --git a/NxGraph.Tests/DataBranchDslTests.cs b/NxGraph.Tests/DataBranchDslTests.cs index 74b4379..b5c65ed 100644 --- a/NxGraph.Tests/DataBranchDslTests.cs +++ b/NxGraph.Tests/DataBranchDslTests.cs @@ -9,10 +9,11 @@ namespace NxGraph.Tests; /// /// The data-built authoring surface (spec 023, Authoring/Dsl.Conditions.cs): /// .If(condition), .If(match, conditions…) and .Switch(blackboardKey) on -/// both StartToken (the branch as the graph's first node) and StateToken. The -/// builders are the same IfBuilder / SwitchBuilder the delegate paths return, so -/// these tests pin that the chain shape is unchanged and that the data mode really builds the -/// serializable states rather than a Relay* one. +/// both StartToken (the branch as the graph's first node) and StateToken. +/// .If(...) returns the same IfBuilder the delegate path returns; .Switch(key) +/// returns KeySwitchBuilder, the data-built twin of SwitchBuilder that mirrors its +/// authoring surface. These tests pin that the chain shape is unchanged and that the data path +/// really builds the serializable states rather than a Relay* one. /// [TestFixture] [Category("branching_dsl")] @@ -24,6 +25,18 @@ public class DataBranchDslTests return Result.Success; }); + private static Result Mark(string name, List trace) + { + trace.Add(name); + return Result.Success; + } + + private static ValueTask MarkAsync(string name, List trace) + { + trace.Add(name); + return ResultHelpers.Success; + } + private static (BlackboardSchema schema, Blackboard board, BlackboardKey armed, BlackboardKey mode) Boards() { @@ -281,4 +294,78 @@ public void Switch_data_mode_rejects_an_invalid_key() Assert.That(ex!.ParamName, Is.EqualTo("key")); } + + [Test] + public async Task Switch_key_mirrors_the_selector_builders_lambda_surface() + { + // KeySwitchBuilder must offer every convenience overload SwitchBuilder offers, so that + // swapping a selector for a key changes the .Switch(...) call and nothing else. One graph + // per lambda flavour — plain sync, plain async, context sync, context async — makes this a + // compile-time completeness proof as well as a routing check. + // + // Each graph runs on the runtime its own arms can run on: the async-lambda flavours build + // AsyncRelayState arms, which the sync StateMachine rejects at construction by design (it + // names the first node lacking ILogic). That is the pre-existing rule for every async + // lambda in the DSL, not something the data switch changes — so this test is not + // parameterised over the runtime; the branch node itself is pinned on both runtimes by the + // sibling tests and the parity conformance matrix. + (BlackboardSchema schema, Blackboard board, _, BlackboardKey mode) = Boards(); + List trace = []; + + Graph plainSync = GraphBuilder.Start() + .Switch(mode) + .Case("alpha", () => Mark("sync:alpha", trace)) + .Default(() => Mark("sync:default", trace)) + .End() + .WithSchema(schema) + .Build(); + + Graph plainAsync = GraphBuilder.Start() + .Switch(mode) + .CaseAsync("alpha", _ => MarkAsync("async:alpha", trace)) + .DefaultAsync(_ => MarkAsync("async:default", trace)) + .End() + .WithSchema(schema) + .Build(); + + Graph contextSync = GraphBuilder.Start() + .Switch(mode) + .Case("alpha", bb => Mark($"bb:{bb.Get(mode)}", trace)) + .Default(bb => Mark($"bb:default:{bb.Get(mode)}", trace)) + .End() + .WithSchema(schema) + .Build(); + + Graph contextAsync = GraphBuilder.Start() + .Switch(mode) + .CaseAsync("alpha", (bb, _) => MarkAsync($"bbAsync:{bb.Get(mode)}", trace)) + .DefaultAsync((bb, _) => MarkAsync($"bbAsync:default:{bb.Get(mode)}", trace)) + .End() + .WithSchema(schema) + .Build(); + + // (graph, runs on the sync machine) + (Graph Graph, bool Sync)[] graphs = + [ + (plainSync, true), (plainAsync, false), (contextSync, true), (contextAsync, false), + ]; + + board.Set(mode, "alpha"); + foreach ((Graph graph, bool runSync) in graphs) + { + Assert.That(await RunAsync(graph, board, runSync), Is.EqualTo(Result.Success)); + } + + board.Set(mode, "omega"); + foreach ((Graph graph, bool runSync) in graphs) + { + Assert.That(await RunAsync(graph, board, runSync), Is.EqualTo(Result.Success)); + } + + Assert.That(trace, Is.EqualTo(new[] + { + "sync:alpha", "async:alpha", "bb:alpha", "bbAsync:alpha", + "sync:default", "async:default", "bb:default:omega", "bbAsync:default:omega", + })); + } } diff --git a/NxGraph.Tests/PublicApi/NxGraph.approved.txt b/NxGraph.Tests/PublicApi/NxGraph.approved.txt index 72c995a..cb8d4d5 100644 --- a/NxGraph.Tests/PublicApi/NxGraph.approved.txt +++ b/NxGraph.Tests/PublicApi/NxGraph.approved.txt @@ -39,6 +39,16 @@ static class NxGraph.Authoring.Dsl method IfBuilder If(NxGraph.Authoring.StateToken, NxGraph.Conditions.ICondition) method IfBuilder If(NxGraph.Authoring.StateToken, System.Func`1[System.Boolean]) method IfBuilder If(NxGraph.Authoring.StateToken, System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Boolean]) + method KeySwitchBuilder`1 CaseAsync[TKey](KeySwitchBuilder`1, TKey, System.Func`2[System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) + method KeySwitchBuilder`1 CaseAsync[TKey](KeySwitchBuilder`1, TKey, System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) + method KeySwitchBuilder`1 Case[TKey](KeySwitchBuilder`1, TKey, System.Func`1[NxGraph.Result]) + method KeySwitchBuilder`1 Case[TKey](KeySwitchBuilder`1, TKey, System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Result]) + method KeySwitchBuilder`1 DefaultAsync[TKey](KeySwitchBuilder`1, System.Func`2[System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) + method KeySwitchBuilder`1 DefaultAsync[TKey](KeySwitchBuilder`1, System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) + method KeySwitchBuilder`1 Default[TKey](KeySwitchBuilder`1, System.Func`1[NxGraph.Result]) + method KeySwitchBuilder`1 Default[TKey](KeySwitchBuilder`1, System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Result]) + method KeySwitchBuilder`1 Switch[TKey](NxGraph.Authoring.StartToken, NxGraph.Blackboards.BlackboardKey`1[TKey]) + method KeySwitchBuilder`1 Switch[TKey](NxGraph.Authoring.StateToken, NxGraph.Blackboards.BlackboardKey`1[TKey]) method NxGraph.Authoring.StartToken WithSchema(NxGraph.Authoring.StartToken, NxGraph.Blackboards.BlackboardSchema) method NxGraph.Authoring.StateToken OnError(NxGraph.Authoring.StateToken, System.Func`1[NxGraph.Result]) method NxGraph.Authoring.StateToken OnErrorAsync(NxGraph.Authoring.StateToken, System.Func`2[System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) @@ -148,9 +158,7 @@ static class NxGraph.Authoring.Dsl method SwitchBuilder`1 DefaultAsync[TKey](SwitchBuilder`1, System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) method SwitchBuilder`1 Default[TKey](SwitchBuilder`1, System.Func`1[NxGraph.Result]) method SwitchBuilder`1 Default[TKey](SwitchBuilder`1, System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Result]) - method SwitchBuilder`1 Switch[TKey](NxGraph.Authoring.StartToken, NxGraph.Blackboards.BlackboardKey`1[TKey]) method SwitchBuilder`1 Switch[TKey](NxGraph.Authoring.StartToken, System.Func`2[NxGraph.Blackboards.BlackboardContext,TKey]) - method SwitchBuilder`1 Switch[TKey](NxGraph.Authoring.StateToken, NxGraph.Blackboards.BlackboardKey`1[TKey]) method SwitchBuilder`1 Switch[TKey](NxGraph.Authoring.StateToken, System.Func`1[TKey]) method SwitchBuilder`1 Switch[TKey](NxGraph.Authoring.StateToken, System.Func`2[NxGraph.Blackboards.BlackboardContext,TKey]) method System.TimeSpan Days(Double) @@ -192,6 +200,12 @@ struct NxGraph.Authoring.Dsl+BranchEnd struct NxGraph.Authoring.Dsl+IfBuilder method BranchBuilder Then(NxGraph.Graphs.ILogic) method BranchBuilder ThenAsync(NxGraph.Graphs.IAsyncLogic) +struct NxGraph.Authoring.Dsl+KeySwitchBuilder`1 + method KeySwitchBuilder`1 Case(TKey, NxGraph.Graphs.ILogic) + method KeySwitchBuilder`1 CaseAsync(TKey, NxGraph.Graphs.IAsyncLogic) + method KeySwitchBuilder`1 Default(NxGraph.Graphs.ILogic) + method KeySwitchBuilder`1 DefaultAsync(NxGraph.Graphs.IAsyncLogic) + method NxGraph.Authoring.StateToken End() struct NxGraph.Authoring.Dsl+SwitchBuilder`1 method NxGraph.Authoring.StateToken End() method SwitchBuilder`1 Case(TKey, NxGraph.Graphs.ILogic) @@ -328,11 +342,11 @@ struct NxGraph.Behaviors.BehaviorContext method Void Report(System.String) property Boolean HasReporter { get; } property NxGraph.Blackboards.BlackboardContext Bb { get; } -sealed class NxGraph.Behaviors.BehaviorState : NxGraph.Fsm.State, NxGraph.Behaviors.IBehaviorComposite, NxGraph.Behaviors.IBehaviorReportSink, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Behaviors.BehaviorState : NxGraph.Fsm.State, NxGraph.Behaviors.IBehaviorComposite, NxGraph.Behaviors.IBehaviorReportSink, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor Void .ctor(NxGraph.Behaviors.IBehavior[]) method NxGraph.Result OnRun() property System.Collections.Generic.IReadOnlyList`1[NxGraph.Behaviors.IBehavior] Behaviors { get; } -sealed class NxGraph.Behaviors.BehaviorState`1 : State`1, IAgentSettable`1, NxGraph.Behaviors.IBehaviorComposite, NxGraph.Behaviors.IBehaviorReportSink, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Behaviors.BehaviorState`1 : State`1, IAgentSettable`1, NxGraph.Behaviors.IBehaviorComposite, NxGraph.Behaviors.IBehaviorReportSink, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor Void .ctor(NxGraph.Behaviors.IBehavior[]) method NxGraph.Result OnRun() property System.Collections.Generic.IReadOnlyList`1[NxGraph.Behaviors.IBehavior] Behaviors { get; } @@ -572,7 +586,7 @@ enum NxGraph.Diagnostics.Validations.Severity : System.IComparable, System.IConv Error = 2 Info = 0 Warning = 1 -sealed class NxGraph.Fsm.AllState : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.AllState : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Result][]) method NxGraph.Result OnRun() sealed class NxGraph.Fsm.Async.AsyncAllState : NxGraph.Fsm.Async.AsyncState, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.IAsyncLogic @@ -677,7 +691,7 @@ enum NxGraph.Fsm.BackoffKind : System.IComparable, System.IConvertible, System.I Exponential = 2 Fixed = 0 Linear = 1 -sealed class NxGraph.Fsm.ChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Fsm.IChoiceNode, NxGraph.Fsm.IDirector, NxGraph.Graphs.IAsyncLogic, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.ChoiceState : NxGraph.Behaviors.IBehaviorReportSink, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Fsm.IAsyncDirector, NxGraph.Fsm.IChoiceNode, NxGraph.Fsm.IDirector, NxGraph.Graphs.IAsyncLogic, NxGraph.Graphs.ILogic ctor Void .ctor(NxGraph.Conditions.ICondition, NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) ctor Void .ctor(System.Collections.Generic.IReadOnlyList`1[NxGraph.Conditions.ICondition], NxGraph.Conditions.ConditionMatch, NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) method NxGraph.Graphs.NodeId SelectNext() @@ -803,13 +817,13 @@ sealed class NxGraph.Fsm.ParallelState : NxGraph.Blackboards.IBlackboardSettable enum NxGraph.Fsm.ParallelStepMode : System.IComparable, System.IConvertible, System.IFormattable, System.ISpanFormattable RoundPerTick = 1 RunToJoin = 0 -sealed class NxGraph.Fsm.PortConsumerRelayState`1 : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.PortConsumerRelayState`1 : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor Void .ctor(NxGraph.Blackboards.BlackboardKey`1[TIn], System.Func`3[TIn,NxGraph.Blackboards.BlackboardContext,NxGraph.Result]) method NxGraph.Result OnRun() -sealed class NxGraph.Fsm.PortPipeRelayState`2 : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.PortPipeRelayState`2 : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor Void .ctor(NxGraph.Blackboards.BlackboardKey`1[TIn], NxGraph.Blackboards.BlackboardKey`1[TOut], System.Func`3[TIn,NxGraph.Blackboards.BlackboardContext,TOut]) method NxGraph.Result OnRun() -sealed class NxGraph.Fsm.PortProducerRelayState`1 : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.PortProducerRelayState`1 : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor Void .ctor(NxGraph.Blackboards.BlackboardKey`1[TOut], System.Func`2[NxGraph.Blackboards.BlackboardContext,TOut]) method NxGraph.Result OnRun() struct NxGraph.Fsm.RegionMask : System.IEquatable`1[[NxGraph.Fsm.RegionMask]] @@ -833,13 +847,13 @@ sealed class NxGraph.Fsm.RelayChoiceState : NxGraph.Blackboards.IBlackboardSetta method NxGraph.Graphs.NodeId SelectNext() method NxGraph.Result Execute() method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() -sealed class NxGraph.Fsm.RelayState : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.RelayState : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor Void .ctor(System.Func`1[NxGraph.Result], System.Action, System.Action) ctor Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Result], System.Action`1[NxGraph.Blackboards.BlackboardContext], System.Action`1[NxGraph.Blackboards.BlackboardContext]) method NxGraph.Result OnRun() method Void OnEnter() method Void OnExit() -sealed class NxGraph.Fsm.RelayState`1 : State`1, IAgentSettable`1, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.RelayState`1 : State`1, IAgentSettable`1, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor Void .ctor(System.Func`2[TAgent,NxGraph.Result], System.Action`1[TAgent], System.Action`1[TAgent]) ctor Void .ctor(System.Func`3[TAgent,NxGraph.Blackboards.BlackboardContext,NxGraph.Result], System.Action`2[TAgent,NxGraph.Blackboards.BlackboardContext], System.Action`2[TAgent,NxGraph.Blackboards.BlackboardContext]) method NxGraph.Result OnRun() @@ -862,7 +876,7 @@ struct NxGraph.Fsm.RetryPolicy property Byte MaxAttempts { get; } property NxGraph.Fsm.BackoffKind BackoffKind { get; } property System.TimeSpan Backoff { get; } -abstract class NxGraph.Fsm.State : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +abstract class NxGraph.Fsm.State : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor Void .ctor() method NxGraph.Result Execute() method NxGraph.Result OnRun() @@ -871,7 +885,7 @@ abstract class NxGraph.Fsm.State : NxGraph.Blackboards.IBlackboardSettable, NxGr method Void OnExit() property NxGraph.Blackboards.BlackboardContext Bb { get; } property System.Action`1[System.String] SyncLogReport { get; set; } -class NxGraph.Fsm.StateMachine : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Fsm.ISuspendableComposite, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider +class NxGraph.Fsm.StateMachine : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Fsm.ISuspendableComposite, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider ctor Void .ctor(NxGraph.Graphs.Graph, NxGraph.Fsm.IStateMachineObserver) field readonly NxGraph.Graphs.Graph Graph method NxGraph.Fsm.StateMachineDeepSnapshot SuspendDeep() @@ -920,10 +934,10 @@ sealed class NxGraph.Fsm.StateMachineSnapshot : System.IEquatable`1[[NxGraph.Fsm property Int32 CurrentNodeIndex { get; set; } property Int32 LastOutcome { get; set; } property NxGraph.Fsm.ExecutionStatus Status { get; set; } -class NxGraph.Fsm.StateMachine`1 : NxGraph.Fsm.StateMachine, IAgentSettable`1, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Fsm.ISuspendableComposite, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider +class NxGraph.Fsm.StateMachine`1 : NxGraph.Fsm.StateMachine, IAgentSettable`1, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Fsm.ISuspendableComposite, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider ctor Void .ctor(NxGraph.Graphs.Graph, NxGraph.Fsm.IStateMachineObserver) method Void SetAgent(TAgent) -abstract class NxGraph.Fsm.State`1 : NxGraph.Fsm.State, IAgentSettable`1, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +abstract class NxGraph.Fsm.State`1 : NxGraph.Fsm.State, IAgentSettable`1, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor Void .ctor() field TAgent Agent method Void SetAgent(TAgent) @@ -1175,7 +1189,7 @@ struct NxGraph.Tokens.JoinPolicy sealed class NxGraph.Tokens.JoinState : NxGraph.Graphs.IAsyncLogic, NxGraph.Graphs.ILogic ctor Void .ctor(NxGraph.Tokens.JoinPolicy) property NxGraph.Tokens.JoinPolicy Policy { get; } -class NxGraph.Tokens.TokenMachine : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider +class NxGraph.Tokens.TokenMachine : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider ctor Void .ctor(NxGraph.Graphs.Graph, NxGraph.Tokens.ITokenMachineObserver, Int32) field readonly NxGraph.Graphs.Graph Graph field static System.Int32 DefaultMaxTokens @@ -1208,7 +1222,7 @@ sealed class NxGraph.Tokens.TokenMachineSnapshot : System.IEquatable`1[[NxGraph. property Int32[] JoinArrivals { get; set; } property NxGraph.Fsm.ExecutionStatus Status { get; set; } property NxGraph.Tokens.TokenRecord[] Tokens { get; set; } -class NxGraph.Tokens.TokenMachine`1 : NxGraph.Tokens.TokenMachine, IAgentSettable`1, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider +class NxGraph.Tokens.TokenMachine`1 : NxGraph.Tokens.TokenMachine, IAgentSettable`1, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider ctor Void .ctor(NxGraph.Graphs.Graph, NxGraph.Tokens.ITokenMachineObserver, Int32) method Void SetAgent(TAgent) enum NxGraph.Tokens.TokenPhase : System.IComparable, System.IConvertible, System.IFormattable, System.ISpanFormattable diff --git a/NxGraph.Tests/PublicApi/NxGraph.netstandard2.1.approved.txt b/NxGraph.Tests/PublicApi/NxGraph.netstandard2.1.approved.txt index c98d242..0ad4b84 100644 --- a/NxGraph.Tests/PublicApi/NxGraph.netstandard2.1.approved.txt +++ b/NxGraph.Tests/PublicApi/NxGraph.netstandard2.1.approved.txt @@ -39,6 +39,16 @@ static class NxGraph.Authoring.Dsl method NxGraph.Authoring.Dsl+IfBuilder If(NxGraph.Authoring.StateToken, NxGraph.Conditions.ICondition) method NxGraph.Authoring.Dsl+IfBuilder If(NxGraph.Authoring.StateToken, System.Func`1[System.Boolean]) method NxGraph.Authoring.Dsl+IfBuilder If(NxGraph.Authoring.StateToken, System.Func`2[NxGraph.Blackboards.BlackboardContext,System.Boolean]) + method NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey] CaseAsync[TKey](NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey], TKey, System.Func`2[System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) + method NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey] CaseAsync[TKey](NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey], TKey, System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) + method NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey] Case[TKey](NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey], TKey, System.Func`1[NxGraph.Result]) + method NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey] Case[TKey](NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey], TKey, System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Result]) + method NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey] DefaultAsync[TKey](NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey], System.Func`2[System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) + method NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey] DefaultAsync[TKey](NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey], System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) + method NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey] Default[TKey](NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey], System.Func`1[NxGraph.Result]) + method NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey] Default[TKey](NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey], System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Result]) + method NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey] Switch[TKey](NxGraph.Authoring.StartToken, NxGraph.Blackboards.BlackboardKey`1[TKey]) + method NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey] Switch[TKey](NxGraph.Authoring.StateToken, NxGraph.Blackboards.BlackboardKey`1[TKey]) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] CaseAsync[TKey](NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey], TKey, System.Func`2[System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] CaseAsync[TKey](NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey], TKey, System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Case[TKey](NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey], TKey, System.Func`1[NxGraph.Result]) @@ -47,9 +57,7 @@ static class NxGraph.Authoring.Dsl method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] DefaultAsync[TKey](NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey], System.Func`3[NxGraph.Blackboards.BlackboardContext,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask`1[NxGraph.Result]]) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Default[TKey](NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey], System.Func`1[NxGraph.Result]) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Default[TKey](NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey], System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Result]) - method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Switch[TKey](NxGraph.Authoring.StartToken, NxGraph.Blackboards.BlackboardKey`1[TKey]) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Switch[TKey](NxGraph.Authoring.StartToken, System.Func`2[NxGraph.Blackboards.BlackboardContext,TKey]) - method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Switch[TKey](NxGraph.Authoring.StateToken, NxGraph.Blackboards.BlackboardKey`1[TKey]) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Switch[TKey](NxGraph.Authoring.StateToken, System.Func`1[TKey]) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Switch[TKey](NxGraph.Authoring.StateToken, System.Func`2[NxGraph.Blackboards.BlackboardContext,TKey]) method NxGraph.Authoring.StartToken WithSchema(NxGraph.Authoring.StartToken, NxGraph.Blackboards.BlackboardSchema) @@ -192,6 +200,12 @@ struct NxGraph.Authoring.Dsl+BranchEnd struct NxGraph.Authoring.Dsl+IfBuilder method NxGraph.Authoring.Dsl+BranchBuilder Then(NxGraph.Graphs.ILogic) method NxGraph.Authoring.Dsl+BranchBuilder ThenAsync(NxGraph.Graphs.IAsyncLogic) +struct NxGraph.Authoring.Dsl+KeySwitchBuilder`1 + method NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey] Case(TKey, NxGraph.Graphs.ILogic) + method NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey] CaseAsync(TKey, NxGraph.Graphs.IAsyncLogic) + method NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey] Default(NxGraph.Graphs.ILogic) + method NxGraph.Authoring.Dsl+KeySwitchBuilder`1[TKey] DefaultAsync(NxGraph.Graphs.IAsyncLogic) + method NxGraph.Authoring.StateToken End() struct NxGraph.Authoring.Dsl+SwitchBuilder`1 method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] Case(TKey, NxGraph.Graphs.ILogic) method NxGraph.Authoring.Dsl+SwitchBuilder`1[TKey] CaseAsync(TKey, NxGraph.Graphs.IAsyncLogic) @@ -328,11 +342,11 @@ struct NxGraph.Behaviors.BehaviorContext method T Resolve[T](NxGraph.Behaviors.BlackboardValue`1[T]&) property NxGraph.Blackboards.BlackboardContext Bb { get; } property System.Boolean HasReporter { get; } -sealed class NxGraph.Behaviors.BehaviorState : NxGraph.Fsm.State, NxGraph.Behaviors.IBehaviorComposite, NxGraph.Behaviors.IBehaviorReportSink, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Behaviors.BehaviorState : NxGraph.Fsm.State, NxGraph.Behaviors.IBehaviorComposite, NxGraph.Behaviors.IBehaviorReportSink, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor System.Void .ctor(NxGraph.Behaviors.IBehavior[]) method NxGraph.Result OnRun() property System.Collections.Generic.IReadOnlyList`1[NxGraph.Behaviors.IBehavior] Behaviors { get; } -sealed class NxGraph.Behaviors.BehaviorState`1 : State`1, IAgentSettable`1, NxGraph.Behaviors.IBehaviorComposite, NxGraph.Behaviors.IBehaviorReportSink, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Behaviors.BehaviorState`1 : State`1, IAgentSettable`1, NxGraph.Behaviors.IBehaviorComposite, NxGraph.Behaviors.IBehaviorReportSink, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor System.Void .ctor(NxGraph.Behaviors.IBehavior[]) method NxGraph.Result OnRun() property System.Collections.Generic.IReadOnlyList`1[NxGraph.Behaviors.IBehavior] Behaviors { get; } @@ -572,7 +586,7 @@ enum NxGraph.Diagnostics.Validations.Severity : System.IComparable, System.IConv Error = 2 Info = 0 Warning = 1 -sealed class NxGraph.Fsm.AllState : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.AllState : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Result][]) method NxGraph.Result OnRun() sealed class NxGraph.Fsm.Async.AsyncAllState : NxGraph.Fsm.Async.AsyncState, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.IAsyncLogic @@ -677,7 +691,7 @@ enum NxGraph.Fsm.BackoffKind : System.IComparable, System.IConvertible, System.I Exponential = 2 Fixed = 0 Linear = 1 -sealed class NxGraph.Fsm.ChoiceState : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Fsm.IAsyncDirector, NxGraph.Fsm.IChoiceNode, NxGraph.Fsm.IDirector, NxGraph.Graphs.IAsyncLogic, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.ChoiceState : NxGraph.Behaviors.IBehaviorReportSink, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Fsm.IAsyncDirector, NxGraph.Fsm.IChoiceNode, NxGraph.Fsm.IDirector, NxGraph.Graphs.IAsyncLogic, NxGraph.Graphs.ILogic ctor System.Void .ctor(NxGraph.Conditions.ICondition, NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) ctor System.Void .ctor(System.Collections.Generic.IReadOnlyList`1[NxGraph.Conditions.ICondition], NxGraph.Conditions.ConditionMatch, NxGraph.Graphs.NodeId, NxGraph.Graphs.NodeId) method NxGraph.Graphs.NodeId SelectNext() @@ -803,13 +817,13 @@ sealed class NxGraph.Fsm.ParallelState : NxGraph.Blackboards.IBlackboardSettable enum NxGraph.Fsm.ParallelStepMode : System.IComparable, System.IConvertible, System.IFormattable RoundPerTick = 1 RunToJoin = 0 -sealed class NxGraph.Fsm.PortConsumerRelayState`1 : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.PortConsumerRelayState`1 : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor System.Void .ctor(NxGraph.Blackboards.BlackboardKey`1[TIn], System.Func`3[TIn,NxGraph.Blackboards.BlackboardContext,NxGraph.Result]) method NxGraph.Result OnRun() -sealed class NxGraph.Fsm.PortPipeRelayState`2 : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.PortPipeRelayState`2 : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor System.Void .ctor(NxGraph.Blackboards.BlackboardKey`1[TIn], NxGraph.Blackboards.BlackboardKey`1[TOut], System.Func`3[TIn,NxGraph.Blackboards.BlackboardContext,TOut]) method NxGraph.Result OnRun() -sealed class NxGraph.Fsm.PortProducerRelayState`1 : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.PortProducerRelayState`1 : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor System.Void .ctor(NxGraph.Blackboards.BlackboardKey`1[TOut], System.Func`2[NxGraph.Blackboards.BlackboardContext,TOut]) method NxGraph.Result OnRun() struct NxGraph.Fsm.RegionMask : System.IEquatable`1[[NxGraph.Fsm.RegionMask]] @@ -833,13 +847,13 @@ sealed class NxGraph.Fsm.RelayChoiceState : NxGraph.Blackboards.IBlackboardSetta method NxGraph.Graphs.NodeId SelectNext() method NxGraph.Result Execute() method System.Collections.Generic.IEnumerable`1[NxGraph.Graphs.NodeId] EnumerateStaticTargets() -sealed class NxGraph.Fsm.RelayState : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.RelayState : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor System.Void .ctor(System.Func`1[NxGraph.Result], System.Action, System.Action) ctor System.Void .ctor(System.Func`2[NxGraph.Blackboards.BlackboardContext,NxGraph.Result], System.Action`1[NxGraph.Blackboards.BlackboardContext], System.Action`1[NxGraph.Blackboards.BlackboardContext]) method NxGraph.Result OnRun() method System.Void OnEnter() method System.Void OnExit() -sealed class NxGraph.Fsm.RelayState`1 : State`1, IAgentSettable`1, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +sealed class NxGraph.Fsm.RelayState`1 : State`1, IAgentSettable`1, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor System.Void .ctor(System.Func`2[TAgent,NxGraph.Result], System.Action`1[TAgent], System.Action`1[TAgent]) ctor System.Void .ctor(System.Func`3[TAgent,NxGraph.Blackboards.BlackboardContext,NxGraph.Result], System.Action`2[TAgent,NxGraph.Blackboards.BlackboardContext], System.Action`2[TAgent,NxGraph.Blackboards.BlackboardContext]) method NxGraph.Result OnRun() @@ -862,7 +876,7 @@ struct NxGraph.Fsm.RetryPolicy property NxGraph.Fsm.BackoffKind BackoffKind { get; } property System.Byte MaxAttempts { get; } property System.TimeSpan Backoff { get; } -abstract class NxGraph.Fsm.State : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +abstract class NxGraph.Fsm.State : NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor System.Void .ctor() method NxGraph.Result Execute() method NxGraph.Result OnRun() @@ -871,7 +885,7 @@ abstract class NxGraph.Fsm.State : NxGraph.Blackboards.IBlackboardSettable, NxGr method System.Void OnExit() property NxGraph.Blackboards.BlackboardContext Bb { get; } property System.Action`1[System.String] SyncLogReport { get; set; } -class NxGraph.Fsm.StateMachine : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Fsm.ISuspendableComposite, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider +class NxGraph.Fsm.StateMachine : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Fsm.ISuspendableComposite, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider ctor System.Void .ctor(NxGraph.Graphs.Graph, NxGraph.Fsm.IStateMachineObserver) field readonly NxGraph.Graphs.Graph Graph method NxGraph.Fsm.StateMachineDeepSnapshot SuspendDeep() @@ -920,10 +934,10 @@ sealed class NxGraph.Fsm.StateMachineSnapshot : System.IEquatable`1[[NxGraph.Fsm property System.Int32 Attempts { get; set; } property System.Int32 CurrentNodeIndex { get; set; } property System.Int32 LastOutcome { get; set; } -class NxGraph.Fsm.StateMachine`1 : NxGraph.Fsm.StateMachine, IAgentSettable`1, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Fsm.ISuspendableComposite, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider +class NxGraph.Fsm.StateMachine`1 : NxGraph.Fsm.StateMachine, IAgentSettable`1, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Fsm.ISuspendableComposite, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider ctor System.Void .ctor(NxGraph.Graphs.Graph, NxGraph.Fsm.IStateMachineObserver) method System.Void SetAgent(TAgent) -abstract class NxGraph.Fsm.State`1 : NxGraph.Fsm.State, IAgentSettable`1, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic +abstract class NxGraph.Fsm.State`1 : NxGraph.Fsm.State, IAgentSettable`1, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic ctor System.Void .ctor() field TAgent Agent method System.Void SetAgent(TAgent) @@ -1164,7 +1178,7 @@ struct NxGraph.Tokens.JoinPolicy sealed class NxGraph.Tokens.JoinState : NxGraph.Graphs.IAsyncLogic, NxGraph.Graphs.ILogic ctor System.Void .ctor(NxGraph.Tokens.JoinPolicy) property NxGraph.Tokens.JoinPolicy Policy { get; } -class NxGraph.Tokens.TokenMachine : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider +class NxGraph.Tokens.TokenMachine : NxGraph.Fsm.State, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider ctor System.Void .ctor(NxGraph.Graphs.Graph, NxGraph.Tokens.ITokenMachineObserver, System.Int32) field readonly NxGraph.Graphs.Graph Graph field static System.Int32 DefaultMaxTokens @@ -1197,7 +1211,7 @@ sealed class NxGraph.Tokens.TokenMachineSnapshot : System.IEquatable`1[[NxGraph. property System.Boolean[] JoinsFired { get; set; } property System.Int32 NextTokenId { get; set; } property System.Int32[] JoinArrivals { get; set; } -class NxGraph.Tokens.TokenMachine`1 : NxGraph.Tokens.TokenMachine, IAgentSettable`1, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider +class NxGraph.Tokens.TokenMachine`1 : NxGraph.Tokens.TokenMachine, IAgentSettable`1, NxGraph.Blackboards.IBlackboardBindable, NxGraph.Blackboards.IBlackboardSettable, NxGraph.Diagnostics.Replay.ILogReporter, NxGraph.Diagnostics.Replay.ISyncLogReporter, NxGraph.Graphs.ILogic, NxGraph.Graphs.ISubGraphProvider ctor System.Void .ctor(NxGraph.Graphs.Graph, NxGraph.Tokens.ITokenMachineObserver, System.Int32) method System.Void SetAgent(TAgent) enum NxGraph.Tokens.TokenPhase : System.IComparable, System.IConvertible, System.IFormattable diff --git a/NxGraph/Authoring/Dsl.Blackboard.cs b/NxGraph/Authoring/Dsl.Blackboard.cs index db06eb5..d219e0f 100644 --- a/NxGraph/Authoring/Dsl.Blackboard.cs +++ b/NxGraph/Authoring/Dsl.Blackboard.cs @@ -255,4 +255,36 @@ public static SwitchBuilder DefaultAsync(this SwitchBuilder sw Guard.NotNull(run, nameof(run)); return switchBuilder.DefaultAsync(new AsyncRelayState(run)); } + + /// Adds a case with a synchronous context lambda to the data-built switch builder. + public static KeySwitchBuilder Case(this KeySwitchBuilder switchBuilder, TKey key, + Func run) where TKey : notnull + { + Guard.NotNull(run, nameof(run)); + return switchBuilder.Case(key, new RelayState(run)); + } + + /// Adds a case with an asynchronous context lambda to the data-built switch builder. + public static KeySwitchBuilder CaseAsync(this KeySwitchBuilder switchBuilder, TKey key, + Func> run) where TKey : notnull + { + Guard.NotNull(run, nameof(run)); + return switchBuilder.CaseAsync(key, new AsyncRelayState(run)); + } + + /// Adds a default case with a synchronous context lambda to the data-built switch builder. + public static KeySwitchBuilder Default(this KeySwitchBuilder switchBuilder, + Func run) where TKey : notnull + { + Guard.NotNull(run, nameof(run)); + return switchBuilder.Default(new RelayState(run)); + } + + /// Adds a default case with an asynchronous context lambda to the data-built switch builder. + public static KeySwitchBuilder DefaultAsync(this KeySwitchBuilder switchBuilder, + Func> run) where TKey : notnull + { + Guard.NotNull(run, nameof(run)); + return switchBuilder.DefaultAsync(new AsyncRelayState(run)); + } } diff --git a/NxGraph/Authoring/Dsl.Conditions.cs b/NxGraph/Authoring/Dsl.Conditions.cs index 2953d64..b0f4f92 100644 --- a/NxGraph/Authoring/Dsl.Conditions.cs +++ b/NxGraph/Authoring/Dsl.Conditions.cs @@ -11,9 +11,12 @@ namespace NxGraph.Authoring; /// with these round-trips through GraphSerializer with zero options and survives /// suspend/resume — and its arms carry labels into the Mermaid export. /// -/// The builders returned here are the same / -/// the delegate paths return, so -/// .Then(...)/.Else(...) and .Case(...)/.Default(...)/.End() are unchanged. +/// .If(...) returns the same the delegate path returns — +/// it builds its node eagerly either way. .Switch(key) returns +/// instead of , +/// because the data-built state is immutable and can only be constructed at .End(); +/// the two builders mirror each other's surface, so .Then(...)/.Else(...) and +/// .Case(...)/.Default(...)/.End() read exactly the same in both paths. /// /// public static partial class Dsl @@ -55,17 +58,17 @@ public static IfBuilder If(this StartToken root, ConditionMatch match, params IC /// .End(): a switch is a lookup, so at most one case may match. Ordered, /// first-match-wins rules are a chain of .If(condition) branches. /// - public static SwitchBuilder Switch(this StateToken prev, BlackboardKey key) + public static KeySwitchBuilder Switch(this StateToken prev, BlackboardKey key) where TKey : notnull { - return new SwitchBuilder(prev, key); + return new KeySwitchBuilder(prev, key); } /// - public static SwitchBuilder Switch(this StartToken root, BlackboardKey key) + public static KeySwitchBuilder Switch(this StartToken root, BlackboardKey key) where TKey : notnull { - return new SwitchBuilder(root, key); + return new KeySwitchBuilder(root, key); } private static ICondition[] Single(ICondition condition) diff --git a/NxGraph/Authoring/Dsl.KeySwitchBuilder.cs b/NxGraph/Authoring/Dsl.KeySwitchBuilder.cs new file mode 100644 index 0000000..fd0b615 --- /dev/null +++ b/NxGraph/Authoring/Dsl.KeySwitchBuilder.cs @@ -0,0 +1,133 @@ +using NxGraph.Blackboards; +using NxGraph.Fsm; +using NxGraph.Graphs; + +namespace NxGraph.Authoring; + +public static partial class Dsl +{ + /// + /// Builds a data-built switch (spec 023): the tested value is a blackboard key and the + /// arms are literals, so the node is a serializable rather than + /// the delegate-backed that + /// produces. + /// + /// The two builders are separate types because their lifecycles differ — the delegate builder + /// mutates one mutable state as arms arrive, while this one accumulates arms and constructs an + /// immutable state at . Their authoring surfaces are identical, so swapping a + /// selector for a key changes the .Switch(...) call and nothing else. + /// + /// + /// A value cased twice is rejected at by the state's constructor, naming the + /// offending value: a switch is a lookup, so at most one case may match. + /// + /// + /// The tested key's value type. + public readonly struct KeySwitchBuilder where TKey : notnull + { + private readonly GraphBuilder _builder; + private readonly StateToken _prev; + private readonly BlackboardKey _key; + + // The state is immutable and only exists at End(), so the arms and the default accumulate + // in reference-typed cells — this is a readonly struct that every chaining call returns by + // value, and a value-typed cell would drop the writes made through the returned copy. + private readonly List> _cases; + private readonly NodeId[] _defaultCell; + private readonly bool _isStart; + + internal KeySwitchBuilder(StateToken prev, BlackboardKey key) + { + _prev = prev; + _builder = prev.Builder; + _isStart = false; + _key = ValidatedKey(key); + _cases = new List>(); + _defaultCell = [NodeId.Default]; + } + + internal KeySwitchBuilder(StartToken start, BlackboardKey key) + { + _prev = new StateToken(NodeId.Default, start.Builder); + _builder = start.Builder; + _isStart = true; + _key = ValidatedKey(key); + _cases = new List>(); + _defaultCell = [NodeId.Default]; + } + + private static BlackboardKey ValidatedKey(BlackboardKey key) + { + // Rejected here rather than at End() so the stack trace points at the .Switch(...) + // call that supplied the bad key. + if (!key.IsValid) + { + throw new ArgumentException( + "Invalid blackboard key — obtain keys via BlackboardSchema.Register(...).", nameof(key)); + } + + return key; + } + + /// + /// Adds an async case to the switch statement. + /// + public KeySwitchBuilder CaseAsync(TKey key, IAsyncLogic asyncLogic) + { + NodeId id = _builder.AddNode(asyncLogic); + _cases.Add(new SwitchCase(key, id)); + return this; + } + + /// + /// Adds a sync case to the switch statement. + /// + public KeySwitchBuilder Case(TKey key, ILogic syncLogic) + { + NodeId id = _builder.AddNode(syncLogic); + _cases.Add(new SwitchCase(key, id)); + return this; + } + + /// + /// Adds an async default case to the switch statement. + /// + /// The logic to execute if no case matches. + /// Returns the current instance of . + public KeySwitchBuilder DefaultAsync(IAsyncLogic asyncLogic) + { + _defaultCell[0] = _builder.AddNode(asyncLogic); + return this; + } + + /// + /// Adds a sync default case to the switch statement. + /// + /// The synchronous logic to execute if no case matches. + /// Returns the current instance of . + public KeySwitchBuilder Default(ILogic syncLogic) + { + _defaultCell[0] = _builder.AddNode(syncLogic); + return this; + } + + /// + /// Ends the switch statement and returns a representing the switch state. + /// + /// Returns a representing the switch state. + public StateToken End() + { + // Added through the IAsyncLogic overload: SwitchState implements both logic slots, + // so the node exposes the same instance on Logic and AsyncLogic and runs unchanged + // under either runtime family. + NodeId switchId = _builder.AddNode( + (IAsyncLogic)new SwitchState(_key, _cases, _defaultCell[0]), _isStart); + if (_prev.Id != NodeId.Default) + { + _builder.AddTransition(_prev.Id, switchId); + } + + return new StateToken(switchId, _builder); + } + } +} diff --git a/NxGraph/Authoring/Dsl.SwitchBuilder.cs b/NxGraph/Authoring/Dsl.SwitchBuilder.cs index 87afa86..ac2da9e 100644 --- a/NxGraph/Authoring/Dsl.SwitchBuilder.cs +++ b/NxGraph/Authoring/Dsl.SwitchBuilder.cs @@ -1,4 +1,4 @@ -using NxGraph.Blackboards; +using NxGraph.Blackboards; using NxGraph.Fsm; using NxGraph.Graphs; @@ -9,11 +9,11 @@ public static partial class Dsl /// /// Represents a switch statement in the FSM graph, allowing for multiple branches based on a key. /// - /// Two modes share this builder. The delegate mode (.Switch(selector)) builds a - /// ; the data mode (.Switch(blackboardKey), - /// spec 023) builds a serializable whose cases are literals. - /// Both take the same .Case(...) / .Default(...) / .End() chain; the data - /// mode additionally rejects a value cased twice, at .End(). + /// The key is chosen by a delegate (.Switch(selector)), so this builds a + /// and the graph cannot serialize. The serializable twin + /// is (.Switch(blackboardKey), spec 023), which + /// offers the same .Case(...) / .Default(...) / .End() surface — swapping + /// a selector for a key changes one call and nothing else. /// /// /// @@ -22,14 +22,7 @@ public static partial class Dsl private readonly GraphBuilder _builder; private readonly StateToken _prev; private readonly Dictionary _map = new(); - private readonly RelaySwitchState? _switchNode; - - // Data mode (spec 023): the state is immutable and built at End(), so the arms and the - // default accumulate in reference-typed cells — this builder is a readonly struct that - // every chaining call returns by value. - private readonly List>? _cases; - private readonly BlackboardKey _dataKey; - private readonly NodeId[]? _defaultCell; + private readonly RelaySwitchState _switchNode; private readonly bool _isStart; internal SwitchBuilder(StateToken prev, Func selector) @@ -64,68 +57,13 @@ internal SwitchBuilder(StartToken start, Func selector) _switchNode = new RelaySwitchState(selector, _map); } - internal SwitchBuilder(StateToken prev, BlackboardKey key) - { - _prev = prev; - _builder = prev.Builder; - _isStart = false; - _switchNode = null; - _dataKey = ValidatedKey(key); - _cases = new List>(); - _defaultCell = [NodeId.Default]; - } - - internal SwitchBuilder(StartToken start, BlackboardKey key) - { - _prev = new StateToken(NodeId.Default, start.Builder); - _builder = start.Builder; - _isStart = true; - _switchNode = null; - _dataKey = ValidatedKey(key); - _cases = new List>(); - _defaultCell = [NodeId.Default]; - } - - private static BlackboardKey ValidatedKey(BlackboardKey key) - { - if (!key.IsValid) - { - throw new ArgumentException( - "Invalid blackboard key — obtain keys via BlackboardSchema.Register(...).", nameof(key)); - } - - return key; - } - - private void Record(TKey key, NodeId id) - { - if (_cases is not null) - { - _cases.Add(new SwitchCase(key, id)); - return; - } - - _map[key] = id; - } - - private void RecordDefault(NodeId id) - { - if (_defaultCell is not null) - { - _defaultCell[0] = id; - return; - } - - _switchNode!.SetDefault(id); - } - /// /// Adds an async case to the switch statement. /// public SwitchBuilder CaseAsync(TKey key, IAsyncLogic asyncLogic) { NodeId id = _builder.AddNode(asyncLogic); - Record(key, id); + _map[key] = id; return this; } @@ -135,7 +73,7 @@ public SwitchBuilder CaseAsync(TKey key, IAsyncLogic asyncLogic) public SwitchBuilder Case(TKey key, ILogic syncLogic) { NodeId id = _builder.AddNode(syncLogic); - Record(key, id); + _map[key] = id; return this; } @@ -147,7 +85,7 @@ public SwitchBuilder Case(TKey key, ILogic syncLogic) public SwitchBuilder DefaultAsync(IAsyncLogic asyncLogic) { NodeId defaultNode = _builder.AddNode(asyncLogic); - RecordDefault(defaultNode); + _switchNode.SetDefault(defaultNode); return this; } @@ -159,7 +97,7 @@ public SwitchBuilder DefaultAsync(IAsyncLogic asyncLogic) public SwitchBuilder Default(ILogic syncLogic) { NodeId defaultNode = _builder.AddNode(syncLogic); - RecordDefault(defaultNode); + _switchNode.SetDefault(defaultNode); return this; } @@ -169,12 +107,7 @@ public SwitchBuilder Default(ILogic syncLogic) /// Returns a representing the switch state. public StateToken End() { - // Data mode adds the state through the IAsyncLogic overload: SwitchState implements - // both logic slots, so the node exposes the same instance on Logic and AsyncLogic and - // runs unchanged under either runtime family. - NodeId switchId = _switchNode is null - ? _builder.AddNode((IAsyncLogic)new SwitchState(_dataKey, _cases!, _defaultCell![0]), _isStart) - : _builder.AddNode((ILogic)_switchNode, _isStart); + NodeId switchId = _builder.AddNode((ILogic)_switchNode, _isStart); if (_prev.Id != NodeId.Default) { _builder.AddTransition(_prev.Id, switchId); @@ -183,4 +116,4 @@ public StateToken End() return new StateToken(switchId, _builder); } } -} \ No newline at end of file +} diff --git a/NxGraph/Authoring/Dsl.Sync.cs b/NxGraph/Authoring/Dsl.Sync.cs index 2a36900..9e26035 100644 --- a/NxGraph/Authoring/Dsl.Sync.cs +++ b/NxGraph/Authoring/Dsl.Sync.cs @@ -88,6 +88,28 @@ public static SwitchBuilder Default(this SwitchBuilder switchB return switchBuilder.Default(new RelayState(run)); } + // ── KeySwitchBuilder overloads accepting Func ─────────────── + + /// + /// Adds a case with a synchronous lambda to the data-built switch builder. + /// + public static KeySwitchBuilder Case(this KeySwitchBuilder switchBuilder, TKey key, + Func run) where TKey : notnull + { + Guard.NotNull(run, nameof(run)); + return switchBuilder.Case(key, new RelayState(run)); + } + + /// + /// Adds a default case with a synchronous lambda to the data-built switch builder. + /// + public static KeySwitchBuilder Default(this KeySwitchBuilder switchBuilder, + Func run) where TKey : notnull + { + Guard.NotNull(run, nameof(run)); + return switchBuilder.Default(new RelayState(run)); + } + // ── AsyncSwitchBuilder overloads accepting Func ────────────── /// diff --git a/NxGraph/Authoring/Dsl.cs b/NxGraph/Authoring/Dsl.cs index a39846e..91f0b0e 100644 --- a/NxGraph/Authoring/Dsl.cs +++ b/NxGraph/Authoring/Dsl.cs @@ -309,6 +309,39 @@ public static SwitchBuilder DefaultAsync(this SwitchBuilder sw return switchBuilder.DefaultAsync(asyncRelay); } + /// + /// Creates a case in the data-built switch statement of the FSM graph with async logic. + /// + /// The KeySwitchBuilder that represents the switch statement. + /// The key that identifies the case in the switch statement. + /// A function that defines the async logic to be executed for this case. + /// The type of the key used to identify the case. + /// A KeySwitchBuilder that allows chaining further cases or a default case. + public static KeySwitchBuilder CaseAsync(this KeySwitchBuilder switchBuilder, TKey key, + Func> run) + where TKey : notnull + { + Guard.NotNull(run, nameof(run)); + AsyncRelayState asyncRelay = new(run); + return switchBuilder.CaseAsync(key, asyncRelay); + } + + /// + /// Creates a default case in the data-built switch statement of the FSM graph with async logic. + /// + /// The KeySwitchBuilder that represents the switch statement. + /// A function that defines the async logic to be executed for the default case. + /// The type of the key used to identify the case. + /// A KeySwitchBuilder that allows chaining further cases or finalizing the switch statement. + public static KeySwitchBuilder DefaultAsync(this KeySwitchBuilder switchBuilder, + Func> run) + where TKey : notnull + { + Guard.NotNull(run, nameof(run)); + AsyncRelayState asyncRelay = new(run); + return switchBuilder.DefaultAsync(asyncRelay); + } + // ── AsyncSwitchBuilder convenience overloads (lambda → AsyncRelayState) ── /// diff --git a/NxGraph/Behaviors/BehaviorState.cs b/NxGraph/Behaviors/BehaviorState.cs index 45108ff..0353bc8 100644 --- a/NxGraph/Behaviors/BehaviorState.cs +++ b/NxGraph/Behaviors/BehaviorState.cs @@ -190,24 +190,30 @@ internal static ArgumentException AgentTypeMismatch(object entry, int index, Typ } #pragma warning restore CA2208 - internal static bool SyncHasReporter(State state) => - state.SyncLogReport is not null || ((ILogReporter)state).LogReport is not null; + /// + /// Whether either machine-owned slot of a sync-capable node's report channel is wired. + /// Typed to rather than State so nodes that own a + /// report channel without deriving from State (the data-built branch states) share + /// this bridge verbatim. + /// + internal static bool SyncHasReporter(ISyncLogReporter reporter) => + reporter.SyncLogReport is not null || reporter.LogReport is not null; /// - /// Delivers a report from a sync composite: through the sync callback when the sync - /// machine wired it, else through the async callback (the async machine wires that slot - /// when the composite runs behind the sync-logic adapter), waiting out a genuinely + /// Delivers a report from a sync-capable node: through the sync callback when a sync + /// machine wired it, else through the async callback (the async machines wire that slot, + /// reaching a sync composite behind the sync-logic adapter), waiting out a genuinely /// asynchronous observer so delivery-before-return holds on both runtimes. /// - internal static void SyncReport(State state, string message) + internal static void SyncReport(ISyncLogReporter reporter, string message) { - if (state.SyncLogReport is { } sync) + if (reporter.SyncLogReport is { } sync) { sync(message); return; } - if (((ILogReporter)state).LogReport is { } asyncReport) + if (reporter.LogReport is { } asyncReport) { ValueTaskSync.Await(asyncReport(message, CancellationToken.None)); } diff --git a/NxGraph/Diagnostics/Replay/ILogReporter.cs b/NxGraph/Diagnostics/Replay/ILogReporter.cs index d1210c1..de12637 100644 --- a/NxGraph/Diagnostics/Replay/ILogReporter.cs +++ b/NxGraph/Diagnostics/Replay/ILogReporter.cs @@ -1,6 +1,49 @@ -namespace NxGraph.Diagnostics.Replay; +namespace NxGraph.Diagnostics.Replay; +/// +/// The asynchronous half of a node's log-report channel: the slot the async runtimes +/// (AsyncStateMachine, AsyncTokenMachine) wire so the node can emit messages to +/// the machine observer's OnLogReport. The slot is machine-owned: every machine +/// reassigns it on every visit — its own callback from its observer, +/// when it has no observer — so machines sharing one Graph each attribute reports to +/// their own observer and a stale callback never survives into a later run. +/// public interface ILogReporter { + /// + /// The machine-wired async report callback, or when the running + /// machine has no observer. Never invoke it outside a run — it belongs to whichever + /// machine last visited this node. + /// Func? LogReport { get; set; } -} \ No newline at end of file +} + +/// +/// The synchronous half of the same channel, for nodes the sync runtimes +/// (StateMachine, TokenMachine) can execute. The split is deliberate: a sync +/// node must not be forced to await, so the sync machines wire a plain +/// here while the async machines wire +/// — and each family clears the other family's slot +/// on every visit, because a node that reads both (preferring the sync one) would otherwise +/// deliver this run's reports through a callback a differently-typed machine left behind. +/// +/// Implementing this is the sync half of the report capability, not a base-class +/// membership: State implements it, and so does any non-State node that owns a +/// report channel (the data-built branch states, which are plain ILogic/IAsyncLogic +/// implementations). The machines target this interface rather than State precisely so +/// the second group is reached; resolution goes through LogicWrappers, so a node behind a +/// timeout decorator is wired too. +/// +/// +/// Internal on purpose: it exposes a machine-owned mutable slot that no caller outside the +/// library may write, and keeping it internal leaves State's public surface unchanged. +/// +/// +internal interface ISyncLogReporter : ILogReporter +{ + /// + /// The machine-wired sync report callback, or when the running + /// machine has no observer (which is what makes report-formatting nodes free there). + /// + Action? SyncLogReport { get; set; } +} diff --git a/NxGraph/Fsm/Async/AsyncStateMachine.cs b/NxGraph/Fsm/Async/AsyncStateMachine.cs index f729f0c..fb62a83 100644 --- a/NxGraph/Fsm/Async/AsyncStateMachine.cs +++ b/NxGraph/Fsm/Async/AsyncStateMachine.cs @@ -890,19 +890,22 @@ private async ValueTask StepCoreAsync(CancellationToken ct) ILogReporter? reporter = _reporters[_current.Index]; if (reporter is not null) { - // Reassigned on every visit so interleaved machines sharing a graph each - // attribute log reports to their own observer; null when this machine has no - // observer, so nodes that gate report formatting on a wired callback - // (behavior composites) pay nothing on observer-less machines. + // Wired before the node executes — and therefore before a director's + // SelectNextAsync runs — so a report raised while *deciding* (a condition + // inside a data-built branch node) is attributed to this node. Reassigned on + // every visit so interleaved machines sharing a graph each attribute log + // reports to their own observer; null when this machine has no observer, so + // nodes that gate report formatting on a wired callback (behavior composites, + // conditions) pay nothing on observer-less machines. reporter.LogReport = _observer is null ? null : _cachedLogReportCallback; - // Sync states read both slots (State.Log prefers the sync one), so the sync - // slot is cleared too: a callback left by a sync machine that ran this shared - // graph earlier must neither shadow this machine's observer nor receive - // reports from this run. - if (reporter is State syncState) + // Sync-capable nodes read both slots (State.Log and the shared report bridge + // prefer the sync one), so the sync slot is cleared too: a callback left by a + // sync machine that ran this shared graph earlier must neither shadow this + // machine's observer nor receive reports from this run. + if (reporter is ISyncLogReporter syncReporter) { - syncState.SyncLogReport = null; + syncReporter.SyncLogReport = null; } } diff --git a/NxGraph/Fsm/ChoiceState.cs b/NxGraph/Fsm/ChoiceState.cs index 049bd44..db63f76 100644 --- a/NxGraph/Fsm/ChoiceState.cs +++ b/NxGraph/Fsm/ChoiceState.cs @@ -1,6 +1,7 @@ using NxGraph.Behaviors; using NxGraph.Blackboards; using NxGraph.Conditions; +using NxGraph.Diagnostics.Replay; using NxGraph.Graphs; namespace NxGraph.Fsm; @@ -26,13 +27,20 @@ namespace NxGraph.Fsm; /// the false arm, so reachability validation and Mermaid export need no special casing. /// /// -/// The condition list is evaluated through a whose report channel -/// is inert: a branch node's decision is side-effect free by contract, so nothing is routed to -/// the observer from here ( is -/// ). Selection is an array walk over one stack-allocated context — 0 B. +/// The condition list is evaluated through a carrying this node's +/// live report channel, so from inside a condition +/// reaches the running machine's observer (OnLogReport) attributed to this node, exactly +/// as State.Log and the behavior composites do. Reporting a decision is diagnostics, not +/// a side effect — the side-effect-free contract still forbids writing +/// to the boards, which is what keeps short-circuit evaluation legal. Because this state is not +/// a State subclass it owns the two machine-wired slots itself; on an observer-less +/// machine both are wired , so +/// -gated conditions pay nothing. Selection stays an +/// array walk over one stack-allocated context — 0 B. /// /// -public sealed class ChoiceState : ILogic, IAsyncLogic, IDirector, IAsyncDirector, IBlackboardSettable, IChoiceNode +public sealed class ChoiceState : ILogic, IAsyncLogic, IDirector, IAsyncDirector, IBlackboardSettable, IChoiceNode, + ISyncLogReporter, IBehaviorReportSink { private readonly ICondition[] _conditions; private readonly ConditionMatch _match; @@ -41,6 +49,12 @@ public sealed class ChoiceState : ILogic, IAsyncLogic, IDirector, IAsyncDirector private readonly NodeId[] _staticTargets; private BlackboardContext _blackboards; + // The node's report channel. Both slots are machine-owned and reassigned on every visit + // (the running machine wires its own family's slot and clears the other's), which is what + // keeps reports attributed to the machine that is actually running — see ISyncLogReporter. + private Action? _syncLogReport; + private Func? _asyncLogReport; + /// The conditions to evaluate, in order. At least one is /// required; null entries are rejected. /// How the conditions combine. @@ -76,9 +90,28 @@ public ChoiceState(ICondition condition, NodeId trueTarget, NodeId falseTarget) void IBlackboardSettable.SetBlackboards(in BlackboardContext context) => _blackboards = context; + Action? ISyncLogReporter.SyncLogReport + { + get => _syncLogReport; + set => _syncLogReport = value; + } + + Func? ILogReporter.LogReport + { + get => _asyncLogReport; + set => _asyncLogReport = value; + } + + // Reuses the behavior composites' bridge verbatim: prefer the sync callback, fall back to + // the async one (waited out, so delivery completes before Report returns under either + // runtime). `this` is the sink, so no per-selection allocation. + bool IBehaviorReportSink.HasReporter => BehaviorComposition.SyncHasReporter(this); + + void IBehaviorReportSink.Report(string message) => BehaviorComposition.SyncReport(this, message); + private NodeId SelectNextCore() { - BehaviorContext ctx = new(in _blackboards, null); + BehaviorContext ctx = new(in _blackboards, this); ICondition[] conditions = _conditions; if (_match == ConditionMatch.All) { diff --git a/NxGraph/Fsm/State.cs b/NxGraph/Fsm/State.cs index bd67713..303fa4e 100644 --- a/NxGraph/Fsm/State.cs +++ b/NxGraph/Fsm/State.cs @@ -16,7 +16,10 @@ namespace NxGraph.Fsm; /// (zero-allocation on .NET 8+). /// /// -public abstract class State : ILogic, ILogReporter, IBlackboardSettable +// ISyncLogReporter (which extends ILogReporter) is what the machines' sync report tables +// target: the capability, not this base class. Declaring it here is a declaration change only +// — both slots below already exist and keep their exact shape. +public abstract class State : ILogic, ISyncLogReporter, IBlackboardSettable { /// /// Routed blackboard access (see ). Non-nullable: when the diff --git a/NxGraph/Fsm/StateMachine.cs b/NxGraph/Fsm/StateMachine.cs index b19ef36..e563a9d 100644 --- a/NxGraph/Fsm/StateMachine.cs +++ b/NxGraph/Fsm/StateMachine.cs @@ -93,7 +93,10 @@ public class StateMachine : State, ISubGraphProvider, IBlackboardBindable, IBlac // skips OnEnter entirely and never observes _executeGate. private bool _reentranceGuard; private readonly Action _cachedLogReportCallback; - private readonly State?[] _logReportStates; // indexed by NodeId.Index, resolved once at construction + // Indexed by NodeId.Index, resolved once at construction. Typed to the capability, not to + // State: a node owning a report channel need not be a State subclass (the data-built branch + // states are plain ILogic/IAsyncDirector implementations that carry both slots themselves). + private readonly ISyncLogReporter?[] _syncReporters; private readonly RetryPolicy[]? _retryPolicies; // graph-owned; null when no node declares one private int _attempts; // executions of the current node in this run private bool _nodeEntered; // the current node's EnterAction has fired for this visit @@ -146,7 +149,7 @@ public StateMachine(Graph graph, IStateMachineObserver? observer = null) _initial = graph.StartNode.Id; _current = _initial; _cachedLogReportCallback = LogReportCallback; - _logReportStates = BuildLogReportTable(graph); + _syncReporters = BuildSyncReporterTable(graph); _eventEntry = FindEventEntry(graph); _retryPolicies = graph.RetryPolicies; _outcomeCodes = graph.OutcomeCodes; @@ -321,18 +324,18 @@ private static void ThrowRaiseWhileExecuting() => "Cannot raise an event while the machine is executing — an event starts a new run. Finish the " + "current run before raising."); - private static State?[] BuildLogReportTable(Graph graph) + private static ISyncLogReporter?[] BuildSyncReporterTable(Graph graph) { - State?[] table = new State?[graph.NodeCount]; + ISyncLogReporter?[] table = new ISyncLogReporter?[graph.NodeCount]; for (int i = 0; i < table.Length; i++) { if (graph.TryGetNodeByIndex(i, out INode? node) && node is LogicNode logicNode) { - // Decorator logic (timeout wrappers) hides the state it wraps — resolve - // through the seam so the machine wires (and clears) the wrapped state's own - // report slots, exactly as it would for the bare state. - table[i] = logicNode.Logic as State - ?? LogicWrappers.ResolveThroughWrappers(logicNode); + // Decorator logic (timeout wrappers) hides the reporter it wraps — resolve + // through the seam so the machine wires (and clears) the wrapped node's own + // report slots, exactly as it would for the bare node. + table[i] = logicNode.Logic as ISyncLogReporter + ?? LogicWrappers.ResolveThroughWrappers(logicNode); } } @@ -729,20 +732,23 @@ private Result TickInternal() LogicNode logicNode = (LogicNode)node; - // Wire log-report callback for nodes that support it. Reassigned on every visit so - // interleaved machines sharing a graph each attribute reports to their own observer; - // null when this machine has no observer, so nodes that gate report formatting on a - // wired callback (behavior composites) pay nothing on observer-less machines. - State? stateForLog = _logReportStates[_current.Index]; - if (stateForLog is not null) - { - stateForLog.SyncLogReport = _observer is null ? null : _cachedLogReportCallback; - - // Both slots are machine-owned per visit: State.Log (and the behavior-composite - // report bridge) falls back to the async slot when the sync one is null, so a - // callback left by an async machine that ran this shared graph earlier must not - // receive reports from this run. - ((ILogReporter)stateForLog).LogReport = null; + // Wire the log-report callback for nodes that own a report channel. Done before the + // node executes — and therefore before a director's SelectNext runs — so a report + // raised while *deciding* (a condition inside a data-built branch node) is attributed + // to this node under this machine's observer. Reassigned on every visit so interleaved + // machines sharing a graph each attribute reports to their own observer; null when this + // machine has no observer, so nodes that gate report formatting on a wired callback + // (behavior composites, conditions) pay nothing on observer-less machines. + ISyncLogReporter? reporter = _syncReporters[_current.Index]; + if (reporter is not null) + { + reporter.SyncLogReport = _observer is null ? null : _cachedLogReportCallback; + + // Both slots are machine-owned per visit: State.Log (and the shared report bridge) + // falls back to the async slot when the sync one is null, so a callback left by an + // async machine that ran this shared graph earlier must not receive reports from + // this run. + reporter.LogReport = null; } // Execute the node synchronously. diff --git a/NxGraph/Fsm/SwitchState.cs b/NxGraph/Fsm/SwitchState.cs index dee9444..1511a2f 100644 --- a/NxGraph/Fsm/SwitchState.cs +++ b/NxGraph/Fsm/SwitchState.cs @@ -34,8 +34,13 @@ namespace NxGraph.Fsm; /// /// One class implements both logic slots and both director interfaces, so a single instance /// authors either runtime. returns — -/// a decision never faults. Selection is one typed Get plus a linear scan over one -/// stack-allocated context — 0 B. +/// a decision never faults. Selection is one typed Get plus a linear scan — 0 B. +/// +/// +/// Unlike this state runs no user code: its decision is a key lookup +/// against literals, so there is nothing to evaluate through a BehaviorContext and no +/// report channel to wire. Reporting a routed case would be new observable behavior, not +/// plumbing — deliberately not invented here. /// /// /// The tested key's value type. diff --git a/NxGraph/Tokens/AsyncTokenMachine.cs b/NxGraph/Tokens/AsyncTokenMachine.cs index b19c138..6da6458 100644 --- a/NxGraph/Tokens/AsyncTokenMachine.cs +++ b/NxGraph/Tokens/AsyncTokenMachine.cs @@ -853,12 +853,13 @@ private async ValueTask StepTokenAsync(Token t, CancellationToken ct) { reporter.LogReport = _observer is null ? null : _cachedLogReportCallback; - // Sync states read both slots (State.Log prefers the sync one), so the sync slot - // is cleared too — a callback left by a sync machine that ran this shared graph - // earlier must neither shadow this machine's observer nor receive its reports. - if (reporter is State syncState) + // Sync-capable nodes read both slots (State.Log and the shared report bridge prefer + // the sync one), so the sync slot is cleared too — a callback left by a sync machine + // that ran this shared graph earlier must neither shadow this machine's observer nor + // receive its reports. + if (reporter is ISyncLogReporter syncReporter) { - syncState.SyncLogReport = null; + syncReporter.SyncLogReport = null; } } diff --git a/NxGraph/Tokens/TokenMachine.cs b/NxGraph/Tokens/TokenMachine.cs index 101ca82..467c37e 100644 --- a/NxGraph/Tokens/TokenMachine.cs +++ b/NxGraph/Tokens/TokenMachine.cs @@ -89,7 +89,10 @@ public class TokenMachine : State, ISubGraphProvider, IBlackboardBindable, IBlac private readonly ForkState?[] _forks; // index = NodeId.Index; null for non-fork nodes private readonly JoinState?[] _joins; // index = NodeId.Index; null for non-join nodes private readonly IBlackboardSettable?[] _settables; // per-node, for per-token scratch stamping - private readonly State?[] _logReportStates; // indexed by NodeId.Index, resolved once + // Indexed by NodeId.Index, resolved once. Typed to the capability, not to State: a node + // owning a report channel need not be a State subclass (the data-built branch states carry + // both slots themselves). + private readonly ISyncLogReporter?[] _syncReporters; private readonly Action _cachedLogReportCallback; private readonly bool _hasNodeSchema; @@ -150,7 +153,7 @@ public TokenMachine(Graph graph, ITokenMachineObserver? observer = null, int max _forks = new ForkState?[graph.NodeCount]; _joins = new JoinState?[graph.NodeCount]; _settables = new IBlackboardSettable?[graph.NodeCount]; - _logReportStates = new State?[graph.NodeCount]; + _syncReporters = new ISyncLogReporter?[graph.NodeCount]; bool anyJoin = false; for (int i = 0; i < graph.NodeCount; i++) { @@ -163,10 +166,10 @@ public TokenMachine(Graph graph, ITokenMachineObserver? observer = null, int max _joins[i] = logicNode.Logic as JoinState ?? logicNode.AsyncLogic as JoinState; anyJoin |= _joins[i] is not null; _settables[i] = logicNode.AsyncLogic as IBlackboardSettable ?? logicNode.Logic as IBlackboardSettable; - // Decorator logic (timeout wrappers) hides the state it wraps — resolve through - // the seam so the machine wires the wrapped state's own report slots. - _logReportStates[i] = logicNode.Logic as State - ?? LogicWrappers.ResolveThroughWrappers(logicNode); + // Decorator logic (timeout wrappers) hides the reporter it wraps — resolve through + // the seam so the machine wires the wrapped node's own report slots. + _syncReporters[i] = logicNode.Logic as ISyncLogReporter + ?? LogicWrappers.ResolveThroughWrappers(logicNode); } _joinArrivals = anyJoin ? new int[graph.NodeCount] : null; @@ -726,16 +729,18 @@ private void StepToken(Token t) settable.SetBlackboards(_blackboards.With(t.NodeBoard!)); } - State? stateForLog = _logReportStates[idx]; - if (stateForLog is not null) + // Wired before the node executes — and therefore before a director's SelectNext runs — + // so a report raised while *deciding* is attributed to this node and this token. + ISyncLogReporter? reporter = _syncReporters[idx]; + if (reporter is not null) { - stateForLog.SyncLogReport = _observer is null ? null : _cachedLogReportCallback; + reporter.SyncLogReport = _observer is null ? null : _cachedLogReportCallback; - // Both slots are machine-owned per visit: State.Log (and the behavior-composite - // report bridge) falls back to the async slot when the sync one is null, so a - // callback left by an async machine that ran this shared graph earlier must not - // receive reports from this run. - ((ILogReporter)stateForLog).LogReport = null; + // Both slots are machine-owned per visit: State.Log (and the shared report bridge) + // falls back to the async slot when the sync one is null, so a callback left by an + // async machine that ran this shared graph earlier must not receive reports from + // this run. + reporter.LogReport = null; } ILogic syncLogic = logicNode.Logic!;