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
62 changes: 18 additions & 44 deletions client/space_lua/stdlib/math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import {
LuaTable,
} from "../runtime.ts";
import { isNegativeZero, isTaggedFloat } from "../numeric.ts";
import { LuaPRNG } from "./prng.ts";

// One PRNG per module load, auto-seeded at startup
const prng = new LuaPRNG();

// Fast unwrap: avoids function call overhead for the common plain-number case
function untagNumber(x: any): number {
Expand Down Expand Up @@ -52,51 +56,21 @@ export const mathApi = new LuaTable({
random: new LuaBuiltinFunction((_sf, m?: number, n?: number) => {
if (m !== undefined) m = untagNumber(m);
if (n !== undefined) n = untagNumber(n);

if (m === undefined && n === undefined) {
return Math.random();
}

if (!Number.isInteger(m)) {
throw new LuaRuntimeError(
"bad argument #1 to 'math.random' (integer expected)",
_sf,
);
}

if (n === undefined) {
if (m! == 0) {
const high = Math.floor(Math.random() * 0x100000000);
const low = Math.floor(Math.random() * 0x100000000);
let result = (BigInt(high) << 32n) | BigInt(low);
if (result & (1n << 63n)) {
result -= 1n << 64n;
}
return result;
}
if (m! < 1) {
throw new LuaRuntimeError(
"bad argument #1 to 'math.random' (interval is empty)",
_sf,
);
}
return Math.floor(Math.random() * m!) + 1;
}

if (!Number.isInteger(n!)) {
throw new LuaRuntimeError(
"bad argument #2 to 'math.random' (integer expected)",
_sf,
);
}

if (n! < m!) {
throw new LuaRuntimeError(
"bad argument #1 to 'math.random' (interval is empty)",
_sf,
);
try {
return prng.random(m, n);
} catch (e: any) {
throw new LuaRuntimeError(e.message, _sf);
}
return Math.floor(Math.random() * (n! - m! + 1)) + m!;
}),
/**
* Seeds the pseudo-random generator. With no arguments, uses a
* time-based seed. Returns the two seed integers used (Lua 5.4 contract).
*/
randomseed: new LuaBuiltinFunction((_sf, x?: number, y?: number) => {
if (x !== undefined) x = untagNumber(x);
if (y !== undefined) y = untagNumber(y);
const [s1, s2] = prng.randomseed(x, y);
return new LuaMultiRes([s1, s2]);
}),

// Basic functions
Expand Down
115 changes: 115 additions & 0 deletions client/space_lua/stdlib/math_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ local function assertClose(a, b, eps, msg)
end
end

local function assertError(fn, msg)
local ok, err = pcall(fn)
if ok then
error((msg or "assertError failed") .. ": expected an error but none was raised")
end
end

-- math.type (basic)
do
assertEquals(math.type(0), "integer", "math.type(0)")
Expand Down Expand Up @@ -161,3 +168,111 @@ do
assertEquals(f2, 3, "floor(8*0.49)")
assertEquals(f3, 4, "floor(9*0.5)")
end

-- random and randomseed
do
-- random(): float in [0, 1)
for _ = 1, 20 do
local v = math.random()
assertTrue(v >= 0 and v < 1, "random() in [0,1)")
assertEquals(math.type(v), "float", "random() returns float")
end

-- random(n): integer in [1, n]
for _ = 1, 20 do
local v = math.random(10)
assertTrue(v >= 1 and v <= 10, "random(10) in [1,10]")
assertEquals(math.type(v), "integer", "random(n) returns integer")
end

-- random(m, n): integer in [m, n]
for _ = 1, 20 do
local v = math.random(5, 10)
assertTrue(v >= 5 and v <= 10, "random(5,10) in [5,10]")
assertEquals(math.type(v), "integer", "random(m,n) returns integer")
end

-- random(m, m): always returns m
for _ = 1, 5 do
assertEquals(math.random(7, 7), 7, "random(m,m) == m")
end

-- random(0): raw 64-bit integer, type integer
local r0 = math.random(0)
assertEquals(math.type(r0), "integer", "random(0) returns integer")

-- random(n) with n=1: always 1
for _ = 1, 5 do
assertEquals(math.random(1), 1, "random(1) == 1")
end

-- error: random(0.5) — non-integer arg1
assertError(function() math.random(0.5) end)

-- error: random(1, 0) — empty interval
assertError(function() math.random(1, 0) end)

-- error: random(0) is valid (raw), but random(-1) is not
assertError(function() math.random(-1) end)

-- error: random(1, 1.5) — non-integer arg2
assertError(function() math.random(1, 1.5) end)

-- randomseed arg validation
assertError(function() math.randomseed(0.5) end)
assertError(function() math.randomseed(1, 0.5) end)
assertError(function() math.randomseed(1/0) end)
end

-- randomseed: determinism
do
-- same seed yields identical sequence
math.randomseed(42)
local a1, a2, a3 = math.random(), math.random(100), math.random(1, 50)

math.randomseed(42)
local b1, b2, b3 = math.random(), math.random(100), math.random(1, 50)

assertEquals(a1, b1, "randomseed: float sequence reproducible")
assertEquals(a2, b2, "randomseed: random(n) reproducible")
assertEquals(a3, b3, "randomseed: random(m,n) reproducible")

-- different seeds yields different first values
math.randomseed(1)
local c1 = math.random()
math.randomseed(2)
local d1 = math.random()
assertTrue(c1 ~= d1, "different seeds give different values")

-- two-argument seed: same pair yields same sequence
math.randomseed(123, 456)
local e1, e2 = math.random(), math.random()
math.randomseed(123, 456)
local f1, f2 = math.random(), math.random()
assertEquals(e1, f1, "randomseed(x,y): first value reproducible")
assertEquals(e2, f2, "randomseed(x,y): second value reproducible")

-- two-argument seed: different second arg yields different sequence
math.randomseed(123, 456)
local g1 = math.random()
math.randomseed(123, 789)
local h1 = math.random()
assertTrue(g1 ~= h1, "randomseed(x,y1) ~= randomseed(x,y2)")

-- no-args seed does not error and produces valid range
math.randomseed()
local i1 = math.random()
assertTrue(i1 >= 0 and i1 < 1, "randomseed(): range valid after auto-seed")

-- return values: two integers (Lua 5.4 contract)
local s1, s2 = math.randomseed(99)
assertEquals(math.type(s1), "integer", "randomseed returns integer s1")
assertEquals(math.type(s2), "integer", "randomseed returns integer s2")

-- re-seeding restores determinism after auto-seed
math.randomseed(7)
local j1 = math.random()
math.randomseed(7)
local j2 = math.random()
assertEquals(j1, j2, "determinism restored after re-seed")
end
145 changes: 145 additions & 0 deletions client/space_lua/stdlib/prng.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// PRNG based on xoshiro256** for Space Lua

export class LuaPRNG {
private state: BigUint64Array;

constructor() {
this.state = new BigUint64Array(4);
this.autoSeed();
}

private rotl(x: bigint, k: number): bigint {
k = k & 63;
return ((x << BigInt(k)) | (x >> BigInt(64 - k))) & 0xFFFFFFFFFFFFFFFFn;
}

private nextrand(): bigint {
const s = this.state;
const s0 = s[0];
const s1 = s[1];
const s2 = s[2];
const s3 = s[3];

const res = this.rotl((s1 * 5n) & 0xFFFFFFFFFFFFFFFFn, 7) * 9n &
0xFFFFFFFFFFFFFFFFn;

const t = (s1 << 17n) & 0xFFFFFFFFFFFFFFFFn;
s[2] = s2 ^ s0;
s[3] = s3 ^ s1;
s[1] = s1 ^ s[2];
s[0] = s0 ^ s[3];
s[2] = s[2] ^ t;
s[3] = this.rotl(s[3], 45);

return res;
}

public setSeed(seed1: bigint, seed2: bigint = 0n): [bigint, bigint] {
const MASK = 0xFFFFFFFFFFFFFFFFn;
const s = this.state;

const sm64 = (x: bigint): bigint => {
x = (x ^ (x >> 30n)) * 0xBF58476D1CE4E5B9n & MASK;
x = (x ^ (x >> 27n)) * 0x94D049BB133111EBn & MASK;
return (x ^ (x >> 31n)) & MASK;
};

s[0] = sm64(seed1 & MASK);
s[1] = sm64((seed1 & MASK) | 0xFFn);
s[2] = sm64(seed2 & MASK);
s[3] = sm64(0n);

for (let i = 0; i < 16; i++) {
this.nextrand();
}

return [seed1, seed2];
}

private autoSeed(): [bigint, bigint] {
const t = BigInt(Date.now());
const entropy = BigInt(Math.floor(performance.now() * 1000));
return this.setSeed(t, entropy);
}

private project(ran: bigint, n: bigint): bigint {
if (n === 0n) return 0n;

let lim = n;
lim |= lim >> 1n;
lim |= lim >> 2n;
lim |= lim >> 4n;
lim |= lim >> 8n;
lim |= lim >> 16n;
lim |= lim >> 32n;

while (true) {
ran &= lim;
if (ran <= n) return ran;
ran = this.nextrand();
}
}

// `random()` yields float in [0, 1)
// `random(0)` yields raw 64-bit signed integer (all bits random)
// `random(n)` yields integer in [1, n]
// `random(m, n)` yields integer in [m, n]
public random(arg1?: number, arg2?: number): number | bigint {
const rv = this.nextrand();

if (arg1 === undefined) {
// Top 53 bits for full double precision
return Number(rv >> 11n) * (1.0 / 9007199254740992.0);
}

if (!isFinite(arg1) || !Number.isInteger(arg1)) {
throw new Error(
"bad argument #1 to 'random' (number has no integer representation)",
);
}

if (arg2 === undefined) {
if (arg1 === 0) {
// Raw 64-bit as signed bigint
const signed = rv > 0x7FFFFFFFFFFFFFFFn
? rv - 0x10000000000000000n
: rv;
return signed;
}
if (arg1 < 1) {
throw new Error("bad argument #1 to 'random' (interval is empty)");
}
return Number(this.project(rv, BigInt(arg1) - 1n) + 1n);
}

if (!isFinite(arg2) || !Number.isInteger(arg2)) {
throw new Error(
"bad argument #2 to 'random' (number has no integer representation)",
);
}
if (arg2 < arg1) {
throw new Error("bad argument #2 to 'random' (interval is empty)");
}
return Number(this.project(rv, BigInt(arg2) - BigInt(arg1)) + BigInt(arg1));
}

// Returns [seed1, seed2]
public randomseed(arg1?: number, arg2?: number): [bigint, bigint] {
if (arg1 === undefined) {
return this.autoSeed();
}
if (!isFinite(arg1) || !Number.isInteger(arg1)) {
throw new Error(
"bad argument #1 to 'randomseed' (number has no integer representation)",
);
}
if (arg2 !== undefined && (!isFinite(arg2) || !Number.isInteger(arg2))) {
throw new Error(
"bad argument #2 to 'randomseed' (number has no integer representation)",
);
}
const s1 = BigInt(Math.trunc(arg1));
const s2 = arg2 !== undefined ? BigInt(Math.trunc(arg2)) : 0n;
return this.setSeed(s1, s2);
}
}