diff --git a/.plans/future-features.md b/.plans/future-features.md index 35c775b..7a2d906 100644 --- a/.plans/future-features.md +++ b/.plans/future-features.md @@ -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()` 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`-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` 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` 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. diff --git a/Directory.Packages.props b/Directory.Packages.props index 9296df1..db13f9e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,15 +3,17 @@ true - - - - + + + + + - - + + + \ No newline at end of file diff --git a/README.md b/README.md index b4685f9..31706a1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 0536ddd..521512f 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -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() 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. diff --git a/benchmarks/BenchmarkHistory.csv b/benchmarks/BenchmarkHistory.csv new file mode 100644 index 0000000..f337784 --- /dev/null +++ b/benchmarks/BenchmarkHistory.csv @@ -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 diff --git a/benchmarks/d20Tek.DiceNotation.Benchmarks/BatchRollBenchmarks.cs b/benchmarks/d20Tek.DiceNotation.Benchmarks/BatchRollBenchmarks.cs new file mode 100644 index 0000000..aee7248 --- /dev/null +++ b/benchmarks/d20Tek.DiceNotation.Benchmarks/BatchRollBenchmarks.cs @@ -0,0 +1,29 @@ +using BenchmarkDotNet.Attributes; +using d20Tek.DiceNotation.DieRoller; +using d20Tek.DiceNotation.Results; + +namespace d20Tek.DiceNotation.Benchmarks; + +/// +/// Benchmarks batch rolling via for +/// different batch sizes, representative of simulation and statistics workloads. +/// +[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 RollMany() => _dice.RollMany("4d6+2", Count, _roller); +} diff --git a/benchmarks/d20Tek.DiceNotation.Benchmarks/ExpressionRollBenchmarks.cs b/benchmarks/d20Tek.DiceNotation.Benchmarks/ExpressionRollBenchmarks.cs new file mode 100644 index 0000000..3d3297a --- /dev/null +++ b/benchmarks/d20Tek.DiceNotation.Benchmarks/ExpressionRollBenchmarks.cs @@ -0,0 +1,28 @@ +using BenchmarkDotNet.Attributes; +using d20Tek.DiceNotation.DieRoller; +using d20Tek.DiceNotation.Results; + +namespace d20Tek.DiceNotation.Benchmarks; + +/// +/// Benchmarks rolling a pre-built , isolating the evaluator/roll +/// pipeline from the lexer and parser. +/// +[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); +} diff --git a/benchmarks/d20Tek.DiceNotation.Benchmarks/NotationRollBenchmarks.cs b/benchmarks/d20Tek.DiceNotation.Benchmarks/NotationRollBenchmarks.cs new file mode 100644 index 0000000..2139c4c --- /dev/null +++ b/benchmarks/d20Tek.DiceNotation.Benchmarks/NotationRollBenchmarks.cs @@ -0,0 +1,29 @@ +using BenchmarkDotNet.Attributes; +using d20Tek.DiceNotation.DieRoller; +using d20Tek.DiceNotation.Results; + +namespace d20Tek.DiceNotation.Benchmarks; + +/// +/// 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. +/// +[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); +} diff --git a/benchmarks/d20Tek.DiceNotation.Benchmarks/Program.cs b/benchmarks/d20Tek.DiceNotation.Benchmarks/Program.cs new file mode 100644 index 0000000..e386ad5 --- /dev/null +++ b/benchmarks/d20Tek.DiceNotation.Benchmarks/Program.cs @@ -0,0 +1,4 @@ +using System.Reflection; +using BenchmarkDotNet.Running; + +BenchmarkSwitcher.FromAssembly(Assembly.GetExecutingAssembly()).Run(args); diff --git a/benchmarks/d20Tek.DiceNotation.Benchmarks/README.md b/benchmarks/d20Tek.DiceNotation.Benchmarks/README.md new file mode 100644 index 0000000..9177b3c --- /dev/null +++ b/benchmarks/d20Tek.DiceNotation.Benchmarks/README.md @@ -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. diff --git a/benchmarks/d20Tek.DiceNotation.Benchmarks/Update-BenchmarkHistory.ps1 b/benchmarks/d20Tek.DiceNotation.Benchmarks/Update-BenchmarkHistory.ps1 new file mode 100644 index 0000000..69dbf11 --- /dev/null +++ b/benchmarks/d20Tek.DiceNotation.Benchmarks/Update-BenchmarkHistory.ps1 @@ -0,0 +1,146 @@ +<# +.SYNOPSIS + Runs the d20Tek.DiceNotation benchmarks and appends trimmed Mean/Allocated results to a + tracked history CSV, for lightweight regression detection over time. + +.DESCRIPTION + BenchmarkDotNet writes full diagnostic reports to BenchmarkDotNet.Artifacts/ (gitignored, + since it is large and regenerated per machine/run). This script runs the benchmarks, reads + the resulting "*-report.csv" files BenchmarkDotNet already produces, and appends one row per + benchmark/parameter combination to benchmarks\BenchmarkHistory.csv (tracked in git) with just + the fields needed to spot speed or allocation regressions: timestamp, git commit, class, + method, params, mean (ns), and allocated bytes/op. + +.PARAMETER Filter + Optional BenchmarkDotNet --filter glob (for example "*NotationRollBenchmarks*"). Defaults to + running all benchmarks. + +.PARAMETER SkipRun + If set, skips running the benchmarks and only parses the most recent artifacts already on + disk. Useful for re-appending history from a run you already have. + +.EXAMPLE + .\Update-BenchmarkHistory.ps1 + .\Update-BenchmarkHistory.ps1 -Filter "*NotationRollBenchmarks*" +#> +param( + [string]$Filter = "*", + [switch]$SkipRun +) + +$ErrorActionPreference = "Stop" + +$scriptDir = $PSScriptRoot +$projectDir = $scriptDir +$solutionDir = Split-Path (Split-Path $projectDir -Parent) -Parent +$artifactsResultsDir = Join-Path $solutionDir "BenchmarkDotNet.Artifacts\results" +$historyPath = Join-Path (Split-Path $projectDir -Parent) "BenchmarkHistory.csv" + +if (-not $SkipRun) { + Write-Host "Running benchmarks (filter: $Filter)..." + dotnet run -c Release --project $projectDir -- --filter $Filter + if ($LASTEXITCODE -ne 0) { + throw "Benchmark run failed with exit code $LASTEXITCODE." + } +} + +if (-not (Test-Path $artifactsResultsDir)) { + throw "No BenchmarkDotNet artifacts found at '$artifactsResultsDir'. Run without -SkipRun first." +} + +$gitCommit = "unknown" +if (Get-Command git -ErrorAction SilentlyContinue) { + $result = git rev-parse --short HEAD 2>$null + if ($LASTEXITCODE -eq 0 -and $result) { $gitCommit = $result } +} +$timestamp = (Get-Date).ToUniversalTime().ToString("o") + +if (-not (Test-Path $historyPath)) { + "Timestamp,GitCommit,Class,Method,Params,Mean_ns,Allocated_bytes" | Out-File -FilePath $historyPath -Encoding utf8 +} + +$reportFiles = Get-ChildItem -Path $artifactsResultsDir -Filter "*-report.csv" +if ($reportFiles.Count -eq 0) { + throw "No '*-report.csv' files found in '$artifactsResultsDir'." +} + +function ConvertTo-Nanoseconds([string]$value) { + if ($value -match '^\s*([\d.,]+)\s*([a-zA-Z\u00b5\u03bc]+)\s*$') { + $number = [double]::Parse($matches[1], [System.Globalization.CultureInfo]::InvariantCulture) + $unit = $matches[2] + switch -Regex ($unit) { + '^ns$' { return $number } + '^ms$' { return $number * 1000000 } + '^s$' { return $number * 1000000000 } + '^.?s$' { return $number * 1000 } # matches "us"/"µs"/"μs" (any single lead char + s) + } + } + return $null +} + +function ConvertTo-Bytes([string]$value) { + if ($value -match '^\s*([\d.,]+)\s*(B|KB|MB|GB)\s*$') { + $number = [double]::Parse($matches[1], [System.Globalization.CultureInfo]::InvariantCulture) + switch ($matches[2]) { + "B" { return $number } + "KB" { return $number * 1024 } + "MB" { return $number * 1024 * 1024 } + "GB" { return $number * 1024 * 1024 * 1024 } + } + } + return $null +} + +$rows = @() +foreach ($file in $reportFiles) { + $className = $file.BaseName -replace '-report$', '' + $records = Import-Csv -Path $file.FullName + foreach ($record in $records) { + $meanNs = ConvertTo-Nanoseconds $record.Mean + $allocatedBytes = ConvertTo-Bytes $record.Allocated + + # Collect any extra [Params]-driven columns (anything not a known BenchmarkDotNet column) + $knownColumns = @( + "Method","Job","AnalyzeLaunchVariance","EvaluateOverhead","MaxAbsoluteError", + "MaxRelativeError","MinInvokeCount","MinIterationTime","OutlierMode","Affinity", + "EnvironmentVariables","Jit","LargeAddressAware","Platform","PowerPlanMode","Runtime", + "AllowVeryLargeObjects","Concurrent","CpuGroups","Force","HeapAffinitizeMask", + "HeapCount","NoAffinitize","RetainVm","Server","Arguments","BuildConfiguration","Clock", + "EngineFactory","NuGetReferences","Toolchain","IsMutator","InvocationCount", + "IterationCount","IterationTime","LaunchCount","MaxIterationCount", + "MaxWarmupIterationCount","MemoryRandomization","MinIterationCount", + "MinWarmupIterationCount","RunStrategy","UnrollFactor","WarmupCount","Mean","Error", + "StdDev","StdErr","Median","Min","Max","Q1","Q3","Op/s","Ratio","RatioSD","Gen0","Gen1", + "Gen2","Allocated","Alloc Ratio","Completed Workitems","Lock Contentions" + ) + $paramColumns = $record.PSObject.Properties.Name | Where-Object { $_ -notin $knownColumns } + $paramsText = ($paramColumns | ForEach-Object { "$_=$($record.$_)" }) -join ";" + + $rows += [PSCustomObject]@{ + Timestamp = $timestamp + GitCommit = $gitCommit + Class = $className + Method = $record.Method + Params = $paramsText + Mean_ns = $meanNs + Allocated_bytes = $allocatedBytes + } + } +} + +function ConvertTo-CsvField([string]$value) { + if ($null -eq $value) { return "" } + if ($value -match '[,"\r\n]') { + return '"' + ($value -replace '"', '""') + '"' + } + return $value +} + +$rows | ForEach-Object { + $line = "{0},{1},{2},{3},{4},{5},{6}" -f ` + (ConvertTo-CsvField $_.Timestamp), (ConvertTo-CsvField $_.GitCommit), (ConvertTo-CsvField $_.Class), ` + (ConvertTo-CsvField $_.Method), (ConvertTo-CsvField $_.Params), $_.Mean_ns, $_.Allocated_bytes + $line | Out-File -FilePath $historyPath -Append -Encoding utf8 +} + +Write-Host "Appended $($rows.Count) row(s) to $historyPath" diff --git a/benchmarks/d20Tek.DiceNotation.Benchmarks/d20Tek.DiceNotation.Benchmarks.csproj b/benchmarks/d20Tek.DiceNotation.Benchmarks/d20Tek.DiceNotation.Benchmarks.csproj new file mode 100644 index 0000000..a884d51 --- /dev/null +++ b/benchmarks/d20Tek.DiceNotation.Benchmarks/d20Tek.DiceNotation.Benchmarks.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + enable + enable + false + false + + + + + + + + + + + + diff --git a/dicenotation.sln b/dicenotation.sln index 4270987..ce2aedf 100644 --- a/dicenotation.sln +++ b/dicenotation.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.10.12201.205 stable +VisualStudioVersion = 18.10.12201.205 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "d20Tek.DiceNotation", "src\d20Tek.DiceNotation\d20Tek.DiceNotation.csproj", "{FA511E01-EAE2-4810-85EB-82684C017B68}" EndProject @@ -16,30 +16,75 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".plans", ".plans", "{33DAD1 .plans\future-features.md = .plans\future-features.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{66320409-64EC-F7C5-3DEF-65E7510DAAD1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "d20Tek.DiceNotation.Benchmarks", "benchmarks\d20Tek.DiceNotation.Benchmarks\d20Tek.DiceNotation.Benchmarks.csproj", "{44AC60BE-85A7-4D32-98C3-8C9D056C45CA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {FA511E01-EAE2-4810-85EB-82684C017B68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FA511E01-EAE2-4810-85EB-82684C017B68}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA511E01-EAE2-4810-85EB-82684C017B68}.Debug|x64.ActiveCfg = Debug|Any CPU + {FA511E01-EAE2-4810-85EB-82684C017B68}.Debug|x64.Build.0 = Debug|Any CPU + {FA511E01-EAE2-4810-85EB-82684C017B68}.Debug|x86.ActiveCfg = Debug|Any CPU + {FA511E01-EAE2-4810-85EB-82684C017B68}.Debug|x86.Build.0 = Debug|Any CPU {FA511E01-EAE2-4810-85EB-82684C017B68}.Release|Any CPU.ActiveCfg = Release|Any CPU {FA511E01-EAE2-4810-85EB-82684C017B68}.Release|Any CPU.Build.0 = Release|Any CPU + {FA511E01-EAE2-4810-85EB-82684C017B68}.Release|x64.ActiveCfg = Release|Any CPU + {FA511E01-EAE2-4810-85EB-82684C017B68}.Release|x64.Build.0 = Release|Any CPU + {FA511E01-EAE2-4810-85EB-82684C017B68}.Release|x86.ActiveCfg = Release|Any CPU + {FA511E01-EAE2-4810-85EB-82684C017B68}.Release|x86.Build.0 = Release|Any CPU {E361945C-A40B-4B44-9F2D-6F230249FFC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E361945C-A40B-4B44-9F2D-6F230249FFC4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E361945C-A40B-4B44-9F2D-6F230249FFC4}.Debug|x64.ActiveCfg = Debug|Any CPU + {E361945C-A40B-4B44-9F2D-6F230249FFC4}.Debug|x64.Build.0 = Debug|Any CPU + {E361945C-A40B-4B44-9F2D-6F230249FFC4}.Debug|x86.ActiveCfg = Debug|Any CPU + {E361945C-A40B-4B44-9F2D-6F230249FFC4}.Debug|x86.Build.0 = Debug|Any CPU {E361945C-A40B-4B44-9F2D-6F230249FFC4}.Release|Any CPU.ActiveCfg = Release|Any CPU {E361945C-A40B-4B44-9F2D-6F230249FFC4}.Release|Any CPU.Build.0 = Release|Any CPU + {E361945C-A40B-4B44-9F2D-6F230249FFC4}.Release|x64.ActiveCfg = Release|Any CPU + {E361945C-A40B-4B44-9F2D-6F230249FFC4}.Release|x64.Build.0 = Release|Any CPU + {E361945C-A40B-4B44-9F2D-6F230249FFC4}.Release|x86.ActiveCfg = Release|Any CPU + {E361945C-A40B-4B44-9F2D-6F230249FFC4}.Release|x86.Build.0 = Release|Any CPU {14FE8E35-5FCC-4041-8BD6-22A189C841ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {14FE8E35-5FCC-4041-8BD6-22A189C841ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {14FE8E35-5FCC-4041-8BD6-22A189C841ED}.Debug|x64.ActiveCfg = Debug|Any CPU + {14FE8E35-5FCC-4041-8BD6-22A189C841ED}.Debug|x64.Build.0 = Debug|Any CPU + {14FE8E35-5FCC-4041-8BD6-22A189C841ED}.Debug|x86.ActiveCfg = Debug|Any CPU + {14FE8E35-5FCC-4041-8BD6-22A189C841ED}.Debug|x86.Build.0 = Debug|Any CPU {14FE8E35-5FCC-4041-8BD6-22A189C841ED}.Release|Any CPU.ActiveCfg = Release|Any CPU {14FE8E35-5FCC-4041-8BD6-22A189C841ED}.Release|Any CPU.Build.0 = Release|Any CPU + {14FE8E35-5FCC-4041-8BD6-22A189C841ED}.Release|x64.ActiveCfg = Release|Any CPU + {14FE8E35-5FCC-4041-8BD6-22A189C841ED}.Release|x64.Build.0 = Release|Any CPU + {14FE8E35-5FCC-4041-8BD6-22A189C841ED}.Release|x86.ActiveCfg = Release|Any CPU + {14FE8E35-5FCC-4041-8BD6-22A189C841ED}.Release|x86.Build.0 = Release|Any CPU + {44AC60BE-85A7-4D32-98C3-8C9D056C45CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44AC60BE-85A7-4D32-98C3-8C9D056C45CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44AC60BE-85A7-4D32-98C3-8C9D056C45CA}.Debug|x64.ActiveCfg = Debug|Any CPU + {44AC60BE-85A7-4D32-98C3-8C9D056C45CA}.Debug|x64.Build.0 = Debug|Any CPU + {44AC60BE-85A7-4D32-98C3-8C9D056C45CA}.Debug|x86.ActiveCfg = Debug|Any CPU + {44AC60BE-85A7-4D32-98C3-8C9D056C45CA}.Debug|x86.Build.0 = Debug|Any CPU + {44AC60BE-85A7-4D32-98C3-8C9D056C45CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44AC60BE-85A7-4D32-98C3-8C9D056C45CA}.Release|Any CPU.Build.0 = Release|Any CPU + {44AC60BE-85A7-4D32-98C3-8C9D056C45CA}.Release|x64.ActiveCfg = Release|Any CPU + {44AC60BE-85A7-4D32-98C3-8C9D056C45CA}.Release|x64.Build.0 = Release|Any CPU + {44AC60BE-85A7-4D32-98C3-8C9D056C45CA}.Release|x86.ActiveCfg = Release|Any CPU + {44AC60BE-85A7-4D32-98C3-8C9D056C45CA}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {14FE8E35-5FCC-4041-8BD6-22A189C841ED} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {44AC60BE-85A7-4D32-98C3-8C9D056C45CA} = {66320409-64EC-F7C5-3DEF-65E7510DAAD1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {28421BFA-9DC6-4A11-9E57-0F8D8E6E3EC1} diff --git a/samples/DiceCli/Commands/Favorites/AddFavoriteCommand.cs b/samples/DiceCli/Commands/Favorites/AddFavoriteCommand.cs index 3017115..34a1713 100644 --- a/samples/DiceCli/Commands/Favorites/AddFavoriteCommand.cs +++ b/samples/DiceCli/Commands/Favorites/AddFavoriteCommand.cs @@ -23,7 +23,7 @@ public class Settings : CommandSettings private readonly IAnsiConsole _console = console; private readonly LowDb _db = db; - protected override int Execute(CommandContext context, Settings request, CancellationToken _) + public override int Execute(CommandContext context, Settings request, CancellationToken _) { _console.WriteMessages("Add Favorite", "------------"); var favoriteRoll = CreateFavoriteRoll(request); diff --git a/samples/DiceCli/Commands/Favorites/DeleteFavoriteCommand.cs b/samples/DiceCli/Commands/Favorites/DeleteFavoriteCommand.cs index 70df87b..5ffb31f 100644 --- a/samples/DiceCli/Commands/Favorites/DeleteFavoriteCommand.cs +++ b/samples/DiceCli/Commands/Favorites/DeleteFavoriteCommand.cs @@ -15,7 +15,7 @@ public class Settings : CommandSettings private readonly IAnsiConsole _console = console; private readonly LowDb _db = db; - protected override int Execute(CommandContext context, Settings request, CancellationToken _) + public override int Execute(CommandContext context, Settings request, CancellationToken _) { _console.WriteMessages("Delete Favorite", "---------------"); diff --git a/samples/DiceCli/Commands/Favorites/EditFavoriteCommand.cs b/samples/DiceCli/Commands/Favorites/EditFavoriteCommand.cs index ebf6cc9..11f3228 100644 --- a/samples/DiceCli/Commands/Favorites/EditFavoriteCommand.cs +++ b/samples/DiceCli/Commands/Favorites/EditFavoriteCommand.cs @@ -23,7 +23,7 @@ public class Settings : CommandSettings private readonly IAnsiConsole _console = console; private readonly LowDb _db = db; - protected override int Execute(CommandContext context, Settings request, CancellationToken _) + public override int Execute(CommandContext context, Settings request, CancellationToken _) { _console.WriteMessages("Edit Favorite", "-------------"); var id = _console.PromptIfNull(request.Id, "Enter the identifier of favorite to edit:"); diff --git a/samples/DiceCli/Commands/Favorites/ListFavoritesCommand.cs b/samples/DiceCli/Commands/Favorites/ListFavoritesCommand.cs index 059e2c0..1e8185e 100644 --- a/samples/DiceCli/Commands/Favorites/ListFavoritesCommand.cs +++ b/samples/DiceCli/Commands/Favorites/ListFavoritesCommand.cs @@ -7,7 +7,7 @@ internal class ListFavoritesCommand(IAnsiConsole console, LowDb _db = db; - protected override int Execute(CommandContext context, CancellationToken _) => + public override int Execute(CommandContext context, CancellationToken _) => _db.Get().ToIdentity() .Iter(favoriteDoc => (favoriteDoc.Rolls.Count is 0).IfTrueOrElse( diff --git a/samples/DiceCli/Commands/Favorites/RollFavoriteCommand.cs b/samples/DiceCli/Commands/Favorites/RollFavoriteCommand.cs index 3e4bdcd..c7a6829 100644 --- a/samples/DiceCli/Commands/Favorites/RollFavoriteCommand.cs +++ b/samples/DiceCli/Commands/Favorites/RollFavoriteCommand.cs @@ -17,7 +17,7 @@ public class Settings : CommandSettings private readonly LowDb _db = db; private readonly IDice _dice = dice; - protected override int Execute(CommandContext context, Settings request, CancellationToken _) + public override int Execute(CommandContext context, Settings request, CancellationToken _) { _console.WriteMessages("Roll Favorite", "-------------"); diff --git a/samples/DiceCli/Commands/NotationCommand.cs b/samples/DiceCli/Commands/NotationCommand.cs index d051e99..91324df 100644 --- a/samples/DiceCli/Commands/NotationCommand.cs +++ b/samples/DiceCli/Commands/NotationCommand.cs @@ -4,7 +4,7 @@ internal sealed class NotationCommand(IAnsiConsole console) : Command { private readonly IAnsiConsole _console = console; - protected override int Execute(CommandContext context, CancellationToken cancellation) + public override int Execute(CommandContext context, CancellationToken cancellation) { _console.WriteMessages( "[bold]Basic Dice Notation[/]", diff --git a/samples/DiceCli/Commands/RollCommand.cs b/samples/DiceCli/Commands/RollCommand.cs index 282eb30..1986866 100644 --- a/samples/DiceCli/Commands/RollCommand.cs +++ b/samples/DiceCli/Commands/RollCommand.cs @@ -15,7 +15,7 @@ public sealed class Request : CommandSettings private readonly IAnsiConsole _console = console; private readonly IDice _dice = dice; - protected override int Execute(CommandContext context, Request request, CancellationToken cancellation) + public override int Execute(CommandContext context, Request request, CancellationToken cancellation) { _console.MarkupLine($"[yellow]Rolling dice for '{request.Notation}'[/]:"); diff --git a/src/d20Tek.DiceNotation/DependencyInjection/DiceNotationOptionsBuilder.cs b/src/d20Tek.DiceNotation/DependencyInjection/DiceNotationOptionsBuilder.cs index 1a65e46..7cac40d 100644 --- a/src/d20Tek.DiceNotation/DependencyInjection/DiceNotationOptionsBuilder.cs +++ b/src/d20Tek.DiceNotation/DependencyInjection/DiceNotationOptionsBuilder.cs @@ -1,5 +1,6 @@ using d20Tek.DiceNotation.DieRoller; using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics.CodeAnalysis; namespace d20Tek.DiceNotation.DependencyInjection; @@ -137,7 +138,9 @@ public DiceNotationOptionsBuilder UseAggregateRollTracker() => /// The concrete tracker implementation type. /// The tracker service interface to expose. /// The current instance for chaining. - public DiceNotationOptionsBuilder UseRollTracker() + public DiceNotationOptionsBuilder UseRollTracker< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTracker, + TService>() where TTracker : class, TService, IAllowRollTrackerEntry where TService : class { diff --git a/src/d20Tek.DiceNotation/Dice.cs b/src/d20Tek.DiceNotation/Dice.cs index 1f95540..195d90a 100644 --- a/src/d20Tek.DiceNotation/Dice.cs +++ b/src/d20Tek.DiceNotation/Dice.cs @@ -33,6 +33,25 @@ public DiceResult Roll(string notation, IDieRoller? dieRoller = null) => public DiceResult Roll(DiceExpression expression, IDieRoller? dieRoller = null) => RollTerms(expression.Evaluate(), dieRoller ?? Configuration.DefaultDieRoller); + /// + public IReadOnlyList RollMany(string notation, int count, IDieRoller? dieRoller = null) + { + ArgumentOutOfRangeException.ThrowIfLessThan(count, 1, nameof(count)); + var roller = dieRoller ?? Configuration.DefaultDieRoller; + + return [.. Enumerable.Range(0, count).Select(_ => Roll(notation, roller))]; + } + + /// + public IReadOnlyList RollMany(DiceExpression expression, int count, IDieRoller? dieRoller = null) + { + ArgumentOutOfRangeException.ThrowIfLessThan(count, 1, nameof(count)); + var roller = dieRoller ?? Configuration.DefaultDieRoller; + var expressionTerms = expression.Evaluate(); + + return [.. Enumerable.Range(0, count).Select(_ => RollTerms(expressionTerms, roller))]; + } + private DiceResult RollTerms(IReadOnlyList expresssionTerms, IDieRoller dieRoller) => new( Constants.JoinSigns(expresssionTerms), diff --git a/src/d20Tek.DiceNotation/DieRoller/AggregateRollTracker.cs b/src/d20Tek.DiceNotation/DieRoller/AggregateRollTracker.cs index ed6a727..c426124 100644 --- a/src/d20Tek.DiceNotation/DieRoller/AggregateRollTracker.cs +++ b/src/d20Tek.DiceNotation/DieRoller/AggregateRollTracker.cs @@ -67,11 +67,12 @@ public IList GetFrequencyDataView() public void LoadFromJson(string jsonText) { if (string.IsNullOrEmpty(jsonText)) return; - _aggRollData = JsonSerializer.Deserialize>(jsonText)!; + _aggRollData = JsonSerializer.Deserialize(jsonText, DieTrackingJsonContext.Default.ListAggregateDieTrackingData)!; } /// - public string ToJson() => JsonSerializer.Serialize(_aggRollData); + public string ToJson() => + JsonSerializer.Serialize(_aggRollData, DieTrackingJsonContext.Default.ListAggregateDieTrackingData); private List GetOrderedData() => [.. _aggRollData.OrderBy(p => p.RollerType).ThenBy(p => p.DieSides).ThenBy(p => p.Result)]; diff --git a/src/d20Tek.DiceNotation/DieRoller/DieRollTracker.cs b/src/d20Tek.DiceNotation/DieRoller/DieRollTracker.cs index e83ffcb..02ed518 100644 --- a/src/d20Tek.DiceNotation/DieRoller/DieRollTracker.cs +++ b/src/d20Tek.DiceNotation/DieRoller/DieRollTracker.cs @@ -36,7 +36,7 @@ public async Task> GetTrackingDataAsync(string? dieType = public async Task ToJsonAsync() => await Task.Run(() => { rollData = [.. GetTrimmedData()]; - return JsonSerializer.Serialize(rollData); + return JsonSerializer.Serialize(rollData, DieTrackingJsonContext.Default.ListDieTrackingData); }); /// @@ -44,7 +44,7 @@ public async Task LoadFromJsonAsync(string jsonText) => await Task.Run(() => { if (string.IsNullOrEmpty(jsonText)) return; - var data = JsonSerializer.Deserialize>(jsonText)!; + var data = JsonSerializer.Deserialize(jsonText, DieTrackingJsonContext.Default.ListDieTrackingData)!; rollData = [.. data.Take(TrackerDataLimit)]; }); diff --git a/src/d20Tek.DiceNotation/DieRoller/DieTrackingJsonContext.cs b/src/d20Tek.DiceNotation/DieRoller/DieTrackingJsonContext.cs new file mode 100644 index 0000000..1ed8927 --- /dev/null +++ b/src/d20Tek.DiceNotation/DieRoller/DieTrackingJsonContext.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace d20Tek.DiceNotation.DieRoller; + +/// +/// Provides a source-generated JSON serializer context for the roll tracking data types, so that +/// tracker serialization works without reflection and is compatible with trimming and Native AOT. +/// +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +internal sealed partial class DieTrackingJsonContext : JsonSerializerContext +{ +} diff --git a/src/d20Tek.DiceNotation/IDice.cs b/src/d20Tek.DiceNotation/IDice.cs index 91d71aa..38dd677 100644 --- a/src/d20Tek.DiceNotation/IDice.cs +++ b/src/d20Tek.DiceNotation/IDice.cs @@ -33,4 +33,42 @@ public interface IDice /// /// A containing the outcome of the roll. DiceResult Roll(string notation, IDieRoller? dieRoller = null); + + /// + /// Rolls the specified dice expression multiple times and returns the calculated result for + /// each roll. + /// + /// The dice expression to evaluate. + /// The number of times to roll the expression. + /// + /// The die roller used to generate random values, or to use the + /// configured default die roller. + /// + /// + /// A list of instances, one for each roll, in the order they were + /// produced. + /// + /// + /// Thrown when is less than 1. + /// + IReadOnlyList RollMany(DiceExpression expression, int count, IDieRoller? dieRoller = null); + + /// + /// Parses the specified dice notation and rolls it multiple times, returning the calculated + /// result for each roll. + /// + /// The dice notation to parse and evaluate (for example, "3d6+1"). + /// The number of times to roll the notation. + /// + /// The die roller used to generate random values, or to use the + /// configured default die roller. + /// + /// + /// A list of instances, one for each roll, in the order they were + /// produced. + /// + /// + /// Thrown when is less than 1. + /// + IReadOnlyList RollMany(string notation, int count, IDieRoller? dieRoller = null); } diff --git a/src/d20Tek.DiceNotation/Parser/Evaluator.cs b/src/d20Tek.DiceNotation/Parser/Evaluator.cs index d274e32..b16cd62 100644 --- a/src/d20Tek.DiceNotation/Parser/Evaluator.cs +++ b/src/d20Tek.DiceNotation/Parser/Evaluator.cs @@ -8,16 +8,23 @@ namespace d20Tek.DiceNotation.Parser; /// public sealed class Evaluator { - private static readonly ModifierEvaluator _modsEval = new(); - private static readonly Dictionary _evaluatorlets = new() + private readonly ModifierEvaluator _modsEval = new(); + private readonly NumberEvaluator _numberEval = new(); + private readonly UnaryEvaluator _unaryEval = new(); + private readonly BinaryEvaluator _binaryEval = new(); + private readonly GroupEvaluator _groupEval; + private readonly DiceEvaluator _diceEval; + private readonly FudgeDiceEvaluator _fudgeEval; + + /// + /// Initializes a new instance of the class. + /// + public Evaluator() { - { typeof(NumberExpression), new NumberEvaluator() }, - { typeof(UnaryExpression), new UnaryEvaluator() }, - { typeof(BinaryExpression), new BinaryEvaluator() }, - { typeof(GroupExpression), new GroupEvaluator() }, - { typeof(DiceExpression), new DiceEvaluator(_modsEval) }, - { typeof(FudgeExpression), new FudgeDiceEvaluator(_modsEval) }, - }; + _groupEval = new GroupEvaluator(); + _diceEval = new DiceEvaluator(_modsEval); + _fudgeEval = new FudgeDiceEvaluator(_modsEval); + } /// /// Parses and evaluates the specified dice notation into a . @@ -48,8 +55,14 @@ public DiceResult Evaluate(string notation, IDieRoller roller, IDiceConfiguratio (ex is ParseException || ex is EvalException) ? ex.Message : Constants.Errors.UnknownException(ex.Message), notation); - internal int EvalInternal(Expression expr, IDieRoller roller, List terms) => - _evaluatorlets.TryGetValue(expr.GetType(), out var eval) - ? ((dynamic)eval).Eval(this, roller, terms, (dynamic)expr) - : throw new EvalException(Constants.Errors.UnknownExpression(expr)); + internal int EvalInternal(Expression expr, IDieRoller roller, List terms) => expr switch + { + NumberExpression num => _numberEval.Eval(this, roller, terms, num), + UnaryExpression unary => _unaryEval.Eval(this, roller, terms, unary), + BinaryExpression binary => _binaryEval.Eval(this, roller, terms, binary), + GroupExpression group => _groupEval.Eval(this, roller, terms, group), + DiceExpression dice => _diceEval.Eval(this, roller, terms, dice), + FudgeExpression fudge => _fudgeEval.Eval(this, roller, terms, fudge), + _ => throw new EvalException(Constants.Errors.UnknownExpression(expr)), + }; } diff --git a/src/d20Tek.DiceNotation/Parser/Evaluatorlets/ModifierEvaluator.cs b/src/d20Tek.DiceNotation/Parser/Evaluatorlets/ModifierEvaluator.cs index b54de85..a90a9f2 100644 --- a/src/d20Tek.DiceNotation/Parser/Evaluatorlets/ModifierEvaluator.cs +++ b/src/d20Tek.DiceNotation/Parser/Evaluatorlets/ModifierEvaluator.cs @@ -11,7 +11,16 @@ internal class ModifierEvaluator IDieRoller roller, List terms) { - var selectMod = mods.OfType().LastOrDefault(); + SelectModifier? selectMod = null; + for (int i = mods.Count - 1; i >= 0; i--) + { + if (mods[i] is SelectModifier sm) + { + selectMod = sm; + break; + } + } + return selectMod switch { { Kind: SelectKind.KeepHigh } => evaluator.EvalInternal(selectMod.CountArg, roller, terms), @@ -28,7 +37,16 @@ internal class ModifierEvaluator IDieRoller roller, List terms) { - var mod = mods.OfType().LastOrDefault(); + ExplodingModifier? mod = null; + for (int i = mods.Count - 1; i >= 0; i--) + { + if (mods[i] is ExplodingModifier em) + { + mod = em; + break; + } + } + if (mod is null) return null; return mod.ThresholdArg is null ? diceCount : evaluator.EvalInternal(mod.ThresholdArg, roller, terms); diff --git a/src/d20Tek.DiceNotation/Parser/Grammar.cs b/src/d20Tek.DiceNotation/Parser/Grammar.cs index 69496f9..9ad9865 100644 --- a/src/d20Tek.DiceNotation/Parser/Grammar.cs +++ b/src/d20Tek.DiceNotation/Parser/Grammar.cs @@ -4,48 +4,17 @@ namespace d20Tek.DiceNotation.Parser; internal static partial class Grammar { + // Only the WS group is named; all other tokens are classified from the matched text itself + // in Lexer.MapMatch to avoid allocating a Group/GroupCollection entry per named capture. [GeneratedRegex(""" (?ix) # i=ignore case, x=ignore whitespace/comments \G # anchor to current position (_index) (?: - (? [\u0009\u0020\u000D\u000A]+ ) # \t space \r \n - | (? (?:0|[1-9][0-9]*) ) - | (? \+ ) - | (? - ) - | (? \* ) - | (? x ) - | (? / ) - | (? \() - | (? \) ) - | (? d ) - | (? f ) - | (? % ) - | (? ! ) - | (? k ) - | (? p ) - | (? l ) - | (? . ) # any other single char (error) + (? [\u0009\u0020\u000D\u000A]+ ) # \t space \r \n + | (?:0|[1-9][0-9]*) # NUMBER + | [+\-*x/()dfk%!pl] # single-char tokens + | . # any other single char (error) ) """, RegexOptions.Compiled | RegexOptions.CultureInvariant)] public static partial Regex GetRegex(); - - public static readonly IReadOnlyDictionary> MatchFactories = - new Dictionary> - { - ["NUMBER"] = m => (TokenKind.Number, m.Value, int.Parse(m.Value)), - ["PLUS"] = m => (TokenKind.Plus, m.Value, null), - ["MINUS"] = m => (TokenKind.Minus, m.Value, null), - ["STAR"] = m => (TokenKind.Star, m.Value, null), - ["TIMES"] = m => (TokenKind.Times, m.Value, null), - ["DIVIDE"] = m => (TokenKind.Divide, m.Value, null), - ["GROUPSTART"] = m => (TokenKind.GroupStart, m.Value, null), - ["GROUPEND"] = m => (TokenKind.GroupEnd, m.Value, null), - ["DICE"] = m => (TokenKind.Dice, m.Value, null), - ["FUDGEDICE"] = m => (TokenKind.FudgeDice, m.Value, null), - ["PERCENT"] = m => (TokenKind.Percent, m.Value, null), - ["EXPLODING"] = m => (TokenKind.Exploding, m.Value, null), - ["KEEP"] = m => (TokenKind.Keep, m.Value, null), - ["DROP"] = m => (TokenKind.Drop, m.Value, null), - ["KEEPLOWEST"] = m => (TokenKind.KeepLowest, m.Value, null) - }; } diff --git a/src/d20Tek.DiceNotation/Parser/Lexer.cs b/src/d20Tek.DiceNotation/Parser/Lexer.cs index 10ea4f1..b844b41 100644 --- a/src/d20Tek.DiceNotation/Parser/Lexer.cs +++ b/src/d20Tek.DiceNotation/Parser/Lexer.cs @@ -39,10 +39,30 @@ private Token ProcessTokenGroup(Match match) private (TokenKind kind, string lexeme, int? intValue) MapMatch(Match m) { - var (key, factoryFunc) = Grammar.MatchFactories.FirstOrDefault(e => m.Groups[e.Key].Success); - return key is null - ? throw new ParseException(Constants.Errors.UnexpectedCharacter(m.Value), CurrentPos) - : factoryFunc(m); + string lexeme = m.Value; + char first = lexeme[0]; + + if (char.IsAsciiDigit(first)) + return (TokenKind.Number, lexeme, int.Parse(lexeme)); + + return first switch + { + '+' => (TokenKind.Plus, lexeme, null), + '-' => (TokenKind.Minus, lexeme, null), + '*' => (TokenKind.Star, lexeme, null), + 'x' or 'X' => (TokenKind.Times, lexeme, null), + '/' => (TokenKind.Divide, lexeme, null), + '(' => (TokenKind.GroupStart, lexeme, null), + ')' => (TokenKind.GroupEnd, lexeme, null), + 'd' or 'D' => (TokenKind.Dice, lexeme, null), + 'f' or 'F' => (TokenKind.FudgeDice, lexeme, null), + '%' => (TokenKind.Percent, lexeme, null), + '!' => (TokenKind.Exploding, lexeme, null), + 'k' or 'K' => (TokenKind.Keep, lexeme, null), + 'p' or 'P' => (TokenKind.Drop, lexeme, null), + 'l' or 'L' => (TokenKind.KeepLowest, lexeme, null), + _ => throw new ParseException(Constants.Errors.UnexpectedCharacter(lexeme), CurrentPos) + }; } private void Advance(Match m) diff --git a/src/d20Tek.DiceNotation/d20Tek.DiceNotation.csproj b/src/d20Tek.DiceNotation/d20Tek.DiceNotation.csproj index 3bbc63c..7536ca5 100644 --- a/src/d20Tek.DiceNotation/d20Tek.DiceNotation.csproj +++ b/src/d20Tek.DiceNotation/d20Tek.DiceNotation.csproj @@ -6,6 +6,10 @@ true true snupkg + true + true + true + true 4.0.1 DiceNotation Parsing and Rolling Library to parse and roll any dice notation. Use this library in your applications to roll random dice without having to write all of the code yourself. diff --git a/tests/d20Tek.DiceNotation.UnitTests/DiceTests.RollMany.cs b/tests/d20Tek.DiceNotation.UnitTests/DiceTests.RollMany.cs new file mode 100644 index 0000000..adcb58b --- /dev/null +++ b/tests/d20Tek.DiceNotation.UnitTests/DiceTests.RollMany.cs @@ -0,0 +1,106 @@ +using d20Tek.DiceNotation.DieRoller; + +namespace d20Tek.DiceNotation.UnitTests; + +[TestClass] +public class DiceTests_RollMany +{ + private const string _rollerType = "RandomDieRoller"; + + [TestMethod] + public void RollMany_WithNotation_ReturnsRequestedCount() + { + // Arrange + var dice = new Dice(); + + // Act + var results = dice.RollMany("1d20", 5, new ConstantDieRoller(4)); + + // Assert + Assert.HasCount(5, results); + foreach (var result in results) + { + Assert.AreEqual(4, result.Value); + Assert.Contains("ConstantDieRoller", result.DieRollerUsed); + } + } + + [TestMethod] + public void RollMany_WithExpression_ReturnsRequestedCount() + { + // Arrange + var dice = new Dice(); + var expression = DiceExpression.Create().AddDice(6, 2); + + // Act + var results = dice.RollMany(expression, 3, new ConstantDieRoller(2)); + + // Assert + Assert.HasCount(3, results); + foreach (var result in results) + { + Assert.AreEqual(4, result.Value); + Assert.Contains("ConstantDieRoller", result.DieRollerUsed); + } + } + + [TestMethod] + public void RollMany_WithNotation_AndNullRoller_UsesDefaultRoller() + { + // Arrange + var dice = new Dice(); + + // Act + var results = dice.RollMany("1d20", 4, null); + + // Assert + Assert.HasCount(4, results); + foreach (var result in results) + { + Assert.Contains(_rollerType, result.DieRollerUsed); + AssertHelpers.IsWithinRangeInclusive(1, 20, result.Value); + } + } + + [TestMethod] + public void RollMany_WithExpression_AndNullRoller_UsesDefaultRoller() + { + // Arrange + var dice = new Dice(); + var expression = DiceExpression.Create().AddDice(20); + + // Act + var results = dice.RollMany(expression, 4, null); + + // Assert + Assert.HasCount(4, results); + foreach (var result in results) + { + Assert.Contains(_rollerType, result.DieRollerUsed); + AssertHelpers.IsWithinRangeInclusive(1, 20, result.Value); + } + } + + [TestMethod] + [ExcludeFromCodeCoverage] + public void RollMany_WithNotation_AndCountLessThanOne_ThrowsArgumentOutOfRangeException() + { + // Arrange + var dice = new Dice(); + + // Act & Assert + Assert.ThrowsExactly(() => dice.RollMany("1d20", 0)); + } + + [TestMethod] + [ExcludeFromCodeCoverage] + public void RollMany_WithExpression_AndCountLessThanOne_ThrowsArgumentOutOfRangeException() + { + // Arrange + var dice = new Dice(); + var expression = DiceExpression.Create().AddDice(20); + + // Act & Assert + Assert.ThrowsExactly(() => dice.RollMany(expression, -1)); + } +}