Skip to content
Merged
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
37 changes: 37 additions & 0 deletions .plans/future-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,40 @@ frequency statistics.

- [DONE] **DI extension package**: `AddDiceNotation()` service-collection extensions to register `IDice`,
configuration, roller, and tracker cleanly.
- [DONE] **Trimming/AOT annotations**: Annotated the library with `IsTrimmable`/`IsAotCompatible` (plus
the trim and AOT analyzers) so consumers publishing with trimming or Native AOT (for example,
native console or game clients) get full support. This involved replacing the `dynamic`-based
evaluator dispatch in `Evaluator` with a type-safe `switch` expression, adding a source-generated
`DieTrackingJsonContext` (`JsonSerializerContext`) for `DieRollTracker`/`AggregateRollTracker`
JSON serialization, and annotating `UseRollTracker<TTracker, TService>()` with
`DynamicallyAccessedMembers` for its generic service registration.

## Performance and Allocation Enhancements

- **Reduce allocations in the parser/evaluator pipeline**: Profile `Lexer`, `Parser`, and `Evaluator`
for intermediate list/string allocations on hot paths, especially when the same notation is rolled
repeatedly, and consider `Span<char>`-based tokenization to cut down on allocations.
- **Expression caching/precompilation**: Cache the parsed term tree for a notation string (for
example, keyed by the `"4d6+2"` text) so repeated rolls of the same notation skip re-parsing. This
is especially useful for simulations that roll the same expression thousands of times.
- **Pooled buffers for term results**: Use `ArrayPool<T>` or similar pooling for `TermResult` lists in
high-volume Monte Carlo or simulation scenarios to reduce GC pressure.

## API Enhancements

- [DONE] **`IDice` batch rolling**: Added `RollMany(string notation, int count, IDieRoller? dieRoller = null)`
and `RollMany(DiceExpression expression, int count, IDieRoller? dieRoller = null)` returning
`IReadOnlyList<DiceResult>` to support simulation and statistics use cases, pairing naturally with
`IAggregateRollTracker` for aggregating results across the batch.

## Testing and Quality Infrastructure

- **Property-based/fuzz testing**: Add a lightweight generator-based or fuzz testing pass for the
parser to catch edge cases in notation combinations beyond the hand-written unit tests.
- [DONE] **Benchmark project**: Added `benchmarks\d20Tek.DiceNotation.Benchmarks`, a BenchmarkDotNet
project with `NotationRollBenchmarks`, `ExpressionRollBenchmarks`, and `BatchRollBenchmarks`,
using a seeded `RandomDieRoller` for reproducible inputs. `[MemoryDiagnoser]` reports allocations
per operation, giving a baseline to guide the parser/evaluator allocation work above and to guard
against performance regressions over time.
- **Mutation testing**: Run a mutation testing pass (for example, with Stryker.NET) to validate the
strength of the existing unit test suite.
14 changes: 8 additions & 6 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="D20Tek.Functional" Version="1.1.11" />
<PackageVersion Include="D20Tek.Spectre.Console.Extensions" Version="1.57.2" />
<PackageVersion Include="LowDb.Net" Version="1.4.2" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="coverlet.collector" Version="10.1.0" />
<PackageVersion Include="D20Tek.Functional" Version="1.1.12" />
<PackageVersion Include="D20Tek.Spectre.Console.Extensions" Version="1.57.3" />
<PackageVersion Include="LowDb.Net" Version="1.4.3" />
<PackageVersion Include="MathNet.Numerics" Version="5.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.12" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageVersion Include="MSTest.TestAdapter" Version="4.4.1" />
<PackageVersion Include="MSTest.TestFramework" Version="4.4.1" />
<PackageVersion Include="Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers" Version="18.7.37220.1" />
<PackageVersion Include="MSTest.TestAdapter" Version="4.5.1" />
<PackageVersion Include="MSTest.TestFramework" Version="4.5.1" />
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ To build the source code, please read [Building Code](docs/BuildProject.md) page
# Installation
This library is a NuGet package so it is easy to add to your project. To install these packages into your solution, you can use the Package Manager. In PM, please use the following commands:
```
PM > Install-Package d20tek-dicenotation -Version 5.1.7
PM > Install-Package d20tek-dicenotation -Version 5.1.8
```

To install in the Visual Studio UI, go to the Tools menu > "Manage NuGet Packages". Then search for d20Tek.dicenotation and install it from there.
Expand Down
10 changes: 9 additions & 1 deletion ReleaseNotes.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
# Release Notes
These are the release notes for our various updates. You can see how the library has evolved and the latest capabilities.

