Skip to content

Commit 2fc03de

Browse files
committed
feat(core): add pluggable IRandom facade over NRandom with weighted selection
Introduce a random-number facade in SquidStd.Core (SquidStd.Core.Rng) that exposes multiple PRNG algorithms and gameplay helpers behind a SquidStd-owned interface, so consumers never depend on the underlying NRandom types. - feat(core): add IRandom abstraction (ints/longs/doubles, ranges, gaussian, NextBool with probability, NextBytes, Pick, Fisher-Yates Shuffle) - feat(core): add RandomFactory with Shared (thread-safe) and seeded/algorithm Create overloads; RandomAlgorithmType selects xoshiro256**/xoshiro128**/pcg32/ splitmix64/mersenne-twister/chacha - feat(core): add NRandomAdapter wrapping NRandom generators; add NRandom 2.0.2 package reference (non-cryptographic; crypto stays on CryptoUtils) - feat(core): add IWeightedList/WeightedList for O(log n) weighted selection (loot/spawn tables) on cumulative weights over IRandom - feat(game): add DiceExpression.Roll(IRandom) overload to roll from an injected reproducible source alongside the ambient BuiltInRng roll - test: cover all algorithms (reproducibility, ranges, gaussian mean, bool extremes, shuffle multiset, pick), weighted distribution, and injected dice rolls
1 parent 593d6ee commit 2fc03de

11 files changed

