Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,10 @@
.idea
*.DotSettings.user

# Unity hides directories whose name ends with '~' from the Editor, which is how a UPM package
# ships its samples (Samples~) and docs (Documentation~). A user-global gitignore carrying the
# usual editor-backup pattern '*~' also matches those directories, silently dropping them from
# every commit — and therefore from the `git subtree split` the release workflow publishes.
# Re-include them here: repository rules take precedence over core.excludesFile.
!**/*~/
!**/*~/**
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
using System;
using System.Linq;
using System.Collections.Generic;
using Xunit;

namespace Aspid.Core.HSM.Generators.Tests.StateMachineTests;

#region Helpers

/// <summary>Root state that redirects the machine exactly once, from inside its own OnEnter.</summary>
public sealed class RedirectRootState : BaseTestState, IEnterController
{
public Action? RedirectOnce { get; set; }

public void OnEnter()
{
var redirect = RedirectOnce;
RedirectOnce = null;
redirect?.Invoke();
}
}

public sealed class RedirectChildState : BaseTestState, IChildState<RedirectRootState> { }

public sealed class RedirectLeafState : BaseTestState, IChildState<RedirectChildState> { }

public sealed class RedirectAltState : BaseTestState, IChildState<RedirectRootState> { }

/// <summary>State machine exposing the edge-level guard and strict mode for assertions.</summary>
public sealed class GuardedStateMachine(StateFactory stateFactory) : StateMachineBase(stateFactory)
{
public Func<Type, Type, bool>? EdgeGuard { get; set; }

public bool Strict { get; set; }

public List<(Type Source, Type Target)> ObservedEdges { get; } = [];

protected override bool StrictTransitions => Strict;

protected override bool IsTransitionEnabled(Type sourceType, Type targetType)
{
ObservedEdges.Add((sourceType, targetType));
return EdgeGuard?.Invoke(sourceType, targetType) ?? true;
}
}

#endregion

public class ReentrancyAndGuardsTests
{
private static TestStateFactory CreateHierarchyFactory()
{
var factory = new TestStateFactory();
factory.RegisterState<ParentTestState>();
factory.RegisterState<ChildTestState>();
factory.RegisterState<SiblingChildTestState>();
factory.RegisterState<GrandchildTestState>();
return factory;
}

// The factory used to hand out its own chain buffer, so the previous result silently
// became the next result. Callers may now hold a chain across further factory calls.
[Fact]
public void CreateState_result_is_not_invalidated_by_a_later_call()
{
var factory = CreateHierarchyFactory();
factory.RegisterState<SimpleTestStateForFactory>();
var empty = Array.Empty<IState>();

// A two-state chain first, then a one-state chain: a shared buffer would shrink `first` underneath us.
var first = factory.CreateState<ChildTestState>(empty);
var snapshot = first.ToArray();

factory.CreateState<SimpleTestStateForFactory>(empty);

Assert.Equal(snapshot.Length, first.Count);
Assert.Equal(snapshot, first.ToArray());
}

// A ChangeState issued from OnEnter used to rewrite the chain the outer loop was still
// iterating, entering states twice and leaving duplicates in CurrentStates. It is now
// queued and applied once the running change completes (run-to-completion).
[Fact]
public void Reentrant_ChangeState_from_OnEnter_does_not_corrupt_the_chain()
{
var root = new RedirectRootState();
var child = new RedirectChildState();
var leaf = new RedirectLeafState();
var alt = new RedirectAltState();

var factory = new TestStateFactory();
factory.RegisterState<RedirectRootState>(() => root);
factory.RegisterState<RedirectChildState>(() => child);
factory.RegisterState<RedirectLeafState>(() => leaf);
factory.RegisterState<RedirectAltState>(() => alt);

var sm = new TestableStateMachine(factory);
root.RedirectOnce = () => sm.ChangeState<RedirectAltState>();

sm.ChangeState<RedirectLeafState>();

// No state appears twice, and the queued request produced the final chain.
Assert.Equal(sm.CurrentStates.Count, sm.CurrentStates.Distinct().Count());
Assert.Equal(new IState[] { root, alt }, sm.CurrentStates.ToArray());

// The interrupted change still ran to completion before the queued one was applied.
Assert.Equal(1, child.EnterCalled);
Assert.Equal(1, child.ExitCalled);
Assert.Equal(1, leaf.EnterCalled);
Assert.Equal(1, leaf.ExitCalled);

// Each state was entered exactly once — no double enter from a rewritten chain.
Assert.Equal(1, root.EnterCalled);
Assert.Equal(1, alt.EnterCalled);
Assert.Equal(0, alt.ExitCalled);
}

[Fact]
public void IsTransitionEnabled_blocks_a_denied_edge()
{
var factory = new TestStateFactory();
factory.RegisterState<SimpleTestState>();
factory.RegisterState<AnotherTestState>();

var sm = new GuardedStateMachine(factory);
sm.ChangeState<SimpleTestState>();

sm.EdgeGuard = (source, target) =>
!(source == typeof(SimpleTestState) && target == typeof(AnotherTestState));

sm.ChangeState<AnotherTestState>();

Assert.IsType<SimpleTestState>(sm.CurrentStates[^1]);
}

[Fact]
public void IsTransitionEnabled_receives_the_source_leaf_and_the_target()
{
var factory = new TestStateFactory();
factory.RegisterState<SimpleTestState>();
factory.RegisterState<AnotherTestState>();

var sm = new GuardedStateMachine(factory);
sm.ChangeState<SimpleTestState>();
sm.ObservedEdges.Clear();

sm.ChangeState<AnotherTestState>();

Assert.Contains((typeof(SimpleTestState), typeof(AnotherTestState)), sm.ObservedEdges);
}

[Fact]
public void StrictTransitions_throws_for_an_unregistered_edge()
{
var sm = new GuardedStateMachine(CreateHierarchyFactory());
sm.ChangeState<ChildTestState>();
sm.Strict = true;

var exception = Assert.Throws<InvalidOperationException>(
() => sm.TransitionTo<SiblingChildTestState>());

Assert.Contains(nameof(ChildTestState), exception.Message);
Assert.Contains(nameof(SiblingChildTestState), exception.Message);
Assert.IsType<ChildTestState>(sm.CurrentStates[^1]);
}

[Fact]
public void StrictTransitions_allows_a_fully_registered_path()
{
var sm = new GuardedStateMachine(CreateHierarchyFactory());
sm.RegisterTransition(new ChildToSiblingSegmentTransition());
sm.ChangeState<ChildTestState>();
sm.Strict = true;

sm.TransitionTo<SiblingChildTestState>();

Assert.IsType<SiblingChildTestState>(sm.CurrentStates[^1]);
}

// A partially covered path used to be treated exactly like a fully covered one.
[Fact]
public void StrictTransitions_throws_when_only_part_of_the_path_is_registered()
{
var sm = new GuardedStateMachine(CreateHierarchyFactory());
sm.RegisterTransition(new GrandchildToChildSegmentTransition());
sm.ChangeState<GrandchildTestState>();
sm.Strict = true;

Assert.Throws<InvalidOperationException>(() => sm.TransitionTo<SiblingChildTestState>());
Assert.IsType<GrandchildTestState>(sm.CurrentStates[^1]);
}

// Same setup as above, with the default permissive mode: behaviour is unchanged.
[Fact]
public void Partially_registered_path_still_transitions_when_not_strict()
{
var sm = new GuardedStateMachine(CreateHierarchyFactory());
sm.RegisterTransition(new GrandchildToChildSegmentTransition());
sm.ChangeState<GrandchildTestState>();

sm.TransitionTo<SiblingChildTestState>();

Assert.IsType<SiblingChildTestState>(sm.CurrentStates[^1]);
}

// ChangeState is the documented escape hatch and stays outside the registry check.
[Fact]
public void ChangeState_is_not_subject_to_StrictTransitions()
{
var sm = new GuardedStateMachine(CreateHierarchyFactory());
sm.ChangeState<ChildTestState>();
sm.Strict = true;

sm.ChangeState<SiblingChildTestState>();

Assert.IsType<SiblingChildTestState>(sm.CurrentStates[^1]);
}
}

/// <summary>Standalone root state used to force a second, differently shaped chain from the factory.</summary>
public sealed class SimpleTestStateForFactory : BaseTestState { }
21 changes: 21 additions & 0 deletions Aspid.Core.HSM/Assets/Plugins/Aspid/Core/HSM/LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2025-2026 Vladislav Panin

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "Aspid.Core.HSM.Samples.GameLoop",
"rootNamespace": "Aspid.Core.HSM.Samples.GameLoop",
"references": [
"Aspid.Core.HSM",
"Aspid.Core.HSM.Unity",
"UniTask"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Aspid.Core.HSM;
using UnityEngine;

namespace Aspid.Core.HSM.Samples.GameLoop.Controllers
{
public class GameHUDController : IEnterController, IExitController
{
public void OnEnter()
{
Debug.Log("[HSM] GameHUD: HUD shown");
}

public void OnExit()
{
Debug.Log("[HSM] GameHUD: HUD hidden");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Aspid.Core.HSM;
using UnityEngine;
using UnityEngine.InputSystem;

namespace Aspid.Core.HSM.Samples.GameLoop.Controllers
{
public class MenuInputController : IUpdateController
{
public void Update(float deltaTime)
{
var keyboard = Keyboard.current;
if (keyboard == null) return;

if (keyboard.enterKey.wasPressedThisFrame)
Debug.Log("[HSM] MenuInput: Enter pressed — start game");

if (keyboard.escapeKey.wasPressedThisFrame)
Debug.Log("[HSM] MenuInput: Escape pressed — quit");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Aspid.Core.HSM;
using UnityEngine;

namespace Aspid.Core.HSM.Samples.GameLoop.Controllers
{
public class MenuUIController : IEnterController, IExitController
{
public void OnEnter()
{
Debug.Log("[HSM] MenuUI: UI enabled");
}

public void OnExit()
{
Debug.Log("[HSM] MenuUI: UI disabled");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Aspid.Core.HSM;
using UnityEngine;
using UnityEngine.InputSystem;

namespace Aspid.Core.HSM.Samples.GameLoop.Controllers
{
public class PlayerInputController : IUpdateController, IEnterController
{
public void OnEnter()
{
Debug.Log("[HSM] PlayerInput: controls activated");
}

public void Update(float deltaTime)
{
var keyboard = Keyboard.current;
if (keyboard == null) return;

if (keyboard.escapeKey.wasPressedThisFrame)
Debug.Log("[HSM] PlayerInput: Escape pressed — open pause");

if (keyboard.f1Key.wasPressedThisFrame)
Debug.Log("[HSM] PlayerInput: F1 pressed — toggle debug overlay");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Aspid.Core.HSM;
using UnityEngine;

namespace Aspid.Core.HSM.Samples.GameLoop.Extensions
{
public class DebugOverlayExtension : IExtensionState, IUpdateController
{
public bool CanAttachTo(IState hostState) => true;

public void OnAttached(IState hostState)
{
Debug.Log($"[HSM] DebugOverlay: attached to {hostState.GetType().Name}");
}

public void OnDetached(IState hostState)
{
Debug.Log($"[HSM] DebugOverlay: detached from {hostState.GetType().Name}");
}

public void Enter()
{
Debug.Log("[HSM] DebugOverlay: enabled");
}

public void Update(float deltaTime)
{
// In a real game this would render debug info via IMGUI or UIToolkit
}

public void Exit()
{
Debug.Log("[HSM] DebugOverlay: disabled");
}
}
}
Loading