### Current Release (5.1.7):
### Current Release (5.1.8):
- Updated package references to newest versions.
- Fixed DiceCli commands due to breaking changes in Spectre.Console.Cli Command and AsyncCommand base classes.
- Added RollMany() overloads to IDice for rolling a dice expression multiple times and returning a list of DiceResults.
- Enabled trimming and Native AOT support for the DiceNotation library by replacing dynamic dispatch in Evaluator with a type-safe switch expression, adding a source-generated DieTrackingJsonContext for JSON serialization, and annotating UseRollTracker<TTracker, TService>() with DynamicallyAccessedMembers for generic service registration.
- Added DiceNotation Benchmarks project to measure performance of the DiceNotation library and compare different die rollers.
- Fixed 3 high memory allocation issues in the Lexer, Parser, and Evaluator for hot paths when rolling the same notation repeatedly. Save 78% of allocations when rolling the same notation repeatedly (with improvement to speed too).

### Release (5.1.7):
- Added `AddDiceNotation()` service-collection extensions to register `IDice`, configuration, die roller, and optional roll tracker cleanly through dependency injection.
- Added the `DiceNotationOptionsBuilder` fluent API for configuring die sides, bounded results, the die roller, and roll tracking.
- Added a verbose/structured breakdown to dice results: each rolled die now records its role (kept, dropped, exploded, or rerolled) through the new `DieRollRole` flags enum and the `TermResult.Roles` property.
Expand Down
9 changes: 9 additions & 0 deletions benchmarks/BenchmarkHistory.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Timestamp,GitCommit,Class,Method,Params,Mean_ns,Allocated_bytes
2026-10-12T19:38:29.4861291Z,a2232d0,d20Tek.DiceNotation.Benchmarks.BatchRollBenchmarks,RollMany,Count=10,11270,34385.92
2026-10-12T19:38:29.4861291Z,a2232d0,d20Tek.DiceNotation.Benchmarks.BatchRollBenchmarks,RollMany,Count=100,113950,340817.92
2026-10-12T19:38:29.4861291Z,a2232d0,d20Tek.DiceNotation.Benchmarks.BatchRollBenchmarks,RollMany,Count=1000,1250390,3400960
2026-10-12T19:38:29.4861291Z,a2232d0,d20Tek.DiceNotation.Benchmarks.ExpressionRollBenchmarks,RollExpression,,758.9,2007.04
2026-10-12T19:38:29.4861291Z,a2232d0,d20Tek.DiceNotation.Benchmarks.NotationRollBenchmarks,RollNotation,Notation=(2+1)d4+2d6!6-3,2787,8550.4
2026-10-12T19:38:29.4861291Z,a2232d0,d20Tek.DiceNotation.Benchmarks.NotationRollBenchmarks,RollNotation,Notation=1d20,686.2,2140.16
2026-10-12T19:38:29.4861291Z,a2232d0,d20Tek.DiceNotation.Benchmarks.NotationRollBenchmarks,RollNotation,Notation=4d6+2,1308.4,3379.2
2026-10-12T19:38:29.4861291Z,a2232d0,d20Tek.DiceNotation.Benchmarks.NotationRollBenchmarks,RollNotation,Notation=4d6k3+1d8+5,2377.2,6707.2
29 changes: 29 additions & 0 deletions benchmarks/d20Tek.DiceNotation.Benchmarks/BatchRollBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using BenchmarkDotNet.Attributes;
using d20Tek.DiceNotation.DieRoller;
using d20Tek.DiceNotation.Results;

namespace d20Tek.DiceNotation.Benchmarks;

