Introduction
In this post I switch from talking about Blazor infrastructure to focusing on application logic. I will discuss rules-based processing, which is an approach to problem solving that deviates from the typical procedural programming model.
Rules-based processing came out of AI research in the 1960s and 1970s, through work on Expert Systems. The intent was to build a system that can deduce new facts from a set of existing facts by applying a series of rules. These rules would be specified by “experts” in a given field, thus the new system would have the appearance of being guided by these experts themselves.
The term “rules-based processing” may be a bit misleading. The “rules” here are not constraints. Rather, they are if-then constructs that allow a set of facts to be progressively expanded. For example, from the rules for Crazy Eights:
- If …
- Then …
- …
- …
- … have a card with matching rank
- … discard that card
- … have a card with matching suit
- … discard that card
- … have an eight of any suit
- … discard that card, declare a new suit
- … have one of the above and don’t wish to discard
- … draw a card
- … none of the above and stock cards exist
- … draw a card and go back to the beginning
- … none of the above and the stock is empty
- … pass play to left
Many procedural programming languages support this type of logic through constructs such as if-then and switch. Rules-based systems differ from procedural languages in several ways:
- There is no explicit nor implicit order to how rules are evaluated.
- Multiple rules may apply for a given set of facts. How the rules engine reacts to this state depends on how the underlying facts are presented and what conflict resolution mechanisms are in place.
- There is no else or default construct. Such a requirement would need to be specified as a test of the non-existence of a set of facts.
- If the application of a rule results in facts being created or modified, these facts can cause other rules to be applied, including rules that may have already been run. This is known as forward chaining.
- If multiple facts are created or modified, as a result of external action or by running a rule, the rule set is evaluated for each fact individually. There is no way to update multiple facts atomically.
These differences may cause surprising results for those more used to procedural programming.
Early rules-based systems performed a brute-force approach to applying rules based on fact changes by testing each rule for each change. These systems were severely limited in the number of rules they could process due to the limitations in available computer processing at the time. In the mid-1970s Charles L. Forgy created the Rete Algorithm, which greatly improved the processing time for such a system. This algorithm uses a special structure that has indexing to help locate applicable conditions for rules associated with fact changes. A consequence of this is that rules must be compiled into this special structure at the beginning of processing, and that facts need to be specifically inserted into and updated within this structure.
There are many modern implementations of the Rete algorithm, including some built into commercial decision support systems. In this post we will use NRules, which is an open-source tool built in C#.
A Simple Game
Although the most common application for rules-base processing is in business decision making, I will instead show how the approach can be used to play the game of Tic-Tac-Toe. Hopefully the study of a fairly trivial game will allow the ideas to be presented clearly.
The game of Tic-Tac-Toe involves two players alternatively placing markers (typically “X” and “O”) in cells of a 3×3 grid until one player has three of their markers in a row – horizontally, vertically, or diagonally – or until there are no more empty cells.
Create a class library project:
- Project name: TicTacToe
- Template: Class Library
I used .NET 10.0 (Preview) for my project. It should work equally well with .NET 9.
The source code for this post is available on GitHub.
The Model
One aspect of the game domain is clear enough – the 3×3 grid. I created class Grid with an array with three rows and three columns. Less obvious is the support for the concept of three cells “in a row”. I created a Line class with three cells. I chose the name “Line” rather than “Row” to avoid confusion with the use of the word “row” to mean a horizontal set of cells. I also have a Move object to capture a player’s move and a GameStage object to capture the current state of the game, whether starting, active, or a win or a draw has taken place. All of these objects become facts in the NRules session.
Create folder Engine with subfolders Model and Rules.
Create Engine\Model\Grid.cs:
namespace TicTacToe.Engine.Model
{
internal enum CellType
{
Empty, X, O, None
}
internal class Grid
{
public static int MinRow = 1;
public static int MaxRow = 3;
public static int MinColumn = 1;
public static int MaxColumn = 3;
private readonly CellType[,] cells = new CellType[3, 3]
{
{ CellType.Empty, CellType.Empty, CellType.Empty },
{ CellType.Empty, CellType.Empty, CellType.Empty },
{ CellType.Empty, CellType.Empty, CellType.Empty }
};
public CellType this[int row, int column]
{
get
{
if (row < MinRow || row > MaxRow ||
column < MinColumn || column > MaxColumn) return CellType.None;
return cells[row - 1, column - 1];
}
set
{
if (row < MinRow || row > MaxRow ||
column < MinColumn || column > MaxColumn) return;
cells[row - 1, column - 1] = value;
}
}
public void SetCellType(int row, int column, CellType cellType) =>
this[row, column] = cellType;
public void Clear()
{
for (int row = MinRow; row <= MaxRow; row++)
for (int column = MinColumn; column <= MaxColumn; column++)
this[row, column] = CellType.Empty;
}
}
}
Note that I have a public method SetCellType that is a proxy for the indexer set method. The reason for this will become apparent shortly.
Create Engine\Model\Move.cs:
namespace TicTacToe.Engine.Model
{
internal enum MoveStatus
{
Pending, Processed, Error
}
internal class Move(CellType type, int row, int column)
{
public CellType Type { get; set; } = type;
public MoveStatus Status { get; set; } = MoveStatus.Pending;
public int Row { get; } = row;
public int Column { get; } = column;
public void SetStatus(MoveStatus status) => Status = status;
}
}
Once again, we have an explicit set method.
Create Engine\Model\Line.cs:
namespace TicTacToe.Engine.Model
{
internal enum LineType
{
Row, Column, Diagonal
}
internal class Line(LineType type, int id)
{
public LineType Type { get; set; } = type;
public int Id { get; set; } = id;
public static int MaxLines = 8;
public static int MinIndex = 1;
public static int MaxIndex = 3;
public static int MainDiagonal = 0;
public static int CounterDiagonal = 1;
private CellType[] cells = [CellType.Empty, CellType.Empty, CellType.Empty];
private readonly CellType[] XWin = [CellType.X, CellType.X, CellType.X];
private readonly CellType[] OWin = [CellType.O, CellType.O, CellType.O];
public void Clear()
{
cells = [CellType.Empty, CellType.Empty, CellType.Empty];
}
public bool AcceptsMove(Move move)
{
if (move.Row < MinIndex || move.Row > MaxIndex ||
move.Column < MinIndex || move.Column > MaxIndex) return false;
switch (Type)
{
case LineType.Row:
return Id == move.Row && cells[move.Column - 1] == CellType.Empty;
case LineType.Column:
return Id == move.Column && cells[move.Row - 1] == CellType.Empty;
case LineType.Diagonal:
if (Id == MainDiagonal)
{
return move.Row == move.Column &&
cells[move.Column - 1] == CellType.Empty;
}
else
{
return move.Row + move.Column == 4 &&
cells[move.Column - 1] == CellType.Empty;
}
}
return false;
}
public void AddMove(Move move)
{
if (!AcceptsMove(move)) return;
switch (Type)
{
case LineType.Row:
cells[move.Column - 1] = move.Type;
break;
case LineType.Column:
cells[move.Row - 1] = move.Type;
break;
case LineType.Diagonal:
cells[move.Column - 1] = move.Type;
break;
}
}
public bool XWins => cells.SequenceEqual(XWin);
public bool OWins => cells.SequenceEqual(OWin);
public bool HasWin()
{
return XWins || OWins;
}
public bool IsFullMixed()
{
if (HasWin()) return false;
return !cells.Any(p => p == CellType.Empty);
}
public CellType GetWinType()
{
if (HasWin())
{
return cells[0];
}
else
{
return CellType.Empty;
}
}
}
}
This is the most complex of the model objects. When working with rules processing in general and NRules in particular there is a tradeoff in where to put the complexity. Much of the testing that is done in methods such as AcceptsMove and HasWin could be done using rules. This tradeoff will become more apparent when we look at the rules themselves.
Create Engine\Model\GameStage.cs:
namespace TicTacToe.Engine.Model
{
internal enum GameStageType
{
Initial, New, Active, Win, Draw, Error
}
internal enum GameStageErrorType
{
None, MoveNotValid, MoveInUse
}
internal class GameStage(GameStageType stage)
{
public GameStageType Stage { get; set; } = stage;
public CellType Winner { get; set; } = CellType.Empty;
public GameStageErrorType Error { get; set; } = GameStageErrorType.None;
public void SetStage(GameStageType newStage) => Stage = newStage;
public void SetWinner(CellType winner) => Winner = winner;
public void SetErrorType(GameStageErrorType error) => Error = error;
}
}
There are six stages for the game engine:
| Initial | Engine is initialized, but no actual game is in progress |
| New | A trigger stage to start a new game |
| Active | A tic-tac-toe game is currently in progress |
| Win | The tic-tac-toe game has been won |
| Draw | The tic-tac-toe game has ended in a draw |
| Error | An error has occurred |
The game will switch between these stages as a result of external actions and rule processing.
The API
As we are building a class library, we will need to provide some sort of API to be able to use it.
Create API.cs at the root of the class library project (or rename the template-provided Class1.cs):
using NRules;
using NRules.Fluent;
using System.Reflection;
using TicTacToe.Engine.Model;
namespace TicTacToe
{
public class API
{
private readonly RuleRepository ruleRepository;
private readonly ISessionFactory sessionFactory;
private readonly ISession session;
private readonly GameStage gameStage;
private readonly Grid grid;
public void NewGame()
{
gameStage.SetStage(GameStageType.New);
session.Update(gameStage);
session.Fire();
}
public enum Side { X, O, None };
public void Move(Side side, int row, int column)
{
if (side == Side.None) return;
CellType type = side switch
{
Side.X => CellType.X,
Side.O => CellType.O,
_ => CellType.Empty,
};
var move = new Move(type, row, column);
session.Insert(move);
session.Fire();
}
public API()
{
ruleRepository = new RuleRepository();
ruleRepository.Load(
x => x.PrivateTypes(true).From(Assembly.GetExecutingAssembly()));
sessionFactory = ruleRepository.Compile();
session = sessionFactory.CreateSession();
// initialize model
gameStage = new GameStage(GameStageType.Initial);
session.Insert(gameStage);
grid = new Grid();
session.Insert(grid);
foreach (int item in Enumerable.Range(1, 3))
{
session.Insert(new Line(LineType.Row, item));
session.Insert(new Line(LineType.Column, item));
}
session.Insert(new Line(LineType.Diagonal, Line.MainDiagonal));
session.Insert(new Line(LineType.Diagonal, Line.CounterDiagonal));
}
}
}The constructor at lines 38-61 initializes the NRules engine:
- The rule repository is created and loaded with the rules defined in the library using reflection. The PrivateTypes method is used to allow rules that are defined as “internal” to be loaded. By default only public classes are examined.
- The loaded rules are compiled into the session.
- The model is initialized with the GameStage, Grid, and the eight Lines needed to represent the state of the game.
Note that the Line instances are created and inserted individually. NRules has its own internal structures for efficiently accessing facts. Using a local structure such as a List here could be confusing as the rules engine would not be aware of list operations such as Add or Remove, resulting in a disparity between what is visible in the external program and what is “known” to the rules engine.
The NewGame method at lines 16-21 updates the GameStage to trigger a new game. Note that while the GameStage instance is available as a local variable to be modified, in order for the rules engine to act upon any changes it needs to be notified of these changes through the Update method on the rules session. The session Fire method is called to run any rules that are impacted by the changes. For details on what happens next, see the next section.
The Move method at lines 23-36 introduces a new game move. The new object is introduced to the rules engine via the Insert method on the session. The call to the session Fire method causes the move to be evaluated and acted upon. This will be detailed in the next section.
The Rules
Rules are composed of two parts: the antecedent, which describes the conditions under which the rule applies, and the consequent, which describes the action to take under those circumstances. The antecedent is specified as a set of conditions for facts within the system. The consequent is specified as a set of updates to these facts. In NRules these facts are represented by C# class objects that have been inserted into the current NRules session. When a fact is inserted or updated NRules locates rules that have this fact as part of their antecedent. If all of the pre-conditions for a rule are satisfied the rule becomes activated. When the Fire method of the NRules session is called the set of activated rules is examined to determine which rule has the highest priority. That rule then fires, which means that its consequent is acted upon. While it is possible to explicitly specify the priority for a rule, it is best to avoid having ambiguity in which rule is fired by choosing the pre-conditions carefully.
The rules engine is designed to use rules to transform facts into new or updated facts. Since NRules uses plain C# objects for facts it is possible to update the facts in plain C# code. The rules engine will be unaware of these changes unless you explicitly tell it that you have done so. As a result you need to be careful when updating facts in open code.
Getting Started
Note that the firing of a rule depends on its pre-conditions being met. There is no way to force a rule to be fired directly. In some cases we want to indirectly cause a rule to fire by deliberately introducing a fact change. In the previous section we set the GameStage Stage to New in the API NewGame method. The NewGameRule responds to this change and (re)initializes the Grid and Lines for a new game.
Create Engine\Rules\NewGameRule.cs:
using NRules.Fluent.Dsl;
using NRules.RuleModel;
using TicTacToe.Engine.Model;
namespace TicTacToe.Engine.Rules
{
internal class NewGameRule : Rule
{
public override void Define()
{
GameStage stage = default!;
Grid grid = default!;
IEnumerable<Line> lines = default!;
When()
.Match(() => stage, gameStage => gameStage.Stage == GameStageType.New)
.Match(() => grid)
.Query(() => lines, query => query.Match<Line>([]).Collect())
;
Then()
.Do(ctx => stage.SetStage(GameStageType.Active))
.Do(ctx => ctx.Update(stage))
.Do(ctx => ClearLines(ctx, lines))
.Do(ctx => grid.Clear())
.Do(ctx => ctx.Update(grid))
;
}
private static void ClearLines(IContext context, IEnumerable<Line> lines)
{
foreach (Line line in lines)
{
line.Clear();
context.Update(line);
}
}
}
}Lines 15-19 above list the conditions for this rule firing (the antecedent).
Line 16 matches on an instance of GameStage that has a State of New. As it happens, we will only ever have one instance of GameStage, so this rule will come into effect if that instance happens to be in stage New. This GameStage instance is assigned to the local variable stage.
Line 17 matches on any instance of Grid. Once again, there will only be one instance, and that will be assigned to variable grid.
Line 18 matches on all instances of Line, and assigns the collection to the variable lines.
If the GameStage is in the correct state, this rule will fire and cause lines 21-27 to execute (the consequent).
Lines 22 and 23 update the GameStage Stage to Active. Note that the setter method SetStage is called rather than setting the public property Stage. This is because the Do method accepts an expression tree. Expression trees are fairly limited in what they can do, and in particular do not support assignment. This update is done first so that the rule will not be activated again when the Grid and Lines are updated. Note that after modifying the GameStage we need to inform the rules engine of the change via the Update method on the NRules context.
Line 24 calls the static method ClearLines in order to set all of the Line instances that were captured in the lines collection to their initial state. As with GameStage, we need to inform the rules engine of the modifications to the Line instances via the Update method on the context.
Lines 25 and 26 clear the Grid.
Making Moves
The API Move method simply inserts a new Move object into the session. This is intended to trigger the MoveRule rule.
Create Engine\Rules\MoveRule.cs:
using NRules.Fluent.Dsl;
using NRules.RuleModel;
using TicTacToe.Engine.Model;
namespace TicTacToe.Engine.Rules
{
internal class MoveRule : Rule
{
public override void Define()
{
GameStage stage = default!;
Grid grid = default!;
Move move = default!;
IEnumerable<Line> lines = default!;
When()
.Match(() => stage, gameStage => gameStage.Stage == GameStageType.Active)
.Match(() => move, move => move.Status == MoveStatus.Pending)
.Match(() => grid, grid => grid[move.Row, move.Column] == CellType.Empty)
.Query(() => lines, query => query
.Match<Line>(l => l.AcceptsMove(move))
.Collect()
)
;
Then()
.Do(ctx => move.SetStatus(MoveStatus.Processed))
.Do(ctx => ctx.Update(move))
.Do(ctx => grid.SetCellType(move.Row, move.Column, move.Type))
.Do(ctx => ctx.Update(grid))
.Do(ctx => UpdateLines(ctx, lines, move))
;
}
private static void UpdateLines(IContext context, IEnumerable<Line> lines, Move move)
{
foreach (Line line in lines)
{
line.AddMove(move);
context.Update(line);
}
}
}
}Now we have a couple of more pre-conditions to meet.
- The GameStage Stage needs to be Active.
- We have a Move that is in Status Pending.
- The Grid cell that is the target of the Move is Empty.
- We have a set of Lines that will accept the Move.
Note that we have used the AcceptsMove method of Line to determine if the Line can accommodate the Move (is appropriate for the Row and Column and has an Empty spot for the new move Type). We could have instead put the test in the rule itself. Copying the logic from AcceptsMove into the rule would have made it much more complex and harder to debug. The tradeoff is that placing the logic in the rule would allow more flexibility in handling special cases driven by other conditions.
If these conditions are met make some updates.
- Set the Move Status to Processed.
- Set the target Grid cell to the Move Type.
- Update the matched Lines to account for the Move.
Note that we first set the Status for Move to Processed. This prevents the rule from re-activating when the Grid and Lines are updated.
Handling Errors
The requirement of multiple pre-conditions for this rule leads to the question of what happens when they are not all met. More specifically, what happens we have a Pending Move that targets a Grid cell that is already in use? Such a move would be invalid but it might be better to have some sort of response rather than just ignoring it.
In procedural code we might use a “then” clause to handle the error condition. Such a construct is not available here. Instead, we need to create another rule to handle the condition.
Create Engine\Rules\DuplicateMoveRule.cs:
using NRules.Fluent.Dsl;
using TicTacToe.Engine.Model;
namespace TicTacToe.Engine.Rules
{
internal class DuplicateMoveRule : Rule
{
public override void Define()
{
GameStage stage = default!;
Move move = default!;
Grid grid = default!;
When()
.Match(() => stage, gameStage => gameStage.Stage == GameStageType.Active)
.Match(() => move, m => m.Status == MoveStatus.Pending)
.Or(x => x
.Match(() => grid, g => g[move.Row, move.Column] == CellType.X)
.Match(() => grid, g => g[move.Row, move.Column] == CellType.O)
)
;
Then()
.Do(ctx => move.SetStatus(MoveStatus.Error))
.Do(ctx => ctx.Update(move))
.Do(ctx => stage.SetStage(GameStageType.Error))
.Do(ctx => stage.SetErrorType(GameStageErrorType.MoveInUse))
.Do(ctx => ctx.Update(stage))
;
}
}
}This tests for the case where the target of the Pending Move already has a marker from a previous move and sets the error conditions appropriately.
We also have the case where the proposed move is not on the board at all, for example if it specifies column “4”.
Create Engine\Rules\InvalidMoveRule.cs:
using NRules.Fluent.Dsl;
using TicTacToe.Engine.Model;
namespace TicTacToe.Engine.Rules
{
internal class InvalidMoveRule : Rule
{
public override void Define()
{
GameStage stage = default!;
Move move = default!;
Grid grid = default!;
When()
.Match(() => stage, gameStage => gameStage.Stage == GameStageType.Active)
.Match(() => move, m => m.Status == MoveStatus.Pending)
.Match(() => grid, g => g[move.Row, move.Column] == CellType.None);
;
Then()
.Do(ctx => move.SetStatus(MoveStatus.Error))
.Do(ctx => ctx.Update(move))
.Do(ctx => stage.SetStage(GameStageType.Error))
.Do(ctx => stage.SetErrorType(GameStageErrorType.MoveNotValid))
.Do(ctx => ctx.Update(stage))
;
}
}
}Our Grid is set up to respond None when queried about the type of a cell that is out of range.
Ordinarily you would put checks in place to ensure that such a Move is not created in the first place, perhaps by making it impossible to select such an action in a game front end. It is still worthwhile to handle the condition in the backend logic.
Both of these rules cause the game to enter a state that is unrecoverable via the API.
Modify API.cs to add a way out:
using NRules;
using NRules.Fluent;
using System.Reflection;
using TicTacToe.Engine.Model;
namespace TicTacToe
{
public class API
{
...
public void ResetError()
{
if (gameStage.Stage != GameStageType.Error) return;
gameStage.SetStage(GameStageType.Active);
session.Update(gameStage);
session.Fire();
}
...
}
}Since the Move that caused the error has already been transitioned to Status Error, this will not cause the problem to reoccur.
Note that while the above two rules handle error conditions, this does not prevent the pre-condition tests for other rules from being executed on error. For this reason, tests such as
.Match(() => grid, grid => grid[move.Row, move.Column] == CellType.Empty)for MoveRule need to function when the move row/column values are out of bounds.
Ending the Game
If we keep making valid moves we will eventually get to the point where either someone has won or the game has ended in a draw.
For the first case, create Engine\Rules\HaveWinRule.cs:
using NRules.Fluent.Dsl;
using TicTacToe.Engine.Model;
namespace TicTacToe.Engine.Rules
{
internal class HaveWinRule : Rule
{
public override void Define()
{
GameStage stage = default!;
Move move = default!;
IEnumerable<Line> lines = default!;
When()
.Match(() => stage, gameStage => gameStage.Stage == GameStageType.Active)
.Match(() => move, move => move.Status == MoveStatus.Processed)
.Query(() => lines, query => query
.Match<Line>(l => l.HasWin())
.Collect()
.Where(l => l.Any())
)
;
Then()
.Do(ctx => stage.SetStage(GameStageType.Win))
.Do(ctx => stage.SetWinner(lines.First().GetWinType()))
.Do(ctx => ctx.Update(stage))
;
}
}
}Since a single move can create more than one winning three-in-a-row line, the query above collects all of them and the consequent picks one to indicate the win.
For the second case, create Engine\Rules\HaveDrawRule.cs:
using NRules.Fluent.Dsl;
using TicTacToe.Engine.Model;
namespace TicTacToe.Engine.Rules
{
internal class HaveDrawRule : Rule
{
public override void Define()
{
GameStage stage = default!;
Move move = default!;
IEnumerable<Line> lines = default!;
When()
.Match(() => stage, gameStage => gameStage.Stage == GameStageType.Active)
.Match(() => move, move => move.Status == MoveStatus.Processed)
.Query(() => lines, query => query
.Match<Line>(l => l.IsFullMixed())
.Collect()
.Where(l => l.Count() == Line.MaxLines)
)
;
Then()
.Do(ctx => stage.SetStage(GameStageType.Draw))
.Do(ctx => stage.SetWinner(CellType.Empty))
.Do(ctx => ctx.Update(stage))
;
}
}
}This is slightly more complicated. We need to make sure that all of the lines have three filled cells and are not wins.
These two rules depend on the results of MoveRule – when MoveRule sets the Move Status to Processed and updates the Lines, these two rules test the results. As such, they are examples of forward chaining.
Testing
Testing is always a good idea, but it is especially helpful when developing with NRules. I find that even simple changes can cause some mystifying results without insight into what is happening.
I created an MSTest project called TicTacToeTest in the same TicTacToe solution and referenced the main project. I also added some extra methods to API.cs for testing purposes:
using NRules;
using NRules.Diagnostics;
using NRules.Fluent;
using System.Reflection;
using System.Text;
using TicTacToe.Engine.Model;
namespace TicTacToe
{
public class API
{
private readonly RuleRepository ruleRepository;
private readonly ISessionFactory sessionFactory;
private readonly ISession session;
private readonly GameStage gameStage;
private readonly Grid grid;
public void NewGame()
{
gameStage.SetStage(GameStageType.New);
session.Update(gameStage);
session.Fire();
}
public enum Side { X, O, None };
public void Move(Side side, int row, int column)
{
if (side == Side.None) return;
CellType type = side switch
{
Side.X => CellType.X,
Side.O => CellType.O,
_ => CellType.Empty,
};
var move = new Move(type, row, column);
session.Insert(move);
session.Fire();
}
public void ResetError()
{
if (gameStage.Stage != GameStageType.Error) return;
gameStage.SetStage(GameStageType.Active);
session.Update(gameStage);
session.Fire();
}
public List<string> GetRules()
{
List<string> rules = [];
foreach (var rule in ruleRepository.GetRules())
{
rules.Add(rule.Name);
}
return rules;
}
public List<string> GetLines()
{
List<string> lines = [];
foreach (var line in session.Query<Line>())
{
lines.Add(line.ToString());
}
return lines;
}
public List<string> GetGrid()
{
List<string> gridLines = [];
for (int row = Grid.MinRow; row <= Grid.MaxRow; row++)
{
StringBuilder sb = new();
for (int column = Grid.MinColumn; column <= Grid.MaxColumn; column++)
{
sb.Append(grid[row, column] switch {
CellType.Empty => " ",
CellType.X => "X",
CellType.O => "O",
_ => "*" });
}
gridLines.Add(sb.ToString());
}
return gridLines;
}
public enum GameStatus { None, Active, Win, Draw, Error };
public GameStatus GetStatus()
{
return gameStage.Stage switch
{
GameStageType.Active => GameStatus.Active,
GameStageType.Win => GameStatus.Win,
GameStageType.Draw => GameStatus.Draw,
GameStageType.Error => GameStatus.Error,
_ => GameStatus.None,
};
}
public enum ErrorType { None, MoveNotValid, MoveInUse };
public ErrorType GetErrorType()
{
return gameStage.Error switch
{
GameStageErrorType.MoveNotValid => ErrorType.MoveNotValid,
GameStageErrorType.MoveInUse => ErrorType.MoveInUse,
_ => ErrorType.None,
};
}
public Side GetWinner()
{
if (gameStage.Stage == GameStageType.Win)
{
return gameStage.Winner switch
{
CellType.X => Side.X,
CellType.O => Side.O,
_ => Side.None,
};
}
else
{
return Side.None;
}
}
private void RuleFiring(Object? obj, AgendaEventArgs args)
{
Console.WriteLine($"..rule {args.Rule.Name} firing");
}
private void RuleFired(Object? obj, AgendaEventArgs args)
{
Console.WriteLine($"..rule {args.Rule.Name} fired");
}
public API()
{
ruleRepository = new RuleRepository();
ruleRepository.Load(
x => x.PrivateTypes(true).From(Assembly.GetExecutingAssembly()));
sessionFactory = ruleRepository.Compile();
session = sessionFactory.CreateSession();
// initialize model
gameStage = new GameStage(GameStageType.Initial);
session.Insert(gameStage);
grid = new Grid();
session.Insert(grid);
foreach (int item in Enumerable.Range(1, 3))
{
session.Insert(new Line(LineType.Row, item));
session.Insert(new Line(LineType.Column, item));
}
session.Insert(new Line(LineType.Diagonal, Line.MainDiagonal));
session.Insert(new Line(LineType.Diagonal, Line.CounterDiagonal));
session.Events.RuleFiringEvent += RuleFiring;
session.Events.RuleFiredEvent += RuleFired;
}
}
}
Here are some example tests in TestRules.cs in the TicTacToeTest project:
using TicTacToe;
namespace TicTacToeTest
{
[TestClass]
public sealed class TestRules
{
[TestMethod]
public void TestNewGame()
{
var api = new API();
var rules = api.GetRules();
Assert.IsGreaterThan(0, rules.Count);
api.NewGame();
var lines = api.GetLines();
Assert.HasCount(8, lines);
}
[TestMethod]
public void TestMove()
{
var api = new API();
api.NewGame();
api.Move(API.Side.X, 1, 1);
var lines = api.GetLines();
int count = lines.Where(l => l.Contains('X')).Count();
Assert.IsGreaterThan(0, count);
}
...
[TestMethod]
public void TestDuplicateMoveWithReset()
{
var api = new API();
api.NewGame();
api.Move(API.Side.X, 1, 1);
api.Move(API.Side.O, 1, 1);
var status1 = api.GetStatus();
var errorType1 = api.GetErrorType();
Assert.AreEqual(API.GameStatus.Error, status1, "no error");
Assert.AreEqual(API.ErrorType.MoveInUse, errorType1, "error type not correct");
api.ResetError();
api.Move(API.Side.O, 1, 2);
var status2 = api.GetStatus();
Assert.AreEqual(API.GameStatus.Active, status2, "not active");
}
...
}
}A common issue with rules-based processing is determining why a rule doesn’t fire. There is no direct way to track the execution of pre-condition tests, but you can put a test into a static method in the rule and trace that.
For example, modify Engine\Rules\MoveRule.cs:
using NRules.Fluent.Dsl;
using NRules.RuleModel;
using TicTacToe.Engine.Model;
namespace TicTacToe.Engine.Rules
{
internal class MoveRule : Rule
{
public override void Define()
{
GameStage stage = default!;
Grid grid = default!;
Move move = default!;
IEnumerable<Line> lines = default!;
When()
.Match(() => stage, gameStage => gameStage.Stage == GameStageType.Active)
.Match(() => move, move => move.Status == MoveStatus.Pending)
// .Match(() => grid, grid => grid[move.Row, move.Column] == CellType.Empty)
.Match(() => grid, grid => TestGridCell(grid, move.Row, move.Column))
.Query(() => lines, query => query
.Match<Line>(l => l.AcceptsMove(move))
.Collect()
)
;
Then()
.Do(ctx => move.SetStatus(MoveStatus.Processed))
.Do(ctx => ctx.Update(move))
.Do(ctx => grid.SetCellType(move.Row, move.Column, move.Type))
.Do(ctx => ctx.Update(grid))
.Do(ctx => UpdateLines(ctx, lines, move))
;
}
private static bool TestGridCell(Grid grid, int row, int column)
{
bool result = grid[row, column] == CellType.Empty;
Console.WriteLine(
$"..rule {nameof(MoveRule)}: test 'grid[{row}, {column}] == CellType.Empty' = {result}");
return result;
}
private static void UpdateLines(IContext context, IEnumerable<Line> lines, Move move)
{
foreach (Line line in lines)
{
line.AddMove(move);
context.Update(line);
}
}
}
}Now when you run TestDuplicateMoveWithReset from the test suite you get the following output:

Conclusion
In this post I have shown how to use NRules to implement basic validation for a tic-tac-toe game. This implementation just verifies the moves it receives through the API. It does not actually play the game. It does not even verify that the moves are made in alternating player sequence.
In my next post I will expand on this to show how to hook up a simple Blazor web application and also to have the program “play” one of the sides.
0 Comments