import { describe, expect, it } from "vitest"; import { CryptoRng, SeededRng, runSpin, simulate, validateDefinition, evaluateWays, evaluateLines, defineGame, sym, wildSym, scatterSym, LINES_10 } from "../src"; import type { GameDefinition } from "../src"; const mini = defineGame({ slug: "test-ways", name: "Test Ways", version: "1.0.0", tagline: "", description: "", theme: "", grid: { reels: 5, rows: 3 }, payModel: { type: "ways" }, rtp: 0.96, volatility: "medium", maxMultiplier: 1000, betDivisor: 1, symbols: [ wildSym("W", "Wild", [0, 1, 1, 1, 0], { shape: "ring", color: "#fff" }), scatterSym("S", "Scatter", 1, { shape: "star", color: "#fff" }, { 3: 2 }), sym("A", "A", "premium", { 3: 1, 4: 2, 5: 5 }, 3, { shape: "gem", color: "#f00" }), sym("B", "B", "high", { 3: 0.5, 4: 1, 5: 2 }, 4, { shape: "gem", color: "#0f0" }), sym("C", "C", "mid", { 3: 0.3, 4: 0.5, 5: 1 }, 6, { shape: "gem", color: "#00f" }), sym("D", "D", "low", { 3: 0.1, 4: 0.2, 5: 0.5 }, 9, { shape: "gem", color: "#ff0" }), sym("E", "E", "low", { 3: 0.1, 4: 0.2, 5: 0.5 }, 9, { shape: "gem", color: "#0ff" }), ], wild: { id: "W" }, scatter: { id: "S", triggers: { 3: 5 } }, presentation: { palette: { primary: "#fff", secondary: "#fff", glow: "#fff", bg: "#000", surface: "#000" }, particles: "stars", ambience: "neon", frame: "metal", backdrop: "grid" }, }); describe("RNG", () => { it("CryptoRng.int is uniform-ish and in range", () => { const rng = new CryptoRng(); const counts = new Array(6).fill(0); for (let i = 0; i < 60_000; i++) counts[rng.int(6)]++; for (const c of counts) expect(Math.abs(c - 10_000)).toBeLessThan(600); }); it("weighted respects weights", () => { const rng = new CryptoRng(); let ones = 0; for (let i = 0; i < 20_000; i++) if (rng.weighted([1, 3]) === 1) ones++; expect(ones / 20_000).toBeGreaterThan(0.72); expect(ones / 20_000).toBeLessThan(0.78); }); it("SeededRng is deterministic", () => { const a = new SeededRng(42); const b = new SeededRng(42); for (let i = 0; i < 100; i++) expect(a.int(1000)).toBe(b.int(1000)); }); }); describe("evaluation", () => { it("ways: counts ways with wilds", () => { const grid = [ ["A", "D", "E"], ["A", "A", "W"], ["A", "E", "D"], ["D", "E", "C"], ["A", "A", "A"], ]; const wins = evaluateWays(mini, grid, { bet: 100 }); const a = wins.find((w) => w.symbol === "A")!; expect(a.count).toBe(3); expect(a.ways).toBe(1 * 3 * 1); expect(a.payout).toBe(300); // 1 × bet × 3 ways }); it("lines: substitutes wilds and pays longest match", () => { const def: GameDefinition = { ...mini, payModel: { type: "lines", lines: LINES_10 }, betDivisor: 10 }; const grid = [ ["D", "B", "D"], ["D", "W", "D"], ["D", "B", "D"], ["D", "B", "D"], ["D", "C", "D"], ]; const wins = evaluateLines(def, grid, { bet: 100 }); const mid = wins.find((w) => w.line === 0)!; // middle row = line 0 expect(mid.symbol).toBe("B"); expect(mid.count).toBe(4); expect(mid.payout).toBe(10); // 1 × 100 / 10 }); }); describe("engine", () => { it("runSpin is pure and returns a coherent outcome", () => { const rng = new SeededRng(7); const out = runSpin(mini, 100, { rng }); expect(out.bet).toBe(100); expect(out.totalWin).toBeGreaterThanOrEqual(0); expect(out.steps.length).toBeGreaterThan(0); expect(out.steps[0].grid.length).toBe(5); expect(out.multiplier).toBeCloseTo(out.totalWin / 100, 6); const sum = out.steps.reduce((a, s) => a + s.win, 0); expect(Math.min(sum, mini.maxMultiplier * 100)).toBe(out.totalWin); }); it("never exceeds maxMultiplier", () => { const def = { ...mini, maxMultiplier: 2 }; const rng = new SeededRng(3); for (let i = 0; i < 2000; i++) expect(runSpin(def, 100, { rng }).totalWin).toBeLessThanOrEqual(200); }); it("simulation reports stable RTP for a seeded run", () => { const r = simulate(mini, { spins: 20_000, seed: 1 }); expect(r.spins).toBe(20_000); expect(r.observedRtp).toBeGreaterThan(0); expect(r.hitRate).toBeGreaterThan(0.05); expect(r.distribution.reduce((a, b) => a + b.count, 0)).toBe(20_000); }); it("validateDefinition catches bad definitions", () => { const bad = { ...mini, rtp: 1.2, slug: "Bad Slug" }; const issues = validateDefinition(bad); expect(issues.some((i) => i.message.includes("RTP"))).toBe(true); expect(issues.some((i) => i.message.includes("kebab"))).toBe(true); }); });