/// <summary>
/// Benchmarks batch rolling via <see cref="IDice.RollMany(string, int, IDieRoller?)"/> for
/// different batch sizes, representative of simulation and statistics workloads.
/// </summary>
[MemoryDiagnoser]
public class BatchRollBenchmarks
{
private IDice _dice = null!;
private IDieRoller _roller = null!;

[Params(10, 100, 1000)]
public int Count { get; set; }

[GlobalSetup]
public void Setup()
{
_dice = new Dice();
_roller = new RandomDieRoller(seed: 12345);
}

[Benchmark(Baseline = true)]
public IReadOnlyList<DiceResult> RollMany() => _dice.RollMany("4d6+2", Count, _roller);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using BenchmarkDotNet.Attributes;
using d20Tek.DiceNotation.DieRoller;
using d20Tek.DiceNotation.Results;

namespace d20Tek.DiceNotation.Benchmarks;

/// <summary>
/// Benchmarks rolling a pre-built <see cref="DiceExpression"/>, isolating the evaluator/roll
/// pipeline from the lexer and parser.
/// </summary>
[MemoryDiagnoser]
public class ExpressionRollBenchmarks
{
private IDice _dice = null!;
private IDieRoller _roller = null!;
private DiceExpression _expression = null!;

[GlobalSetup]
public void Setup()
{
_dice = new Dice();
_roller = new RandomDieRoller(seed: 12345);
_expression = DiceExpression.Create().AddDice(6, 4, choose: 3).AddDice(8).AddConstant(5);
}

[Benchmark(Baseline = true)]
public DiceResult RollExpression() => _dice.Roll(_expression, _roller);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using BenchmarkDotNet.Attributes;
using d20Tek.DiceNotation.DieRoller;
using d20Tek.DiceNotation.Results;

namespace d20Tek.DiceNotation.Benchmarks;

/// <summary>
/// Benchmarks the end-to-end cost of parsing and rolling dice notation strings, covering the
/// lexer, parser, and evaluator pipeline for a range of expression complexities.
/// </summary>
[MemoryDiagnoser]
public class NotationRollBenchmarks
{
private IDice _dice = null!;
private IDieRoller _roller = null!;

[Params("1d20", "4d6+2", "4d6k3+1d8+5", "(2+1)d4+2d6!6-3")]
public string Notation { get; set; } = "1d20";

[GlobalSetup]
public void Setup()
{
_dice = new Dice();
_roller = new RandomDieRoller(seed: 12345);
}

[Benchmark(Baseline = true)]
public DiceResult RollNotation() => _dice.Roll(Notation, _roller);
}
4 changes: 4 additions & 0 deletions benchmarks/d20Tek.DiceNotation.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
using System.Reflection;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(Assembly.GetExecutingAssembly()).Run(args);
58 changes: 58 additions & 0 deletions benchmarks/d20Tek.DiceNotation.Benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# d20Tek.DiceNotation.Benchmarks

This project contains [BenchmarkDotNet](https://benchmarkdotnet.org/) benchmarks for the
`d20Tek.DiceNotation` library. It measures execution time and allocations across the parsing,
evaluation, and rolling pipeline, to guide performance and allocation reduction work and to guard
against regressions over time.

## Benchmarks

- `NotationRollBenchmarks`: Parses and rolls dice notation strings of increasing complexity
(`1d20`, `4d6+2`, `4d6k3+1d8+5`, `(2+1)d4+2d6!6-3`), covering the full lexer, parser, and
evaluator pipeline.
- `ExpressionRollBenchmarks`: Rolls a pre-built `DiceExpression`, isolating the evaluator/roll
pipeline from the lexer and parser.
- `BatchRollBenchmarks`: Rolls the same notation repeatedly via `IDice.RollMany` for batch sizes of
10, 100, and 1000, representative of simulation and statistics workloads.

All benchmarks use a seeded `RandomDieRoller` so that results are based on a reproducible sequence
of rolls.

## Running the Benchmarks

Benchmarks must be run in the `Release` configuration.

```
dotnet run -c Release --project benchmarks\d20Tek.DiceNotation.Benchmarks
```

To run a specific benchmark class, use the `--filter` option:

```
dotnet run -c Release --project benchmarks\d20Tek.DiceNotation.Benchmarks --filter *NotationRollBenchmarks*
```

Results, including allocation data from `[MemoryDiagnoser]`, are written to the console and to
`BenchmarkDotNet.Artifacts/results` as Markdown, CSV, and HTML reports. The `BenchmarkDotNet.Artifacts`
folder is gitignored since it contains full per-run diagnostics that do not need to be tracked.

## Tracking Results Over Time (Regression Detection)

To guard against speed/allocation regressions without committing full diagnostic artifacts, use
`Update-BenchmarkHistory.ps1`. It runs the benchmarks, then appends one trimmed row per
benchmark/parameter combination (timestamp, git commit, class, method, params, mean time,
allocated bytes/op) to `benchmarks\BenchmarkHistory.csv`, which is tracked in source control.

```
.\benchmarks\d20Tek.DiceNotation.Benchmarks\Update-BenchmarkHistory.ps1
```

To scope to a subset of benchmarks:

```
.\benchmarks\d20Tek.DiceNotation.Benchmarks\Update-BenchmarkHistory.ps1 -Filter "*NotationRollBenchmarks*"
```

Diff `BenchmarkHistory.csv` against a prior commit (or open it in Excel/a CSV viewer) to compare
`Mean_ns`/`Allocated_bytes` across runs and confirm a change improved, rather than regressed,
performance.
Loading
Loading