LogicBlocks

repository·main·Indexed 43 days ago

https://github.com/chickensoft-games/logicblocks

A serializable, hierarchical state machine package for C# designed for high-performance environments like games. It utilizes the state pattern with class records to model complex behaviors, featuring a history stack for pushdown automaton behavior, async input handling, and a source generator for UML state diagrams. Includes AutoBlock for automated serialization and built-in utilities for testing states and bindings.

Tokens
2.2K
Snippets
7
Records
8
Agent score
6%

What's inside LogicBlocks

  1. Use Pushdown Automaton behavior (History Stack)

    main

    Logic blocks support a state history stack. You can use Push() to save the current state type onto the stack and Pop() to return to a previous state. Pop() returns the previous state type or allows you to provide a fallback using the null-coalescing operator. The history stack has a default maximum capacity of 8.

    public Type On(in Input.Pause input)
    {
      Push();               // save current state type on the history stack
      return To<Paused>();
    }
    
    public Type On(in Input.Resume input)
    {
      return Pop() ?? To<Playing>();  // restore previous state, or fall back
    }
  2. Test States and Bindings

    main

    LogicBlocks provides utilities for testing both individual states and bindings:

    • Testing States: Use state.Test() to create a tester for a specific state. You can use tester.Set() to inject dependencies and tester.Outputs to assert that the state produced the expected outputs during its lifecycle.
    • Testing Bindings: Use LogicBlock.CreateFakeBinding() to test binding callbacks in isolation without needing a real LogicBlock instance. You can manually trigger state changes using binding.SetState().
    // Testing States
    var state = new LightSwitchState.PoweredOff();
    var tester = state.Test();
    tester.Set(new SomeDependency());
    state.Enter();
    tester.Outputs.ShouldContain(new LightSwitchState.Output.StatusChanged(IsOn: false));
    
    // Testing Bindings
    using var binding = LogicBlock.CreateFakeBinding();
    binding.OnState<LightSwitchState.PoweredOn>(_ => ranCallback = true);
    binding.SetState(new LightSwitchState.PoweredOn());
    ranCallback.ShouldBeTrue();
  3. Serialize LogicBlocks with AutoBlock

    main

    For stateful logic that requires serialization, extend AutoBlock instead of LogicBlock. AutoBlock uses Chickensoft.Introspection and Chickensoft.Serialization to automatically discover and preallocate concrete states.

    To implement:

    1. Inherit from AutoBlock and use [Meta, Id("...")] attributes.
    2. Call Preallocate<T>() in the constructor.
    3. Implement Serialize(LogicBlockData data) to return your custom save data object.
    4. Use GetSaveData() to save and Start(saveData.Data) to resume from a saved state.
    [Meta, Id("my_logic")]
    public partial class MyLogic : AutoBlock
    {
      public MyLogic()
      {
        Preallocate<MyState>();
      }
    
      public override ILogicBlockSaveData Serialize(LogicBlockData data) =>
        new MySaveData { Data = data };
    
      [Meta, Id("my_save_data")]
      public class MySaveData : ILogicBlockSaveData 
      {
        [Save("data")]
        public LogicBlockData Data { get; set; }
      }
    }
    
    // Save
    var saveData = myLogic.GetSaveData();
    
    // Load
    myLogic.Start(saveData.Data);
  4. Generate UML State Diagrams

    main

    LogicBlocks includes a source generator that creates UML state diagrams. To enable this, add the [StateDiagram] attribute to your base state class. This generates *.g.puml files alongside your code, which can be visualized using PlantUML or the PlantUML VSCode Extension.

    [StateDiagram]
    public abstract partial record LightSwitchState : LogicBlockState { ... }
  5. Implement a LogicBlock state machine

    main

    A LogicBlock is a class that receives inputs, maintains a single state instance, and produces outputs. In v6, logic blocks do not use generic type parameters. Instead, states are defined as class records that inherit from LogicBlockState and live outside the logic block class.

    To implement one:

    1. Create a class inheriting from LogicBlock.
    2. Preallocate states in the constructor using Set().
    3. Define an abstract LogicBlockState record containing nested Input and Output classes.
    4. Implement concrete state records that implement IGet<T> for specific inputs and return a Type (via To<T>()) to trigger transitions in their On handlers.
    public partial class LightSwitchLogic : LogicBlock
    {
      public LightSwitchLogic()
      {
        Set(new LightSwitchState.PoweredOn());
        Set(new LightSwitchState.PoweredOff());
      }
    }
    
    public abstract partial record LightSwitchState : LogicBlockState
    {
      public static class Input { public readonly record struct Toggle; }
      public static class Output { public readonly record struct StatusChanged(bool IsOn); }
    }
    
    public partial record LightSwitchState
    {
      public record PoweredOn : LightSwitchState, IGet<Input.Toggle>
      {
        public PoweredOn() => this.OnEnter(() => Output(new Output.StatusChanged(true)));
        public Type On(in Input.Toggle input) => To<PoweredOff>();
      }
    
      public record PoweredOff : LightSwitchState, IGet<Input.Toggle>
      {
        public PoweredOff() => this.OnEnter(() => Output(new Output.StatusChanged(false)));
        public Type On(in Input.Toggle input) => To<PoweredOn>();
      }
    }
  6. Handle Async Inputs safely

    main

    To bridge asynchronous tasks back into the synchronous input pipeline, use the Async() method within an input handler. This allows you to map the result of an async operation to a new Input, handle errors via ErrorInput(), or handle cancellations via CanceledInput().

    public Type On(in Input.Load input)
    {
      Async(FetchDataAsync())
        .Input(data => new Input.Loaded(data))
        .ErrorInput(ex => new Input.LoadFailed(ex.Message))
        .CanceledInput(() => new Input.LoadCanceled());
    
      return ToSelf();
    }
  7. Observe a LogicBlock using Bindings

    main

    Bindings allow you to observe a logic block's state changes and outputs. Bindings are IDisposable, so you must dispose of them when they are no longer needed. You can chain methods to react to specific states, outputs, or the start/stop lifecycle of the block.

    using var binding = logic.Bind();
    
    binding
      .OnState<LightSwitchState.PoweredOn>(_ => Console.WriteLine("Light is on"))
      .OnState<LightSwitchState.PoweredOff>(_ => Console.WriteLine("Light is off"))
      .OnOutput<LightSwitchState.Output.StatusChanged>(
        output => Console.WriteLine($"Status changed: {output.IsOn}")
      )
      .OnStart(() => Console.WriteLine("Started"))
      .OnStop(() => Console.WriteLine("Stopped"));