Skip to content

Commit 593d6ee

Browse files
committed
feat(game): add SquidStd.Game library with dice-notation parser
Introduce a new SquidStd.Game NuGet library that hosts game mechanics on top of SquidStd.Core, keeping Core focused on infrastructure. - feat(game): add DiceExpression (readonly record struct) parsing [N]dS[±M] notation, pure constants, and the wrapped dice(...) form; whitespace-tolerant and case-insensitive on the d separator - feat(game): expose Min/Max/Average bounds and Roll() reusing the existing RandomUtils.Dice engine and BuiltInRng, so a fixed seed is reproducible - feat(game): scaffold SquidStd.Game.csproj (PublishNuget, references Core), register it in SquidStd.slnx, and reference it from SquidStd.Tests - test(game): cover parsing of all notation forms, invalid inputs (TryParse false / Parse throws), Min/Max/Average bounds, and Roll range + reproducibility
1 parent bcee478 commit 593d6ee

5 files changed

Lines changed: 264 additions & 0 deletions

File tree

SquidStd.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<Solution>
22
<Folder Name="/src/">
33
<Project Path="src/SquidStd.Abstractions/SquidStd.Abstractions.csproj"/>
4+
<Project Path="src/SquidStd.Game/SquidStd.Game.csproj"/>
45
<Project Path="src/SquidStd.Generators/SquidStd.Generators.csproj"/>
56
<Project Path="src/SquidStd.Telemetry.Abstractions/SquidStd.Telemetry.Abstractions.csproj"/>
67
<Project Path="src/SquidStd.Telemetry.OpenTelemetry/SquidStd.Telemetry.OpenTelemetry.csproj"/>
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using System.Globalization;
2+
using SquidStd.Core.Utils;
3+
4+
namespace SquidStd.Game.Dice;
5+
6+
/// <summary>
7+
/// A parsed dice-notation expression of the form <c>[N]dS[±M]</c> (for example <c>2d4+1</c>,
8+
/// <c>d6</c>) or a pure constant <c>N</c>. Rolling reuses the ambient
9+
/// <see cref="RandomUtils.Dice(int, int, int)" /> engine, so a fixed <see cref="BuiltInRng" />
10+
/// seed yields reproducible sequences.
11+
/// </summary>
12+
/// <param name="Count">Number of dice; <c>0</c> for a pure constant.</param>
13+
/// <param name="Sides">Sides per die; <c>0</c> for a pure constant.</param>
14+
/// <param name="Modifier">Flat bonus added to (or, for a constant, equal to) the total.</param>
15+
public readonly record struct DiceExpression(int Count, int Sides, int Modifier)
16+
{
17+
private bool IsConstant => Count <= 0 || Sides <= 0;
18+
19+
/// <summary>The lowest total this expression can produce (every die shows 1).</summary>
20+
public int Min => IsConstant ? Modifier : Count + Modifier;
21+
22+
/// <summary>The highest total this expression can produce (every die shows its max face).</summary>
23+
public int Max => IsConstant ? Modifier : (Count * Sides) + Modifier;
24+
25+
/// <summary>The expected (mean) total of this expression.</summary>
26+
public double Average => IsConstant ? Modifier : (Count * (Sides + 1) / 2.0) + Modifier;
27+
28+
/// <summary>Rolls the expression, returning a random total in <c>[Min, Max]</c>.</summary>
29+
/// <returns>The rolled total, or <see cref="Modifier" /> for a pure constant.</returns>
30+
public int Roll()
31+
=> IsConstant ? Modifier : RandomUtils.Dice(Count, Sides, Modifier);
32+
33+
/// <summary>
34+
/// Parses dice notation such as <c>2d4+1</c>, <c>d6</c>, <c>5</c>, or the wrapped form
35+
/// <c>dice(2d4+1)</c>. Whitespace is ignored and the <c>d</c> separator is case-insensitive.
36+
/// </summary>
37+
/// <param name="input">The notation to parse.</param>
38+
/// <returns>The parsed <see cref="DiceExpression" />.</returns>
39+
/// <exception cref="FormatException">The input is not valid dice notation.</exception>
40+
public static DiceExpression Parse(string input)
41+
{
42+
if (TryParse(input, out var result))
43+
{
44+
return result;
45+
}
46+
47+
throw new FormatException($"Invalid dice notation: '{input}'.");
48+
}
49+
50+
/// <summary>Attempts to parse dice notation without throwing.</summary>
51+
/// <param name="input">The notation to parse; may be wrapped in <c>dice( ... )</c>.</param>
52+
/// <param name="result">The parsed expression on success; otherwise <c>default</c>.</param>
53+
/// <returns><c>true</c> if <paramref name="input" /> was valid dice notation.</returns>
54+
public static bool TryParse(string? input, out DiceExpression result)
55+
{
56+
result = default;
57+
58+
if (string.IsNullOrWhiteSpace(input))
59+
{
60+
return false;
61+
}
62+
63+
var text = input.Trim();
64+
65+
if (text.StartsWith("dice(", StringComparison.OrdinalIgnoreCase) && text.EndsWith(')'))
66+
{
67+
text = text[5..^1];
68+
}
69+
70+
text = text.Replace(" ", string.Empty, StringComparison.Ordinal);
71+
72+
if (text.Length == 0)
73+
{
74+
return false;
75+
}
76+
77+
var dIndex = text.IndexOf('d', StringComparison.OrdinalIgnoreCase);
78+
79+
if (dIndex < 0)
80+
{
81+
if (int.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var constant))
82+
{
83+
result = new DiceExpression(0, 0, constant);
84+
return true;
85+
}
86+
87+
return false;
88+
}
89+
90+
var countPart = text[..dIndex];
91+
var count = 1;
92+
93+
if (countPart.Length > 0 &&
94+
!int.TryParse(countPart, NumberStyles.None, CultureInfo.InvariantCulture, out count))
95+
{
96+
return false;
97+
}
98+
99+
if (count <= 0)
100+
{
101+
return false;
102+
}
103+
104+
var rest = text[(dIndex + 1)..];
105+
106+
if (rest.Length == 0)
107+
{
108+
return false;
109+
}
110+
111+
var signIndex = rest.IndexOfAny(['+', '-']);
112+
var sidesPart = signIndex >= 0 ? rest[..signIndex] : rest;
113+
var modifier = 0;
114+
115+
if (signIndex >= 0 &&
116+
!int.TryParse(rest[signIndex..], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out modifier))
117+
{
118+
return false;
119+
}
120+
121+
if (!int.TryParse(sidesPart, NumberStyles.None, CultureInfo.InvariantCulture, out var sides) || sides <= 0)
122+
{
123+
return false;
124+
}
125+
126+
result = new DiceExpression(count, sides, modifier);
127+
return true;
128+
}
129+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<PublishNuget>true</PublishNuget>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<InternalsVisibleTo Include="SquidStd.Tests"/>
12+
</ItemGroup>
13+
14+
<ItemGroup>
15+
<ProjectReference Include="..\SquidStd.Core\SquidStd.Core.csproj"/>
16+
</ItemGroup>
17+
18+
</Project>
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
using SquidStd.Core.Utils;
2+
using SquidStd.Game.Dice;
3+
4+
namespace SquidStd.Tests.Game.Dice;
5+
6+
public class DiceExpressionTests
7+
{
8+
[Theory]
9+
[InlineData("2d4", 2, 4, 0)]
10+
[InlineData("2d4+1", 2, 4, 1)]
11+
[InlineData("2d4-1", 2, 4, -1)]
12+
[InlineData("d6", 1, 6, 0)]
13+
[InlineData("D6", 1, 6, 0)]
14+
[InlineData(" 2 d 4 + 1 ", 2, 4, 1)]
15+
[InlineData("dice(2d4+1)", 2, 4, 1)]
16+
[InlineData("DICE(3d6-2)", 3, 6, -2)]
17+
public void Parse_ValidDiceNotation_ReturnsComponents(string input, int count, int sides, int modifier)
18+
{
19+
var expression = DiceExpression.Parse(input);
20+
21+
Assert.Equal(count, expression.Count);
22+
Assert.Equal(sides, expression.Sides);
23+
Assert.Equal(modifier, expression.Modifier);
24+
}
25+
26+
[Fact]
27+
public void Parse_PureConstant_HasZeroDice()
28+
{
29+
var expression = DiceExpression.Parse("5");
30+
31+
Assert.Equal(0, expression.Count);
32+
Assert.Equal(0, expression.Sides);
33+
Assert.Equal(5, expression.Modifier);
34+
}
35+
36+
[Theory]
37+
[InlineData("2d4", 2, 8, 5.0)]
38+
[InlineData("2d4+1", 3, 9, 6.0)]
39+
[InlineData("2d4-1", 1, 7, 4.0)]
40+
[InlineData("d6", 1, 6, 3.5)]
41+
public void MinMaxAverage_MatchExpectedBounds(string input, int min, int max, double average)
42+
{
43+
var expression = DiceExpression.Parse(input);
44+
45+
Assert.Equal(min, expression.Min);
46+
Assert.Equal(max, expression.Max);
47+
Assert.Equal(average, expression.Average, 3);
48+
}
49+
50+
[Fact]
51+
public void MinMaxAverage_Constant_AllEqualModifier()
52+
{
53+
var expression = DiceExpression.Parse("7");
54+
55+
Assert.Equal(7, expression.Min);
56+
Assert.Equal(7, expression.Max);
57+
Assert.Equal(7.0, expression.Average, 3);
58+
}
59+
60+
[Fact]
61+
public void Roll_StaysWithinMinMax()
62+
{
63+
var expression = DiceExpression.Parse("3d6+2");
64+
BuiltInRng.Reset(1234);
65+
66+
for (var i = 0; i < 1000; i++)
67+
{
68+
var roll = expression.Roll();
69+
Assert.InRange(roll, expression.Min, expression.Max);
70+
}
71+
}
72+
73+
[Fact]
74+
public void Roll_IsReproducibleWithSameSeed()
75+
{
76+
var expression = DiceExpression.Parse("2d20+3");
77+
78+
BuiltInRng.Reset(42);
79+
var first = expression.Roll();
80+
BuiltInRng.Reset(42);
81+
var second = expression.Roll();
82+
83+
Assert.Equal(first, second);
84+
}
85+
86+
[Fact]
87+
public void Roll_Constant_ReturnsModifier()
88+
{
89+
var expression = DiceExpression.Parse("9");
90+
91+
Assert.Equal(9, expression.Roll());
92+
}
93+
94+
[Theory]
95+
[InlineData("")]
96+
[InlineData(" ")]
97+
[InlineData(null)]
98+
[InlineData("abc")]
99+
[InlineData("2d")]
100+
[InlineData("d")]
101+
[InlineData("0d6")]
102+
[InlineData("2d0")]
103+
[InlineData("-1d6")]
104+
[InlineData("2d4+x")]
105+
public void TryParse_InvalidInput_ReturnsFalse(string? input)
106+
{
107+
Assert.False(DiceExpression.TryParse(input, out _));
108+
}
109+
110+
[Fact]
111+
public void Parse_InvalidInput_Throws()
112+
{
113+
Assert.Throws<FormatException>(() => DiceExpression.Parse("not-a-dice"));
114+
}
115+
}

tests/SquidStd.Tests/SquidStd.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737
<ItemGroup>
3838
<ProjectReference Include="..\..\src\SquidStd.Core\SquidStd.Core.csproj"/>
39+
<ProjectReference Include="..\..\src\SquidStd.Game\SquidStd.Game.csproj"/>
3940
<ProjectReference Include="..\..\src\SquidStd.Abstractions\SquidStd.Abstractions.csproj"/>
4041
<ProjectReference Include="..\..\src\SquidStd.Generators\SquidStd.Generators.csproj"/>
4142
<ProjectReference Include="..\..\src\SquidStd.Telemetry.Abstractions\SquidStd.Telemetry.Abstractions.csproj"/>

0 commit comments

Comments
 (0)