Lines changed: 593 additions & 0 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
namespace SquidStd.Core.Interfaces.Rng;
2+
3+
/// <summary>
4+
/// A source of non-cryptographic pseudo-random values. Obtain instances from
5+
/// <see cref="SquidStd.Core.Rng.RandomFactory" />; instances created with a fixed seed produce
6+
/// reproducible sequences.
7+
/// </summary>
8+
public interface IRandom
9+
{
10+
/// <summary>Returns a random 32-bit integer across the full range (may be negative).</summary>
11+
int NextInt();
12+
13+
/// <summary>Returns a random integer in <c>[0, maxExclusive)</c>.</summary>
14+
/// <param name="maxExclusive">Exclusive upper bound; must be positive.</param>
15+
int NextInt(int maxExclusive);
16+
17+
/// <summary>Returns a random integer in <c>[minInclusive, maxExclusive)</c>.</summary>
18+
/// <param name="minInclusive">Inclusive lower bound.</param>
19+
/// <param name="maxExclusive">Exclusive upper bound; must exceed <paramref name="minInclusive" />.</param>
20+
int NextInt(int minInclusive, int maxExclusive);
21+
22+
/// <summary>Returns a random 64-bit integer.</summary>
23+
long NextLong();
24+
25+
/// <summary>Returns a random double in <c>[0, 1)</c>.</summary>
26+
double NextDouble();
27+
28+
/// <summary>Returns a random double in <c>[minInclusive, maxExclusive)</c>.</summary>
29+
/// <param name="minInclusive">Inclusive lower bound.</param>
30+
/// <param name="maxExclusive">Exclusive upper bound.</param>
31+
double NextDouble(double minInclusive, double maxExclusive);
32+
33+
/// <summary>Returns a normally-distributed random double.</summary>
34+
/// <param name="mean">The distribution mean.</param>
35+
/// <param name="standardDeviation">The distribution standard deviation.</param>
36+
double NextGaussian(double mean = 0, double standardDeviation = 1);
37+
38+
/// <summary>Returns <c>true</c> with the given probability.</summary>
39+
/// <param name="probability">Chance of <c>true</c> in <c>[0, 1]</c>; defaults to 0.5.</param>
40+
bool NextBool(double probability = 0.5);
41+
42+
/// <summary>Fills the buffer with random bytes.</summary>
43+
/// <param name="buffer">The buffer to fill.</param>
44+
void NextBytes(Span<byte> buffer);
45+
46+
/// <summary>Returns a uniformly-chosen element from a non-empty list.</summary>
47+
/// <typeparam name="T">The element type.</typeparam>
48+
/// <param name="items">The list to pick from; must be non-empty.</param>
49+
T Pick<T>(IReadOnlyList<T> items);
50+
51+
/// <summary>Shuffles the list in place using the Fisher–Yates algorithm.</summary>
52+
/// <typeparam name="T">The element type.</typeparam>
53+
/// <param name="items">The list to shuffle.</param>
54+
void Shuffle<T>(IList<T> items);
55+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
namespace SquidStd.Core.Interfaces.Rng;
2+
3+
/// <summary>
4+
/// A mutable collection of items with associated positive weights, supporting weighted random
5+
/// selection — useful for loot tables, spawn tables, and similar gameplay data.
6+
/// </summary>
7+
/// <typeparam name="T">The element type.</typeparam>
8+
public interface IWeightedList<T>
9+
{
10+
/// <summary>The number of items in the list.</summary>
11+
int Count { get; }
12+
13+
/// <summary>The sum of all item weights.</summary>
14+
double TotalWeight { get; }
15+
16+
/// <summary>Adds an item with the given weight.</summary>
17+
/// <param name="item">The item to add.</param>
18+
/// <param name="weight">The selection weight; must be positive.</param>
19+
void Add(T item, double weight);
20+
21+
/// <summary>Selects an item at random, with probability proportional to its weight.</summary>
22+
/// <param name="random">The random source to draw from.</param>
23+
/// <returns>The selected item.</returns>
24+
T Next(IRandom random);
25+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using SquidStd.Core.Interfaces.Rng;
2+
3+
namespace SquidStd.Core.Rng;
4+
5+
/// <summary>
6+
/// Adapts an <see cref="NRandom.IRandom" /> generator to the SquidStd <see cref="IRandom" /> surface,
7+
/// keeping consumer code independent of the underlying NRandom types.
8+
/// </summary>
9+
internal sealed class NRandomAdapter : IRandom
10+
{
11+
private readonly NRandom.IRandom _inner;
12+
13+
public NRandomAdapter(NRandom.IRandom inner)
14+
{
15+
_inner = inner;
16+
}
17+
18+
public int NextInt()
19+
{
20+
return NRandom.RandomEx.NextInt(_inner);
21+
}
22+
23+
public int NextInt(int maxExclusive)
24+
{
25+
return NRandom.RandomEx.NextInt(_inner, maxExclusive);
26+
}
27+
28+
public int NextInt(int minInclusive, int maxExclusive)
29+
{
30+
return NRandom.RandomEx.NextInt(_inner, minInclusive, maxExclusive);
31+
}
32+
33+
public long NextLong()
34+
{
35+
return NRandom.RandomEx.NextLong(_inner);
36+
}
37+
38+
public double NextDouble()
39+
{
40+
return NRandom.RandomEx.NextDouble(_inner);
41+
}
42+
43+
public double NextDouble(double minInclusive, double maxExclusive)
44+
{
45+
return NRandom.RandomEx.NextDouble(_inner, minInclusive, maxExclusive);
46+
}
47+
48+
public double NextGaussian(double mean = 0, double standardDeviation = 1)
49+
{
50+
return mean + (standardDeviation * NRandom.RandomEx.NextDoubleGaussian(_inner));
51+
}
52+
53+
public bool NextBool(double probability = 0.5)
54+
{
55+
if (probability <= 0)
56+
{
57+
return false;
58+
}
59+
60+
if (probability >= 1)
61+
{
62+
return true;
63+
}
64+
65+
return NRandom.RandomEx.NextDouble(_inner) < probability;
66+
}
67+
68+
public void NextBytes(Span<byte> buffer)
69+
{
70+
NRandom.RandomEx.NextBytes(_inner, buffer);
71+
}
72+
73+
public T Pick<T>(IReadOnlyList<T> items)
74+
{
75+
ArgumentNullException.ThrowIfNull(items);
76+
77+
if (items.Count == 0)
78+
{
79+
throw new ArgumentException("Cannot pick from an empty collection.", nameof(items));
80+
}
81+
82+
return items[NRandom.RandomEx.NextInt(_inner, items.Count)];
83+
}
84+
85+
public void Shuffle<T>(IList<T> items)
86+
{
87+
ArgumentNullException.ThrowIfNull(items);
88+
89+
for (var i = items.Count - 1; i > 0; i--)
90+
{
91+
var j = NRandom.RandomEx.NextInt(_inner, i + 1);
92+
(items[i], items[j]) = (items[j], items[i]);
93+
}
94+
}
95+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using SquidStd.Core.Interfaces.Rng;
2+
using SquidStd.Core.Types;
3+
4+
namespace SquidStd.Core.Rng;
5+
6+
/// <summary>
7+
/// Entry point for obtaining <see cref="IRandom" /> instances. Consumers depend only on this factory
8+
/// and <see cref="IRandom" />, never on the underlying NRandom generators.
9+
/// </summary>
10+
public static class RandomFactory
11+
{
12+
private const int ChaChaRounds = 20;
13+
14+
/// <summary>A shared, thread-safe random source seeded non-deterministically.</summary>
15+
public static IRandom Shared { get; } = new NRandomAdapter(NRandom.RandomEx.Shared);
16+
17+
/// <summary>Creates a random source using the default algorithm and a non-deterministic seed.</summary>
18+
/// <returns>A new, non-thread-safe random source.</returns>
19+
public static IRandom Create()
20+
{
21+
return new NRandomAdapter(NRandom.RandomEx.Create());
22+
}
23+
24+
/// <summary>Creates a reproducible random source using the default algorithm and the given seed.</summary>
25+
/// <param name="seed">The seed for reproducible sequences.</param>
26+
/// <returns>A new, non-thread-safe random source.</returns>
27+
public static IRandom Create(uint seed)
28+
{
29+
return Create(RandomAlgorithmType.Xoshiro256, seed);
30+
}
31+
32+
/// <summary>Creates a random source using the given algorithm and a non-deterministic seed.</summary>
33+
/// <param name="algorithm">The pseudo-random algorithm to use.</param>
34+
/// <returns>A new, non-thread-safe random source.</returns>
35+
public static IRandom Create(RandomAlgorithmType algorithm)
36+
{
37+
return new NRandomAdapter(NewGenerator(algorithm));
38+
}
39+
40+
/// <summary>Creates a reproducible random source using the given algorithm and seed.</summary>
41+
/// <param name="algorithm">The pseudo-random algorithm to use.</param>
42+
/// <param name="seed">The seed for reproducible sequences.</param>
43+
/// <returns>A new, non-thread-safe random source.</returns>
44+
public static IRandom Create(RandomAlgorithmType algorithm, uint seed)
45+
{
46+
var generator = NewGenerator(algorithm);
47+
generator.InitState(seed);
48+
49+
return new NRandomAdapter(generator);
50+
}
51+
52+
private static NRandom.IRandom NewGenerator(RandomAlgorithmType algorithm)
53+
{
54+
return algorithm switch
55+
{
56+
RandomAlgorithmType.Xoshiro256 => new NRandom.Xoshiro256StarStarRandom(),
57+
RandomAlgorithmType.Xoshiro128 => new NRandom.Xoshiro128StarStarRandom(),
58+
RandomAlgorithmType.Pcg32 => new NRandom.Pcg32Random(),
59+
RandomAlgorithmType.SplitMix64 => new NRandom.SplitMix64Random(),
60+
RandomAlgorithmType.MersenneTwister => new NRandom.MersenneTwisterRandom(),
61+
RandomAlgorithmType.ChaCha => new NRandom.ChaChaRandom(ChaChaRounds),
62+
_ => throw new ArgumentOutOfRangeException(nameof(algorithm), algorithm, null),
63+
};
64+
}
65+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using SquidStd.Core.Interfaces.Rng;
2+
3+
namespace SquidStd.Core.Rng;
4+
5+
/// <summary>
6+
/// A weighted collection backed by cumulative weights, selecting items in <c>O(log n)</c> via
7+
/// binary search. Weights must be positive; selection probability is proportional to weight.
8+
/// </summary>
9+
/// <typeparam name="T">The element type.</typeparam>
10+
public sealed class WeightedList<T> : IWeightedList<T>
11+
{
12+
private readonly List<T> _items = [];
13+
private readonly List<double> _cumulative = [];
14+
private double _total;
15+
16+
public int Count => _items.Count;
17+
18+
public double TotalWeight => _total;
19+
20+
public void Add(T item, double weight)
21+
{
22+
if (weight <= 0 || double.IsNaN(weight) || double.IsInfinity(weight))
23+
{
24+
throw new ArgumentOutOfRangeException(nameof(weight), weight, "Weight must be a positive, finite number.");
25+
}
26+
27+
_total += weight;
28+
_items.Add(item);
29+
_cumulative.Add(_total);
30+
}
31+
32+
public T Next(IRandom random)
33+
{
34+
ArgumentNullException.ThrowIfNull(random);
35+
36+
if (_items.Count == 0)
37+
{
38+
throw new InvalidOperationException("The weighted list is empty.");
39+
}
40+
41+
var roll = random.NextDouble() * _total;
42+
var index = _cumulative.BinarySearch(roll);
43+
44+
if (index < 0)
45+
{
46+
index = ~index;
47+
}
48+
49+
if (index >= _items.Count)
50+
{
51+
index = _items.Count - 1;
52+
}
53+
54+
return _items[index];
55+
}
56+
}

src/SquidStd.Core/SquidStd.Core.csproj

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

1111
<ItemGroup>
1212
<PackageReference Include="DryIoc.dll" Version="5.4.3"/>
13+
<PackageReference Include="NRandom" Version="2.0.2"/>
1314
<PackageReference Include="Serilog" Version="4.3.1"/>
1415
<PackageReference Update="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.301">
1516
<PrivateAssets>all</PrivateAssets>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace SquidStd.Core.Types;
2+
3+
/// <summary>
4+
/// Selects the pseudo-random algorithm backing an <see cref="Interfaces.Rng.IRandom" /> instance.
5+
/// All options are non-cryptographic; use <see cref="Utils.CryptoUtils" /> or
6+
/// <see cref="System.Security.Cryptography.RandomNumberGenerator" /> for security-sensitive randomness.
7+
/// </summary>
8+
public enum RandomAlgorithmType
9+
{
10+
/// <summary>xoshiro256** — fast, high-quality general-purpose default.</summary>
11+
Xoshiro256,
12+
13+
/// <summary>xoshiro128** — 32-bit oriented variant.</summary>
14+
Xoshiro128,
15+
16+
/// <summary>PCG32 (PCG-XSH-RR) — compact state with strong statistical quality.</summary>
17+
Pcg32,
18+
19+
/// <summary>SplitMix64 — minimal state, well suited to seeding other generators.</summary>
20+
SplitMix64,
21+
22+
/// <summary>Mersenne Twister (MT19937) — very long period, classic choice.</summary>
23+
MersenneTwister,
24+
25+
/// <summary>ChaCha (20 rounds) — highest statistical quality of the set, but slower.</summary>
26+
ChaCha,
27+
}

src/SquidStd.Game/Dice/DiceExpression.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Globalization;
2+
using SquidStd.Core.Interfaces.Rng;
23
using SquidStd.Core.Utils;
34

45
namespace SquidStd.Game.Dice;
@@ -27,9 +28,32 @@ public readonly record struct DiceExpression(int Count, int Sides, int Modifier)
2728

2829
/// <summary>Rolls the expression, returning a random total in <c>[Min, Max]</c>.</summary>
2930
/// <returns>The rolled total, or <see cref="Modifier" /> for a pure constant.</returns>
31+
/// <remarks>Uses the ambient <see cref="BuiltInRng" />; for an injected source use <see cref="Roll(IRandom)" />.</remarks>
3032
public int Roll()
3133
=> IsConstant ? Modifier : RandomUtils.Dice(Count, Sides, Modifier);
3234

35+
/// <summary>Rolls the expression using the supplied random source, returning a total in <c>[Min, Max]</c>.</summary>
36+
/// <param name="random">The random source to draw from.</param>
37+
/// <returns>The rolled total, or <see cref="Modifier" /> for a pure constant.</returns>
38+
public int Roll(IRandom random)
39+
{
40+
ArgumentNullException.ThrowIfNull(random);
41+
42+
if (IsConstant)
43+
{
44+
return Modifier;
45+
}
46+
47+
var total = Modifier;
48+
49+
for (var i = 0; i < Count; i++)
50+
{
51+
total += random.NextInt(1, Sides + 1);
52+
}
53+
54+
return total;
55+
}
56+
3357
/// <summary>
3458
/// Parses dice notation such as <c>2d4+1</c>, <c>d6</c>, <c>5</c>, or the wrapped form
3559
/// <c>dice(2d4+1)</c>. Whitespace is ignored and the <c>d</c> separator is case-insensitive.

0 commit comments

Comments
 (0)