SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
4.5 KB · 116 lines typescript
Raw Blame History
1import { describe, expect, it } from "vitest";2import { CryptoRng, SeededRng, runSpin, simulate, validateDefinition, evaluateWays, evaluateLines, defineGame, sym, wildSym, scatterSym, LINES_10 } from "../src";3import type { GameDefinition } from "../src";45const mini = defineGame({6  slug: "test-ways",7  name: "Test Ways",8  version: "1.0.0",9  tagline: "",10  description: "",11  theme: "",12  grid: { reels: 5, rows: 3 },13  payModel: { type: "ways" },14  rtp: 0.96,15  volatility: "medium",16  maxMultiplier: 1000,17  betDivisor: 1,18  symbols: [19    wildSym("W", "Wild", [0, 1, 1, 1, 0], { shape: "ring", color: "#fff" }),20    scatterSym("S", "Scatter", 1, { shape: "star", color: "#fff" }, { 3: 2 }),21    sym("A", "A", "premium", { 3: 1, 4: 2, 5: 5 }, 3, { shape: "gem", color: "#f00" }),22    sym("B", "B", "high", { 3: 0.5, 4: 1, 5: 2 }, 4, { shape: "gem", color: "#0f0" }),23    sym("C", "C", "mid", { 3: 0.3, 4: 0.5, 5: 1 }, 6, { shape: "gem", color: "#00f" }),24    sym("D", "D", "low", { 3: 0.1, 4: 0.2, 5: 0.5 }, 9, { shape: "gem", color: "#ff0" }),25    sym("E", "E", "low", { 3: 0.1, 4: 0.2, 5: 0.5 }, 9, { shape: "gem", color: "#0ff" }),26  ],27  wild: { id: "W" },28  scatter: { id: "S", triggers: { 3: 5 } },29  presentation: { palette: { primary: "#fff", secondary: "#fff", glow: "#fff", bg: "#000", surface: "#000" }, particles: "stars", ambience: "neon", frame: "metal", backdrop: "grid" },30});3132describe("RNG", () => {33  it("CryptoRng.int is uniform-ish and in range", () => {34    const rng = new CryptoRng();35    const counts = new Array(6).fill(0);36    for (let i = 0; i < 60_000; i++) counts[rng.int(6)]++;37    for (const c of counts) expect(Math.abs(c - 10_000)).toBeLessThan(600);38  });39  it("weighted respects weights", () => {40    const rng = new CryptoRng();41    let ones = 0;42    for (let i = 0; i < 20_000; i++) if (rng.weighted([1, 3]) === 1) ones++;43    expect(ones / 20_000).toBeGreaterThan(0.72);44    expect(ones / 20_000).toBeLessThan(0.78);45  });46  it("SeededRng is deterministic", () => {47    const a = new SeededRng(42);48    const b = new SeededRng(42);49    for (let i = 0; i < 100; i++) expect(a.int(1000)).toBe(b.int(1000));50  });51});5253describe("evaluation", () => {54  it("ways: counts ways with wilds", () => {55    const grid = [56      ["A", "D", "E"],57      ["A", "A", "W"],58      ["A", "E", "D"],59      ["D", "E", "C"],60      ["A", "A", "A"],61    ];62    const wins = evaluateWays(mini, grid, { bet: 100 });63    const a = wins.find((w) => w.symbol === "A")!;64    expect(a.count).toBe(3);65    expect(a.ways).toBe(1 * 3 * 1);66    expect(a.payout).toBe(300); // 1 × bet × 3 ways67  });68  it("lines: substitutes wilds and pays longest match", () => {69    const def: GameDefinition = { ...mini, payModel: { type: "lines", lines: LINES_10 }, betDivisor: 10 };70    const grid = [71      ["D", "B", "D"],72      ["D", "W", "D"],73      ["D", "B", "D"],74      ["D", "B", "D"],75      ["D", "C", "D"],76    ];77    const wins = evaluateLines(def, grid, { bet: 100 });78    const mid = wins.find((w) => w.line === 0)!; // middle row = line 079    expect(mid.symbol).toBe("B");80    expect(mid.count).toBe(4);81    expect(mid.payout).toBe(10); // 1 × 100 / 1082  });83});8485describe("engine", () => {86  it("runSpin is pure and returns a coherent outcome", () => {87    const rng = new SeededRng(7);88    const out = runSpin(mini, 100, { rng });89    expect(out.bet).toBe(100);90    expect(out.totalWin).toBeGreaterThanOrEqual(0);91    expect(out.steps.length).toBeGreaterThan(0);92    expect(out.steps[0].grid.length).toBe(5);93    expect(out.multiplier).toBeCloseTo(out.totalWin / 100, 6);94    const sum = out.steps.reduce((a, s) => a + s.win, 0);95    expect(Math.min(sum, mini.maxMultiplier * 100)).toBe(out.totalWin);96  });97  it("never exceeds maxMultiplier", () => {98    const def = { ...mini, maxMultiplier: 2 };99    const rng = new SeededRng(3);100    for (let i = 0; i < 2000; i++) expect(runSpin(def, 100, { rng }).totalWin).toBeLessThanOrEqual(200);101  });102  it("simulation reports stable RTP for a seeded run", () => {103    const r = simulate(mini, { spins: 20_000, seed: 1 });104    expect(r.spins).toBe(20_000);105    expect(r.observedRtp).toBeGreaterThan(0);106    expect(r.hitRate).toBeGreaterThan(0.05);107    expect(r.distribution.reduce((a, b) => a + b.count, 0)).toBe(20_000);108  });109  it("validateDefinition catches bad definitions", () => {110    const bad = { ...mini, rtp: 1.2, slug: "Bad Slug" };111    const issues = validateDefinition(bad);112    expect(issues.some((i) => i.message.includes("RTP"))).toBe(true);113    expect(issues.some((i) => i.message.includes("kebab"))).toBe(true);114  });115});116