landsandboat/scripts/tests/framework/prng.lua
2026-07-05 14:33:55 +01:00

428 lines
16 KiB
Lua

-- The number of draws used by the range/spread tests below. High enough to make
-- coverage failures (a missed endpoint, a skewed bucket) mean a real regression
-- rather than noise, low enough to keep the suite fast.
local kSamples = 10000
describe('math.random() contract', function()
-- math.random is overridden in luautils.cpp to forward into xirand. These tests
-- lock the surface scripts rely on: interval endpoints, integer-ness, and the
-- half-open float contract. The 1-in-33-million rounding edge (a float draw
-- landing exactly on the excluded max) is locked at compile time by the
-- static_asserts in src/test/tests/rng_spread_tests.cpp; these tests lock the
-- everyday shape of the API.
it('math.random() returns floats in [0, 1)', function()
xi.test.world:setSeed(1)
for _ = 1, kSamples do
local value = math.random()
assert(value >= 0 and value < 1, string.format('math.random() out of [0, 1): %.17g', value))
end
end)
it('math.random(n) returns integers in [1, n] and reaches both endpoints', function()
xi.test.world:setSeed(1)
local seen = {}
for _ = 1, kSamples do
local value = math.random(6)
assert(value >= 1 and value <= 6, string.format('math.random(6) out of [1, 6]: %s', tostring(value)))
assert(value == math.floor(value), 'math.random(6) should return a whole number')
seen[value] = true
end
assert(seen[1], 'math.random(6) never produced its lower endpoint 1')
assert(seen[6], 'math.random(6) never produced its upper endpoint 6')
end)
it('math.random(n, m) is inclusive of both endpoints', function()
xi.test.world:setSeed(1)
local seen = {}
for _ = 1, kSamples do
local value = math.random(-3, 3)
assert(value >= -3 and value <= 3, string.format('math.random(-3, 3) out of range: %s', tostring(value)))
seen[value] = true
end
assert(seen[-3], 'math.random(-3, 3) never produced its lower endpoint -3')
assert(seen[3], 'math.random(-3, 3) never produced its upper endpoint 3')
end)
it('math.random(n, n) returns n', function()
xi.test.world:setSeed(1)
for _ = 1, 100 do
assert(math.random(4, 4) == 4, 'math.random(4, 4) should always return 4')
assert(math.random(1) == 1, 'math.random(1) should always return 1')
end
end)
-- math.random mimics stock Lua exactly: every argument form yields integers
-- except the zero-argument call. Fractional bounds are rounded to the nearest
-- integer -- scripts wanting a float range must say so by name, via
-- math.randomFloat.
it('math.random(a, b) rounds fractional bounds to the nearest integer', function()
xi.test.world:setSeed(1)
local seen = {}
for _ = 1, kSamples do
-- 2.4 and 7.6 round to 2 and 8; the result is an integer in [2, 8].
local value = math.random(2.4, 7.6)
assert(value == math.floor(value), string.format('math.random(2.4, 7.6) returned a fraction: %.17g', value))
assert(value >= 2 and value <= 8, string.format('math.random(2.4, 7.6) out of [2, 8]: %s', tostring(value)))
seen[value] = true
end
assert(seen[2] and seen[8], 'math.random(2.4, 7.6) should reach both rounded endpoints 2 and 8')
end)
it('math.random(a, b) with a sub-integer span collapses to a constant', function()
-- Both bounds of math.random(0.7, 1.1) round to 1, so the roll is always 1.
-- Scripts wanting a fractional roll must use math.randomFloat(0.7, 1.1).
for _ = 1, 100 do
assert(math.random(0.7, 1.1) == 1, 'math.random(0.7, 1.1) should always return 1')
end
end)
it('math.random(n) with a fractional argument rounds it', function()
xi.test.world:setSeed(1)
for _ = 1, kSamples do
-- 2.4 rounds to 2; the result is an integer in [1, 2], never a float in [0, 2.4).
local value = math.random(2.4)
assert(value == math.floor(value), string.format('math.random(2.4) returned a fraction: %.17g', value))
assert(value >= 1 and value <= 2, string.format('math.random(2.4) out of [1, 2]: %s', tostring(value)))
end
end)
end)
describe('math.randomInt() contract', function()
-- Custom extension bound in luautils.cpp: identical to math.random(lower, upper),
-- but explicit about its integer semantics at the call site.
it('returns integers in [lower, upper] and reaches both endpoints', function()
xi.test.world:setSeed(1)
local seen = {}
for _ = 1, kSamples do
local value = math.randomInt(-3, 3)
assert(value == math.floor(value), string.format('math.randomInt(-3, 3) returned a fraction: %.17g', value))
assert(value >= -3 and value <= 3, string.format('math.randomInt(-3, 3) out of [-3, 3]: %s', tostring(value)))
seen[value] = true
end
assert(seen[-3], 'math.randomInt(-3, 3) never produced its lower endpoint -3')
assert(seen[3], 'math.randomInt(-3, 3) never produced its upper endpoint 3')
end)
it('rounds fractional bounds to the nearest integer', function()
xi.test.world:setSeed(1)
for _ = 1, kSamples do
-- 2.4 and 7.6 round to 2 and 8.
local value = math.randomInt(2.4, 7.6)
assert(value == math.floor(value), string.format('math.randomInt(2.4, 7.6) returned a fraction: %.17g', value))
assert(value >= 2 and value <= 8, string.format('math.randomInt(2.4, 7.6) out of [2, 8]: %s', tostring(value)))
end
end)
it('math.randomInt(n, n) returns n', function()
for _ = 1, 100 do
assert(math.randomInt(4, 4) == 4, 'math.randomInt(4, 4) should always return 4')
end
end)
end)
describe('math.randomFloat() contract', function()
-- Custom extension bound in luautils.cpp: always a double in [lower, upper),
-- even when the bounds are integral-valued. This is the only way to request a
-- float range with whole-number bounds -- LuaJIT cannot tell 7.0 from 7, so
-- math.random(2.0, 7.0) necessarily rolls integers.
it('returns doubles in [lower, upper) even with whole-number bounds', function()
xi.test.world:setSeed(1)
local sawFraction = false
for _ = 1, kSamples do
local value = math.randomFloat(2, 7)
assert(value >= 2 and value < 7, string.format('math.randomFloat(2, 7) out of [2, 7): %.17g', value))
if value ~= math.floor(value) then
sawFraction = true
end
end
assert(sawFraction, 'math.randomFloat(2, 7) should produce fractional values')
end)
it('rolls real float ranges with sub-integer spans', function()
-- The self-destruct mobskills use math.randomFloat(0.7, 1.1) as a damage
-- multiplier; with math.random this span would collapse to a constant 1.
xi.test.world:setSeed(1)
local minSeen = math.huge
local maxSeen = -math.huge
for _ = 1, kSamples do
local value = math.randomFloat(0.7, 1.1)
assert(value >= 0.7 and value < 1.1, string.format('math.randomFloat(0.7, 1.1) out of [0.7, 1.1): %.17g', value))
minSeen = math.min(minSeen, value)
maxSeen = math.max(maxSeen, value)
end
assert(maxSeen > minSeen, 'math.randomFloat(0.7, 1.1) should vary, not collapse to a constant')
end)
it('math.randomFloat(n, n) returns n', function()
for _ = 1, 100 do
assert(math.randomFloat(5, 5) == 5, 'math.randomFloat(5, 5) should always return 5')
end
end)
end)
describe('math.randomNormal() contract', function()
-- math.randomNormal is bound in luautils.cpp on top of xirand's inverse-CDF
-- sampler: one engine draw per call, exact truncation, no rejection loops.
it('matches the requested mean and standard deviation', function()
xi.test.world:setSeed(1)
local sum = 0
local sumSq = 0
for _ = 1, kSamples do
local value = math.randomNormal(3.5, 1.5)
sum = sum + value
sumSq = sumSq + value * value
end
local mean = sum / kSamples
local stddev = math.sqrt(sumSq / kSamples - mean * mean)
assert(math.abs(mean - 3.5) < 0.05, string.format('Mean drifted: expected ~3.5, got %.5f', mean))
assert(math.abs(stddev - 1.5) < 0.05, string.format('Stddev drifted: expected ~1.5, got %.5f', stddev))
end)
it('respects both truncation bounds', function()
xi.test.world:setSeed(1)
local minSeen = math.huge
local maxSeen = -math.huge
for _ = 1, kSamples do
local value = math.randomNormal(3.5, 1.5, 2, 7)
assert(value >= 2 and value <= 7, string.format('math.randomNormal(3.5, 1.5, 2, 7) out of [2, 7]: %.17g', value))
minSeen = math.min(minSeen, value)
maxSeen = math.max(maxSeen, value)
end
assert(maxSeen > minSeen, 'Truncated normal should vary, not collapse to a constant')
end)
it('supports one-sided bounds via nil', function()
xi.test.world:setSeed(1)
for _ = 1, kSamples do
local lowerOnly = math.randomNormal(3.5, 1.5, 0)
assert(lowerOnly >= 0, string.format('math.randomNormal(3.5, 1.5, 0) below lower bound: %.17g', lowerOnly))
local upperOnly = math.randomNormal(3.5, 1.5, nil, 4)
assert(upperOnly <= 4, string.format('math.randomNormal(3.5, 1.5, nil, 4) above upper bound: %.17g', upperOnly))
end
end)
it('handles degenerate inputs', function()
xi.test.world:setSeed(1)
for _ = 1, 100 do
-- Zero stddev collapses to mean, clamped into any bounds.
assert(math.randomNormal(5, 0) == 5, 'math.randomNormal(5, 0) should always return 5')
assert(math.randomNormal(5, 0, nil, 3) == 3, 'math.randomNormal(5, 0, nil, 3) should clamp to 3')
assert(math.randomNormal(5, 0, 7) == 7, 'math.randomNormal(5, 0, 7) should clamp to 7')
-- Empty interval returns the lower bound, mirroring math.random(n, n < m).
assert(math.randomNormal(5, 1.5, 7, 2) == 7, 'math.randomNormal with inverted bounds should return lower')
end
end)
it('reproduces the same stream for the same seed', function()
local first = {}
xi.test.world:setSeed(42)
for _ = 1, 8 do
table.insert(first, math.randomNormal(3.5, 1.5, 2, 7))
end
local second = {}
xi.test.world:setSeed(42)
for _ = 1, 8 do
table.insert(second, math.randomNormal(3.5, 1.5, 2, 7))
end
for i = 1, 8 do
assert(first[i] == second[i], string.format('Element %d: %.17g ~= %.17g', i, first[i], second[i]))
end
end)
end)
describe('PRNG distribution', function()
it('math.random() spreads evenly across buckets', function()
xi.test.world:setSeed(1)
local buckets = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
local total = 0
for _ = 1, kSamples do
local value = math.random()
buckets[math.floor(value * 10) + 1] = buckets[math.floor(value * 10) + 1] + 1
total = total + value
end
local ideal = kSamples / 10
for i = 1, 10 do
assert(
math.abs(buckets[i] - ideal) < ideal * 0.2,
string.format('Bucket %d skewed: expected ~%d, got %d', i, ideal, buckets[i])
)
end
local mean = total / kSamples
assert(math.abs(mean - 0.5) < 0.02, string.format('Mean drifted: expected ~0.5, got %.5f', mean))
end)
it('math.random(n) covers every value roughly evenly', function()
xi.test.world:setSeed(1)
local counts = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
for _ = 1, kSamples do
local value = math.random(10)
counts[value] = counts[value] + 1
end
local ideal = kSamples / 10
for i = 1, 10 do
assert(
math.abs(counts[i] - ideal) < ideal * 0.2,
string.format('Value %d skewed: expected ~%d, got %d', i, ideal, counts[i])
)
end
end)
end)
describe('PRNG usage patterns', function()
-- Idioms used across scripts/, locked here so an engine or binding change that
-- breaks them fails loudly instead of corrupting gameplay quietly.
it('1 - math.random() is always a valid input to math.log()', function()
-- Sampling transforms (e.g. Box-Muller for normal distributions) rely on
-- math.random() < 1 so that math.log(1 - math.random()) is finite.
xi.test.world:setSeed(1)
for _ = 1, kSamples do
local u = 1 - math.random()
assert(u > 0, 'math.random() returned 1.0; log(0) would be -inf')
local logValue = math.log(u)
assert(logValue <= 0 and logValue == logValue, 'math.log(1 - math.random()) must be finite')
end
end)
it('random angles stay strictly below 2 * pi', function()
-- Pattern from npc_util.lua and mob repositioning scripts.
xi.test.world:setSeed(1)
for _ = 1, kSamples do
local angle = math.random() * 2 * math.pi
assert(angle >= 0 and angle < 2 * math.pi, string.format('Angle out of [0, 2*pi): %.17g', angle))
end
end)
it('random table indexing never overruns', function()
xi.test.world:setSeed(1)
local t = { 'a', 'b', 'c', 'd', 'e', 'f', 'g' }
for _ = 1, kSamples do
assert(t[math.random(#t)] ~= nil, 'math.random(#t) produced an invalid index')
end
end)
end)
describe('PRNG', function()
it('can be forced to a specific seed', function()
local expected = { 141, 597, 964, 998, 667, 697, 572, 741, 488, 371 }
local actual = {}
xi.test.world:setSeed(1)
for _ = 1, 10 do
table.insert(actual, math.random(1000))
end
for i = 1, #expected do
assert(expected[i] == actual[i], string.format('Element %d: expected %d, got %d', i, expected[i], actual[i]))
end
end)
it('reproduces the same float stream for the same seed', function()
local first = {}
xi.test.world:setSeed(42)
for _ = 1, 8 do
table.insert(first, math.random())
end
local second = {}
xi.test.world:setSeed(42)
for _ = 1, 8 do
table.insert(second, math.random())
end
for i = 1, 8 do
assert(first[i] == second[i], string.format('Element %d: %.17g ~= %.17g', i, first[i], second[i]))
end
end)
it('produces a stable golden float stream', function()
-- Bit-exact goldens for the default engine (Squirrel5 + canonical53 + the
-- float narrowing in the math.random binding). The RNG stack is deterministic
-- on all platforms, so any drift here is a real behavior change, not noise.
local expected =
{
0.1404843523841196,
0.96306491641577729,
0.66603129130620931,
0.57130454287094867,
0.48767078198955727,
}
xi.test.world:setSeed(1)
local actuals = {}
for i = 1, #expected do
actuals[i] = math.random()
end
for i = 1, #expected do
assert(expected[i] == actuals[i], string.format('Element %d: expected %.17g, got %.17g', i, expected[i], actuals[i]))
end
end)
it('test seed does not leak', function()
local expected = { 141, 597, 964, 998, 667, 697, 572, 741, 488, 371 }
local actual = {}
for _ = 1, 10 do
table.insert(actual, math.random(1000))
end
-- Check that at least one element is different
local allSame = true
for i = 1, #expected do
if expected[i] ~= actual[i] then
allSame = false
break
end
end
assert(not allSame, 'Arrays should not be the same')
end)
end)