Lost Lily/Grid ToolkitDocs 1.0
Table of Contents

Create a custom action and event

Use case

Create a project-owned IGridAction when one logical request must validate or mutate a single board in a way that no built-in action represents. Compose built-in actions in GridBatchAction when they already express the required behavior.

Contract and ownership

Build receives a GridActionPlanBuilder with a read-only view of the planned board and methods for recording mutations and events. Validate all inputs before adding work. The action must not mutate live state or external objects while it plans.

Events added to the plan are published only after a successful commit. A successful no-op publishes nothing; an event-only plan publishes a batch without advancing revision; a mutation advances the board once and publishes its batch.

Implementation

The example adds two adjacent cells atomically and publishes a project-owned BoardExpandedEvent.

Validate and describe the request

Excerpt — define the committed event. Tested using public APIs.

public sealed class BoardExpandedEvent : IGridEvent
{
    public BoardExpandedEvent(GridCoordinate first, GridCoordinate second)
    {
        First = first ?? throw new ArgumentNullException(nameof(first));
        Second = second ?? throw new ArgumentNullException(nameof(second));
    }

    public GridCoordinate First { get; }
    public GridCoordinate Second { get; }
}

The request itself is an immutable value that implements IGridAction:

Excerpt — capture the requested coordinates. Tested using public APIs.

public sealed partial class ExpandBoardPairAction : IGridAction
{
    public ExpandBoardPairAction(GridCoordinate first, GridCoordinate second)
    {
        First = first ?? throw new ArgumentNullException(nameof(first));
        Second = second ?? throw new ArgumentNullException(nameof(second));
    }

    public GridCoordinate First { get; }
    public GridCoordinate Second { get; }
}

Plan mutations and the committed event

Excerpt — add all work to the transaction builder. Tested using public APIs.

if (builder == null)
    throw new ArgumentNullException(nameof(builder));

if (First.Equals(Second))
{
    return GridActionResult.Failure(new GridActionFailure(
        GridActionFailureCode.InvalidAction,
        "The two new cells must use different coordinates.",
        First));
}

if (builder.State.ContainsCell(First) || builder.State.ContainsCell(Second))
{
    return GridActionResult.Failure(new GridActionFailure(
        GridActionFailureCode.InvalidAction,
        "Both coordinates must be absent before the board can expand."));
}

if (!builder.AddCell(First) || !builder.AddCell(Second))
{
    return GridActionResult.Failure(new GridActionFailure(
        GridActionFailureCode.InvalidAction,
        "The board could not add both cells to the same action plan."));
}

builder.AddEvent(new BoardExpandedEvent(First, Second));
return GridActionResult.SuccessResult(new[] { First, Second });

Preview and apply

Excerpt — preview without mutation and apply with fresh planning. Tested using public APIs.

public static (GridActionResult Preview, GridActionResult Applied) Run(
    GridBoardState state,
    GridCoordinate first,
    GridCoordinate second)
{
    IGridAction action = new ExpandBoardPairAction(first, second);
    GridActionResult preview = GridActionRunner.Preview(state, action);
    GridActionResult applied = preview.Success
        ? GridActionRunner.Apply(state, action)
        : preview;
    return (preview, applied);
}
Complete tested example
using System;
using LostLily.GridToolkit.Actions;
using LostLily.GridToolkit.Board;
using LostLily.GridToolkit.Coordinates;
using LostLily.GridToolkit.Events;

namespace GridToolkit.Tutorials
{
    public sealed class BoardExpandedEvent : IGridEvent
    {
        public BoardExpandedEvent(GridCoordinate first, GridCoordinate second)
        {
            First = first ?? throw new ArgumentNullException(nameof(first));
            Second = second ?? throw new ArgumentNullException(nameof(second));
        }

        public GridCoordinate First { get; }
        public GridCoordinate Second { get; }
    }

    public sealed partial class ExpandBoardPairAction : IGridAction
    {
        public ExpandBoardPairAction(GridCoordinate first, GridCoordinate second)
        {
            First = first ?? throw new ArgumentNullException(nameof(first));
            Second = second ?? throw new ArgumentNullException(nameof(second));
        }

        public GridCoordinate First { get; }
        public GridCoordinate Second { get; }
    }

    public sealed partial class ExpandBoardPairAction
    {
        public GridActionResult Build(GridActionPlanBuilder builder)
        {
            if (builder == null)
                throw new ArgumentNullException(nameof(builder));

            if (First.Equals(Second))
            {
                return GridActionResult.Failure(new GridActionFailure(
                    GridActionFailureCode.InvalidAction,
                    "The two new cells must use different coordinates.",
                    First));
            }

            if (builder.State.ContainsCell(First) || builder.State.ContainsCell(Second))
            {
                return GridActionResult.Failure(new GridActionFailure(
                    GridActionFailureCode.InvalidAction,
                    "Both coordinates must be absent before the board can expand."));
            }

            if (!builder.AddCell(First) || !builder.AddCell(Second))
            {
                return GridActionResult.Failure(new GridActionFailure(
                    GridActionFailureCode.InvalidAction,
                    "The board could not add both cells to the same action plan."));
            }

            builder.AddEvent(new BoardExpandedEvent(First, Second));
            return GridActionResult.SuccessResult(new[] { First, Second });
        }
    }

    public static class ExpandBoardPairWorkflow
    {
        public static (GridActionResult Preview, GridActionResult Applied) Run(
            GridBoardState state,
            GridCoordinate first,
            GridCoordinate second)
        {
            IGridAction action = new ExpandBoardPairAction(first, second);
            GridActionResult preview = GridActionRunner.Preview(state, action);
            GridActionResult applied = preview.Success
                ? GridActionRunner.Apply(state, action)
                : preview;
            return (preview, applied);
        }
    }
}

Verification

Preview succeeds without changing revision, cells, or batches. Apply adds both cells, advances the board once, and publishes one batch containing BoardExpandedEvent.

Tests

Also preview an invalid coordinate pair and verify that Apply is not attempted, no cell appears, revision remains unchanged, and no event is published.

Troubleshooting

Symptom Check
Preview changes state Move all writes into transaction-builder mutations.
One of two cells appears after failure Plan both mutations in the same action transaction.
An event arrives before state is readable Add it to the plan rather than publishing from Build.
More than one board participates Use a declared multi-board operation instead.

Done when

The action plans only through public transaction contracts, rejection is immutable, and its event is observable only after a successful atomic commit.