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%

Spinza v0.1.0 — fictional social casino: engine, 20 certified games, API, web, admin

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 16 days ago (Sep 8, 2026)

133 changed files +20,001 −0

added .env.example +12 −0
@@ -0,0 +1,12 @@
1 +# Spinza — local development
2 +DATABASE_URL=postgres://localhost:5432/spinza
3 +REDIS_URL=redis://127.0.0.1:6379
4 +API_PORT=8231
5 +API_HOST=127.0.0.1
6 +WEB_PORT=8230
7 +API_URL=http://127.0.0.1:8231
8 +NEXT_PUBLIC_SITE_URL=http://localhost:8230
9 +SESSION_SECRET=change-me-64-hex
10 +ADMIN_IP_ALLOWLIST=
11 +COOKIE_SECURE=0
12 +LOG_LEVEL=info
added .gitignore +18 −0
@@ -0,0 +1,18 @@
1 +node_modules/
2 +.next/
3 +dist/
4 +*.tsbuildinfo
5 +.env
6 +.env.*
7 +!.env.example
8 +logs/
9 +tmp/
10 +coverage/
11 +.DS_Store
12 +.turbo/
13 +.claude/
14 +qa/node_modules/
15 +qa/screens/
16 +deploy/spinza.mld.json
17 +apps/web/next-env.d.ts
18 +data/
added CLAUDE.md +48 −0
@@ -0,0 +1,48 @@
1 +# Spinza — repository guide
2 +
3 +Spinza (www.spinza.dev) is a premium **fictional** social casino. Virtual credits only: no deposits, no withdrawals, no purchases, no crypto, no cash value, no prizes. The full product brief is in `docs/SPEC-original.md`; this file is the condensed working guide.
4 +
5 +## Layout (pnpm workspaces + Turborepo)
6 +
7 +| Path | Package | Role |
8 +|---|---|---|
9 +| `packages/shared` | `@spinza/shared` | Economy constants (10,000 SC start, bet levels, daily rewards, XP curve), zod schemas, formatting (`formatSC` — never a currency sign), API DTO types. |
10 +| `packages/game-core` | `@spinza/game-core` | **Server-only game engine**: `CryptoRng` (crypto.randomBytes, rejection sampling — `Math.random` is banned for outcomes), reels, ways/lines evaluation, wild/scatter/cascade/hold-respin/pick-bonus/meter/heat/mystery/quantum/dynamic-grid features, `runSpin()`, `simulate()`, `certify()`, `validateDefinition()`, `defineGame()`. `./client` export = types only (safe for the browser). |
11 +| `games/` | `@spinza/games` | 20 original game definitions (`src/<slug>/index.ts`), `registry.ts`, `calibration.json` (payScale per slug+version, written by the simulator), `certifications/<slug>.json` (PASS/FAIL reports). |
12 +| `packages/database` | `@spinza/database` | Drizzle schema (25 tables, snake_case casing), migrations in `drizzle/`, `seed.ts` (games sync, achievements, missions, levels, flags, settings), `sync-games.ts`. |
13 +| `apps/api` | `@spinza/api` | Fastify on 127.0.0.1:8231. Auth (argon2id, opaque 256-bit sessions hashed, recovery codes), atomic spin (`services/spin.ts`), wallet ledger (`services/wallet.ts`), progression, rewards, leaderboards, admin (TOTP), health. |
14 +| `apps/simulator` | `@spinza/simulator` | CLI `pnpm sim <validate|run|calibrate|certify>` with worker threads; also used by the admin simulator. |
15 +| `apps/web` | `@spinza/web` | Next 16 (port 8230). `/api/*` is rewritten to the Fastify service. Game screen = `src/components/game/*` (PixiJS renderer, procedural symbols, WebAudio sound). Admin console under `/admin`. |
16 +
17 +## Non-negotiable rules
18 +
19 +- Outcomes are decided **only** in `apps/api` via `runSpin`. The browser animates `result.steps`; it never computes wins.
20 +- Every balance change goes through `lockWallet``applyCredit` (ledger row) → `saveWallet` inside one Postgres transaction. `sum(credit_transactions.amount) == wallets.balance` is an invariant checked in admin.
21 +- Spin idempotency: unique `(user_id, client_round_id)`; duplicates (even concurrent) return the stored round with `replayed: true`.
22 +- A game is `published` only if `games/certifications/<slug>.json` is `PASS` for its exact `version`. Changing pays/weights ⇒ bump `version`, re-run `pnpm sim calibrate <slug>` then `pnpm sim certify <slug>`.
23 +- Never log passwords, hashes, recovery codes, session tokens (pino redact list in `apps/api/src/app.ts`).
24 +- No payments, no `$`/`€` anywhere in UI copy. Credits are formatted `10,000 SC`.
25 +
26 +## Commands
27 +
28 +```bash
29 +pnpm install
30 +createdb spinza && pnpm db:migrate && pnpm db:seed # local Postgres 17 + Redis required
31 +pnpm dev:api # 8231 pnpm dev:web # 8230
32 +pnpm sim validate | pnpm sim run <slug> --spins 1000000 | pnpm sim calibrate all --spins 10000000 --threads 26 | pnpm sim certify all --spins 10000000
33 +pnpm --filter @spinza/game-core test # engine unit tests
34 +pnpm --filter @spinza/api test # integration tests (needs local DB/Redis)
35 +pnpm admin:create <username> [password] # prints TOTP secret once
36 +```
37 +
38 +## Deployment (MacLustr)
39 +
40 +`mld deploy spinza` from the laptop (`~/Desktop/cluster-skill/mld`). Manifest with secrets: `M1M32:~/dispatch/apps/spinza.json` (copy: `deploy/spinza.mld.json`, gitignored). Node M3U96a, PM2 `spinza-api` (8231 loopback) + `spinza-web` (8230) + `spinza-ngrok` (`www.spinza.dev`). Postgres db `spinza` + Redis local to the node. Hooks: install → migrate + seed → `next build`. Backups: `deploy/backup.sh` (daily launchd on the node).
41 +
42 +## Gotchas
43 +
44 +- drizzle-orm ≥0.44 wraps pg errors: use `pgCode(e)` (`apps/api/src/lib/pg.ts`), not `e.code`.
45 +- Way/line pays are in bet units (`betDivisor`: 25 for 5-reel ways, 50 for 6+ reels, `lines.length` for line games); scatter/bonus/coin values are × total bet. `payScale` from `calibration.json` scales all non-jackpot wins.
46 +- Certification tolerance is statistical: `max(0.4%, 3σ/√n)` capped at 1.5% — high-volatility games need ≥10M spins.
47 +- Turbopack: import workspace packages without `.js` extensions; browser code imports `@spinza/game-core/client` only.
48 +- PixiJS renderer must be created client-side only (`GameClient` is `"use client"`; `Application.init` is async).
added README.md +38 −0
@@ -0,0 +1,38 @@
1 +# Spinza
2 +
3 +**www.spinza.dev — The Virtual Casino Playground.**
4 +
5 +Spinza is a premium *fictional* social casino: twenty original games, a real server-authoritative game engine, cryptographically secure RNG, versioned game mathematics, a simulation & certification platform and an immutable wallet ledger — all running on **Spinza Credits (SC)**, a virtual currency with no monetary value.
6 +
7 +> Virtual credits only. No deposits. No withdrawals. No cash value. 18+.
8 +
9 +## What's inside
10 +
11 +- `packages/game-core` — the Spinza Game Engine (ways/lines, wilds, scatters, cascades, hold & respin, pick bonuses, persistent meters, heat, mystery, quantum split, dynamic grids, fictional jackpots), simulator and certification.
12 +- `games/` — 20 original definitions (`defineGame`), calibration and 10,000,000-spin certification reports.
13 +- `apps/api` — Fastify API: username/password accounts (Argon2id), recovery codes, opaque sessions, atomic spins with idempotency, ledger, XP/levels, achievements, missions, daily & rescue rewards, leaderboards, TOTP admin.
14 +- `apps/web` — Next.js 16 front-end: landing, lobby, PixiJS game screen with procedural art and WebAudio sound, rewards, leaderboards, profile, settings, admin console.
15 +- `apps/simulator` — multi-threaded CLI (`pnpm sim …`).
16 +
17 +See `CLAUDE.md` for the working guide and `docs/SPEC-original.md` for the product brief.
18 +
19 +## Quick start
20 +
21 +```bash
22 +pnpm install
23 +createdb spinza
24 +pnpm db:migrate && pnpm db:seed
25 +pnpm dev:api # http://127.0.0.1:8231
26 +pnpm dev:web # http://localhost:8230
27 +```
28 +
29 +Requires Node ≥ 22, pnpm 11, PostgreSQL 17 and Redis on localhost.
30 +
31 +## Certification
32 +
33 +Every game ships with `games/certifications/<slug>.json`; the seed publishes a game only when the report is `PASS` for the exact version. Re-certify after any math change:
34 +
35 +```bash
36 +pnpm sim calibrate <slug> --spins 10000000
37 +pnpm sim certify <slug> --spins 10000000
38 +```
added apps/api/package.json +34 −0
@@ -0,0 +1,34 @@
1 +{
2 + "name": "@spinza/api",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "scripts": {
7 + "dev": "tsx watch src/server.ts",
8 + "start": "tsx src/server.ts",
9 + "admin:create": "tsx src/admin-create.ts",
10 + "typecheck": "tsc -p tsconfig.json --noEmit",
11 + "test": "vitest run"
12 + },
13 + "dependencies": {
14 + "@fastify/cookie": "^11.0.2",
15 + "@spinza/database": "workspace:*",
16 + "@spinza/game-core": "workspace:*",
17 + "@spinza/games": "workspace:*",
18 + "@spinza/shared": "workspace:*",
19 + "@spinza/simulator": "workspace:*",
20 + "argon2": "^0.44.0",
21 + "fastify": "^5.4.0",
22 + "fastify-plugin": "^6.0.0",
23 + "ioredis": "^5.6.0",
24 + "pino": "^9.7.0",
25 + "pino-pretty": "^13.0.0",
26 + "tsx": "^4.20.0",
27 + "zod": "^4.0.0"
28 + },
29 + "devDependencies": {
30 + "@types/node": "^24.0.0",
31 + "typescript": "^5.9.3",
32 + "vitest": "^3.2.0"
33 + }
34 +}
added apps/api/src/admin-create.ts +36 −0
@@ -0,0 +1,36 @@
1 +/**
2 + * Create (or reset) an admin account with TOTP.
3 + * pnpm admin:create <username> [password]
4 + * Prints the TOTP secret + otpauth URL once. Never logs the password.
5 + */
6 +import { adminUsers, closeDb, db, eq } from "@spinza/database";
7 +import { encrypt, generateTotpSecret, hashPassword, otpauthUrl, randomToken } from "./lib/crypto";
8 +
9 +async function main() {
10 + const [username, passwordArg] = process.argv.slice(2);
11 + if (!username) {
12 + console.error("usage: admin:create <username> [password]");
13 + process.exit(1);
14 + }
15 + const password = passwordArg ?? randomToken(12);
16 + const secret = generateTotpSecret();
17 + const passwordHash = await hashPassword(password);
18 + const existing = await db.query.adminUsers.findFirst({ where: eq(adminUsers.username, username.toLowerCase()) });
19 + if (existing) {
20 + await db.update(adminUsers).set({ passwordHash, totpSecret: encrypt(secret), disabled: false }).where(eq(adminUsers.id, existing.id));
21 + console.log(`Admin "${username}" reset.`);
22 + } else {
23 + await db.insert(adminUsers).values({ username: username.toLowerCase(), passwordHash, totpSecret: encrypt(secret) });
24 + console.log(`Admin "${username}" created.`);
25 + }
26 + console.log(`Password: ${passwordArg ? "(as provided)" : password}`);
27 + console.log(`TOTP secret (base32): ${secret}`);
28 + console.log(`otpauth URL: ${otpauthUrl(username, secret)}`);
29 + console.log("Add the secret to an authenticator app now — it is not shown again.");
30 + await closeDb();
31 +}
32 +
33 +main().catch((e) => {
34 + console.error(e);
35 + process.exit(1);
36 +});
added apps/api/src/app.ts +75 −0
@@ -0,0 +1,75 @@
1 +import Fastify, { type FastifyInstance } from "fastify";
2 +import cookie from "@fastify/cookie";
3 +import { ZodError } from "zod";
4 +import { config } from "./config";
5 +import { ApiError } from "./lib/errors";
6 +import { authPlugin } from "./plugins/auth";
7 +import { authRoutes } from "./routes/auth";
8 +import { userRoutes } from "./routes/user";
9 +import { gameRoutes } from "./routes/games";
10 +import { rewardRoutes } from "./routes/rewards";
11 +import { healthRoutes } from "./routes/health";
12 +import { adminRoutes } from "./routes/admin";
13 +import { refreshSettings } from "./lib/settings";
14 +
15 +const REDACT = ["req.headers.cookie", "req.headers.authorization", "*.password", "*.newPassword", "*.currentPassword", "*.recoveryCode", "*.totp", "*.passwordHash", "*.tokenHash"];
16 +
17 +export async function buildApp(): Promise<FastifyInstance> {
18 + const app = Fastify({
19 + logger: {
20 + level: config.logLevel,
21 + redact: { paths: REDACT, censor: "[redacted]" },
22 + transport: config.env === "development" ? { target: "pino-pretty", options: { translateTime: "HH:MM:ss", ignore: "pid,hostname" } } : undefined,
23 + },
24 + trustProxy: config.trustProxy,
25 + bodyLimit: 64 * 1024,
26 + genReqId: () => Math.random().toString(36).slice(2, 10), // request ids only — never used for game outcomes
27 + });
28 +
29 + await app.register(cookie, { secret: config.sessionSecret });
30 + await app.register(authPlugin);
31 +
32 + app.addHook("onRequest", async (_req, reply) => {
33 + reply.header("Cache-Control", "no-store");
34 + reply.header("X-Content-Type-Options", "nosniff");
35 + });
36 +
37 + app.addHook("onReady", async () => {
38 + await refreshSettings(true);
39 + setInterval(() => refreshSettings().catch(() => {}), 10_000).unref();
40 + });
41 +
42 + app.setErrorHandler((err: Error & { validation?: unknown; statusCode?: number }, req, reply) => {
43 + if (err instanceof ApiError) {
44 + reply.code(err.status).send({ error: err.code, message: err.message, details: err.details });
45 + return;
46 + }
47 + if (err instanceof ZodError) {
48 + reply.code(400).send({ error: "VALIDATION", message: "Invalid request.", details: err.flatten() });
49 + return;
50 + }
51 + if ((err as { validation?: unknown }).validation) {
52 + reply.code(400).send({ error: "VALIDATION", message: err.message });
53 + return;
54 + }
55 + if ((err as { statusCode?: number }).statusCode === 413) {
56 + reply.code(413).send({ error: "PAYLOAD_TOO_LARGE", message: "Request too large." });
57 + return;
58 + }
59 + req.log.error({ err }, "unhandled error");
60 + reply.code(500).send({ error: "INTERNAL", message: "Something went wrong on our side." });
61 + });
62 +
63 + app.setNotFoundHandler((_req, reply) => {
64 + reply.code(404).send({ error: "NOT_FOUND", message: "Route not found." });
65 + });
66 +
67 + await app.register(healthRoutes);
68 + await app.register(authRoutes);
69 + await app.register(userRoutes);
70 + await app.register(gameRoutes);
71 + await app.register(rewardRoutes);
72 + await app.register(adminRoutes);
73 +
74 + return app;
75 +}
added apps/api/src/config.ts +22 −0
@@ -0,0 +1,22 @@
1 +export const config = {
2 + env: process.env.NODE_ENV ?? "development",
3 + port: Number(process.env.API_PORT ?? 8231),
4 + host: process.env.API_HOST ?? "127.0.0.1",
5 + databaseUrl: process.env.DATABASE_URL ?? "postgres://localhost:5432/spinza",
6 + redisUrl: process.env.REDIS_URL ?? "redis://127.0.0.1:6379",
7 + sessionSecret: process.env.SESSION_SECRET ?? "dev-secret-change-me-dev-secret-change-me",
8 + siteUrl: process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:8230",
9 + cookieSecure: process.env.COOKIE_SECURE ? process.env.COOKIE_SECURE === "1" : (process.env.NODE_ENV ?? "development") === "production",
10 + adminIpAllowlist: (process.env.ADMIN_IP_ALLOWLIST ?? "")
11 + .split(",")
12 + .map((s) => s.trim())
13 + .filter(Boolean),
14 + logLevel: process.env.LOG_LEVEL ?? "info",
15 + version: process.env.SPINZA_VERSION ?? "0.1.0",
16 + /** Trust X-Forwarded-For from the local reverse proxy / Next rewrite. */
17 + trustProxy: true,
18 +};
19 +
20 +export const allowedOrigins = new Set(
21 + [config.siteUrl, "http://localhost:8230", "http://127.0.0.1:8230", "http://localhost:3000"].map((u) => u.replace(/\/$/, "")),
22 +);
added apps/api/src/lib/crypto.ts +154 −0
@@ -0,0 +1,154 @@
1 +import argon2 from "argon2";
2 +import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
3 +import { RECOVERY_CODE_PREFIX, ROUND_ID_PREFIX } from "@spinza/shared";
4 +import { config } from "../config";
5 +
6 +/* ------------------------------------------------------------- passwords */
7 +
8 +const ARGON_OPTS: argon2.Options = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };
9 +
10 +export async function hashPassword(password: string): Promise<string> {
11 + return argon2.hash(password, ARGON_OPTS);
12 +}
13 +
14 +export async function verifyPassword(hash: string, password: string): Promise<boolean> {
15 + try {
16 + return await argon2.verify(hash, password);
17 + } catch {
18 + return false;
19 + }
20 +}
21 +
22 +/* ---------------------------------------------------------------- tokens */
23 +
24 +export function randomToken(bytes = 32): string {
25 + return randomBytes(bytes).toString("base64url");
26 +}
27 +
28 +export function sha256(input: string): string {
29 + return createHash("sha256").update(input).digest("hex");
30 +}
31 +
32 +export function hmac(input: string): string {
33 + return createHmac("sha256", config.sessionSecret).update(input).digest("hex");
34 +}
35 +
36 +/** Round ids: spz_rnd_ + 12 chars from an unambiguous alphabet. */
37 +const ROUND_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
38 +export function newRoundId(): string {
39 + let s = "";
40 + for (let i = 0; i < 12; i++) s += ROUND_ALPHABET[randomInt(ROUND_ALPHABET.length)];
41 + return `${ROUND_ID_PREFIX}${s}`;
42 +}
43 +
44 +/* --------------------------------------------------------- recovery code */
45 +
46 +export function generateRecoveryCode(): string {
47 + const groups: string[] = [];
48 + for (let g = 0; g < 3; g++) {
49 + let s = "";
50 + for (let i = 0; i < 4; i++) s += ROUND_ALPHABET[randomInt(ROUND_ALPHABET.length)];
51 + groups.push(s);
52 + }
53 + return `${RECOVERY_CODE_PREFIX}-${groups.join("-")}`;
54 +}
55 +
56 +export function normalizeRecoveryCode(code: string): string {
57 + return code.trim().toUpperCase().replace(/[^A-Z0-9]/g, "");
58 +}
59 +
60 +export async function hashRecoveryCode(code: string): Promise<string> {
61 + return argon2.hash(normalizeRecoveryCode(code), ARGON_OPTS);
62 +}
63 +
64 +export async function verifyRecoveryCode(hash: string, code: string): Promise<boolean> {
65 + try {
66 + return await argon2.verify(hash, normalizeRecoveryCode(code));
67 + } catch {
68 + return false;
69 + }
70 +}
71 +
72 +/* -------------------------------------------------- symmetric encryption */
73 +
74 +function key(): Buffer {
75 + return createHash("sha256").update(config.sessionSecret).digest();
76 +}
77 +
78 +export function encrypt(plain: string): string {
79 + const iv = randomBytes(12);
80 + const c = createCipheriv("aes-256-gcm", key(), iv);
81 + const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
82 + return `${iv.toString("base64url")}.${enc.toString("base64url")}.${c.getAuthTag().toString("base64url")}`;
83 +}
84 +
85 +export function decrypt(payload: string): string {
86 + const [iv, enc, tag] = payload.split(".").map((p) => Buffer.from(p, "base64url"));
87 + const d = createDecipheriv("aes-256-gcm", key(), iv);
88 + d.setAuthTag(tag);
89 + return Buffer.concat([d.update(enc), d.final()]).toString("utf8");
90 +}
91 +
92 +/* ------------------------------------------------------------------ TOTP */
93 +
94 +const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
95 +
96 +export function base32Encode(buf: Buffer): string {
97 + let bits = 0;
98 + let value = 0;
99 + let out = "";
100 + for (const byte of buf) {
101 + value = (value << 8) | byte;
102 + bits += 8;
103 + while (bits >= 5) {
104 + out += B32[(value >>> (bits - 5)) & 31];
105 + bits -= 5;
106 + }
107 + }
108 + if (bits > 0) out += B32[(value << (5 - bits)) & 31];
109 + return out;
110 +}
111 +
112 +export function base32Decode(s: string): Buffer {
113 + const clean = s.toUpperCase().replace(/=+$/, "").replace(/[^A-Z2-7]/g, "");
114 + let bits = 0;
115 + let value = 0;
116 + const out: number[] = [];
117 + for (const ch of clean) {
118 + value = (value << 5) | B32.indexOf(ch);
119 + bits += 5;
120 + if (bits >= 8) {
121 + out.push((value >>> (bits - 8)) & 255);
122 + bits -= 8;
123 + }
124 + }
125 + return Buffer.from(out);
126 +}
127 +
128 +export function generateTotpSecret(): string {
129 + return base32Encode(randomBytes(20));
130 +}
131 +
132 +export function totpCode(secret: string, time = Date.now(), step = 30, digits = 6): string {
133 + const counter = Math.floor(time / 1000 / step);
134 + const msg = Buffer.alloc(8);
135 + msg.writeBigUInt64BE(BigInt(counter));
136 + const h = createHmac("sha1", base32Decode(secret)).update(msg).digest();
137 + const offset = h[h.length - 1] & 0xf;
138 + const bin = ((h[offset] & 0x7f) << 24) | (h[offset + 1] << 16) | (h[offset + 2] << 8) | h[offset + 3];
139 + return String(bin % 10 ** digits).padStart(digits, "0");
140 +}
141 +
142 +export function verifyTotp(secret: string, code: string, window = 1): boolean {
143 + const c = code.replace(/\s+/g, "");
144 + if (!/^\d{6}$/.test(c)) return false;
145 + for (let w = -window; w <= window; w++) {
146 + const expected = totpCode(secret, Date.now() + w * 30_000);
147 + if (timingSafeEqual(Buffer.from(expected), Buffer.from(c))) return true;
148 + }
149 + return false;
150 +}
151 +
152 +export function otpauthUrl(username: string, secret: string): string {
153 + return `otpauth://totp/Spinza%20Admin:${encodeURIComponent(username)}?secret=${secret}&issuer=Spinza%20Admin&algorithm=SHA1&digits=6&period=30`;
154 +}
added apps/api/src/lib/errors.ts +22 −0
@@ -0,0 +1,22 @@
1 +export class ApiError extends Error {
2 + constructor(
3 + public status: number,
4 + public code: string,
5 + message: string,
6 + public details?: unknown,
7 + ) {
8 + super(message);
9 + }
10 +}
11 +
12 +export const errors = {
13 + badRequest: (msg = "Bad request", details?: unknown) => new ApiError(400, "BAD_REQUEST", msg, details),
14 + unauthorized: (msg = "Please sign in.") => new ApiError(401, "UNAUTHORIZED", msg),
15 + forbidden: (msg = "Forbidden") => new ApiError(403, "FORBIDDEN", msg),
16 + notFound: (msg = "Not found") => new ApiError(404, "NOT_FOUND", msg),
17 + conflict: (code: string, msg: string) => new ApiError(409, code, msg),
18 + rateLimited: (retryAfter: number) => new ApiError(429, "RATE_LIMITED", "Too many requests. Please slow down.", { retryAfter }),
19 + insufficient: (balance: number, bet: number) => new ApiError(402, "INSUFFICIENT_CREDITS", "Not enough Spinza Credits for this bet.", { balance, bet }),
20 + maintenance: (message: string) => new ApiError(503, "MAINTENANCE", message),
21 + unavailable: (msg = "Game unavailable") => new ApiError(503, "GAME_UNAVAILABLE", msg),
22 +};
added apps/api/src/lib/pg.ts +7 −0
@@ -0,0 +1,7 @@
1 +/** Extract the PostgreSQL error code from a raw pg error or a drizzle-wrapped one. */
2 +export function pgCode(e: unknown): string | undefined {
3 + const err = e as { code?: string; cause?: { code?: string } } | undefined;
4 + return err?.code ?? err?.cause?.code;
5 +}
6 +
7 +export const PG_UNIQUE_VIOLATION = "23505";
added apps/api/src/lib/redis.ts +52 −0
@@ -0,0 +1,52 @@
1 +import Redis from "ioredis";
2 +import { config } from "../config";
3 +
4 +let _redis: Redis | null = null;
5 +
6 +export function redis(): Redis {
7 + if (!_redis) {
8 + _redis = new Redis(config.redisUrl, { maxRetriesPerRequest: 2, enableOfflineQueue: true, lazyConnect: false });
9 + _redis.on("error", (e) => console.error("[redis]", e.message));
10 + }
11 + return _redis;
12 +}
13 +
14 +export async function pingRedis(): Promise<number> {
15 + const t = Date.now();
16 + await redis().ping();
17 + return Date.now() - t;
18 +}
19 +
20 +export async function closeRedis(): Promise<void> {
21 + if (_redis) {
22 + await _redis.quit();
23 + _redis = null;
24 + }
25 +}
26 +
27 +/**
28 + * Sliding-window rate limit (fixed window with two buckets). Returns retry-after
29 + * seconds when exceeded, else 0.
30 + */
31 +export async function rateLimit(key: string, limit: number, windowSec: number): Promise<number> {
32 + const r = redis();
33 + const now = Math.floor(Date.now() / 1000);
34 + const bucket = Math.floor(now / windowSec);
35 + const k = `rl:${key}:${bucket}`;
36 + const prevK = `rl:${key}:${bucket - 1}`;
37 + const [[, cur], [, prev]] = (await r.multi().incr(k).get(prevK).exec()) as [[null, number], [null, string | null]];
38 + if (cur === 1) await r.expire(k, windowSec * 2);
39 + const elapsed = (now % windowSec) / windowSec;
40 + const weighted = cur + Number(prev ?? 0) * (1 - elapsed);
41 + if (weighted > limit) return Math.max(1, windowSec - (now % windowSec));
42 + return 0;
43 +}
44 +
45 +export async function setJson(key: string, value: unknown, ttlSec: number): Promise<void> {
46 + await redis().set(key, JSON.stringify(value), "EX", ttlSec);
47 +}
48 +
49 +export async function getJson<T>(key: string): Promise<T | null> {
50 + const v = await redis().get(key);
51 + return v ? (JSON.parse(v) as T) : null;
52 +}
added apps/api/src/lib/security.ts +49 −0
@@ -0,0 +1,49 @@
1 +import { db, securityEvents } from "@spinza/database";
2 +import type { FastifyRequest } from "fastify";
3 +
4 +export type SecurityEventType =
5 + | "login.success"
6 + | "login.failed"
7 + | "register"
8 + | "logout"
9 + | "password.changed"
10 + | "recovery.used"
11 + | "recovery.failed"
12 + | "rate_limit"
13 + | "spin.suspicious"
14 + | "admin.login.success"
15 + | "admin.login.failed"
16 + | "admin.wallet.adjust"
17 + | "admin.user.status"
18 + | "admin.game.lifecycle"
19 + | "admin.game.flags"
20 + | "admin.flag"
21 + | "admin.setting"
22 + | "admin.maintenance"
23 + | "csrf.rejected";
24 +
25 +export function clientIp(req: FastifyRequest): string {
26 + const xff = req.headers["x-forwarded-for"];
27 + if (typeof xff === "string" && xff.length) return xff.split(",").pop()!.trim();
28 + return req.ip;
29 +}
30 +
31 +export async function logSecurity(
32 + req: FastifyRequest | null,
33 + type: SecurityEventType,
34 + opts: { userId?: string | null; adminId?: string | null; severity?: "info" | "warn" | "high"; meta?: Record<string, unknown> } = {},
35 +): Promise<void> {
36 + try {
37 + await db.insert(securityEvents).values({
38 + userId: opts.userId ?? null,
39 + adminId: opts.adminId ?? null,
40 + type,
41 + severity: opts.severity ?? (type.includes("failed") || type === "rate_limit" || type === "csrf.rejected" ? "warn" : "info"),
42 + ip: req ? clientIp(req) : null,
43 + userAgent: req ? (req.headers["user-agent"] ?? "").slice(0, 300) : null,
44 + meta: opts.meta ?? null,
45 + });
46 + } catch (e) {
47 + req?.log.error({ err: e }, "security event write failed");
48 + }
49 +}
added apps/api/src/lib/settings.ts +51 −0
@@ -0,0 +1,51 @@
1 +import { db, eq, featureFlags, platformSettings } from "@spinza/database";
2 +import { DAILY_REWARDS, RESCUE_CREDITS_AMOUNT, RESCUE_CREDITS_COOLDOWN_HOURS } from "@spinza/shared";
3 +
4 +/** In-process cache of flags & settings, refreshed every 10 s. */
5 +let flags = new Map<string, boolean>();
6 +let settings = new Map<string, unknown>();
7 +let loadedAt = 0;
8 +const TTL = 10_000;
9 +
10 +export async function refreshSettings(force = false): Promise<void> {
11 + if (!force && Date.now() - loadedAt < TTL) return;
12 + const [f, s] = await Promise.all([db.select().from(featureFlags), db.select().from(platformSettings)]);
13 + flags = new Map(f.map((x) => [x.key, x.enabled]));
14 + settings = new Map(s.map((x) => [x.key, x.value]));
15 + loadedAt = Date.now();
16 +}
17 +
18 +export function flag(key: string, def = true): boolean {
19 + return flags.has(key) ? (flags.get(key) as boolean) : def;
20 +}
21 +
22 +export function setting<T>(key: string, def: T): T {
23 + return settings.has(key) ? (settings.get(key) as T) : def;
24 +}
25 +
26 +export interface MaintenanceSetting {
27 + enabled: boolean;
28 + message: string;
29 +}
30 +export const maintenance = () => setting<MaintenanceSetting>("maintenance", { enabled: false, message: "Spinza is getting an upgrade. Your credits and progress are safe." });
31 +export const rescueConfig = () => setting("rescue", { amount: RESCUE_CREDITS_AMOUNT, cooldownHours: RESCUE_CREDITS_COOLDOWN_HOURS, threshold: 0 });
32 +export const dailySchedule = () => setting("dailyRewards", { schedule: DAILY_REWARDS as readonly number[] }).schedule;
33 +export const profanityWords = () => setting("profanity", { words: [] as string[] }).words;
34 +
35 +export async function setFlag(key: string, enabled: boolean): Promise<void> {
36 + await db.insert(featureFlags).values({ key, enabled }).onConflictDoUpdate({ target: featureFlags.key, set: { enabled, updatedAt: new Date() } });
37 + await refreshSettings(true);
38 +}
39 +
40 +export async function setSetting(key: string, value: unknown): Promise<void> {
41 + await db.insert(platformSettings).values({ key, value }).onConflictDoUpdate({ target: platformSettings.key, set: { value, updatedAt: new Date() } });
42 + await refreshSettings(true);
43 +}
44 +
45 +export function allFlags(): Record<string, boolean> {
46 + return Object.fromEntries(flags);
47 +}
48 +export function allSettings(): Record<string, unknown> {
49 + return Object.fromEntries(settings);
50 +}
51 +export { eq };
added apps/api/src/plugins/auth.ts +150 −0
@@ -0,0 +1,150 @@
1 +import fp from "fastify-plugin";
2 +import type { FastifyReply, FastifyRequest } from "fastify";
3 +import { and, db, eq, gt, sessions, users, adminSessions, adminUsers } from "@spinza/database";
4 +import { ADMIN_COOKIE, ADMIN_SESSION_TTL_HOURS, SESSION_COOKIE, SESSION_TTL_DAYS } from "@spinza/shared";
5 +import { randomToken, sha256 } from "../lib/crypto";
6 +import { getJson, redis, setJson } from "../lib/redis";
7 +import { config, allowedOrigins } from "../config";
8 +import { errors } from "../lib/errors";
9 +import { clientIp, logSecurity } from "../lib/security";
10 +
11 +export interface SessionUser {
12 + id: string;
13 + username: string;
14 + status: string;
15 +}
16 +
17 +export interface AdminUser {
18 + id: string;
19 + username: string;
20 + role: string;
21 +}
22 +
23 +declare module "fastify" {
24 + interface FastifyRequest {
25 + user: SessionUser | null;
26 + sessionId: string | null;
27 + admin: AdminUser | null;
28 + }
29 +}
30 +
31 +const SESSION_CACHE_TTL = 60;
32 +
33 +export async function createSession(reply: FastifyReply, req: FastifyRequest, userId: string): Promise<void> {
34 + const token = randomToken(32);
35 + const tokenHash = sha256(token);
36 + const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 86400_000);
37 + await db.insert(sessions).values({ userId, tokenHash, expiresAt, userAgent: (req.headers["user-agent"] ?? "").slice(0, 300), ip: clientIp(req) });
38 + reply.setCookie(SESSION_COOKIE, token, {
39 + httpOnly: true,
40 + secure: config.cookieSecure,
41 + sameSite: "lax",
42 + path: "/",
43 + maxAge: SESSION_TTL_DAYS * 86400,
44 + });
45 +}
46 +
47 +export async function destroySession(reply: FastifyReply, req: FastifyRequest): Promise<void> {
48 + const token = req.cookies[SESSION_COOKIE];
49 + if (token) {
50 + const h = sha256(token);
51 + await db.delete(sessions).where(eq(sessions.tokenHash, h));
52 + await redis().del(`sess:${h}`);
53 + }
54 + reply.clearCookie(SESSION_COOKIE, { path: "/" });
55 +}
56 +
57 +export async function destroyAllSessions(userId: string): Promise<void> {
58 + const rows = await db.select({ tokenHash: sessions.tokenHash }).from(sessions).where(eq(sessions.userId, userId));
59 + await db.delete(sessions).where(eq(sessions.userId, userId));
60 + if (rows.length) await redis().del(...rows.map((r) => `sess:${r.tokenHash}`));
61 +}
62 +
63 +async function resolveUser(token: string): Promise<{ user: SessionUser; sessionId: string } | null> {
64 + const h = sha256(token);
65 + const cached = await getJson<{ user: SessionUser; sessionId: string }>(`sess:${h}`);
66 + if (cached) return cached;
67 + const row = await db
68 + .select({ id: users.id, username: users.username, status: users.status, sessionId: sessions.id })
69 + .from(sessions)
70 + .innerJoin(users, eq(users.id, sessions.userId))
71 + .where(and(eq(sessions.tokenHash, h), gt(sessions.expiresAt, new Date())))
72 + .limit(1);
73 + if (!row.length) return null;
74 + const value = { user: { id: row[0].id, username: row[0].username, status: row[0].status }, sessionId: row[0].sessionId };
75 + await setJson(`sess:${h}`, value, SESSION_CACHE_TTL);
76 + // Touch last_seen occasionally (cheap update).
77 + db.update(sessions).set({ lastSeenAt: new Date() }).where(eq(sessions.id, value.sessionId)).catch(() => {});
78 + return value;
79 +}
80 +
81 +export async function createAdminSession(reply: FastifyReply, req: FastifyRequest, adminId: string): Promise<void> {
82 + const token = randomToken(32);
83 + const expiresAt = new Date(Date.now() + ADMIN_SESSION_TTL_HOURS * 3600_000);
84 + await db.insert(adminSessions).values({ adminId, tokenHash: sha256(token), expiresAt, ip: clientIp(req) });
85 + reply.setCookie(ADMIN_COOKIE, token, { httpOnly: true, secure: config.cookieSecure, sameSite: "strict", path: "/", maxAge: ADMIN_SESSION_TTL_HOURS * 3600 });
86 +}
87 +
88 +export async function destroyAdminSession(reply: FastifyReply, req: FastifyRequest): Promise<void> {
89 + const token = req.cookies[ADMIN_COOKIE];
90 + if (token) await db.delete(adminSessions).where(eq(adminSessions.tokenHash, sha256(token)));
91 + reply.clearCookie(ADMIN_COOKIE, { path: "/" });
92 +}
93 +
94 +async function resolveAdmin(token: string): Promise<AdminUser | null> {
95 + const row = await db
96 + .select({ id: adminUsers.id, username: adminUsers.username, role: adminUsers.role, disabled: adminUsers.disabled })
97 + .from(adminSessions)
98 + .innerJoin(adminUsers, eq(adminUsers.id, adminSessions.adminId))
99 + .where(and(eq(adminSessions.tokenHash, sha256(token)), gt(adminSessions.expiresAt, new Date())))
100 + .limit(1);
101 + if (!row.length || row[0].disabled) return null;
102 + return { id: row[0].id, username: row[0].username, role: row[0].role };
103 +}
104 +
105 +export const authPlugin = fp(async (app) => {
106 + app.decorateRequest("user", null);
107 + app.decorateRequest("sessionId", null);
108 + app.decorateRequest("admin", null);
109 +
110 + app.addHook("onRequest", async (req) => {
111 + const token = req.cookies?.[SESSION_COOKIE];
112 + if (token) {
113 + const r = await resolveUser(token);
114 + if (r) {
115 + req.user = r.user;
116 + req.sessionId = r.sessionId;
117 + }
118 + }
119 + if (req.url.startsWith("/api/admin")) {
120 + const at = req.cookies?.[ADMIN_COOKIE];
121 + if (at) req.admin = await resolveAdmin(at);
122 + }
123 + });
124 +
125 + // CSRF: state-changing requests must come from an allowed origin.
126 + app.addHook("preHandler", async (req) => {
127 + if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") return;
128 + const origin = req.headers.origin ?? (req.headers.referer ? new URL(req.headers.referer).origin : null);
129 + const fetchSite = req.headers["sec-fetch-site"];
130 + if (fetchSite === "same-origin" || fetchSite === "none") return;
131 + if (origin && allowedOrigins.has(origin.replace(/\/$/, ""))) return;
132 + // Requests forwarded by the Next.js server keep the browser's Origin header. Missing origin from a
133 + // non-browser client is accepted only when there is no session cookie to protect.
134 + if (!origin && !req.cookies?.[SESSION_COOKIE] && !req.cookies?.[ADMIN_COOKIE]) return;
135 + await logSecurity(req, "csrf.rejected", { meta: { origin, path: req.url } });
136 + throw errors.forbidden("Cross-site request rejected.");
137 + });
138 +});
139 +
140 +export function requireUser(req: FastifyRequest): SessionUser {
141 + if (!req.user) throw errors.unauthorized();
142 + if (req.user.status !== "active") throw errors.forbidden("This account is suspended.");
143 + return req.user;
144 +}
145 +
146 +export function requireAdmin(req: FastifyRequest): AdminUser {
147 + if (config.adminIpAllowlist.length && !config.adminIpAllowlist.includes(clientIp(req))) throw errors.forbidden("Admin access is restricted.");
148 + if (!req.admin) throw errors.unauthorized("Admin sign-in required.");
149 + return req.admin;
150 +}
added apps/api/src/routes/admin.ts +387 −0
@@ -0,0 +1,387 @@
1 +import type { FastifyInstance } from "fastify";
2 +import os from "node:os";
3 +import { achievements, adminUsers, and, count, creditTransactions, db, desc, eq, gameRounds, gameStatistics, games, gte, ilike, missions, securityEvents, sessions, simulationRuns, sql, users, wallets, gameVersions, or } from "@spinza/database";
4 +import { GAMES, getGame } from "@spinza/games";
5 +import { certify, DEFAULT_CERTIFICATION_RULES, validateDefinition } from "@spinza/game-core";
6 +import { simulateParallel } from "@spinza/simulator";
7 +import { GAME_LIFECYCLE } from "@spinza/shared";
8 +import { z } from "zod";
9 +import { errors } from "../lib/errors";
10 +import { decrypt, verifyPassword, verifyTotp } from "../lib/crypto";
11 +import { rateLimit, redis } from "../lib/redis";
12 +import { createAdminSession, destroyAdminSession, requireAdmin } from "../plugins/auth";
13 +import { clientIp, logSecurity } from "../lib/security";
14 +import { allFlags, allSettings, refreshSettings, setFlag, setSetting } from "../lib/settings";
15 +import { grant } from "../services/wallet";
16 +import { config } from "../config";
17 +import { pingDb } from "@spinza/database";
18 +import { pingRedis } from "../lib/redis";
19 +
20 +const startedAt = Date.now();
21 +
22 +export async function adminRoutes(app: FastifyInstance) {
23 + /* ------------------------------------------------------------- auth */
24 + app.post("/api/admin/auth/login", async (req, reply) => {
25 + if (config.adminIpAllowlist.length && !config.adminIpAllowlist.includes(clientIp(req))) throw errors.forbidden("Admin access is restricted.");
26 + const retry = await rateLimit(`admin-login:${clientIp(req)}`, 10, 600);
27 + if (retry) throw errors.rateLimited(retry);
28 + const body = z.object({ username: z.string().min(1), password: z.string().min(1), totp: z.string().min(6).max(8) }).parse(req.body);
29 + const a = await db.query.adminUsers.findFirst({ where: eq(adminUsers.username, body.username.toLowerCase()) });
30 + const ok = !!a && !a.disabled && (await verifyPassword(a.passwordHash, body.password)) && verifyTotp(decrypt(a.totpSecret), body.totp);
31 + if (!ok) {
32 + await logSecurity(req, "admin.login.failed", { adminId: a?.id ?? null, severity: "high", meta: { username: body.username } });
33 + throw errors.unauthorized("Invalid credentials.");
34 + }
35 + await db.update(adminUsers).set({ lastLoginAt: new Date() }).where(eq(adminUsers.id, a.id));
36 + await createAdminSession(reply, req, a.id);
37 + await logSecurity(req, "admin.login.success", { adminId: a.id, severity: "warn" });
38 + return { admin: { id: a.id, username: a.username, role: a.role } };
39 + });
40 +
41 + app.post("/api/admin/auth/logout", async (req, reply) => {
42 + await destroyAdminSession(reply, req);
43 + return { ok: true };
44 + });
45 +
46 + app.get("/api/admin/me", async (req) => {
47 + const admin = requireAdmin(req);
48 + return { admin };
49 + });
50 +
51 + /* -------------------------------------------------------- dashboard */
52 + app.get("/api/admin/dashboard", async (req) => {
53 + requireAdmin(req);
54 + const now = new Date();
55 + const dayAgo = new Date(now.getTime() - 86400_000);
56 + const weekAgo = new Date(now.getTime() - 7 * 86400_000);
57 + const monthAgo = new Date(now.getTime() - 30 * 86400_000);
58 + const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
59 + const [[regs], [dau], [wau], [mau], [activeSessions], todayAgg, topGames, highestWins, latency, errCount] = await Promise.all([
60 + db.select({ n: count() }).from(users),
61 + db.select({ n: sql<number>`count(distinct user_id)::int` }).from(gameRounds).where(gte(gameRounds.createdAt, dayAgo)),
62 + db.select({ n: sql<number>`count(distinct user_id)::int` }).from(gameRounds).where(gte(gameRounds.createdAt, weekAgo)),
63 + db.select({ n: sql<number>`count(distinct user_id)::int` }).from(gameRounds).where(gte(gameRounds.createdAt, monthAgo)),
64 + db.select({ n: count() }).from(sessions).where(gte(sessions.lastSeenAt, new Date(now.getTime() - 30 * 60_000))),
65 + db.execute(sql`select count(*)::int as spins, coalesce(sum(bet),0)::bigint as wagered, coalesce(sum(win),0)::bigint as won, coalesce(avg(duration_ms),0)::float as avg_ms,
66 + coalesce(percentile_cont(0.95) within group (order by duration_ms),0)::float as p95_ms from game_rounds where created_at >= ${todayStart}`),
67 + db.execute(sql`select game_slug, count(*)::int as spins, coalesce(sum(bet),0)::bigint as wagered, coalesce(sum(win),0)::bigint as won from game_rounds where created_at >= ${weekAgo} group by game_slug order by spins desc limit 8`),
68 + db.execute(sql`select r.round_id, r.game_slug, r.bet, r.win, r.multiplier, r.created_at, u.username from game_rounds r join users u on u.id = r.user_id order by r.win desc limit 10`),
69 + pingDb().catch(() => -1),
70 + db.select({ n: count() }).from(securityEvents).where(and(gte(securityEvents.createdAt, dayAgo), eq(securityEvents.severity, "high"))),
71 + ]);
72 + const t = todayAgg.rows[0] as { spins: number; wagered: number; won: number; avg_ms: number; p95_ms: number };
73 + const sessionsAgg = await db.execute(sql`select coalesce(avg(extract(epoch from (last_seen_at - created_at))),0)::float as avg_session from sessions where created_at >= ${weekAgo}`);
74 + const live = await redis().zcount("live:players", Date.now() - 5 * 60_000, "+inf").catch(() => 0);
75 + return {
76 + users: { registered: regs.n, dau: dau.n, wau: wau.n, mau: mau.n, activeSessions: activeSessions.n, livePlayers: live },
77 + today: { spins: t.spins, wagered: Number(t.wagered), won: Number(t.won), effectiveRtp: Number(t.wagered) ? Number(t.won) / Number(t.wagered) : null, avgSpinMs: t.avg_ms, p95SpinMs: t.p95_ms },
78 + averageSessionSec: Number((sessionsAgg.rows[0] as { avg_session: number }).avg_session),
79 + topGames: (topGames.rows as Record<string, unknown>[]).map((r) => ({ slug: r.game_slug, spins: r.spins, wagered: Number(r.wagered), won: Number(r.won), rtp: Number(r.wagered) ? Number(r.won) / Number(r.wagered) : null })),
80 + highestWins: (highestWins.rows as Record<string, unknown>[]).map((r) => ({ roundId: r.round_id, game: r.game_slug, bet: Number(r.bet), win: Number(r.win), multiplier: Number(r.multiplier), at: r.created_at, username: r.username })),
81 + health: { dbLatencyMs: latency, highSeverityEvents24h: errCount[0].n, uptimeSec: Math.round((Date.now() - startedAt) / 1000), version: config.version, node: os.hostname() },
82 + };
83 + });
84 +
85 + /* ------------------------------------------------------------ users */
86 + app.get("/api/admin/users", async (req) => {
87 + requireAdmin(req);
88 + const q = z.object({ q: z.string().optional(), limit: z.coerce.number().int().min(1).max(200).default(50), offset: z.coerce.number().int().min(0).default(0), sort: z.enum(["created", "spins", "balance", "level"]).default("created") }).parse(req.query);
89 + const where = q.q ? ilike(users.username, `%${q.q}%`) : undefined;
90 + const order = { created: desc(users.createdAt), spins: desc(users.totalSpins), balance: desc(wallets.balance), level: desc(users.level) }[q.sort];
91 + const rows = await db
92 + .select({ id: users.id, username: users.username, level: users.level, xp: users.xp, status: users.status, createdAt: users.createdAt, lastLoginAt: users.lastLoginAt, totalSpins: users.totalSpins, biggestWin: users.biggestWin, balance: wallets.balance, wagered: wallets.lifetimeWagered, won: wallets.lifetimeWon })
93 + .from(users)
94 + .innerJoin(wallets, eq(wallets.userId, users.id))
95 + .where(where)
96 + .orderBy(order)
97 + .limit(q.limit)
98 + .offset(q.offset);
99 + const [{ n }] = await db.select({ n: count() }).from(users).where(where);
100 + return { users: rows, total: n };
101 + });
102 +
103 + app.get("/api/admin/users/:id", async (req) => {
104 + requireAdmin(req);
105 + const { id } = req.params as { id: string };
106 + const u = await db.query.users.findFirst({ where: eq(users.id, id) });
107 + if (!u) throw errors.notFound();
108 + const [w, ledger, rounds, sess, events] = await Promise.all([
109 + db.query.wallets.findFirst({ where: eq(wallets.userId, id) }),
110 + db.select().from(creditTransactions).where(eq(creditTransactions.userId, id)).orderBy(desc(creditTransactions.createdAt)).limit(50),
111 + db.select().from(gameRounds).where(eq(gameRounds.userId, id)).orderBy(desc(gameRounds.createdAt)).limit(30),
112 + db.select().from(sessions).where(eq(sessions.userId, id)).orderBy(desc(sessions.lastSeenAt)),
113 + db.select().from(securityEvents).where(eq(securityEvents.userId, id)).orderBy(desc(securityEvents.createdAt)).limit(30),
114 + ]);
115 + const { passwordHash: _p, ...safe } = u;
116 + // Ledger invariant: sum(amount) must equal balance.
117 + const [{ total }] = await db.select({ total: sql<number>`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, id));
118 + return { user: safe, wallet: w, ledger, rounds: rounds.map((r) => ({ ...r, result: undefined })), sessions: sess, events, invariant: { ledgerTotal: Number(total), balance: w?.balance ?? 0, ok: Number(total) === (w?.balance ?? 0) } };
119 + });
120 +
121 + app.post("/api/admin/users/:id/adjust", async (req) => {
122 + const admin = requireAdmin(req);
123 + const { id } = req.params as { id: string };
124 + const body = z.object({ amount: z.number().int().min(-1_000_000_000).max(1_000_000_000).refine((n) => n !== 0), note: z.string().min(3).max(200) }).parse(req.body);
125 + const balance = await db.transaction((tx) => grant(tx, id, "ADMIN_ADJUSTMENT", body.amount, `admin:${admin.username}`, { note: body.note, adminId: admin.id }));
126 + await logSecurity(req, "admin.wallet.adjust", { adminId: admin.id, userId: id, severity: "high", meta: { amount: body.amount, note: body.note } });
127 + return { balance };
128 + });
129 +
130 + app.post("/api/admin/users/:id/status", async (req) => {
131 + const admin = requireAdmin(req);
132 + const { id } = req.params as { id: string };
133 + const body = z.object({ status: z.enum(["active", "suspended"]) }).parse(req.body);
134 + await db.update(users).set({ status: body.status }).where(eq(users.id, id));
135 + if (body.status === "suspended") {
136 + const rows = await db.select({ tokenHash: sessions.tokenHash }).from(sessions).where(eq(sessions.userId, id));
137 + await db.delete(sessions).where(eq(sessions.userId, id));
138 + if (rows.length) await redis().del(...rows.map((r) => `sess:${r.tokenHash}`));
139 + }
140 + await logSecurity(req, "admin.user.status", { adminId: admin.id, userId: id, severity: "high", meta: { status: body.status } });
141 + return { ok: true };
142 + });
143 +
144 + /* ------------------------------------------------------------ games */
145 + app.get("/api/admin/games", async (req) => {
146 + requireAdmin(req);
147 + const rows = await db
148 + .select({ game: games, stats: gameStatistics })
149 + .from(games)
150 + .leftJoin(gameStatistics, eq(gameStatistics.gameId, games.id))
151 + .orderBy(games.sortOrder);
152 + const flags = allFlags();
153 + return {
154 + games: rows.map(({ game, stats }) => {
155 + const def = getGame(game.slug);
156 + return {
157 + ...game,
158 + enabled: flags[`game.${game.slug}.enabled`] ?? true,
159 + rtp: def?.rtp ?? null,
160 + payScale: def?.payScale ?? null,
161 + stats: stats ? { ...stats, effectiveRtp: Number(stats.wagered) ? Number(stats.won) / Number(stats.wagered) : null } : null,
162 + validation: def ? validateDefinition(def) : [{ level: "error", message: "definition missing from library" }],
163 + };
164 + }),
165 + };
166 + });
167 +
168 + app.get("/api/admin/games/:slug", async (req) => {
169 + requireAdmin(req);
170 + const { slug } = req.params as { slug: string };
171 + const g = await db.query.games.findFirst({ where: eq(games.slug, slug) });
172 + if (!g) throw errors.notFound();
173 + const versions = await db.select({ version: gameVersions.version, status: gameVersions.status, createdAt: gameVersions.createdAt, certification: gameVersions.certification, definitionHash: gameVersions.definitionHash }).from(gameVersions).where(eq(gameVersions.gameId, g.id)).orderBy(desc(gameVersions.createdAt));
174 + const stats = await db.query.gameStatistics.findFirst({ where: eq(gameStatistics.gameId, g.id) });
175 + const daily = await db.execute(sql`select date_trunc('day', created_at) as day, count(*)::int as spins, sum(bet)::bigint as wagered, sum(win)::bigint as won, count(distinct user_id)::int as players
176 + from game_rounds where game_id = ${g.id} and created_at >= now() - interval '30 days' group by 1 order by 1`);
177 + const runs = await db.select().from(simulationRuns).where(eq(simulationRuns.gameSlug, slug)).orderBy(desc(simulationRuns.createdAt)).limit(10);
178 + return { game: g, definition: getGame(slug) ?? null, versions, stats, daily: daily.rows, simulationRuns: runs };
179 + });
180 +
181 + app.post("/api/admin/games/:slug/lifecycle", async (req) => {
182 + const admin = requireAdmin(req);
183 + const { slug } = req.params as { slug: string };
184 + const body = z.object({ lifecycle: z.enum(GAME_LIFECYCLE) }).parse(req.body);
185 + const g = await db.query.games.findFirst({ where: eq(games.slug, slug) });
186 + if (!g) throw errors.notFound();
187 + if (body.lifecycle === "published") {
188 + const v = await db.query.gameVersions.findFirst({ where: and(eq(gameVersions.gameId, g.id), eq(gameVersions.version, g.version)) });
189 + const cert = v?.certification as { status?: string; version?: string } | null;
190 + if (!cert || cert.status !== "PASS" || cert.version !== g.version) throw errors.conflict("NOT_CERTIFIED", "A game cannot be published without a PASS certification for its current version.");
191 + }
192 + await db.update(games).set({ lifecycle: body.lifecycle, publishedAt: body.lifecycle === "published" ? sql`coalesce(${games.publishedAt}, now())` : games.publishedAt, updatedAt: new Date() }).where(eq(games.id, g.id));
193 + await logSecurity(req, "admin.game.lifecycle", { adminId: admin.id, severity: "warn", meta: { slug, lifecycle: body.lifecycle } });
194 + return { ok: true };
195 + });
196 +
197 + app.post("/api/admin/games/:slug/flags", async (req) => {
198 + const admin = requireAdmin(req);
199 + const { slug } = req.params as { slug: string };
200 + const body = z.object({ isFeatured: z.boolean().optional(), isNew: z.boolean().optional(), sortOrder: z.number().int().optional() }).parse(req.body);
201 + await db.update(games).set({ ...body, updatedAt: new Date() }).where(eq(games.slug, slug));
202 + await logSecurity(req, "admin.game.flags", { adminId: admin.id, meta: { slug, ...body } });
203 + return { ok: true };
204 + });
205 +
206 + /* -------------------------------------------------------- simulator */
207 + app.post("/api/admin/simulator/run", async (req) => {
208 + const admin = requireAdmin(req);
209 + const body = z.object({ slug: z.string(), spins: z.number().int().min(1000).max(10_000_000), bet: z.number().int().min(10).max(1000).default(100), certify: z.boolean().default(false) }).parse(req.body);
210 + const def = getGame(body.slug);
211 + if (!def) throw errors.notFound("Unknown game");
212 + const running = await db.select({ n: count() }).from(simulationRuns).where(eq(simulationRuns.status, "running"));
213 + if (running[0].n >= 2) throw errors.conflict("BUSY", "Two simulations are already running. Please wait.");
214 + const [run] = await db.insert(simulationRuns).values({ gameSlug: def.slug, gameVersion: def.version, spins: body.spins, requestedBy: admin.id }).returning();
215 + // Fire and forget: worker threads keep the event loop free.
216 + simulateParallel(def.slug, {
217 + spins: body.spins,
218 + bet: body.bet,
219 + payScale: def.payScale,
220 + threads: Math.max(1, Math.min(os.cpus().length - 2, 12)),
221 + onProgress: (done) => {
222 + db.update(simulationRuns).set({ progress: done }).where(eq(simulationRuns.id, run.id)).catch(() => {});
223 + },
224 + })
225 + .then(async (result) => {
226 + const report = body.certify ? certify(def, result, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, body.spins) }) : null;
227 + await db.update(simulationRuns).set({ status: "done", progress: body.spins, result: { simulation: result, certification: report } as unknown as Record<string, unknown>, finishedAt: new Date() }).where(eq(simulationRuns.id, run.id));
228 + // A full-size PASS certification from the admin simulator becomes the official certification of this version,
229 + // which is what unlocks the `published` lifecycle.
230 + if (report && report.status === "PASS" && body.spins >= DEFAULT_CERTIFICATION_RULES.minSpins) {
231 + const g = await db.query.games.findFirst({ where: eq(games.slug, def.slug), columns: { id: true } });
232 + if (g) await db.update(gameVersions).set({ certification: report as unknown as Record<string, unknown> }).where(and(eq(gameVersions.gameId, g.id), eq(gameVersions.version, def.version)));
233 + }
234 + })
235 + .catch(async (e) => {
236 + await db.update(simulationRuns).set({ status: "failed", error: String(e?.message ?? e), finishedAt: new Date() }).where(eq(simulationRuns.id, run.id));
237 + });
238 + return { runId: run.id };
239 + });
240 +
241 + app.get("/api/admin/simulator/runs", async (req) => {
242 + requireAdmin(req);
243 + const q = z.object({ slug: z.string().optional(), limit: z.coerce.number().int().min(1).max(50).default(20) }).parse(req.query);
244 + const rows = await db.select({ id: simulationRuns.id, gameSlug: simulationRuns.gameSlug, gameVersion: simulationRuns.gameVersion, spins: simulationRuns.spins, status: simulationRuns.status, progress: simulationRuns.progress, createdAt: simulationRuns.createdAt, finishedAt: simulationRuns.finishedAt, error: simulationRuns.error }).from(simulationRuns).where(q.slug ? eq(simulationRuns.gameSlug, q.slug) : undefined).orderBy(desc(simulationRuns.createdAt)).limit(q.limit);
245 + return { runs: rows };
246 + });
247 +
248 + app.get("/api/admin/simulator/runs/:id", async (req) => {
249 + requireAdmin(req);
250 + const { id } = req.params as { id: string };
251 + const run = await db.query.simulationRuns.findFirst({ where: eq(simulationRuns.id, id) });
252 + if (!run) throw errors.notFound();
253 + return { run };
254 + });
255 +
256 + /** Library certifications shipped with the build. */
257 + app.get("/api/admin/certifications", async (req) => {
258 + requireAdmin(req);
259 + const rows = await db.select({ slug: games.slug, version: games.version, lifecycle: games.lifecycle, certification: gameVersions.certification }).from(games).innerJoin(gameVersions, and(eq(gameVersions.gameId, games.id), eq(gameVersions.version, games.version))).orderBy(games.sortOrder);
260 + return { certifications: rows };
261 + });
262 +
263 + /* ---------------------------------------------------------- economy */
264 + app.get("/api/admin/economy", async (req) => {
265 + requireAdmin(req);
266 + const days = z.object({ days: z.coerce.number().int().min(1).max(90).default(30) }).parse(req.query).days;
267 + const byType = await db.execute(sql`select type, count(*)::int as n, sum(amount)::bigint as total from credit_transactions where created_at >= now() - (${days} || ' days')::interval group by type order by type`);
268 + const daily = await db.execute(sql`select date_trunc('day', created_at) as day, type, sum(amount)::bigint as total from credit_transactions where created_at >= now() - (${days} || ' days')::interval group by 1,2 order by 1`);
269 + const supply = await db.execute(sql`select coalesce(sum(balance),0)::bigint as circulating, coalesce(sum(lifetime_granted),0)::bigint as granted, coalesce(sum(lifetime_wagered),0)::bigint as wagered, coalesce(sum(lifetime_won),0)::bigint as won from wallets`);
270 + const invariant = await db.execute(sql`select count(*)::int as mismatches from (select w.user_id, w.balance, coalesce(sum(t.amount),0) as ledger from wallets w left join credit_transactions t on t.user_id = w.user_id group by w.user_id, w.balance having w.balance <> coalesce(sum(t.amount),0)) x`);
271 + const dist = await db.execute(sql`select width_bucket(balance, 0, 100000, 10) as bucket, count(*)::int as n from wallets group by 1 order by 1`);
272 + return { byType: byType.rows, daily: daily.rows, supply: supply.rows[0], invariant: invariant.rows[0], balanceDistribution: dist.rows };
273 + });
274 +
275 + /* -------------------------------------------------------- analytics */
276 + app.get("/api/admin/analytics/games", async (req) => {
277 + requireAdmin(req);
278 + const days = z.object({ days: z.coerce.number().int().min(1).max(90).default(30) }).parse(req.query).days;
279 + const rows = await db.execute(sql`select game_slug, count(*)::int as spins, count(distinct user_id)::int as players, sum(bet)::bigint as wagered, sum(win)::bigint as won,
280 + avg(bet)::float as avg_bet, count(*) filter (where bonus or free_spins)::int as bonuses, max(win)::bigint as max_win, max(multiplier)::float as max_multiplier,
281 + count(*) filter (where multiplier >= 20)::int as big_wins, avg(duration_ms)::float as avg_ms
282 + from game_rounds where created_at >= now() - (${days} || ' days')::interval group by game_slug order by spins desc`);
283 + const launches = await db.select({ slug: games.slug, launches: gameStatistics.launches, favorites: gameStatistics.favorites }).from(gameStatistics).innerJoin(games, eq(games.id, gameStatistics.gameId));
284 + const l = new Map(launches.map((x) => [x.slug, x]));
285 + return {
286 + games: (rows.rows as Record<string, unknown>[]).map((r) => ({ ...r, wagered: Number(r.wagered), won: Number(r.won), max_win: Number(r.max_win), rtp: Number(r.wagered) ? Number(r.won) / Number(r.wagered) : null, launches: Number(l.get(r.game_slug as string)?.launches ?? 0), favorites: l.get(r.game_slug as string)?.favorites ?? 0 })),
287 + };
288 + });
289 +
290 + app.get("/api/admin/analytics/players", async (req) => {
291 + requireAdmin(req);
292 + const days = z.object({ days: z.coerce.number().int().min(1).max(90).default(30) }).parse(req.query).days;
293 + const signups = await db.execute(sql`select date_trunc('day', created_at) as day, count(*)::int as n from users where created_at >= now() - (${days} || ' days')::interval group by 1 order by 1`);
294 + const activity = await db.execute(sql`select date_trunc('day', created_at) as day, count(distinct user_id)::int as players, count(*)::int as spins from game_rounds where created_at >= now() - (${days} || ' days')::interval group by 1 order by 1`);
295 + const levels = await db.execute(sql`select width_bucket(level, 1, 101, 10) as bucket, count(*)::int as n from users group by 1 order by 1`);
296 + const retention = await db.execute(sql`select count(*) filter (where last_login_at >= now() - interval '1 day')::int as d1, count(*) filter (where last_login_at >= now() - interval '7 days')::int as d7, count(*)::int as total from users`);
297 + return { signups: signups.rows, activity: activity.rows, levels: levels.rows, retention: retention.rows[0] };
298 + });
299 +
300 + /* --------------------------------------------- missions / achievements */
301 + app.get("/api/admin/missions", async (req) => {
302 + requireAdmin(req);
303 + const rows = await db.select().from(missions).orderBy(missions.sortOrder);
304 + const completions = await db.execute(sql`select mission_key, count(*) filter (where completed_at is not null)::int as completed, count(*)::int as started from user_missions where expires_at >= now() - interval '7 days' group by mission_key`);
305 + return { missions: rows, completions: completions.rows };
306 + });
307 + app.patch("/api/admin/missions/:key", async (req) => {
308 + requireAdmin(req);
309 + const { key } = req.params as { key: string };
310 + const body = z.object({ enabled: z.boolean().optional(), target: z.number().int().min(1).optional(), rewardCredits: z.number().int().min(0).optional(), rewardXp: z.number().int().min(0).optional(), name: z.string().min(2).optional(), description: z.string().min(2).optional() }).parse(req.body);
311 + await db.update(missions).set(body).where(eq(missions.key, key));
312 + return { ok: true };
313 + });
314 + app.get("/api/admin/achievements", async (req) => {
315 + requireAdmin(req);
316 + const rows = await db.select().from(achievements).orderBy(achievements.sortOrder);
317 + const unlocks = await db.execute(sql`select achievement_key, count(*)::int as n from user_achievements group by achievement_key`);
318 + return { achievements: rows, unlocks: unlocks.rows };
319 + });
320 + app.patch("/api/admin/achievements/:key", async (req) => {
321 + requireAdmin(req);
322 + const { key } = req.params as { key: string };
323 + const body = z.object({ enabled: z.boolean().optional(), target: z.number().int().min(1).optional(), rewardCredits: z.number().int().min(0).optional(), rewardXp: z.number().int().min(0).optional(), name: z.string().min(2).optional(), description: z.string().min(2).optional() }).parse(req.body);
324 + await db.update(achievements).set(body).where(eq(achievements.key, key));
325 + return { ok: true };
326 + });
327 +
328 + /* ------------------------------------------- flags / settings / maint */
329 + app.get("/api/admin/settings", async (req) => {
330 + requireAdmin(req);
331 + await refreshSettings(true);
332 + return { flags: allFlags(), settings: allSettings() };
333 + });
334 + app.post("/api/admin/flags/:key", async (req) => {
335 + const admin = requireAdmin(req);
336 + const { key } = req.params as { key: string };
337 + const body = z.object({ enabled: z.boolean() }).parse(req.body);
338 + await setFlag(key, body.enabled);
339 + await logSecurity(req, "admin.flag", { adminId: admin.id, meta: { key, enabled: body.enabled } });
340 + return { ok: true };
341 + });
342 + app.post("/api/admin/settings/:key", async (req) => {
343 + const admin = requireAdmin(req);
344 + const { key } = req.params as { key: string };
345 + const body = z.object({ value: z.unknown() }).parse(req.body);
346 + if (key === "rescue") z.object({ amount: z.number().int().min(0), cooldownHours: z.number().min(0), threshold: z.number().int().min(0) }).parse(body.value);
347 + if (key === "dailyRewards") z.object({ schedule: z.array(z.number().int().min(0)).min(1).max(30) }).parse(body.value);
348 + if (key === "maintenance") z.object({ enabled: z.boolean(), message: z.string().min(3) }).parse(body.value);
349 + if (key === "profanity") z.object({ words: z.array(z.string()) }).parse(body.value);
350 + await setSetting(key, body.value);
351 + await logSecurity(req, key === "maintenance" ? "admin.maintenance" : "admin.setting", { adminId: admin.id, severity: key === "maintenance" ? "high" : "info", meta: { key, value: body.value } });
352 + return { ok: true };
353 + });
354 +
355 + /* ---------------------------------------------------------- security */
356 + app.get("/api/admin/security/events", async (req) => {
357 + requireAdmin(req);
358 + const q = z.object({ type: z.string().optional(), severity: z.string().optional(), limit: z.coerce.number().int().min(1).max(200).default(100) }).parse(req.query);
359 + const conds = [];
360 + if (q.type) conds.push(eq(securityEvents.type, q.type));
361 + if (q.severity) conds.push(eq(securityEvents.severity, q.severity));
362 + const rows = await db.select({ ev: securityEvents, username: users.username }).from(securityEvents).leftJoin(users, eq(users.id, securityEvents.userId)).where(conds.length ? and(...conds) : undefined).orderBy(desc(securityEvents.createdAt)).limit(q.limit);
363 + const summary = await db.execute(sql`select type, count(*)::int as n from security_events where created_at >= now() - interval '24 hours' group by type order by n desc`);
364 + return { events: rows.map((r) => ({ ...r.ev, username: r.username })), summary24h: summary.rows };
365 + });
366 +
367 + /* ------------------------------------------------------------ system */
368 + app.get("/api/admin/system", async (req) => {
369 + requireAdmin(req);
370 + const [dbMs, redisMs] = await Promise.all([pingDb().catch(() => -1), pingRedis().catch(() => -1)]);
371 + const spinLatency = await db.execute(sql`select coalesce(avg(duration_ms),0)::float as avg, coalesce(percentile_cont(0.95) within group (order by duration_ms),0)::float as p95, count(*)::int as n from game_rounds where created_at >= now() - interval '1 hour'`);
372 + const mem = process.memoryUsage();
373 + const sizes = await db.execute(sql`select relname as table, pg_total_relation_size(relid)::bigint as bytes, n_live_tup::bigint as rows from pg_stat_user_tables order by bytes desc limit 15`);
374 + return {
375 + services: [
376 + { name: "spinza-api", node: os.hostname(), version: config.version, uptimeSec: Math.round((Date.now() - startedAt) / 1000), status: "ok", latencyMs: 0 },
377 + { name: "postgresql", node: "local", version: "17", uptimeSec: null, status: dbMs >= 0 ? "ok" : "down", latencyMs: dbMs },
378 + { name: "redis", node: "local", version: "7", uptimeSec: null, status: redisMs >= 0 ? "ok" : "down", latencyMs: redisMs },
379 + ],
380 + process: { rssMb: Math.round(mem.rss / 1048576), heapMb: Math.round(mem.heapUsed / 1048576), cpus: os.cpus().length, load: os.loadavg(), totalMemMb: Math.round(os.totalmem() / 1048576), freeMemMb: Math.round(os.freemem() / 1048576), platform: `${os.platform()} ${os.release()}`, nodeVersion: process.version },
381 + spins: spinLatency.rows[0],
382 + tables: sizes.rows,
383 + games: GAMES.length,
384 + };
385 + });
386 + void or;
387 +}
added apps/api/src/routes/auth.ts +151 −0
@@ -0,0 +1,151 @@
1 +import type { FastifyInstance } from "fastify";
2 +import { db, eq, recoveryCodes, userSettings, users, wallets, dailyRewards, sql } from "@spinza/database";
3 +import { STARTING_BALANCE, checkUsername, loginSchema, recoverSchema, registerSchema, passwordSchema } from "@spinza/shared";
4 +import { errors } from "../lib/errors";
5 +import { generateRecoveryCode, hashPassword, hashRecoveryCode, verifyPassword, verifyRecoveryCode } from "../lib/crypto";
6 +import { rateLimit } from "../lib/redis";
7 +import { createSession, destroyAllSessions, destroySession, requireUser } from "../plugins/auth";
8 +import { clientIp, logSecurity } from "../lib/security";
9 +import { flag, profanityWords, maintenance } from "../lib/settings";
10 +import { applyCredit, lockWallet, saveWallet } from "../services/wallet";
11 +import { z } from "zod";
12 +import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg";
13 +
14 +async function limit(req: Parameters<typeof clientIp>[0], scope: string, max: number, windowSec: number) {
15 + const retry = await rateLimit(`${scope}:${clientIp(req)}`, max, windowSec);
16 + if (retry) {
17 + await logSecurity(req, "rate_limit", { meta: { scope } });
18 + throw errors.rateLimited(retry);
19 + }
20 +}
21 +
22 +export async function authRoutes(app: FastifyInstance) {
23 + app.post("/api/auth/check-username", async (req) => {
24 + await limit(req, "check-username", 60, 60);
25 + const body = z.object({ username: z.string().max(64) }).parse(req.body);
26 + const check = checkUsername(body.username, profanityWords());
27 + if (!check.ok) return { available: false, reason: check.reason };
28 + const normalized = body.username.trim().toLowerCase();
29 + const exists = await db.query.users.findFirst({ where: eq(users.usernameNormalized, normalized), columns: { id: true } });
30 + return { available: !exists, reason: exists ? "taken" : undefined };
31 + });
32 +
33 + app.post("/api/auth/register", async (req, reply) => {
34 + await limit(req, "register", 10, 600);
35 + if (!flag("registration.enabled")) throw errors.forbidden("Registration is temporarily closed.");
36 + const m = maintenance();
37 + if (m.enabled) throw errors.maintenance(m.message);
38 + const parsed = registerSchema.safeParse(req.body);
39 + if (!parsed.success) throw errors.badRequest("Please check the form.", parsed.error.flatten());
40 + const { username, password } = parsed.data;
41 + const check = checkUsername(username, profanityWords());
42 + if (!check.ok) {
43 + const msg = { length: "Username must be 3–24 characters.", charset: "Use lowercase letters, numbers, _ or -.", reserved: "This username is reserved.", profanity: "This username is not allowed." }[check.reason!];
44 + throw errors.badRequest(msg, { field: "username" });
45 + }
46 + const passwordHash = await hashPassword(password);
47 + const recoveryCode = generateRecoveryCode();
48 + const codeHash = await hashRecoveryCode(recoveryCode);
49 +
50 + let userId: string;
51 + try {
52 + userId = await db.transaction(async (tx) => {
53 + const [u] = await tx
54 + .insert(users)
55 + .values({ username, usernameNormalized: username, passwordHash, ageConfirmedAt: new Date(), lastLoginAt: new Date() })
56 + .returning({ id: users.id });
57 + await tx.insert(wallets).values({ userId: u.id, balance: 0 });
58 + await tx.insert(userSettings).values({ userId: u.id });
59 + await tx.insert(recoveryCodes).values({ userId: u.id, codeHash });
60 + await tx.insert(dailyRewards).values({ userId: u.id });
61 + const w = await lockWallet(tx, u.id);
62 + await applyCredit(tx, w, "INITIAL_GRANT", STARTING_BALANCE, "welcome", { reason: "Welcome to Spinza" });
63 + await saveWallet(tx, w);
64 + return u.id;
65 + });
66 + } catch (e) {
67 + if (pgCode(e) === PG_UNIQUE_VIOLATION) throw errors.conflict("USERNAME_TAKEN", "That username is already taken.");
68 + throw e;
69 + }
70 + await createSession(reply, req, userId);
71 + await logSecurity(req, "register", { userId });
72 + return {
73 + user: { id: userId, username, isNew: true },
74 + balance: STARTING_BALANCE,
75 + recoveryCode,
76 + notice: "Save this recovery code. Spinza does not collect your email address. If you lose your password and recovery code, your account cannot be recovered.",
77 + };
78 + });
79 +
80 + app.post("/api/auth/login", async (req, reply) => {
81 + await limit(req, "login", 20, 300);
82 + const parsed = loginSchema.safeParse(req.body);
83 + if (!parsed.success) throw errors.unauthorized("Invalid username or password.");
84 + const { username, password } = parsed.data;
85 + const perUser = await rateLimit(`login-user:${username}`, 10, 300);
86 + if (perUser) throw errors.rateLimited(perUser);
87 + const u = await db.query.users.findFirst({ where: eq(users.usernameNormalized, username) });
88 + const ok = u ? await verifyPassword(u.passwordHash, password) : await verifyPassword("$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", password);
89 + if (!u || !ok) {
90 + await logSecurity(req, "login.failed", { userId: u?.id ?? null, meta: { username } });
91 + throw errors.unauthorized("Invalid username or password.");
92 + }
93 + if (u.status !== "active") throw errors.forbidden("This account is suspended.");
94 + await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, u.id));
95 + await createSession(reply, req, u.id);
96 + await logSecurity(req, "login.success", { userId: u.id });
97 + return { user: { id: u.id, username: u.username } };
98 + });
99 +
100 + app.post("/api/auth/logout", async (req, reply) => {
101 + const uid = req.user?.id;
102 + await destroySession(reply, req);
103 + if (uid) await logSecurity(req, "logout", { userId: uid });
104 + return { ok: true };
105 + });
106 +
107 + app.post("/api/auth/recover", async (req, reply) => {
108 + await limit(req, "recover", 8, 900);
109 + const parsed = recoverSchema.safeParse(req.body);
110 + if (!parsed.success) throw errors.badRequest("Please check the form.", parsed.error.flatten());
111 + const { username, recoveryCode, newPassword } = parsed.data;
112 + const u = await db.query.users.findFirst({ where: eq(users.usernameNormalized, username) });
113 + const rc = u ? await db.query.recoveryCodes.findFirst({ where: eq(recoveryCodes.userId, u.id) }) : null;
114 + const ok = rc ? await verifyRecoveryCode(rc.codeHash, recoveryCode) : false;
115 + if (!u || !rc || !ok) {
116 + await logSecurity(req, "recovery.failed", { userId: u?.id ?? null, severity: "warn", meta: { username } });
117 + throw errors.unauthorized("Invalid username or recovery code.");
118 + }
119 + const nextCode = generateRecoveryCode();
120 + const passwordHash = await hashPassword(newPassword);
121 + await db.transaction(async (tx) => {
122 + await tx.update(users).set({ passwordHash }).where(eq(users.id, u.id));
123 + await tx.update(recoveryCodes).set({ codeHash: await hashRecoveryCode(nextCode), rotatedAt: new Date(), usedAt: new Date(), useCount: sql`${recoveryCodes.useCount} + 1` }).where(eq(recoveryCodes.userId, u.id));
124 + });
125 + await destroyAllSessions(u.id);
126 + await createSession(reply, req, u.id);
127 + await logSecurity(req, "recovery.used", { userId: u.id, severity: "warn" });
128 + return { user: { id: u.id, username: u.username }, recoveryCode: nextCode, notice: "Your recovery code has been rotated. Save the new one." };
129 + });
130 +
131 + app.post("/api/auth/password", async (req) => {
132 + const user = requireUser(req);
133 + const body = z.object({ currentPassword: z.string().min(1), newPassword: passwordSchema }).parse(req.body);
134 + const u = await db.query.users.findFirst({ where: eq(users.id, user.id) });
135 + if (!u || !(await verifyPassword(u.passwordHash, body.currentPassword))) throw errors.unauthorized("Current password is incorrect.");
136 + await db.update(users).set({ passwordHash: await hashPassword(body.newPassword) }).where(eq(users.id, u.id));
137 + await logSecurity(req, "password.changed", { userId: u.id });
138 + return { ok: true };
139 + });
140 +
141 + /** Rotate the recovery code (requires password). */
142 + app.post("/api/auth/recovery-code/rotate", async (req) => {
143 + const user = requireUser(req);
144 + const body = z.object({ password: z.string().min(1) }).parse(req.body);
145 + const u = await db.query.users.findFirst({ where: eq(users.id, user.id) });
146 + if (!u || !(await verifyPassword(u.passwordHash, body.password))) throw errors.unauthorized("Password is incorrect.");
147 + const code = generateRecoveryCode();
148 + await db.insert(recoveryCodes).values({ userId: u.id, codeHash: await hashRecoveryCode(code) }).onConflictDoUpdate({ target: recoveryCodes.userId, set: { codeHash: await hashRecoveryCode(code), rotatedAt: new Date() } });
149 + return { recoveryCode: code };
150 + });
151 +}
added apps/api/src/routes/games.ts +198 −0
@@ -0,0 +1,198 @@
1 +import type { FastifyInstance } from "fastify";
2 +import { and, db, desc, eq, favorites, gameStatistics, games, sql, userGameStats } from "@spinza/database";
3 +import { getGame, FIRST_GAME_RECOMMENDATIONS } from "@spinza/games";
4 +import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared";
5 +import { requireUser } from "../plugins/auth";
6 +import { errors } from "../lib/errors";
7 +import { flag } from "../lib/settings";
8 +import { spin } from "../services/spin";
9 +import { rateLimit, redis } from "../lib/redis";
10 +import { clientIp, logSecurity } from "../lib/security";
11 +import { z } from "zod";
12 +
13 +type GameRow = typeof games.$inferSelect;
14 +
15 +function toCard(g: GameRow, extra: { popularity?: number; favorite?: boolean; lastPlayedAt?: Date | null } = {}): GameCard {
16 + const s = g.summary as Record<string, unknown>;
17 + return {
18 + slug: g.slug,
19 + name: g.name,
20 + tagline: s.tagline as string,
21 + theme: s.theme as string,
22 + volatility: s.volatility as GameCard["volatility"],
23 + version: g.version,
24 + grid: s.grid as GameCard["grid"],
25 + minBet: s.minBet as number,
26 + maxBet: s.maxBet as number,
27 + maxMultiplier: s.maxMultiplier as number,
28 + features: (s.features as string[]) ?? [],
29 + tags: (s.tags as string[]) ?? [],
30 + palette: s.palette as GameCard["palette"],
31 + lifecycle: g.lifecycle as GameCard["lifecycle"],
32 + isNew: g.isNew,
33 + isFeatured: g.isFeatured,
34 + isJackpot: !!s.isJackpot,
35 + popularity: extra.popularity ?? 0,
36 + favorite: extra.favorite,
37 + lastPlayedAt: extra.lastPlayedAt ? extra.lastPlayedAt.toISOString() : null,
38 + };
39 +}
40 +
41 +export async function publishedGames(): Promise<GameRow[]> {
42 + const rows = await db.select().from(games).where(eq(games.lifecycle, "published")).orderBy(games.sortOrder);
43 + return rows.filter((g) => flag(`game.${g.slug}.enabled`));
44 +}
45 +
46 +export async function gameRoutes(app: FastifyInstance) {
47 + /** Public lobby data. When signed in, includes favorites + continue playing. */
48 + app.get("/api/games", async (req) => {
49 + const rows = await publishedGames();
50 + const stats = await db.select({ gameId: gameStatistics.gameId, spins: gameStatistics.spins, launches: gameStatistics.launches }).from(gameStatistics);
51 + const statMap = new Map(stats.map((s) => [s.gameId, s]));
52 + let favs = new Set<string>();
53 + let recent = new Map<string, Date>();
54 + if (req.user) {
55 + const f = await db.select({ gameId: favorites.gameId }).from(favorites).where(eq(favorites.userId, req.user.id));
56 + favs = new Set(f.map((x) => x.gameId));
57 + const r = await db.select({ gameId: userGameStats.gameId, at: userGameStats.lastPlayedAt }).from(userGameStats).where(eq(userGameStats.userId, req.user.id));
58 + recent = new Map(r.map((x) => [x.gameId, x.at]));
59 + }
60 + const live = await redis().zcount("live:players", Date.now() - 5 * 60_000, "+inf").catch(() => 0);
61 + const cards = rows.map((g) => {
62 + const st = statMap.get(g.id);
63 + return toCard(g, { popularity: Number(st?.spins ?? 0) + Number(st?.launches ?? 0) * 20, favorite: favs.has(g.id), lastPlayedAt: recent.get(g.id) ?? null });
64 + });
65 + return { games: cards, livePlayers: live, recommendations: FIRST_GAME_RECOMMENDATIONS };
66 + });
67 +
68 + app.get("/api/games/:slug", async (req) => {
69 + const { slug } = req.params as { slug: string };
70 + const g = await db.query.games.findFirst({ where: eq(games.slug, slug) });
71 + const def = getGame(slug);
72 + if (!g || !def || g.lifecycle !== "published" || !flag(`game.${slug}.enabled`)) throw errors.notFound("Game not found");
73 + const s = g.summary as Record<string, unknown>;
74 + const cert = (s.certification as GameInfo["certification"] | null) ?? null;
75 + let favorite = false;
76 + if (req.user) favorite = !!(await db.query.favorites.findFirst({ where: and(eq(favorites.userId, req.user.id), eq(favorites.gameId, g.id)) }));
77 + const info: GameInfo = {
78 + ...toCard(g, { favorite }),
79 + rtp: def.rtp,
80 + hitFrequency: cert?.hitRate ?? null,
81 + description: def.description,
82 + rules: def.rules,
83 + paytable: def.symbols
84 + .filter((sym) => sym.pays || sym.scatterPays)
85 + .map((sym) => ({
86 + symbol: sym.id,
87 + label: sym.name,
88 + // Displayed as multiples of total bet (payScale/betDivisor applied), rounded to 2 decimals.
89 + pays: Object.fromEntries(
90 + Object.entries(sym.pays ?? sym.scatterPays ?? {}).map(([k, v]) => [k, Number(((v * def.payScale) / (sym.pays ? def.betDivisor : 1)).toFixed(3))]),
91 + ),
92 + })),
93 + certification: cert,
94 + };
95 + return { game: info, definition: clientDefinition(def) };
96 + });
97 +
98 + app.post("/api/games/:slug/launch", async (req) => {
99 + const { slug } = req.params as { slug: string };
100 + const g = await db.query.games.findFirst({ where: eq(games.slug, slug), columns: { id: true } });
101 + if (!g) throw errors.notFound();
102 + await db.update(gameStatistics).set({ launches: sql`${gameStatistics.launches} + 1` }).where(eq(gameStatistics.gameId, g.id));
103 + if (req.user) {
104 + // Persisted game state for meters/heat so the client can render them on load.
105 + const st = await db.execute(sql`select state from game_states where user_id = ${req.user.id} and game_id = ${g.id}`);
106 + return { ok: true, state: (st.rows[0] as { state?: unknown } | undefined)?.state ?? null };
107 + }
108 + return { ok: true, state: null };
109 + });
110 +
111 + app.post("/api/games/:slug/spin", async (req) => {
112 + const user = requireUser(req);
113 + const { slug } = req.params as { slug: string };
114 + const retry = await rateLimit(`spin:${user.id}`, 240, 60);
115 + if (retry) {
116 + await logSecurity(req, "spin.suspicious", { userId: user.id, severity: "warn", meta: { slug, ip: clientIp(req) } });
117 + throw errors.rateLimited(retry);
118 + }
119 + const parsed = spinSchema.safeParse(req.body);
120 + if (!parsed.success) throw errors.badRequest("Invalid spin request.", parsed.error.flatten());
121 + return spin({ userId: user.id, slug, bet: parsed.data.bet, clientRoundId: parsed.data.clientRoundId });
122 + });
123 +
124 + app.get("/api/games/:slug/history", async (req) => {
125 + const user = requireUser(req);
126 + const { slug } = req.params as { slug: string };
127 + const q = z.object({ limit: z.coerce.number().int().min(1).max(50).default(20) }).parse(req.query);
128 + const rows = await db.execute(sql`select round_id, bet, win, multiplier, balance_after, created_at, features from game_rounds where user_id = ${user.id} and game_slug = ${slug} order by created_at desc limit ${q.limit}`);
129 + return {
130 + entries: (rows.rows as Record<string, unknown>[]).map((r) => ({
131 + roundId: r.round_id,
132 + bet: Number(r.bet),
133 + win: Number(r.win),
134 + multiplier: Number(r.multiplier),
135 + balanceAfter: Number(r.balance_after),
136 + createdAt: (r.created_at as Date).toISOString(),
137 + features: r.features,
138 + })),
139 + };
140 + });
141 +
142 + app.post("/api/games/:slug/favorite", async (req) => {
143 + const user = requireUser(req);
144 + const { slug } = req.params as { slug: string };
145 + const g = await db.query.games.findFirst({ where: eq(games.slug, slug), columns: { id: true } });
146 + if (!g) throw errors.notFound();
147 + const existing = await db.query.favorites.findFirst({ where: and(eq(favorites.userId, user.id), eq(favorites.gameId, g.id)) });
148 + if (existing) {
149 + await db.delete(favorites).where(and(eq(favorites.userId, user.id), eq(favorites.gameId, g.id)));
150 + await db.update(gameStatistics).set({ favorites: sql`greatest(${gameStatistics.favorites} - 1, 0)` }).where(eq(gameStatistics.gameId, g.id));
151 + return { favorite: false };
152 + }
153 + await db.insert(favorites).values({ userId: user.id, gameId: g.id });
154 + await db.update(gameStatistics).set({ favorites: sql`${gameStatistics.favorites} + 1` }).where(eq(gameStatistics.gameId, g.id));
155 + return { favorite: true };
156 + });
157 +
158 + app.get("/api/user/games", async (req) => {
159 + const user = requireUser(req);
160 + const rows = await db
161 + .select({ slug: games.slug, name: games.name, spins: userGameStats.spins, won: userGameStats.won, wagered: userGameStats.wagered, biggestWin: userGameStats.biggestWin, biggestMultiplier: userGameStats.biggestMultiplier, lastPlayedAt: userGameStats.lastPlayedAt })
162 + .from(userGameStats)
163 + .innerJoin(games, eq(games.id, userGameStats.gameId))
164 + .where(eq(userGameStats.userId, user.id))
165 + .orderBy(desc(userGameStats.lastPlayedAt));
166 + return { games: rows.map((r) => ({ ...r, biggestMultiplier: Number(r.biggestMultiplier), lastPlayedAt: r.lastPlayedAt.toISOString() })) };
167 + });
168 +}
169 +
170 +/** Definition data the renderer needs (symbols, grid, presentation). Weights are public "Game Info" data too. */
171 +export function clientDefinition(def: ReturnType<typeof getGame> & object) {
172 + return {
173 + slug: def.slug,
174 + name: def.name,
175 + version: def.version,
176 + grid: def.grid,
177 + payModel: def.payModel,
178 + symbols: def.symbols.map((s) => ({ id: s.id, name: s.name, kind: s.kind, tier: s.tier, style: s.style })),
179 + wild: def.wild ? { id: def.wild.id, expanding: !!def.wild.expanding, sticky: !!def.wild.sticky, multiplier: !!def.wild.multiplier, exploding: !!def.wild.exploding, moving: !!def.wild.moving } : null,
180 + scatter: def.scatter ? { id: def.scatter.id, triggers: def.scatter.triggers } : null,
181 + cascades: !!def.cascades,
182 + spinCollect: def.spinCollect ? { symbolId: def.spinCollect.symbolId, ladder: def.spinCollect.ladder } : null,
183 + holdRespin: def.holdRespin ? { symbolId: def.holdRespin.symbolId, respins: def.holdRespin.respins, jackpots: def.holdRespin.jackpots } : null,
184 + meter: def.meter ? { id: def.meter.id, name: def.meter.name, symbolId: def.meter.symbolId, max: def.meter.max, label: def.meter.onFull.label } : null,
185 + heat: def.heat ? { max: def.heat.max, ladder: def.heat.ladder, bandSize: def.heat.bandSize, label: def.heat.meltdown.label } : null,
186 + mystery: def.mystery ? { symbolId: def.mystery.symbolId } : null,
187 + quantum: !!def.quantum,
188 + dynamicGrid: def.dynamicGrid ?? null,
189 + jackpot: def.jackpot ? { tiers: def.jackpot.tiers.map((t) => ({ id: t.id, multiplier: t.multiplier })) } : null,
190 + pickBonuses: def.pickBonuses?.map((b) => ({ id: b.id, name: b.name, cells: b.cells, picks: b.picks })) ?? [],
191 + minBet: def.minBet,
192 + maxBet: def.maxBet,
193 + maxMultiplier: def.maxMultiplier,
194 + volatility: def.volatility,
195 + presentation: def.presentation,
196 + featureNames: def.featureNames,
197 + };
198 +}
added apps/api/src/routes/health.ts +63 −0
@@ -0,0 +1,63 @@
1 +import type { FastifyInstance } from "fastify";
2 +import os from "node:os";
3 +import { pingDb } from "@spinza/database";
4 +import { CryptoRng, runSpin } from "@spinza/game-core";
5 +import { GAMES } from "@spinza/games";
6 +import { pingRedis, redis } from "../lib/redis";
7 +import { config } from "../config";
8 +import { maintenance } from "../lib/settings";
9 +
10 +const startedAt = Date.now();
11 +
12 +export async function healthRoutes(app: FastifyInstance) {
13 + app.get("/api/health", async () => ({
14 + status: "ok",
15 + service: "spinza-api",
16 + version: config.version,
17 + uptime: Math.round((Date.now() - startedAt) / 1000),
18 + node: os.hostname(),
19 + maintenance: maintenance().enabled,
20 + time: new Date().toISOString(),
21 + }));
22 +
23 + app.get("/api/health/database", async (reply) => {
24 + try {
25 + const latency = await pingDb();
26 + return { status: "ok", latencyMs: latency };
27 + } catch (e) {
28 + return { status: "down", error: (e as Error).message };
29 + }
30 + });
31 +
32 + app.get("/api/health/redis", async () => {
33 + try {
34 + const latency = await pingRedis();
35 + return { status: "ok", latencyMs: latency };
36 + } catch (e) {
37 + return { status: "down", error: (e as Error).message };
38 + }
39 + });
40 +
41 + app.get("/api/health/game-engine", async () => {
42 + const t = Date.now();
43 + const def = GAMES[0];
44 + const out = runSpin(def, 100, { rng: new CryptoRng() });
45 + return { status: "ok", games: GAMES.length, sampleGame: def.slug, steps: out.steps.length, latencyMs: Date.now() - t };
46 + });
47 +
48 + app.get("/api/ready", async (_req, reply) => {
49 + try {
50 + await Promise.all([pingDb(), pingRedis()]);
51 + return { ready: true };
52 + } catch (e) {
53 + reply.code(503);
54 + return { ready: false, error: (e as Error).message };
55 + }
56 + });
57 +
58 + app.get("/api/live", async () => {
59 + const cutoff = Date.now() - 5 * 60_000;
60 + const players = await redis().zcount("live:players", cutoff, "+inf");
61 + return { players };
62 + });
63 +}
added apps/api/src/routes/rewards.ts +215 −0
@@ -0,0 +1,215 @@
1 +import type { FastifyInstance } from "fastify";
2 +import { achievements, and, dailyRewards, db, desc, eq, gameRounds, games, inArray, leaderboards, missions, sql, userAchievements, userGameStats, userMissions, users, wallets, userSettings } from "@spinza/database";
3 +import { DAILY_STREAK_GRACE_DAYS, type AchievementView, type DailyRewardStatus, type LeaderboardView, type MissionView, type RescueStatus } from "@spinza/shared";
4 +import { requireUser } from "../plugins/auth";
5 +import { errors } from "../lib/errors";
6 +import { dailySchedule, flag, rescueConfig } from "../lib/settings";
7 +import { applyCredit, lockWallet, saveWallet } from "../services/wallet";
8 +import { awardXp, checkAchievements, dayKey, periodEnd, weekKey, type UserCounters } from "../services/progression";
9 +import { z } from "zod";
10 +
11 +const DAY = 86_400_000;
12 +
13 +function nextStreakDay(current: number, lastClaimedAt: Date | null, now: Date, scheduleLen: number): number {
14 + if (!lastClaimedAt) return 1;
15 + const gapDays = Math.floor((startOfDay(now).getTime() - startOfDay(lastClaimedAt).getTime()) / DAY);
16 + if (gapDays <= 1) return (current % scheduleLen) + 1; // consecutive day (after a full week, loop back to day 1)
17 + if (gapDays <= 1 + DAILY_STREAK_GRACE_DAYS) return Math.max(1, current - 1); // soft reset: lose one step
18 + return 1;
19 +}
20 +
21 +function startOfDay(d: Date): Date {
22 + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
23 +}
24 +
25 +async function counters(userId: string): Promise<UserCounters> {
26 + const u = await db.query.users.findFirst({ where: eq(users.id, userId) });
27 + const agg = await db.execute(sql`
28 + select (select coalesce(sum(bonuses),0) from user_game_stats where user_id = ${userId})::bigint as bonuses,
29 + count(*) filter (where multiplier >= 20)::bigint as big_wins,
30 + count(*) filter (where win > 0)::bigint as wins,
31 + count(*) filter (where jackpot_tier is not null)::bigint as jackpots
32 + from game_rounds where user_id = ${userId}`);
33 + const a = agg.rows[0] as { bonuses: number; big_wins: number; wins: number; jackpots: number };
34 + const streak = await db.query.dailyRewards.findFirst({ where: eq(dailyRewards.userId, userId) });
35 + return {
36 + totalSpins: u!.totalSpins,
37 + gamesPlayed: u!.gamesPlayed,
38 + biggestWin: u!.biggestWin,
39 + biggestMultiplier: Number(u!.biggestMultiplier),
40 + level: u!.level,
41 + xp: u!.xp,
42 + bonuses: Number(a.bonuses),
43 + bigWins: Number(a.big_wins),
44 + wins: Number(a.wins),
45 + jackpots: Number(a.jackpots),
46 + dailyStreak: streak?.streakDay ?? 0,
47 + };
48 +}
49 +
50 +export async function rewardRoutes(app: FastifyInstance) {
51 + /* ------------------------------------------------------------ daily */
52 + app.get("/api/rewards/daily", async (req) => {
53 + const user = requireUser(req);
54 + const schedule = dailySchedule();
55 + const row = (await db.query.dailyRewards.findFirst({ where: eq(dailyRewards.userId, user.id) })) ?? { streakDay: 0, lastClaimedAt: null, nextAvailableAt: null };
56 + const now = new Date();
57 + const available = flag("dailyRewards.enabled") && (!row.nextAvailableAt || row.nextAvailableAt <= now);
58 + const day = available ? nextStreakDay(row.streakDay, row.lastClaimedAt, now, schedule.length) : Math.min(row.streakDay, schedule.length);
59 + const status: DailyRewardStatus = {
60 + available,
61 + streakDay: row.streakDay,
62 + nextAmount: schedule[(available ? day : Math.min(row.streakDay % schedule.length + 1, schedule.length)) - 1] ?? schedule[0],
63 + nextAvailableAt: row.nextAvailableAt?.toISOString() ?? null,
64 + schedule,
65 + claimedToday: !!row.lastClaimedAt && startOfDay(row.lastClaimedAt).getTime() === startOfDay(now).getTime(),
66 + };
67 + return status;
68 + });
69 +
70 + app.post("/api/rewards/daily/claim", async (req) => {
71 + const user = requireUser(req);
72 + if (!flag("dailyRewards.enabled")) throw errors.forbidden("Daily rewards are paused.");
73 + const schedule = dailySchedule();
74 + return db.transaction(async (tx) => {
75 + const lock = await tx.execute(sql`select streak_day, last_claimed_at, next_available_at from daily_rewards where user_id = ${user.id} for update`);
76 + const row = lock.rows[0] as { streak_day: number; last_claimed_at: Date | null; next_available_at: Date | null } | undefined;
77 + const now = new Date();
78 + if (row?.next_available_at && new Date(row.next_available_at) > now) throw errors.conflict("ALREADY_CLAIMED", "Come back tomorrow for your next reward.");
79 + const day = nextStreakDay(row?.streak_day ?? 0, row?.last_claimed_at ? new Date(row.last_claimed_at) : null, now, schedule.length);
80 + const amount = schedule[day - 1];
81 + const nextAvailable = new Date(startOfDay(now).getTime() + DAY);
82 + await tx
83 + .insert(dailyRewards)
84 + .values({ userId: user.id, streakDay: day, lastClaimedAt: now, nextAvailableAt: nextAvailable, totalClaimed: amount, claims: 1 })
85 + .onConflictDoUpdate({ target: dailyRewards.userId, set: { streakDay: day, lastClaimedAt: now, nextAvailableAt: nextAvailable, totalClaimed: sql`${dailyRewards.totalClaimed} + ${amount}`, claims: sql`${dailyRewards.claims} + 1` } });
86 + const w = await lockWallet(tx, user.id);
87 + await applyCredit(tx, w, "DAILY_REWARD", amount, `daily:${dayKey(now)}`, { streakDay: day });
88 + const c = await counters(user.id);
89 + c.dailyStreak = day;
90 + const ach = await checkAchievements(tx, w, c);
91 + const u = await tx.query.users.findFirst({ where: eq(users.id, user.id), columns: { xp: true, level: true } });
92 + const lvl = await awardXp(tx, w, u!, 25 + ach.xp, `daily:${dayKey(now)}`);
93 + await saveWallet(tx, w);
94 + return { amount, streakDay: day, balance: w.balance, nextAvailableAt: nextAvailable.toISOString(), unlocked: ach.unlocked, xp: lvl };
95 + });
96 + });
97 +
98 + /* ----------------------------------------------------------- rescue */
99 + app.get("/api/rewards/rescue", async (req) => {
100 + const user = requireUser(req);
101 + const cfg = rescueConfig();
102 + const [w, u] = await Promise.all([db.query.wallets.findFirst({ where: eq(wallets.userId, user.id) }), db.query.users.findFirst({ where: eq(users.id, user.id), columns: { lastRescueAt: true } })]);
103 + const nextAt = u?.lastRescueAt ? new Date(u.lastRescueAt.getTime() + cfg.cooldownHours * 3600_000) : null;
104 + const eligible = flag("rescue.enabled") && (w?.balance ?? 0) <= cfg.threshold && (!nextAt || nextAt <= new Date());
105 + const status: RescueStatus = { eligible, amount: cfg.amount, balance: w?.balance ?? 0, nextAvailableAt: nextAt && nextAt > new Date() ? nextAt.toISOString() : null };
106 + return status;
107 + });
108 +
109 + app.post("/api/rewards/rescue/claim", async (req) => {
110 + const user = requireUser(req);
111 + const cfg = rescueConfig();
112 + if (!flag("rescue.enabled")) throw errors.forbidden("Rescue credits are paused.");
113 + return db.transaction(async (tx) => {
114 + const w = await lockWallet(tx, user.id);
115 + const [u] = await tx.select({ lastRescueAt: users.lastRescueAt }).from(users).where(eq(users.id, user.id)).for("update");
116 + if (w.balance > cfg.threshold) throw errors.conflict("NOT_ELIGIBLE", "Rescue credits are only available when your balance reaches zero.");
117 + if (u.lastRescueAt && u.lastRescueAt.getTime() + cfg.cooldownHours * 3600_000 > Date.now()) throw errors.conflict("COOLDOWN", "Rescue credits are recharging.");
118 + await applyCredit(tx, w, "RESCUE_CREDITS", cfg.amount, "rescue", { cooldownHours: cfg.cooldownHours });
119 + await tx.update(users).set({ lastRescueAt: new Date() }).where(eq(users.id, user.id));
120 + await saveWallet(tx, w);
121 + return { amount: cfg.amount, balance: w.balance, nextAvailableAt: new Date(Date.now() + cfg.cooldownHours * 3600_000).toISOString() };
122 + });
123 + });
124 +
125 + /* ------------------------------------------------------ achievements */
126 + app.get("/api/achievements", async (req) => {
127 + const user = requireUser(req);
128 + const [all, mine, c] = await Promise.all([db.select().from(achievements).where(eq(achievements.enabled, true)).orderBy(achievements.sortOrder), db.select().from(userAchievements).where(eq(userAchievements.userId, user.id)), counters(user.id)]);
129 + const unlocked = new Map(mine.map((m) => [m.achievementKey, m.unlockedAt]));
130 + const value = (metric: string) =>
131 + ({ spins: c.totalSpins, bonuses: c.bonuses, games_played: c.gamesPlayed, big_wins: c.bigWins, multiplier: c.biggestMultiplier, wins: c.wins, level: c.level, daily_streak: c.dailyStreak, jackpots: c.jackpots })[metric] ?? 0;
132 + const views: AchievementView[] = all.map((a) => ({
133 + key: a.key,
134 + name: a.name,
135 + description: a.description,
136 + rewardCredits: a.rewardCredits,
137 + rewardXp: a.rewardXp,
138 + icon: a.icon,
139 + unlockedAt: unlocked.get(a.key)?.toISOString() ?? null,
140 + progress: Math.min(value(a.metric), a.target),
141 + target: a.target,
142 + }));
143 + return { achievements: views, unlocked: mine.length, total: all.length };
144 + });
145 +
146 + /* ---------------------------------------------------------- missions */
147 + app.get("/api/missions", async (req) => {
148 + const user = requireUser(req);
149 + const defs = await db.select().from(missions).where(eq(missions.enabled, true)).orderBy(missions.sortOrder);
150 + const now = new Date();
151 + const keys = { daily: dayKey(now), weekly: weekKey(now) };
152 + const rows = await db.select().from(userMissions).where(and(eq(userMissions.userId, user.id), inArray(userMissions.periodKey, [keys.daily, keys.weekly])));
153 + const views: MissionView[] = defs.map((m) => {
154 + const period = m.period as "daily" | "weekly";
155 + const r = rows.find((x) => x.missionKey === m.key && x.periodKey === keys[period]);
156 + return {
157 + key: m.key,
158 + name: m.name,
159 + description: m.description,
160 + period,
161 + target: m.target,
162 + progress: Math.min(r?.progress ?? 0, m.target),
163 + rewardCredits: m.rewardCredits,
164 + rewardXp: m.rewardXp,
165 + completedAt: r?.completedAt?.toISOString() ?? null,
166 + claimedAt: r?.claimedAt?.toISOString() ?? null,
167 + expiresAt: periodEnd(period, now).toISOString(),
168 + };
169 + });
170 + return { missions: views, enabled: flag("missions.enabled") };
171 + });
172 +
173 + /* ------------------------------------------------------ leaderboards */
174 + app.get("/api/leaderboards", async (req) => {
175 + const q = z.object({ category: z.enum(["biggest_win_today", "biggest_win_week", "biggest_multiplier", "most_spins", "highest_level"]).default("biggest_win_today") }).parse(req.query);
176 + if (!flag("leaderboards.enabled")) return { category: q.category, label: "", entries: [], you: null, updatedAt: new Date().toISOString() } satisfies LeaderboardView;
177 + const now = new Date();
178 + const map: Record<string, { category: string; periodKey: string; label: string }> = {
179 + biggest_win_today: { category: "biggest_win", periodKey: dayKey(now), label: "Biggest Win Today" },
180 + biggest_win_week: { category: "biggest_win", periodKey: weekKey(now), label: "Biggest Win This Week" },
181 + biggest_multiplier: { category: "biggest_multiplier", periodKey: "all", label: "Biggest Multiplier" },
182 + most_spins: { category: "most_spins", periodKey: "all", label: "Most Spins" },
183 + highest_level: { category: "highest_level", periodKey: "all", label: "Highest Level" },
184 + };
185 + const sel = map[q.category];
186 + const rows = await db
187 + .select({ userId: leaderboards.userId, username: users.username, level: users.level, value: leaderboards.value, gameSlug: leaderboards.gameSlug, optIn: userSettings.leaderboardOptIn })
188 + .from(leaderboards)
189 + .innerJoin(users, eq(users.id, leaderboards.userId))
190 + .leftJoin(userSettings, eq(userSettings.userId, leaderboards.userId))
191 + .where(and(eq(leaderboards.category, sel.category), eq(leaderboards.periodKey, sel.periodKey), eq(users.status, "active")))
192 + .orderBy(desc(leaderboards.value))
193 + .limit(200);
194 + const visible = rows.filter((r) => r.optIn !== false);
195 + const entries = visible.slice(0, 50).map((r, i) => ({ rank: i + 1, username: r.username, level: r.level, value: Number(r.value), game: r.gameSlug, isYou: req.user?.id === r.userId }));
196 + const youIdx = req.user ? visible.findIndex((r) => r.userId === req.user!.id) : -1;
197 + const you = youIdx >= 0 ? { rank: youIdx + 1, username: visible[youIdx].username, level: visible[youIdx].level, value: Number(visible[youIdx].value), game: visible[youIdx].gameSlug, isYou: true } : null;
198 + return { category: q.category, label: sel.label, entries, you, updatedAt: now.toISOString() } satisfies LeaderboardView;
199 + });
200 +
201 + /** Recent notable wins (public, anonymised to username). */
202 + app.get("/api/feed/wins", async () => {
203 + const rows = await db
204 + .select({ username: users.username, game: games.name, slug: games.slug, win: gameRounds.win, multiplier: gameRounds.multiplier, at: gameRounds.createdAt })
205 + .from(gameRounds)
206 + .innerJoin(users, eq(users.id, gameRounds.userId))
207 + .innerJoin(games, eq(games.id, gameRounds.gameId))
208 + .leftJoin(userSettings, eq(userSettings.userId, gameRounds.userId))
209 + .where(and(sql`${gameRounds.multiplier} >= 20`, sql`coalesce(${userSettings.leaderboardOptIn}, true)`))
210 + .orderBy(desc(gameRounds.createdAt))
211 + .limit(20);
212 + return { wins: rows.map((r) => ({ username: r.username, game: r.game, slug: r.slug, win: r.win, multiplier: Number(r.multiplier), at: r.at.toISOString() })) };
213 + });
214 + void userGameStats;
215 +}
added apps/api/src/routes/user.ts +160 −0
@@ -0,0 +1,160 @@
1 +import type { FastifyInstance } from "fastify";
2 +import { and, creditTransactions, db, desc, eq, gameRounds, games, lt, sessions, userAchievements, userGameStats, userSettings, users, wallets, sql } from "@spinza/database";
3 +import { levelForXp, settingsSchema, type LedgerEntry, type PublicUser, type RoundHistoryEntry, type UserSettings, type WalletView } from "@spinza/shared";
4 +import { requireUser } from "../plugins/auth";
5 +import { errors } from "../lib/errors";
6 +import { z } from "zod";
7 +
8 +export function toPublicUser(u: typeof users.$inferSelect): PublicUser {
9 + const lv = levelForXp(u.xp);
10 + return {
11 + id: u.id,
12 + username: u.username,
13 + level: lv.level,
14 + xp: u.xp,
15 + xpIntoLevel: lv.current,
16 + xpForNext: lv.next,
17 + createdAt: u.createdAt.toISOString(),
18 + lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
19 + };
20 +}
21 +
22 +export function toSettings(s: typeof userSettings.$inferSelect): UserSettings {
23 + return {
24 + soundEnabled: s.soundEnabled,
25 + musicVolume: Number(s.musicVolume),
26 + effectsVolume: Number(s.effectsVolume),
27 + masterVolume: Number(s.masterVolume),
28 + reduceMotion: s.reduceMotion,
29 + animationIntensity: s.animationIntensity as UserSettings["animationIntensity"],
30 + sessionReminderMinutes: s.sessionReminderMinutes,
31 + breakReminder: s.breakReminder,
32 + leaderboardOptIn: s.leaderboardOptIn,
33 + };
34 +}
35 +
36 +export async function userRoutes(app: FastifyInstance) {
37 + app.get("/api/user", async (req) => {
38 + const user = requireUser(req);
39 + const [u, w, s] = await Promise.all([
40 + db.query.users.findFirst({ where: eq(users.id, user.id) }),
41 + db.query.wallets.findFirst({ where: eq(wallets.userId, user.id) }),
42 + db.query.userSettings.findFirst({ where: eq(userSettings.userId, user.id) }),
43 + ]);
44 + if (!u || !w || !s) throw errors.notFound("User not found");
45 + const wallet: WalletView = { balance: w.balance, lifetimeWagered: w.lifetimeWagered, lifetimeWon: w.lifetimeWon, updatedAt: w.updatedAt.toISOString() };
46 + return { user: toPublicUser(u), wallet, settings: toSettings(s) };
47 + });
48 +
49 + app.get("/api/user/profile", async (req) => {
50 + const user = requireUser(req);
51 + const u = await db.query.users.findFirst({ where: eq(users.id, user.id) });
52 + if (!u) throw errors.notFound();
53 + const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, user.id) });
54 + const fav = await db
55 + .select({ slug: games.slug, name: games.name, spins: userGameStats.spins, lastPlayedAt: userGameStats.lastPlayedAt, summary: games.summary })
56 + .from(userGameStats)
57 + .innerJoin(games, eq(games.id, userGameStats.gameId))
58 + .where(eq(userGameStats.userId, user.id))
59 + .orderBy(desc(userGameStats.spins))
60 + .limit(1);
61 + const achCount = await db.select({ n: sql<number>`count(*)::int` }).from(userAchievements).where(eq(userAchievements.userId, user.id));
62 + const bonuses = await db.select({ n: sql<number>`coalesce(sum(bonuses),0)::int` }).from(userGameStats).where(eq(userGameStats.userId, user.id));
63 + return {
64 + user: toPublicUser(u),
65 + wallet: { balance: w?.balance ?? 0, lifetimeWagered: w?.lifetimeWagered ?? 0, lifetimeWon: w?.lifetimeWon ?? 0 },
66 + stats: {
67 + totalSpins: u.totalSpins,
68 + gamesPlayed: u.gamesPlayed,
69 + biggestWin: u.biggestWin,
70 + biggestMultiplier: Number(u.biggestMultiplier),
71 + bonuses: bonuses[0]?.n ?? 0,
72 + achievements: achCount[0]?.n ?? 0,
73 + },
74 + favoriteGame: fav[0] ? { slug: fav[0].slug, name: fav[0].name, spins: fav[0].spins, palette: (fav[0].summary as { palette: unknown }).palette } : null,
75 + };
76 + });
77 +
78 + app.patch("/api/user/settings", async (req) => {
79 + const user = requireUser(req);
80 + const patch = settingsSchema.parse(req.body);
81 + const set: Partial<typeof userSettings.$inferInsert> = { updatedAt: new Date() };
82 + if (patch.soundEnabled !== undefined) set.soundEnabled = patch.soundEnabled;
83 + if (patch.musicVolume !== undefined) set.musicVolume = patch.musicVolume.toFixed(2);
84 + if (patch.effectsVolume !== undefined) set.effectsVolume = patch.effectsVolume.toFixed(2);
85 + if (patch.masterVolume !== undefined) set.masterVolume = patch.masterVolume.toFixed(2);
86 + if (patch.reduceMotion !== undefined) set.reduceMotion = patch.reduceMotion;
87 + if (patch.animationIntensity !== undefined) set.animationIntensity = patch.animationIntensity;
88 + if (patch.sessionReminderMinutes !== undefined) set.sessionReminderMinutes = patch.sessionReminderMinutes;
89 + if (patch.breakReminder !== undefined) set.breakReminder = patch.breakReminder;
90 + if (patch.leaderboardOptIn !== undefined) set.leaderboardOptIn = patch.leaderboardOptIn;
91 + const [s] = await db.update(userSettings).set(set).where(eq(userSettings.userId, user.id)).returning();
92 + return { settings: toSettings(s) };
93 + });
94 +
95 + app.get("/api/wallet", async (req) => {
96 + const user = requireUser(req);
97 + const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, user.id) });
98 + if (!w) throw errors.notFound();
99 + return { balance: w.balance, lifetimeWagered: w.lifetimeWagered, lifetimeWon: w.lifetimeWon, updatedAt: w.updatedAt.toISOString() } satisfies WalletView;
100 + });
101 +
102 + app.get("/api/wallet/ledger", async (req) => {
103 + const user = requireUser(req);
104 + const q = z.object({ before: z.string().datetime().optional(), limit: z.coerce.number().int().min(1).max(100).default(50), type: z.string().optional() }).parse(req.query);
105 + const conds = [eq(creditTransactions.userId, user.id)];
106 + if (q.before) conds.push(lt(creditTransactions.createdAt, new Date(q.before)));
107 + if (q.type) conds.push(eq(creditTransactions.type, q.type));
108 + const rows = await db.select().from(creditTransactions).where(and(...conds)).orderBy(desc(creditTransactions.createdAt)).limit(q.limit);
109 + const entries: LedgerEntry[] = rows.map((r) => ({ id: r.id, type: r.type as LedgerEntry["type"], amount: r.amount, balanceAfter: r.balanceAfter, reference: r.reference, createdAt: r.createdAt.toISOString() }));
110 + return { entries, nextBefore: rows.length === q.limit ? rows[rows.length - 1].createdAt.toISOString() : null };
111 + });
112 +
113 + app.get("/api/user/history", async (req) => {
114 + const user = requireUser(req);
115 + const q = z.object({ before: z.string().datetime().optional(), limit: z.coerce.number().int().min(1).max(100).default(40), game: z.string().optional() }).parse(req.query);
116 + const conds = [eq(gameRounds.userId, user.id)];
117 + if (q.before) conds.push(lt(gameRounds.createdAt, new Date(q.before)));
118 + if (q.game) conds.push(eq(gameRounds.gameSlug, q.game));
119 + const rows = await db
120 + .select({ roundId: gameRounds.roundId, game: gameRounds.gameSlug, bet: gameRounds.bet, win: gameRounds.win, multiplier: gameRounds.multiplier, balanceAfter: gameRounds.balanceAfter, createdAt: gameRounds.createdAt, features: gameRounds.features, name: games.name })
121 + .from(gameRounds)
122 + .innerJoin(games, eq(games.id, gameRounds.gameId))
123 + .where(and(...conds))
124 + .orderBy(desc(gameRounds.createdAt))
125 + .limit(q.limit);
126 + const entries: (RoundHistoryEntry & { features: string[] })[] = rows.map((r) => ({
127 + roundId: r.roundId,
128 + game: r.game,
129 + gameName: r.name,
130 + bet: r.bet,
131 + win: r.win,
132 + multiplier: Number(r.multiplier),
133 + balanceAfter: r.balanceAfter,
134 + createdAt: r.createdAt.toISOString(),
135 + features: r.features,
136 + }));
137 + return { entries, nextBefore: rows.length === q.limit ? rows[rows.length - 1].createdAt.toISOString() : null };
138 + });
139 +
140 + app.get("/api/user/rounds/:roundId", async (req) => {
141 + const user = requireUser(req);
142 + const { roundId } = req.params as { roundId: string };
143 + const r = await db.query.gameRounds.findFirst({ where: and(eq(gameRounds.roundId, roundId), eq(gameRounds.userId, user.id)) });
144 + if (!r) throw errors.notFound("Round not found");
145 + return { roundId: r.roundId, game: r.gameSlug, version: r.gameVersion, bet: r.bet, win: r.win, multiplier: Number(r.multiplier), balanceAfter: r.balanceAfter, createdAt: r.createdAt.toISOString(), result: r.result, features: r.features };
146 + });
147 +
148 + app.get("/api/user/sessions", async (req) => {
149 + const user = requireUser(req);
150 + const rows = await db.select().from(sessions).where(eq(sessions.userId, user.id)).orderBy(desc(sessions.lastSeenAt));
151 + return { sessions: rows.map((s) => ({ id: s.id, current: s.id === req.sessionId, createdAt: s.createdAt.toISOString(), lastSeenAt: s.lastSeenAt.toISOString(), userAgent: s.userAgent, ip: s.ip ? s.ip.replace(/\.\d+$/, ".x") : null })) };
152 + });
153 +
154 + app.delete("/api/user/sessions/:id", async (req) => {
155 + const user = requireUser(req);
156 + const { id } = req.params as { id: string };
157 + await db.delete(sessions).where(and(eq(sessions.id, id), eq(sessions.userId, user.id)));
158 + return { ok: true };
159 + });
160 +}
added apps/api/src/server.ts +33 −0
@@ -0,0 +1,33 @@
1 +import { buildApp } from "./app";
2 +import { config } from "./config";
3 +import { closeDb, getDb } from "@spinza/database";
4 +import { syncGames } from "@spinza/database/sync-games";
5 +import { closeRedis } from "./lib/redis";
6 +
7 +async function main() {
8 + const app = await buildApp();
9 + // Keep the games table in sync with the shipped library on boot (lifecycle respects certifications).
10 + try {
11 + const reports = await syncGames(getDb());
12 + app.log.info({ published: reports.filter((r) => r.lifecycle === "published").length, total: reports.length }, "game library synced");
13 + } catch (e) {
14 + app.log.error({ err: e }, "game sync failed");
15 + }
16 + await app.listen({ port: config.port, host: config.host });
17 + app.log.info(`Spinza API listening on http://${config.host}:${config.port} (${config.env})`);
18 +
19 + const shutdown = async (signal: string) => {
20 + app.log.info({ signal }, "shutting down");
21 + await app.close();
22 + await closeDb();
23 + await closeRedis();
24 + process.exit(0);
25 + };
26 + process.on("SIGINT", () => void shutdown("SIGINT"));
27 + process.on("SIGTERM", () => void shutdown("SIGTERM"));
28 +}
29 +
30 +main().catch((e) => {
31 + console.error(e);
32 + process.exit(1);
33 +});
added apps/api/src/services/progression.ts +242 −0
@@ -0,0 +1,242 @@
1 +import { achievements, and, eq, inArray, leaderboards, missions, sql, userAchievements, userMissions, users, type Tx } from "@spinza/database";
2 +import { XP_PER_BONUS, XP_PER_NEW_GAME, XP_PER_SPIN, classifyWin, levelForXp, levelUpReward } from "@spinza/shared";
3 +import { applyCredit, type LockedWallet } from "./wallet";
4 +import { flag } from "../lib/settings";
5 +
6 +export interface SpinFacts {
7 + gameSlug: string;
8 + bet: number;
9 + win: number;
10 + multiplier: number;
11 + bonus: boolean;
12 + jackpot: boolean;
13 + newGame: boolean;
14 +}
15 +
16 +export interface UserCounters {
17 + totalSpins: number;
18 + gamesPlayed: number;
19 + biggestWin: number;
20 + biggestMultiplier: number;
21 + level: number;
22 + xp: number;
23 + bonuses: number;
24 + bigWins: number;
25 + wins: number;
26 + jackpots: number;
27 + dailyStreak: number;
28 +}
29 +
30 +export interface ProgressionResult {
31 + xpGained: number;
32 + xpTotal: number;
33 + level: number;
34 + leveledUp: boolean;
35 + levelReward: number;
36 + achievements: string[];
37 + missions: string[];
38 + creditsGranted: number;
39 +}
40 +
41 +/* --------------------------------------------------------------- periods */
42 +
43 +export function dayKey(d = new Date()): string {
44 + return d.toISOString().slice(0, 10);
45 +}
46 +
47 +export function weekKey(d = new Date()): string {
48 + const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
49 + const day = date.getUTCDay() || 7;
50 + date.setUTCDate(date.getUTCDate() + 4 - day);
51 + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
52 + const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
53 + return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
54 +}
55 +
56 +export function periodEnd(period: "daily" | "weekly", d = new Date()): Date {
57 + if (period === "daily") return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1));
58 + const day = d.getUTCDay() || 7;
59 + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + (8 - day)));
60 +}
61 +
62 +/* ------------------------------------------------------------ XP + level */
63 +
64 +export async function awardXp(tx: Tx, wallet: LockedWallet, current: { xp: number; level: number }, amount: number, reference: string): Promise<{ level: number; xp: number; leveledUp: boolean; reward: number }> {
65 + const xp = current.xp + amount;
66 + const { level } = levelForXp(xp);
67 + let reward = 0;
68 + if (level > current.level) {
69 + for (let l = current.level + 1; l <= level; l++) reward += levelUpReward(l);
70 + await applyCredit(tx, wallet, "LEVEL_UP", reward, `level:${level}`, { from: current.level, to: level });
71 + }
72 + await tx.update(users).set({ xp, level }).where(eq(users.id, wallet.userId));
73 + return { level, xp, leveledUp: level > current.level, reward };
74 +}
75 +
76 +/* --------------------------------------------------------- achievements */
77 +
78 +const metricValue = (c: UserCounters, metric: string): number => {
79 + switch (metric) {
80 + case "spins":
81 + return c.totalSpins;
82 + case "bonuses":
83 + return c.bonuses;
84 + case "games_played":
85 + return c.gamesPlayed;
86 + case "big_wins":
87 + return c.bigWins;
88 + case "multiplier":
89 + return c.biggestMultiplier;
90 + case "wins":
91 + return c.wins;
92 + case "level":
93 + return c.level;
94 + case "daily_streak":
95 + return c.dailyStreak;
96 + case "jackpots":
97 + return c.jackpots;
98 + default:
99 + return 0;
100 + }
101 +};
102 +
103 +/** Unlock every achievement whose target is now reached. Grants credits into the locked wallet. */
104 +export async function checkAchievements(tx: Tx, wallet: LockedWallet, counters: UserCounters): Promise<{ unlocked: string[]; credits: number; xp: number }> {
105 + if (!flag("achievements.enabled")) return { unlocked: [], credits: 0, xp: 0 };
106 + const all = await tx.select().from(achievements).where(eq(achievements.enabled, true));
107 + const have = new Set((await tx.select({ k: userAchievements.achievementKey }).from(userAchievements).where(eq(userAchievements.userId, wallet.userId))).map((r) => r.k));
108 + const unlocked: string[] = [];
109 + let credits = 0;
110 + let xp = 0;
111 + for (const a of all) {
112 + if (have.has(a.key)) continue;
113 + if (metricValue(counters, a.metric) >= a.target) {
114 + await tx.insert(userAchievements).values({ userId: wallet.userId, achievementKey: a.key }).onConflictDoNothing();
115 + if (a.rewardCredits > 0) await applyCredit(tx, wallet, "ACHIEVEMENT", a.rewardCredits, `achievement:${a.key}`, { name: a.name });
116 + credits += a.rewardCredits;
117 + xp += a.rewardXp;
118 + unlocked.push(a.key);
119 + }
120 + }
121 + return { unlocked, credits, xp };
122 +}
123 +
124 +/* --------------------------------------------------------------- missions */
125 +
126 +/** Advance mission progress after a spin. Completed missions are auto-claimed (credits granted). */
127 +export async function advanceMissions(tx: Tx, wallet: LockedWallet, facts: SpinFacts): Promise<{ completed: string[]; credits: number; xp: number }> {
128 + if (!flag("missions.enabled")) return { completed: [], credits: 0, xp: 0 };
129 + const defs = await tx.select().from(missions).where(eq(missions.enabled, true));
130 + if (!defs.length) return { completed: [], credits: 0, xp: 0 };
131 + const now = new Date();
132 + const keys = { daily: dayKey(now), weekly: weekKey(now) };
133 + // Ensure rows exist for this period.
134 + for (const m of defs) {
135 + const period = m.period as "daily" | "weekly";
136 + await tx
137 + .insert(userMissions)
138 + .values({ userId: wallet.userId, missionKey: m.key, periodKey: keys[period], expiresAt: periodEnd(period, now) })
139 + .onConflictDoNothing();
140 + }
141 + const rows = await tx
142 + .select()
143 + .from(userMissions)
144 + .where(and(eq(userMissions.userId, wallet.userId), inArray(userMissions.periodKey, [keys.daily, keys.weekly])));
145 + const completed: string[] = [];
146 + let credits = 0;
147 + let xp = 0;
148 + for (const m of defs) {
149 + const period = m.period as "daily" | "weekly";
150 + const row = rows.find((r) => r.missionKey === m.key && r.periodKey === keys[period]);
151 + if (!row || row.completedAt) continue;
152 + let progress = row.progress;
153 + let set = row.progressSet;
154 + switch (m.metric) {
155 + case "spins":
156 + progress += 1;
157 + break;
158 + case "wins":
159 + if (facts.win > 0) progress += 1;
160 + break;
161 + case "bonuses":
162 + if (facts.bonus) progress += 1;
163 + break;
164 + case "wagered":
165 + progress += facts.bet;
166 + break;
167 + case "multiplier":
168 + progress = Math.max(progress, Math.floor(facts.multiplier));
169 + break;
170 + case "games":
171 + if (!set.includes(facts.gameSlug)) set = [...set, facts.gameSlug];
172 + progress = set.length;
173 + break;
174 + }
175 + if (progress === row.progress && set === row.progressSet) continue;
176 + const done = progress >= m.target;
177 + await tx
178 + .update(userMissions)
179 + .set({ progress, progressSet: set, completedAt: done ? now : null, claimedAt: done ? now : null })
180 + .where(eq(userMissions.id, row.id));
181 + if (done) {
182 + if (m.rewardCredits > 0) await applyCredit(tx, wallet, "MISSION", m.rewardCredits, `mission:${m.key}:${row.periodKey}`, { name: m.name });
183 + credits += m.rewardCredits;
184 + xp += m.rewardXp;
185 + completed.push(m.key);
186 + }
187 + }
188 + return { completed, credits, xp };
189 +}
190 +
191 +/* ------------------------------------------------------------ leaderboards */
192 +
193 +export async function upsertLeaderboards(tx: Tx, userId: string, facts: SpinFacts, roundId: string, counters: { totalSpins: number; level: number }, optIn: boolean): Promise<void> {
194 + if (!optIn || !flag("leaderboards.enabled")) return;
195 + const now = new Date();
196 + const entries: { category: string; periodKey: string; value: number; gameSlug?: string; roundId?: string; mode: "max" | "set" }[] = [];
197 + if (facts.win > 0) {
198 + entries.push({ category: "biggest_win", periodKey: dayKey(now), value: facts.win, gameSlug: facts.gameSlug, roundId, mode: "max" });
199 + entries.push({ category: "biggest_win", periodKey: weekKey(now), value: facts.win, gameSlug: facts.gameSlug, roundId, mode: "max" });
200 + entries.push({ category: "biggest_multiplier", periodKey: "all", value: facts.multiplier, gameSlug: facts.gameSlug, roundId, mode: "max" });
201 + entries.push({ category: "biggest_multiplier", periodKey: weekKey(now), value: facts.multiplier, gameSlug: facts.gameSlug, roundId, mode: "max" });
202 + }
203 + entries.push({ category: "most_spins", periodKey: "all", value: counters.totalSpins, mode: "set" });
204 + entries.push({ category: "most_spins", periodKey: weekKey(now), value: 1, mode: "set" }); // handled as increment below
205 + entries.push({ category: "highest_level", periodKey: "all", value: counters.level, mode: "set" });
206 + for (const e of entries) {
207 + if (e.category === "most_spins" && e.periodKey !== "all") {
208 + await tx
209 + .insert(leaderboards)
210 + .values({ category: e.category, periodKey: e.periodKey, userId, value: "1" })
211 + .onConflictDoUpdate({ target: [leaderboards.category, leaderboards.periodKey, leaderboards.userId], set: { value: sql`${leaderboards.value} + 1`, updatedAt: now } });
212 + continue;
213 + }
214 + await tx
215 + .insert(leaderboards)
216 + .values({ category: e.category, periodKey: e.periodKey, userId, value: String(e.value), gameSlug: e.gameSlug ?? null, roundId: e.roundId ?? null })
217 + .onConflictDoUpdate({
218 + target: [leaderboards.category, leaderboards.periodKey, leaderboards.userId],
219 + set:
220 + e.mode === "max"
221 + ? {
222 + value: sql`greatest(${leaderboards.value}, ${String(e.value)}::numeric)`,
223 + gameSlug: sql`case when ${String(e.value)}::numeric > ${leaderboards.value} then ${e.gameSlug ?? null} else ${leaderboards.gameSlug} end`,
224 + roundId: sql`case when ${String(e.value)}::numeric > ${leaderboards.value} then ${e.roundId ?? null} else ${leaderboards.roundId} end`,
225 + updatedAt: now,
226 + }
227 + : { value: String(e.value), updatedAt: now },
228 + });
229 + }
230 +}
231 +
232 +export function spinXp(facts: SpinFacts): number {
233 + let xp = XP_PER_SPIN;
234 + if (facts.bonus) xp += XP_PER_BONUS;
235 + if (facts.newGame) xp += XP_PER_NEW_GAME;
236 + const cls = classifyWin(facts.multiplier);
237 + if (cls === "big") xp += 10;
238 + else if (cls === "mega") xp += 25;
239 + else if (cls === "epic") xp += 60;
240 + else if (cls === "legendary") xp += 200;
241 + return xp;
242 +}
added apps/api/src/services/spin.ts +254 −0
@@ -0,0 +1,254 @@
1 +import { and, db, eq, gameRounds, gameStates, gameStatistics, games, sql, userGameStats, userSettings, users, dailyRewards } from "@spinza/database";
2 +import { runSpin, type PlayerGameState, type SpinOutcome } from "@spinza/game-core";
3 +import { getGame } from "@spinza/games";
4 +import { BET_LEVELS, classifyWin, type SpinResponse } from "@spinza/shared";
5 +import { errors } from "../lib/errors";
6 +import { newRoundId } from "../lib/crypto";
7 +import { flag, maintenance } from "../lib/settings";
8 +import { applyCredit, lockWallet, saveWallet } from "./wallet";
9 +import { advanceMissions, awardXp, checkAchievements, spinXp, upsertLeaderboards, type UserCounters } from "./progression";
10 +import { redis } from "../lib/redis";
11 +import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg";
12 +
13 +export interface SpinInput {
14 + userId: string;
15 + slug: string;
16 + bet: number;
17 + clientRoundId: string;
18 +}
19 +
20 +/** Strip the pre-state from the stored result to keep rounds compact. */
21 +function storableResult(out: SpinOutcome): Record<string, unknown> {
22 + const { stateBefore: _b, stateAfter: _a, rngReference: _r, ...rest } = out;
23 + return rest as unknown as Record<string, unknown>;
24 +}
25 +
26 +async function replay(userId: string, clientRoundId: string): Promise<SpinResponse | null> {
27 + const row = await db.query.gameRounds.findFirst({ where: and(eq(gameRounds.userId, userId), eq(gameRounds.clientRoundId, clientRoundId)) });
28 + if (!row) return null;
29 + const u = await db.query.users.findFirst({ where: eq(users.id, userId), columns: { xp: true, level: true } });
30 + return {
31 + roundId: row.roundId,
32 + game: row.gameSlug,
33 + version: row.gameVersion,
34 + bet: row.bet,
35 + win: row.win,
36 + multiplier: Number(row.multiplier),
37 + winClass: classifyWin(Number(row.multiplier)),
38 + balance: row.balanceAfter,
39 + xp: { gained: 0, total: u?.xp ?? 0, level: u?.level ?? 1, leveledUp: false, levelReward: 0 },
40 + result: row.result,
41 + unlocked: { achievements: [], missions: [] },
42 + replayed: true,
43 + };
44 +}
45 +
46 +export async function spin(input: SpinInput): Promise<SpinResponse> {
47 + const m = maintenance();
48 + if (m.enabled) throw errors.maintenance(m.message);
49 + const def = getGame(input.slug);
50 + if (!def) throw errors.notFound("Unknown game");
51 + if (!flag(`game.${def.slug}.enabled`)) throw errors.unavailable("This game is temporarily unavailable.");
52 + if (!(BET_LEVELS as readonly number[]).includes(input.bet) || input.bet < def.minBet || input.bet > def.maxBet) throw errors.badRequest("Invalid bet for this game.");
53 +
54 + const game = await db.query.games.findFirst({ where: eq(games.slug, def.slug) });
55 + if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published.");
56 + if (game.version !== def.version) throw errors.unavailable("Game version mismatch — please reload.");
57 +
58 + // Fast idempotency path.
59 + const existing = await replay(input.userId, input.clientRoundId);
60 + if (existing) return existing;
61 +
62 + const started = Date.now();
63 + try {
64 + return await db.transaction(async (tx) => {
65 + const wallet = await lockWallet(tx, input.userId);
66 + if (wallet.balance < input.bet) throw errors.insufficient(wallet.balance, input.bet);
67 +
68 + const stateRow = await tx.execute(sql`select state from game_states where user_id = ${input.userId} and game_id = ${game.id} for update`);
69 + const state = ((stateRow.rows[0] as { state?: PlayerGameState } | undefined)?.state ?? undefined) as PlayerGameState | undefined;
70 +
71 + const outcome = runSpin(def, input.bet, { state });
72 + const roundId = newRoundId();
73 +
74 + await applyCredit(tx, wallet, "BET", -input.bet, roundId, { game: def.slug });
75 + if (outcome.totalWin > 0) await applyCredit(tx, wallet, "WIN", outcome.totalWin, roundId, { game: def.slug, multiplier: outcome.multiplier });
76 +
77 + // User counters.
78 + const [u] = await tx
79 + .select({
80 + xp: users.xp,
81 + level: users.level,
82 + totalSpins: users.totalSpins,
83 + gamesPlayed: users.gamesPlayed,
84 + biggestWin: users.biggestWin,
85 + biggestMultiplier: users.biggestMultiplier,
86 + })
87 + .from(users)
88 + .where(eq(users.id, input.userId))
89 + .for("update");
90 +
91 + const ugsExisting = await tx.query.userGameStats.findFirst({ where: and(eq(userGameStats.userId, input.userId), eq(userGameStats.gameId, game.id)) });
92 + const newGame = !ugsExisting;
93 + const bonus = outcome.bonusTriggered || outcome.freeSpinsTriggered;
94 + const cls = classifyWin(outcome.multiplier);
95 + const isBigWin = cls !== "none" && cls !== "regular" && cls !== "win";
96 +
97 + const facts = { gameSlug: def.slug, bet: input.bet, win: outcome.totalWin, multiplier: outcome.multiplier, bonus, jackpot: !!outcome.jackpot, newGame };
98 +
99 + const totalSpins = u.totalSpins + 1;
100 + const gamesPlayed = u.gamesPlayed + (newGame ? 1 : 0);
101 + const biggestWin = Math.max(u.biggestWin, outcome.totalWin);
102 + const biggestMultiplier = Math.max(Number(u.biggestMultiplier), outcome.multiplier);
103 +
104 + // Aggregate counters for achievements (bonuses / big wins / wins / jackpots from ledger-independent stats).
105 + const agg = await tx.execute(sql`
106 + select coalesce(sum(bonuses),0)::bigint as bonuses from user_game_stats where user_id = ${input.userId}`);
107 + const bonusesBefore = Number((agg.rows[0] as { bonuses: number }).bonuses);
108 + const bigAgg = await tx.execute(sql`
109 + select count(*) filter (where multiplier >= 20)::bigint as big_wins,
110 + count(*) filter (where win > 0)::bigint as wins,
111 + count(*) filter (where jackpot_tier is not null)::bigint as jackpots
112 + from game_rounds where user_id = ${input.userId}`);
113 + const ba = bigAgg.rows[0] as { big_wins: number; wins: number; jackpots: number };
114 + const streak = await tx.query.dailyRewards.findFirst({ where: eq(dailyRewards.userId, input.userId), columns: { streakDay: true } });
115 +
116 + // Round row (unique on user+clientRoundId → duplicate protection).
117 + await tx.insert(gameRounds).values({
118 + roundId,
119 + userId: input.userId,
120 + gameId: game.id,
121 + gameSlug: def.slug,
122 + gameVersion: def.version,
123 + clientRoundId: input.clientRoundId,
124 + bet: input.bet,
125 + win: outcome.totalWin,
126 + multiplier: outcome.multiplier.toFixed(4),
127 + balanceAfter: wallet.balance,
128 + result: storableResult(outcome),
129 + features: outcome.features,
130 + freeSpins: outcome.freeSpinsTriggered,
131 + bonus: outcome.bonusTriggered,
132 + jackpotTier: outcome.jackpot?.tier ?? null,
133 + rngReference: outcome.rngReference,
134 + durationMs: Date.now() - started,
135 + });
136 +
137 + // Persistent game state.
138 + await tx
139 + .insert(gameStates)
140 + .values({ userId: input.userId, gameId: game.id, state: outcome.stateAfter as unknown as Record<string, unknown> })
141 + .onConflictDoUpdate({ target: [gameStates.userId, gameStates.gameId], set: { state: outcome.stateAfter as unknown as Record<string, unknown>, updatedAt: new Date() } });
142 +
143 + // Per-user per-game stats.
144 + await tx
145 + .insert(userGameStats)
146 + .values({
147 + userId: input.userId,
148 + gameId: game.id,
149 + spins: 1,
150 + wagered: input.bet,
151 + won: outcome.totalWin,
152 + bonuses: bonus ? 1 : 0,
153 + biggestWin: outcome.totalWin,
154 + biggestMultiplier: outcome.multiplier.toFixed(2),
155 + })
156 + .onConflictDoUpdate({
157 + target: [userGameStats.userId, userGameStats.gameId],
158 + set: {
159 + spins: sql`${userGameStats.spins} + 1`,
160 + wagered: sql`${userGameStats.wagered} + ${input.bet}`,
161 + won: sql`${userGameStats.won} + ${outcome.totalWin}`,
162 + bonuses: sql`${userGameStats.bonuses} + ${bonus ? 1 : 0}`,
163 + biggestWin: sql`greatest(${userGameStats.biggestWin}, ${outcome.totalWin})`,
164 + biggestMultiplier: sql`greatest(${userGameStats.biggestMultiplier}, ${outcome.multiplier.toFixed(2)}::numeric)`,
165 + lastPlayedAt: new Date(),
166 + },
167 + });
168 +
169 + // Global game statistics.
170 + await tx
171 + .update(gameStatistics)
172 + .set({
173 + spins: sql`${gameStatistics.spins} + 1`,
174 + wagered: sql`${gameStatistics.wagered} + ${input.bet}`,
175 + won: sql`${gameStatistics.won} + ${outcome.totalWin}`,
176 + wins: sql`${gameStatistics.wins} + ${outcome.totalWin > 0 ? 1 : 0}`,
177 + bonuses: sql`${gameStatistics.bonuses} + ${outcome.bonusTriggered ? 1 : 0}`,
178 + freeSpins: sql`${gameStatistics.freeSpins} + ${outcome.freeSpinsTriggered ? 1 : 0}`,
179 + bigWins: sql`${gameStatistics.bigWins} + ${isBigWin ? 1 : 0}`,
180 + maxWin: sql`greatest(${gameStatistics.maxWin}, ${outcome.totalWin})`,
181 + maxMultiplier: sql`greatest(${gameStatistics.maxMultiplier}, ${outcome.multiplier.toFixed(2)}::numeric)`,
182 + updatedAt: new Date(),
183 + })
184 + .where(eq(gameStatistics.gameId, game.id));
185 +
186 + // XP, achievements, missions.
187 + let xpGain = spinXp(facts);
188 + const counters: UserCounters = {
189 + totalSpins,
190 + gamesPlayed,
191 + biggestWin,
192 + biggestMultiplier,
193 + level: u.level,
194 + xp: u.xp,
195 + bonuses: bonusesBefore + (bonus ? 1 : 0),
196 + bigWins: Number(ba.big_wins) + (isBigWin ? 1 : 0),
197 + wins: Number(ba.wins) + (outcome.totalWin > 0 ? 1 : 0),
198 + jackpots: Number(ba.jackpots) + (outcome.jackpot ? 1 : 0),
199 + dailyStreak: streak?.streakDay ?? 0,
200 + };
201 + const ach = await checkAchievements(tx, wallet, counters);
202 + const mis = await advanceMissions(tx, wallet, facts);
203 + xpGain += ach.xp + mis.xp;
204 + const lvl = await awardXp(tx, wallet, { xp: u.xp, level: u.level }, xpGain, roundId);
205 + if (lvl.leveledUp) {
206 + counters.level = lvl.level;
207 + const ach2 = await checkAchievements(tx, wallet, counters);
208 + ach.unlocked.push(...ach2.unlocked);
209 + }
210 +
211 + await tx
212 + .update(users)
213 + .set({ totalSpins, gamesPlayed, biggestWin, biggestMultiplier: biggestMultiplier.toFixed(2) })
214 + .where(eq(users.id, input.userId));
215 +
216 + const settings = await tx.query.userSettings.findFirst({ where: eq(userSettings.userId, input.userId), columns: { leaderboardOptIn: true } });
217 + await upsertLeaderboards(tx, input.userId, facts, roundId, { totalSpins, level: lvl.level }, settings?.leaderboardOptIn ?? true);
218 +
219 + // Balance after the round (before rewards) is recorded on the round row; the returned balance includes rewards.
220 + await saveWallet(tx, wallet);
221 +
222 + // Live counters (best effort, outside the ledger).
223 + redis()
224 + .multi()
225 + .incr("stats:spins:today:" + new Date().toISOString().slice(0, 10))
226 + .pfadd("stats:players:today:" + new Date().toISOString().slice(0, 10), input.userId)
227 + .zadd("live:players", Date.now(), input.userId)
228 + .zadd(`live:game:${def.slug}`, Date.now(), input.userId)
229 + .exec()
230 + .catch(() => {});
231 +
232 + return {
233 + roundId,
234 + game: def.slug,
235 + version: def.version,
236 + bet: input.bet,
237 + win: outcome.totalWin,
238 + multiplier: outcome.multiplier,
239 + winClass: cls,
240 + balance: wallet.balance,
241 + xp: { gained: xpGain, total: lvl.xp, level: lvl.level, leveledUp: lvl.leveledUp, levelReward: lvl.reward },
242 + result: storableResult(outcome),
243 + unlocked: { achievements: ach.unlocked, missions: mis.completed },
244 + } satisfies SpinResponse;
245 + });
246 + } catch (e) {
247 + // Concurrent duplicate: the unique index on (user_id, client_round_id) fired → return the stored round.
248 + if (pgCode(e) === PG_UNIQUE_VIOLATION) {
249 + const again = await replay(input.userId, input.clientRoundId);
250 + if (again) return again;
251 + }
252 + throw e;
253 + }
254 +}
added apps/api/src/services/wallet.ts +64 −0
@@ -0,0 +1,64 @@
1 +import { creditTransactions, eq, sql, wallets, type Tx } from "@spinza/database";
2 +import type { TransactionType } from "@spinza/shared";
3 +import { errors } from "../lib/errors";
4 +
5 +export interface LockedWallet {
6 + userId: string;
7 + balance: number;
8 + lifetimeWagered: number;
9 + lifetimeWon: number;
10 + lifetimeGranted: number;
11 +}
12 +
13 +/** SELECT ... FOR UPDATE on the wallet row. Every balance change must start here. */
14 +export async function lockWallet(tx: Tx, userId: string): Promise<LockedWallet> {
15 + const rows = await tx.execute(sql`select user_id, balance, lifetime_wagered, lifetime_won, lifetime_granted from wallets where user_id = ${userId} for update`);
16 + const r = (rows.rows as Record<string, unknown>[])[0];
17 + if (!r) throw errors.notFound("Wallet not found");
18 + return {
19 + userId,
20 + balance: Number(r.balance),
21 + lifetimeWagered: Number(r.lifetime_wagered),
22 + lifetimeWon: Number(r.lifetime_won),
23 + lifetimeGranted: Number(r.lifetime_granted),
24 + };
25 +}
26 +
27 +/**
28 + * Apply a signed amount to a locked wallet and write the immutable ledger row.
29 + * Never call without holding the wallet lock (see lockWallet).
30 + */
31 +export async function applyCredit(
32 + tx: Tx,
33 + wallet: LockedWallet,
34 + type: TransactionType,
35 + amount: number,
36 + reference: string | null,
37 + meta?: Record<string, unknown>,
38 +): Promise<number> {
39 + if (!Number.isInteger(amount)) throw new Error("credit amounts must be integers");
40 + const next = wallet.balance + amount;
41 + if (next < 0) throw errors.insufficient(wallet.balance, -amount);
42 + wallet.balance = next;
43 + if (type === "BET") wallet.lifetimeWagered += -amount;
44 + else if (type === "WIN") wallet.lifetimeWon += amount;
45 + else if (amount > 0) wallet.lifetimeGranted += amount;
46 + await tx.insert(creditTransactions).values({ userId: wallet.userId, type, amount, balanceAfter: next, reference, meta: meta ?? null });
47 + return next;
48 +}
49 +
50 +/** Persist the wallet aggregate after one or more applyCredit calls. */
51 +export async function saveWallet(tx: Tx, wallet: LockedWallet): Promise<void> {
52 + await tx
53 + .update(wallets)
54 + .set({ balance: wallet.balance, lifetimeWagered: wallet.lifetimeWagered, lifetimeWon: wallet.lifetimeWon, lifetimeGranted: wallet.lifetimeGranted, updatedAt: new Date() })
55 + .where(eq(wallets.userId, wallet.userId));
56 +}
57 +
58 +/** Convenience: single grant in its own transaction. */
59 +export async function grant(tx: Tx, userId: string, type: TransactionType, amount: number, reference: string | null, meta?: Record<string, unknown>): Promise<number> {
60 + const w = await lockWallet(tx, userId);
61 + const b = await applyCredit(tx, w, type, amount, reference, meta);
62 + await saveWallet(tx, w);
63 + return b;
64 +}
added apps/api/test/wallet.test.ts +150 −0
@@ -0,0 +1,150 @@
1 +/**
2 + * Integration tests against the local Postgres/Redis (DATABASE_URL, REDIS_URL).
3 + * Run: pnpm --filter @spinza/api test
4 + */
5 +import { afterAll, beforeAll, describe, expect, it } from "vitest";
6 +import { randomUUID } from "node:crypto";
7 +import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database";
8 +import { syncGames } from "@spinza/database/sync-games";
9 +import { buildApp } from "../src/app";
10 +import { closeRedis } from "../src/lib/redis";
11 +import type { FastifyInstance } from "fastify";
12 +
13 +let app: FastifyInstance;
14 +let cookie = "";
15 +let userId = "";
16 +const username = `t_${randomUUID().slice(0, 8)}`;
17 +const origin = "http://localhost:8230";
18 +
19 +beforeAll(async () => {
20 + process.env.NODE_ENV = "test";
21 + app = await buildApp();
22 + await app.ready();
23 + await syncGames(db);
24 + // Force-publish one game for tests regardless of certification.
25 + await db.execute(sql`update games set lifecycle = 'published' where slug = 'neon-vault'`);
26 +});
27 +
28 +afterAll(async () => {
29 + if (userId) await db.delete(users).where(eq(users.id, userId));
30 + await app.close();
31 + await closeDb();
32 + await closeRedis();
33 +});
34 +
35 +describe("auth + wallet", () => {
36 + it("registers with 10,000 SC and a recovery code", async () => {
37 + const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } });
38 + expect(res.statusCode).toBe(200);
39 + const body = res.json();
40 + expect(body.balance).toBe(10_000);
41 + expect(body.recoveryCode).toMatch(/^SPZ-[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}$/);
42 + cookie = res.cookies.find((c) => c.name === "spinza_session")!.value;
43 + userId = body.user.id;
44 + const ledger = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId));
45 + expect(ledger).toHaveLength(1);
46 + expect(ledger[0].type).toBe("INITIAL_GRANT");
47 + });
48 +
49 + it("rejects duplicate and reserved usernames", async () => {
50 + const dup = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } });
51 + expect(dup.statusCode).toBe(409);
52 + const reserved = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username: "admin", password: "password123", confirmPassword: "password123", ageConfirmed: true } });
53 + expect(reserved.statusCode).toBe(400);
54 + });
55 +
56 + it("login errors do not reveal whether the username exists", async () => {
57 + const a = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin }, payload: { username, password: "wrong-password" } });
58 + const b = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin }, payload: { username: "nobody_here_xyz", password: "wrong-password" } });
59 + expect(a.statusCode).toBe(401);
60 + expect(b.statusCode).toBe(401);
61 + expect(a.json().message).toBe(b.json().message);
62 + });
63 +
64 + it("rejects cross-site state changes", async () => {
65 + const res = await app.inject({ method: "POST", url: "/api/auth/logout", headers: { origin: "https://evil.example", cookie: `spinza_session=${cookie}` } });
66 + expect(res.statusCode).toBe(403);
67 + });
68 +
69 + it("spins atomically, writes BET + WIN ledger rows, and is idempotent", async () => {
70 + const clientRoundId = randomUUID();
71 + const headers = { origin, cookie: `spinza_session=${cookie}` };
72 + const first = await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 100, clientRoundId } });
73 + expect(first.statusCode).toBe(200);
74 + const r1 = first.json();
75 + expect(r1.roundId).toMatch(/^spz_rnd_/);
76 + const second = await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 100, clientRoundId } });
77 + expect(second.statusCode).toBe(200);
78 + const r2 = second.json();
79 + expect(r2.roundId).toBe(r1.roundId);
80 + expect(r2.replayed).toBe(true);
81 + const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId));
82 + expect(rounds).toHaveLength(1);
83 + const bets = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId));
84 + expect(bets.filter((t) => t.type === "BET")).toHaveLength(1);
85 + expect(bets.filter((t) => t.type === "WIN")).toHaveLength(r1.win > 0 ? 1 : 0);
86 + });
87 +
88 + it("concurrent duplicate spins charge once", async () => {
89 + const clientRoundId = randomUUID();
90 + const headers = { origin, cookie: `spinza_session=${cookie}` };
91 + const results = await Promise.all(Array.from({ length: 6 }, () => app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 50, clientRoundId } })));
92 + const ids = new Set(results.map((r) => r.json().roundId));
93 + expect(ids.size).toBe(1);
94 + const bets = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId));
95 + expect(bets.filter((t) => t.type === "BET" && t.amount === -50)).toHaveLength(1);
96 + });
97 +
98 + it("keeps the ledger invariant: sum(ledger) == balance", async () => {
99 + const headers = { origin, cookie: `spinza_session=${cookie}` };
100 + for (let i = 0; i < 15; i++) await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 20, clientRoundId: randomUUID() } });
101 + const [{ total }] = await db.select({ total: sql<number>`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, userId));
102 + const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, userId) });
103 + expect(Number(total)).toBe(w!.balance);
104 + expect(w!.balance).toBeGreaterThanOrEqual(0);
105 + });
106 +
107 + it("refuses bets above balance", async () => {
108 + await db.update(wallets).set({ balance: 5 }).where(eq(wallets.userId, userId));
109 + const headers = { origin, cookie: `spinza_session=${cookie}` };
110 + const res = await app.inject({ method: "POST", url: "/api/games/neon-vault/spin", headers, payload: { bet: 10, clientRoundId: randomUUID() } });
111 + expect(res.statusCode).toBe(402);
112 + expect(res.json().error).toBe("INSUFFICIENT_CREDITS");
113 + });
114 +
115 + it("rescue credits are available at zero balance", async () => {
116 + await db.update(wallets).set({ balance: 0 }).where(eq(wallets.userId, userId));
117 + const headers = { origin, cookie: `spinza_session=${cookie}` };
118 + const status = await app.inject({ method: "GET", url: "/api/rewards/rescue", headers });
119 + expect(status.json().eligible).toBe(true);
120 + const claim = await app.inject({ method: "POST", url: "/api/rewards/rescue/claim", headers });
121 + expect(claim.statusCode).toBe(200);
122 + expect(claim.json().balance).toBe(2500);
123 + const again = await app.inject({ method: "POST", url: "/api/rewards/rescue/claim", headers });
124 + expect(again.statusCode).toBe(409);
125 + });
126 +
127 + it("daily reward claims once per day", async () => {
128 + const headers = { origin, cookie: `spinza_session=${cookie}` };
129 + const claim = await app.inject({ method: "POST", url: "/api/rewards/daily/claim", headers });
130 + expect(claim.statusCode).toBe(200);
131 + expect(claim.json().amount).toBe(1000);
132 + const again = await app.inject({ method: "POST", url: "/api/rewards/daily/claim", headers });
133 + expect(again.statusCode).toBe(409);
134 + });
135 +
136 + it("recovers the account with the recovery code and rotates it", async () => {
137 + // Register a second user to obtain a fresh code.
138 + const u2 = `r_${randomUUID().slice(0, 8)}`;
139 + const reg = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username: u2, password: "password123", confirmPassword: "password123", ageConfirmed: true } });
140 + const code = reg.json().recoveryCode as string;
141 + const bad = await app.inject({ method: "POST", url: "/api/auth/recover", headers: { origin }, payload: { username: u2, recoveryCode: "SPZ-AAAA-BBBB-CCCC", newPassword: "newpassword1" } });
142 + expect(bad.statusCode).toBe(401);
143 + const ok = await app.inject({ method: "POST", url: "/api/auth/recover", headers: { origin }, payload: { username: u2, recoveryCode: code, newPassword: "newpassword1" } });
144 + expect(ok.statusCode).toBe(200);
145 + expect(ok.json().recoveryCode).not.toBe(code);
146 + const login = await app.inject({ method: "POST", url: "/api/auth/login", headers: { origin }, payload: { username: u2, password: "newpassword1" } });
147 + expect(login.statusCode).toBe(200);
148 + await db.delete(users).where(eq(users.id, reg.json().user.id));
149 + });
150 +});
added apps/api/tsconfig.json +5 −0
@@ -0,0 +1,5 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": { "types": ["node"], "outDir": "dist" },
4 + "include": ["src/**/*.ts", "test/**/*.ts"]
5 +}
added apps/simulator/package.json +27 −0
@@ -0,0 +1,27 @@
1 +{
2 + "name": "@spinza/simulator",
3 + "version": "0.1.0",
4 + "private": true,
5 + "type": "module",
6 + "main": "./src/index.ts",
7 + "exports": {
8 + ".": "./src/index.ts"
9 + },
10 + "scripts": {
11 + "sim": "tsx src/cli.ts",
12 + "certify": "tsx src/cli.ts certify all",
13 + "typecheck": "tsc -p tsconfig.json --noEmit",
14 + "test": "vitest run --passWithNoTests"
15 + },
16 + "dependencies": {
17 + "@spinza/game-core": "workspace:*",
18 + "@spinza/games": "workspace:*",
19 + "@spinza/shared": "workspace:*",
20 + "tsx": "^4.20.0"
21 + },
22 + "devDependencies": {
23 + "@types/node": "^24.0.0",
24 + "typescript": "^5.9.3",
25 + "vitest": "^3.2.0"
26 + }
27 +}
added apps/simulator/src/cli.ts +128 −0
@@ -0,0 +1,128 @@
1 +#!/usr/bin/env tsx
2 +/**
3 + * Spinza simulator CLI.
4 + *
5 + * pnpm sim run <slug> [--spins 1000000] [--bet 100] [--threads 8] [--seed 42]
6 + * pnpm sim calibrate <slug|all> [--spins 400000] [--iterations 4]
7 + * pnpm sim certify <slug|all> [--spins 1000000]
8 + * pnpm sim validate
9 + */
10 +import fs from "node:fs";
11 +import path from "node:path";
12 +import { fileURLToPath } from "node:url";
13 +import { certify, formatCertification, validateDefinition, DEFAULT_CERTIFICATION_RULES } from "@spinza/game-core";
14 +import { RAW_GAMES, GAMES } from "@spinza/games";
15 +import { calibrate, simulateParallel } from "./index";
16 +
17 +const here = path.dirname(fileURLToPath(import.meta.url));
18 +const gamesDir = path.resolve(here, "../../../games");
19 +const calibrationPath = path.join(gamesDir, "src/calibration.json");
20 +const certDir = path.join(gamesDir, "certifications");
21 +
22 +function arg(name: string, def?: string): string | undefined {
23 + const i = process.argv.indexOf(`--${name}`);
24 + return i >= 0 ? process.argv[i + 1] : def;
25 +}
26 +function num(name: string, def: number): number {
27 + const v = arg(name);
28 + return v ? Number(v.replace(/[_,]/g, "")) : def;
29 +}
30 +
31 +function targets(sel: string | undefined): string[] {
32 + if (!sel || sel === "all") return RAW_GAMES.map((g) => g.slug);
33 + return sel.split(",");
34 +}
35 +
36 +function readCalibration(): Record<string, { payScale: number; version: string; calibratedAt: string; spins: number; observedRtp: number }> {
37 + try {
38 + return JSON.parse(fs.readFileSync(calibrationPath, "utf8"));
39 + } catch {
40 + return {};
41 + }
42 +}
43 +
44 +function progressBar(done: number, total: number): void {
45 + const pct = Math.min(1, done / total);
46 + const w = 30;
47 + const bar = "█".repeat(Math.round(pct * w)).padEnd(w, "░");
48 + process.stdout.write(`\r [${bar}] ${(pct * 100).toFixed(0)}% ${done.toLocaleString("en-US")}/${total.toLocaleString("en-US")}`);
49 + if (done >= total) process.stdout.write("\n");
50 +}
51 +
52 +async function main() {
53 + const [cmd, sel] = process.argv.slice(2);
54 + const threads = num("threads", 0) || undefined;
55 +
56 + if (cmd === "validate") {
57 + let bad = 0;
58 + for (const g of RAW_GAMES) {
59 + const issues = validateDefinition(g);
60 + const errors = issues.filter((i) => i.level === "error");
61 + console.log(`${errors.length ? "✗" : "✓"} ${g.slug}@${g.version} ${issues.map((i) => `[${i.level}] ${i.message}`).join(" | ")}`);
62 + if (errors.length) bad++;
63 + }
64 + process.exit(bad ? 1 : 0);
65 + }
66 +
67 + if (cmd === "run") {
68 + const slug = sel;
69 + if (!slug) throw new Error("slug required");
70 + const game = GAMES.find((g) => g.slug === slug);
71 + if (!game) throw new Error(`unknown game ${slug}`);
72 + const spins = num("spins", 1_000_000);
73 + const seedArg = arg("seed");
74 + console.log(`Simulating ${game.name} (${slug}@${game.version}) payScale=${game.payScale} — ${spins.toLocaleString("en-US")} spins`);
75 + const res = await simulateParallel(slug, { spins, bet: num("bet", 100), payScale: game.payScale, threads, seed: seedArg ? Number(seedArg) : undefined, onProgress: progressBar });
76 + printResult(res);
77 + return;
78 + }
79 +
80 + if (cmd === "calibrate") {
81 + const cal = readCalibration();
82 + for (const slug of targets(sel)) {
83 + const g = RAW_GAMES.find((x) => x.slug === slug)!;
84 + console.log(`\nCalibrating ${g.name} (${slug}@${g.version}) target ${(g.rtp * 100).toFixed(2)}%`);
85 + const { payScale, result } = await calibrate(slug, { spins: num("spins", 400_000), iterations: num("iterations", 4), threads, log: console.log });
86 + cal[slug] = { payScale, version: g.version, calibratedAt: new Date().toISOString(), spins: result.spins, observedRtp: Number(result.observedRtp.toFixed(5)) };
87 + fs.writeFileSync(calibrationPath, JSON.stringify(cal, null, 2) + "\n");
88 + console.log(` → payScale ${payScale} written (rtp ${(result.observedRtp * 100).toFixed(2)}%, hit ${(result.hitRate * 100).toFixed(1)}%, maxWin ${result.maxWinMultiplier.toFixed(0)}×)`);
89 + }
90 + return;
91 + }
92 +
93 + if (cmd === "certify") {
94 + fs.mkdirSync(certDir, { recursive: true });
95 + const spins = num("spins", 1_000_000);
96 + let failed = 0;
97 + for (const slug of targets(sel)) {
98 + const g = GAMES.find((x) => x.slug === slug)!;
99 + console.log(`\nCertifying ${g.name} (${slug}@${g.version}) payScale=${g.payScale} — ${spins.toLocaleString("en-US")} spins`);
100 + const res = await simulateParallel(slug, { spins, payScale: g.payScale, threads, onProgress: progressBar });
101 + const report = certify(g, res, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, spins) });
102 + fs.writeFileSync(path.join(certDir, `${slug}.json`), JSON.stringify(report, null, 2) + "\n");
103 + console.log(formatCertification(report));
104 + if (report.status === "FAIL") failed++;
105 + }
106 + console.log(`\n${failed === 0 ? "All games PASS" : `${failed} game(s) FAILED`}`);
107 + process.exit(failed ? 1 : 0);
108 + }
109 +
110 + console.log("usage: sim <validate|run|calibrate|certify> [slug|all] [--spins N] [--bet B] [--threads T] [--seed S]");
111 +}
112 +
113 +function printResult(r: Awaited<ReturnType<typeof simulateParallel>>) {
114 + console.log(` RTP ${(r.observedRtp * 100).toFixed(3)}% (target ${(r.configuredRtp * 100).toFixed(2)}%, dev ${(r.deviation * 100).toFixed(3)}%)`);
115 + console.log(` hit rate ${(r.hitRate * 100).toFixed(2)}%`);
116 + console.log(` bonus rate ${(r.bonusRate * 100).toFixed(3)}% free spins ${(r.freeSpinRate * 100).toFixed(3)}% jackpots ${(r.jackpotRate * 100).toFixed(4)}%`);
117 + console.log(` avg win ${r.averageWin.toFixed(1)} median ${r.medianWin.toFixed(0)} max ${r.maxWin.toLocaleString("en-US")} (${r.maxWinMultiplier.toFixed(0)}×) std ${r.stdDev.toFixed(2)}`);
118 + console.log(` capped ${r.cappedRounds} duration ${(r.durationMs / 1000).toFixed(1)}s`);
119 + console.log(" distribution:");
120 + for (const b of r.distribution) console.log(` ${b.label.padEnd(10)} ${(b.share * 100).toFixed(3).padStart(8)}% ${b.count.toLocaleString("en-US")}`);
121 + const feats = Object.entries(r.featureCounts).sort((a, b) => b[1] - a[1]);
122 + if (feats.length) console.log(" features: " + feats.map(([k, v]) => `${k} ${(v / r.spins * 100).toFixed(3)}%`).join(", "));
123 +}
124 +
125 +main().catch((e) => {
126 + console.error(e);
127 + process.exit(1);
128 +});
added apps/simulator/src/index.ts +76 −0
@@ -0,0 +1,76 @@
1 +import { Worker } from "node:worker_threads";
2 +import os from "node:os";
3 +import { mergeResults, simulate, type SimulationResult } from "@spinza/game-core";
4 +import { RAW_GAMES } from "@spinza/games";
5 +
6 +export interface ParallelOptions {
7 + spins: number;
8 + bet?: number;
9 + payScale?: number;
10 + threads?: number;
11 + seed?: number;
12 + onProgress?: (done: number, total: number) => void;
13 +}
14 +
15 +/** Run a simulation across worker threads. Falls back to in-process for small runs. */
16 +export async function simulateParallel(slug: string, opts: ParallelOptions): Promise<SimulationResult> {
17 + const base = RAW_GAMES.find((g) => g.slug === slug);
18 + if (!base) throw new Error(`unknown game ${slug}`);
19 + const payScale = opts.payScale ?? base.payScale;
20 + const bet = opts.bet ?? 100;
21 + const threads = Math.max(1, Math.min(opts.threads ?? Math.max(1, os.cpus().length - 2), 64));
22 + if (opts.spins < 200_000 || threads === 1) {
23 + return simulate({ ...base, payScale }, { spins: opts.spins, bet, seed: opts.seed, onProgress: (d) => opts.onProgress?.(d, opts.spins) });
24 + }
25 + const per = Math.ceil(opts.spins / threads);
26 + const progress = new Array(threads).fill(0);
27 + const workerUrl = new URL("./worker.ts", import.meta.url);
28 + const tasks = Array.from({ length: threads }, (_, i) => {
29 + const spins = i === threads - 1 ? opts.spins - per * (threads - 1) : per;
30 + return new Promise<SimulationResult>((resolve, reject) => {
31 + const w = new Worker(workerUrl, {
32 + workerData: { slug, spins, bet, payScale, seed: opts.seed !== undefined ? opts.seed + i * 7919 : undefined },
33 + execArgv: process.execArgv.some((a) => a.includes("tsx")) ? process.execArgv : ["--import", "tsx"],
34 + });
35 + w.on("message", (m: { type: string; done?: number; result?: SimulationResult }) => {
36 + if (m.type === "progress" && m.done !== undefined) {
37 + progress[i] = m.done;
38 + opts.onProgress?.(progress.reduce((a, b) => a + b, 0), opts.spins);
39 + } else if (m.type === "done" && m.result) {
40 + progress[i] = spins;
41 + resolve(m.result);
42 + }
43 + });
44 + w.on("error", reject);
45 + w.on("exit", (code) => {
46 + if (code !== 0) reject(new Error(`worker exited with code ${code}`));
47 + });
48 + });
49 + });
50 + const parts = await Promise.all(tasks);
51 + return mergeResults(parts);
52 +}
53 +
54 +/** Iteratively find the payScale that brings observed RTP to the configured target. */
55 +export async function calibrate(slug: string, opts: { spins?: number; iterations?: number; threads?: number; log?: (s: string) => void } = {}): Promise<{ payScale: number; result: SimulationResult }> {
56 + const base = RAW_GAMES.find((g) => g.slug === slug);
57 + if (!base) throw new Error(`unknown game ${slug}`);
58 + const spins = opts.spins ?? 1_000_000;
59 + const iterations = opts.iterations ?? 5;
60 + let payScale = 1;
61 + // Iteration 0 is a coarse probe (fewer spins) since the initial RTP can be off by 10×.
62 + let result = await simulateParallel(slug, { spins: Math.max(200_000, Math.floor(spins / 4)), payScale, threads: opts.threads });
63 + const fmt = (r: SimulationResult) => `rtp=${(r.observedRtp * 100).toFixed(2)}% ±${((r.stdDev / Math.sqrt(r.spins)) * 100).toFixed(2)}% hit=${(r.hitRate * 100).toFixed(1)}%`;
64 + opts.log?.(` iter 0: payScale=${payScale.toFixed(4)} ${fmt(result)}`);
65 + for (let i = 1; i <= iterations; i++) {
66 + const se = result.stdDev / Math.sqrt(result.spins);
67 + // Always run at least one full-size iteration; afterwards stop once within half a standard error.
68 + if (i > 1 && Math.abs(result.deviation) < Math.max(0.001, se / 2)) break;
69 + // Wins scale ~linearly with payScale except for jackpots/rounding/caps; damp the correction.
70 + const ratio = base.rtp / result.observedRtp;
71 + payScale = Math.min(5, Math.max(0.05, payScale * (1 + (ratio - 1) * 0.9)));
72 + result = await simulateParallel(slug, { spins, payScale, threads: opts.threads });
73 + opts.log?.(` iter ${i}: payScale=${payScale.toFixed(4)} ${fmt(result)}`);
74 + }
75 + return { payScale: Number(payScale.toFixed(4)), result };
76 +}
added apps/simulator/src/worker.ts +24 −0
@@ -0,0 +1,24 @@
1 +import { parentPort, workerData } from "node:worker_threads";
2 +import { simulate } from "@spinza/game-core";
3 +import { RAW_GAMES } from "@spinza/games";
4 +
5 +interface WorkerInput {
6 + slug: string;
7 + spins: number;
8 + bet: number;
9 + payScale: number;
10 + seed?: number;
11 +}
12 +
13 +const input = workerData as WorkerInput;
14 +const base = RAW_GAMES.find((g) => g.slug === input.slug);
15 +if (!base) throw new Error(`unknown game ${input.slug}`);
16 +const def = { ...base, payScale: input.payScale };
17 +const result = simulate(def, {
18 + spins: input.spins,
19 + bet: input.bet,
20 + seed: input.seed,
21 + progressEvery: 100_000,
22 + onProgress: (done) => parentPort?.postMessage({ type: "progress", done }),
23 +});
24 +parentPort?.postMessage({ type: "done", result });
added apps/simulator/tsconfig.json +5 −0
@@ -0,0 +1,5 @@
1 +{
2 + "extends": "../../tsconfig.base.json",
3 + "compilerOptions": { "types": ["node"], "outDir": "dist" },
4 + "include": ["src/**/*.ts"]
5 +}
added apps/web/eslint.config.mjs +10 −0
@@ -0,0 +1,10 @@
1 +import { defineConfig, globalIgnores } from "eslint/config";
2 +import nextVitals from "eslint-config-next/core-web-vitals";
3 +import nextTs from "eslint-config-next/typescript";
4 +
5 +export default defineConfig([
6 + ...nextVitals,
7 + ...nextTs,
8 + globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
9 + { rules: { "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }] } },
10 +]);
added apps/web/next.config.ts +32 −0
@@ -0,0 +1,32 @@
1 +import type { NextConfig } from "next";
2 +
3 +const apiUrl = (process.env.API_URL ?? "http://127.0.0.1:8231").replace(/\/$/, "");
4 +
5 +const nextConfig: NextConfig = {
6 + reactStrictMode: true,
7 + agentRules: false,
8 + poweredByHeader: false,
9 + transpilePackages: ["@spinza/shared", "@spinza/game-core", "@spinza/games"],
10 + async rewrites() {
11 + // The whole /api namespace is served by the Fastify service on the loopback interface.
12 + return [{ source: "/api/:path*", destination: `${apiUrl}/api/:path*` }];
13 + },
14 + async redirects() {
15 + return [{ source: "/:path*", has: [{ type: "host", value: "spinza.dev" }], destination: "https://www.spinza.dev/:path*", permanent: true }];
16 + },
17 + async headers() {
18 + return [
19 + {
20 + source: "/(.*)",
21 + headers: [
22 + { key: "X-Content-Type-Options", value: "nosniff" },
23 + { key: "X-Frame-Options", value: "DENY" },
24 + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
25 + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=()" },
26 + ],
27 + },
28 + ];
29 + },
30 +};
31 +
32 +export default nextConfig;
added apps/web/package.json +41 −0
@@ -0,0 +1,41 @@
1 +{
2 + "name": "@spinza/web",
3 + "version": "0.1.0",
4 + "private": true,
5 + "scripts": {
6 + "dev": "next dev -p 8230",
7 + "build": "next build",
8 + "start": "next start -p 8230",
9 + "lint": "eslint",
10 + "typecheck": "tsc -p tsconfig.json --noEmit",
11 + "test": "vitest run --passWithNoTests"
12 + },
13 + "dependencies": {
14 + "@spinza/game-core": "workspace:*",
15 + "@spinza/games": "workspace:*",
16 + "@spinza/shared": "workspace:*",
17 + "clsx": "^2.1.1",
18 + "framer-motion": "^12.23.0",
19 + "lucide-react": "^1.0.0",
20 + "next": "16.3.4",
21 + "pixi.js": "^8.6.0",
22 + "react": "19.2.8",
23 + "react-dom": "19.2.8",
24 + "recharts": "^3.0.0",
25 + "server-only": "^0.0.1",
26 + "tailwind-merge": "^3.3.1",
27 + "zod": "^4.0.0",
28 + "zustand": "^5.0.0"
29 + },
30 + "devDependencies": {
31 + "@tailwindcss/postcss": "^4",
32 + "@types/node": "^24.0.0",
33 + "@types/react": "^19",
34 + "@types/react-dom": "^19",
35 + "eslint": "^9",
36 + "eslint-config-next": "16.3.4",
37 + "tailwindcss": "^4",
38 + "typescript": "^5.9.3",
39 + "vitest": "^3.2.0"
40 + }
41 +}
added apps/web/postcss.config.mjs +2 −0
@@ -0,0 +1,2 @@
1 +const config = { plugins: { "@tailwindcss/postcss": {} } };
2 +export default config;
added apps/web/public/apple-icon.png +0 −0

Binary file not shown.

added apps/web/public/icon.svg +19 −0
@@ -0,0 +1,19 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
2 + <defs>
3 + <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4 + <stop offset="0" stop-color="#f3e2ad"/>
5 + <stop offset="0.5" stop-color="#c9a961"/>
6 + <stop offset="1" stop-color="#8a6d2e"/>
7 + </linearGradient>
8 + <filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
9 + <feGaussianBlur stdDeviation="2.2" result="b"/>
10 + <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
11 + </filter>
12 + </defs>
13 + <rect width="64" height="64" rx="14" fill="#07080c"/>
14 + <g filter="url(#glow)">
15 + <circle cx="32" cy="32" r="24" fill="none" stroke="url(#g)" stroke-width="5" stroke-dasharray="112 40" stroke-linecap="round" transform="rotate(-50 32 32)"/>
16 + <path d="M20 40 L44 24" stroke="url(#g)" stroke-width="6" stroke-linecap="round"/>
17 + <circle cx="44" cy="24" r="4.5" fill="#f3e2ad"/>
18 + </g>
19 +</svg>
added apps/web/public/manifest.webmanifest +17 −0
@@ -0,0 +1,17 @@
1 +{
2 + "name": "Spinza",
3 + "short_name": "Spinza",
4 + "description": "Original casino-style games played with free fictional credits. No deposits. No withdrawals. No cash value.",
5 + "start_url": "/",
6 + "scope": "/",
7 + "display": "standalone",
8 + "orientation": "any",
9 + "background_color": "#07080c",
10 + "theme_color": "#07080c",
11 + "lang": "en",
12 + "categories": ["games", "entertainment"],
13 + "icons": [
14 + { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
15 + { "src": "/apple-icon.png", "sizes": "180x180", "type": "image/png", "purpose": "any" }
16 + ]
17 +}
added apps/web/src/app/(auth)/layout.tsx +36 −0
@@ -0,0 +1,36 @@
1 +import Link from "next/link";
2 +import { SpinzaWordmark } from "@/components/brand/logo";
3 +
4 +/** Centered auth layout — logo, card, legal footer. No app navigation. */
5 +export default function AuthLayout({ children }: { children: React.ReactNode }) {
6 + return (
7 + <div className="relative flex min-h-dvh flex-col" style={{ paddingTop: "var(--safe-top)", paddingBottom: "var(--safe-bottom)" }}>
8 + <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_50%_-10%,rgba(201,169,97,0.14),transparent_55%)]" />
9 + <header className="relative flex h-16 items-center justify-center">
10 + <Link href="/" aria-label="Spinza home" className="focus-ring rounded-md">
11 + <SpinzaWordmark />
12 + </Link>
13 + </header>
14 + <main className="relative flex flex-1 flex-col items-center justify-center px-4 py-8">
15 + <div className="w-full max-w-[440px]">{children}</div>
16 + </main>
17 + <footer className="relative px-4 pb-6 text-center text-[12px] text-fg-4">
18 + <p>Virtual credits only. No deposits. No withdrawals. No cash value. 18+.</p>
19 + <nav className="mt-2 flex flex-wrap justify-center gap-x-4 gap-y-1" aria-label="Legal">
20 + <Link href="/legal/terms" className="hover:text-fg-2">
21 + Terms
22 + </Link>
23 + <Link href="/legal/privacy" className="hover:text-fg-2">
24 + Privacy
25 + </Link>
26 + <Link href="/responsible-play" className="hover:text-fg-2">
27 + Responsible play
28 + </Link>
29 + <Link href="/how-it-works" className="hover:text-fg-2">
30 + How it works
31 + </Link>
32 + </nav>
33 + </footer>
34 + </div>
35 + );
36 +}
added apps/web/src/app/(auth)/login/page.tsx +104 −0
@@ -0,0 +1,104 @@
1 +"use client";
2 +
3 +import { Suspense, useEffect, useState } from "react";
4 +import Link from "next/link";
5 +import { useRouter, useSearchParams } from "next/navigation";
6 +import { api } from "@/lib/api";
7 +import { useSession } from "@/lib/store";
8 +import { Button, Input, Skeleton } from "@/components/ui";
9 +
10 +function safeNext(raw: string | null): string {
11 + if (!raw || !raw.startsWith("/") || raw.startsWith("//")) return "/";
12 + return raw;
13 +}
14 +
15 +function LoginForm() {
16 + const router = useRouter();
17 + const params = useSearchParams();
18 + const next = safeNext(params.get("next"));
19 + const expired = params.get("reason") === "expired";
20 + const refresh = useSession((s) => s.refresh);
21 + const status = useSession((s) => s.status);
22 + const [username, setUsername] = useState("");
23 + const [password, setPassword] = useState("");
24 + const [error, setError] = useState<string | null>(null);
25 + const [busy, setBusy] = useState(false);
26 +
27 + useEffect(() => {
28 + if (status === "authenticated" && !busy) router.replace(next);
29 + }, [status, busy, next, router]);
30 +
31 + const submit = async (e: React.FormEvent) => {
32 + e.preventDefault();
33 + if (busy) return;
34 + setBusy(true);
35 + setError(null);
36 + try {
37 + await api("/api/auth/login", { json: { username: username.trim().toLowerCase(), password } });
38 + await refresh();
39 + router.push(next);
40 + router.refresh();
41 + } catch (err) {
42 + setError(err instanceof Error ? err.message : "Invalid username or password.");
43 + setBusy(false);
44 + }
45 + };
46 +
47 + return (
48 + <div className="surface rounded-xl p-6 sm:p-8">
49 + <div className="mb-6">
50 + <div className="eyebrow mb-2">Sign in</div>
51 + <h1 className="text-2xl font-semibold tracking-tight">Welcome back.</h1>
52 + <p className="mt-1.5 text-sm text-fg-3">Your credits, streak and progress are exactly where you left them.</p>
53 + </div>
54 +
55 + {expired ? (
56 + <p className="mb-4 rounded-md border border-line-2 bg-surface-2 px-4 py-3 text-sm text-fg-2" role="status">
57 + Your session expired. Sign in to continue.
58 + </p>
59 + ) : null}
60 +
61 + <form onSubmit={submit} className="space-y-4" noValidate>
62 + <Input label="Username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} placeholder="your username" required />
63 + <Input label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" placeholder="your password" required />
64 + {error ? (
65 + <p className="rounded-md border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger" role="alert">
66 + {error}
67 + </p>
68 + ) : null}
69 + <Button type="submit" size="lg" className="w-full" loading={busy} disabled={!username || !password}>
70 + Sign in
71 + </Button>
72 + </form>
73 +
74 + <div className="mt-6 flex flex-col items-center gap-2 text-sm text-fg-3">
75 + <Link href="/recover" className="tap inline-flex h-9 min-h-0 items-center font-medium text-fg-2 underline-offset-4 hover:underline">
76 + Forgot your password? Use your recovery code
77 + </Link>
78 + <p>
79 + New to Spinza?{" "}
80 + <Link href={next !== "/" ? `/register?next=${encodeURIComponent(next)}` : "/register"} className="font-medium text-accent-2 underline-offset-4 hover:underline">
81 + Create a free account
82 + </Link>
83 + </p>
84 + </div>
85 + </div>
86 + );
87 +}
88 +
89 +export default function LoginPage() {
90 + return (
91 + <Suspense
92 + fallback={
93 + <div className="surface rounded-xl p-6 sm:p-8" aria-busy>
94 + <Skeleton className="h-7 w-40" />
95 + <Skeleton className="mt-6 h-12 w-full" />
96 + <Skeleton className="mt-4 h-12 w-full" />
97 + <Skeleton className="mt-4 h-12 w-full" />
98 + </div>
99 + }
100 + >
101 + <LoginForm />
102 + </Suspense>
103 + );
104 +}
added apps/web/src/app/(auth)/recover/page.tsx +108 −0
@@ -0,0 +1,108 @@
1 +"use client";
2 +
3 +import { useState } from "react";
4 +import Link from "next/link";
5 +import { useRouter } from "next/navigation";
6 +import { RECOVERY_RE } from "@spinza/shared";
7 +import { api } from "@/lib/api";
8 +import { useSession } from "@/lib/store";
9 +import { Button, Input } from "@/components/ui";
10 +import { RecoveryCodePanel } from "@/components/shell/recovery-code";
11 +
12 +/** Normalise user input to SPZ-XXXX-XXXX-XXXX while typing. */
13 +function formatRecoveryCode(raw: string): string {
14 + const clean = raw.toUpperCase().replace(/[^A-Z0-9]/g, "");
15 + const body = clean.startsWith("SPZ") ? clean.slice(3) : clean;
16 + const groups = body.slice(0, 12).match(/.{1,4}/g) ?? [];
17 + return ["SPZ", ...groups].join("-");
18 +}
19 +
20 +interface RecoverResponse {
21 + user: { id: string; username: string };
22 + recoveryCode: string;
23 + notice: string;
24 +}
25 +
26 +export default function RecoverPage() {
27 + const router = useRouter();
28 + const refresh = useSession((s) => s.refresh);
29 + const [username, setUsername] = useState("");
30 + const [code, setCode] = useState("SPZ-");
31 + const [password, setPassword] = useState("");
32 + const [confirm, setConfirm] = useState("");
33 + const [error, setError] = useState<string | null>(null);
34 + const [busy, setBusy] = useState(false);
35 + const [result, setResult] = useState<RecoverResponse | null>(null);
36 + const [continuing, setContinuing] = useState(false);
37 +
38 + const codeOk = RECOVERY_RE.test(code);
39 + const pwError = password && password.length < 8 ? "At least 8 characters." : null;
40 + const confirmError = confirm && confirm !== password ? "Passwords do not match." : null;
41 + const canSubmit = username.trim().length >= 3 && codeOk && password.length >= 8 && confirm === password && !busy;
42 +
43 + const submit = async (e: React.FormEvent) => {
44 + e.preventDefault();
45 + if (!canSubmit) return;
46 + setBusy(true);
47 + setError(null);
48 + try {
49 + const res = await api<RecoverResponse>("/api/auth/recover", { json: { username: username.trim().toLowerCase(), recoveryCode: code, newPassword: password } });
50 + setResult(res);
51 + } catch (err) {
52 + setError(err instanceof Error ? err.message : "Invalid username or recovery code.");
53 + } finally {
54 + setBusy(false);
55 + }
56 + };
57 +
58 + const finish = async () => {
59 + setContinuing(true);
60 + await refresh();
61 + router.push("/");
62 + router.refresh();
63 + };
64 +
65 + if (result) {
66 + return (
67 + <div className="surface rounded-xl p-6 sm:p-8">
68 + <div className="mb-5 rounded-md border border-success/30 bg-success/10 px-4 py-3 text-sm text-success">Password updated. You are signed in as <span className="font-semibold">@{result.user.username}</span>. All other sessions were signed out.</div>
69 + <RecoveryCodePanel title="Your new recovery code" code={result.recoveryCode} notice={`${result.notice} The old code no longer works. Spinza does not collect your email address — if you lose your password and this code, your account cannot be recovered.`} onContinue={finish} loading={continuing} continueLabel="Continue to Spinza" />
70 + </div>
71 + );
72 + }
73 +
74 + return (
75 + <div className="surface rounded-xl p-6 sm:p-8">
76 + <div className="mb-6">
77 + <div className="eyebrow mb-2">Account recovery</div>
78 + <h1 className="text-2xl font-semibold tracking-tight">Reset your password.</h1>
79 + <p className="mt-1.5 text-sm text-fg-3">Enter the recovery code you saved when you created your account. A new code will be issued afterwards.</p>
80 + </div>
81 +
82 + <form onSubmit={submit} className="space-y-4" noValidate>
83 + <Input label="Username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} placeholder="your username" required />
84 + <Input label="Recovery code" value={code} onChange={(e) => setCode(formatRecoveryCode(e.target.value))} onFocus={(e) => e.target.select()} autoComplete="one-time-code" autoCapitalize="characters" autoCorrect="off" spellCheck={false} placeholder="SPZ-XXXX-XXXX-XXXX" className="font-mono uppercase tracking-[0.08em]" error={code.length > 4 && code.length >= 18 && !codeOk ? "Format: SPZ-XXXX-XXXX-XXXX" : null} hint="Format: SPZ-XXXX-XXXX-XXXX" />
85 + <Input label="New password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" placeholder="At least 8 characters" error={pwError} />
86 + <Input label="Confirm new password" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" placeholder="Repeat your new password" error={confirmError} />
87 + {error ? (
88 + <p className="rounded-md border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger" role="alert">
89 + {error}
90 + </p>
91 + ) : null}
92 + <Button type="submit" size="lg" className="w-full" disabled={!canSubmit} loading={busy}>
93 + Reset password
94 + </Button>
95 + </form>
96 +
97 + <div className="mt-6 space-y-3 text-center text-sm text-fg-3">
98 + <p>
99 + Remembered it?{" "}
100 + <Link href="/login" className="font-medium text-accent-2 underline-offset-4 hover:underline">
101 + Back to sign in
102 + </Link>
103 + </p>
104 + <p className="text-[12px] leading-relaxed text-fg-4">Spinza never stores an email address or phone number, so there is no reset link to send. Without the recovery code, the account cannot be recovered — you can always start fresh with a new username.</p>
105 + </div>
106 + </div>
107 + );
108 +}
added apps/web/src/app/(auth)/register/page.tsx +182 −0
@@ -0,0 +1,182 @@
1 +"use client";
2 +
3 +import { useEffect, useRef, useState } from "react";
4 +import Link from "next/link";
5 +import { useRouter } from "next/navigation";
6 +import { Check, X, Loader2 } from "lucide-react";
7 +import { STARTING_BALANCE, USERNAME_RE, formatSC } from "@spinza/shared";
8 +import { api, ApiClientError } from "@/lib/api";
9 +import { useSession } from "@/lib/store";
10 +import { Button, Input } from "@/components/ui";
11 +import { RecoveryCodePanel } from "@/components/shell/recovery-code";
12 +import { cn } from "@/lib/utils";
13 +
14 +type Availability = { state: "idle" | "checking" | "ok" | "bad"; message?: string };
15 +
16 +const REASONS: Record<string, string> = {
17 + length: "Use 3 to 24 characters.",
18 + charset: "Lowercase letters, numbers, _ and - only.",
19 + reserved: "This username is reserved.",
20 + profanity: "This username is not allowed.",
21 + taken: "That username is already taken.",
22 +};
23 +
24 +interface RegisterResponse {
25 + user: { id: string; username: string; isNew: boolean };
26 + balance: number;
27 + recoveryCode: string;
28 + notice: string;
29 +}
30 +
31 +export default function RegisterPage() {
32 + const router = useRouter();
33 + const refresh = useSession((s) => s.refresh);
34 + const status = useSession((s) => s.status);
35 + const [username, setUsername] = useState("");
36 + const [password, setPassword] = useState("");
37 + const [confirm, setConfirm] = useState("");
38 + const [age, setAge] = useState(false);
39 + /** Result of the last server-side availability check, tagged with the username it was for. */
40 + const [checked, setChecked] = useState<{ for: string; available: boolean; reason?: string; failed?: boolean } | null>(null);
41 + const [submitting, setSubmitting] = useState(false);
42 + const [formError, setFormError] = useState<string | null>(null);
43 + const [result, setResult] = useState<RegisterResponse | null>(null);
44 + const [continuing, setContinuing] = useState(false);
45 + const seq = useRef(0);
46 +
47 + // Already signed in → lobby (unless we are mid-flow showing the recovery code).
48 + useEffect(() => {
49 + if (status === "authenticated" && !result) router.replace("/");
50 + }, [status, result, router]);
51 +
52 + // Availability is derived: local rules synchronously, server check debounced.
53 + const normalized = username.trim().toLowerCase();
54 + const localError = !normalized ? null : normalized.length < 3 || normalized.length > 24 ? REASONS.length : !USERNAME_RE.test(normalized) ? REASONS.charset : null;
55 + const serverResult = checked && checked.for === normalized ? checked : null;
56 + const avail: Availability = !normalized
57 + ? { state: "idle" }
58 + : localError
59 + ? { state: "bad", message: localError }
60 + : !serverResult || serverResult.failed
61 + ? { state: serverResult?.failed ? "idle" : "checking" }
62 + : serverResult.available
63 + ? { state: "ok", message: "Available" }
64 + : { state: "bad", message: REASONS[serverResult.reason ?? ""] ?? "Not available." };
65 +
66 + useEffect(() => {
67 + if (!normalized || localError) return;
68 + const my = ++seq.current;
69 + const t = setTimeout(async () => {
70 + try {
71 + const res = await api<{ available: boolean; reason?: string }>("/api/auth/check-username", { json: { username: normalized } });
72 + if (my !== seq.current) return;
73 + setChecked({ for: normalized, available: res.available, reason: res.reason });
74 + } catch {
75 + if (my !== seq.current) return;
76 + setChecked({ for: normalized, available: false, failed: true });
77 + }
78 + }, 350);
79 + return () => clearTimeout(t);
80 + }, [normalized, localError]);
81 +
82 + const pwError = password && password.length < 8 ? "At least 8 characters." : null;
83 + const confirmError = confirm && confirm !== password ? "Passwords do not match." : null;
84 + const canSubmit = avail.state === "ok" && password.length >= 8 && confirm === password && age && !submitting;
85 +
86 + const submit = async (e: React.FormEvent) => {
87 + e.preventDefault();
88 + if (!canSubmit) return;
89 + setSubmitting(true);
90 + setFormError(null);
91 + try {
92 + const res = await api<RegisterResponse>("/api/auth/register", { json: { username: username.trim().toLowerCase(), password, confirmPassword: confirm, ageConfirmed: true } });
93 + setResult(res);
94 + } catch (err) {
95 + if (err instanceof ApiClientError) {
96 + if (err.code === "USERNAME_TAKEN") setChecked({ for: normalized, available: false, reason: "taken" });
97 + setFormError(err.message);
98 + } else setFormError("Something went wrong. Please try again.");
99 + } finally {
100 + setSubmitting(false);
101 + }
102 + };
103 +
104 + const finish = async () => {
105 + setContinuing(true);
106 + await refresh();
107 + router.push("/?welcome=1");
108 + router.refresh();
109 + };
110 +
111 + if (result) {
112 + return (
113 + <div className="surface rounded-xl p-6 sm:p-8">
114 + <div className="mb-5 rounded-md border border-success/30 bg-success/10 px-4 py-3 text-sm text-success">
115 + Account created — <span className="font-semibold">@{result.user.username}</span> starts with {formatSC(result.balance)}.
116 + </div>
117 + <RecoveryCodePanel code={result.recoveryCode} notice={result.notice} onContinue={finish} loading={continuing} continueLabel="Continue to Spinza" />
118 + </div>
119 + );
120 + }
121 +
122 + return (
123 + <div className="surface rounded-xl p-6 sm:p-8">
124 + <div className="mb-6">
125 + <div className="eyebrow mb-2">Create account</div>
126 + <h1 className="text-2xl font-semibold tracking-tight">Pick a username. That&apos;s it.</h1>
127 + <p className="mt-1.5 text-sm text-fg-3">No email, no phone number, no card. You receive {formatSC(STARTING_BALANCE)} — fictional credits with no cash value.</p>
128 + </div>
129 +
130 + <form onSubmit={submit} className="space-y-4" noValidate>
131 + <div className="relative">
132 + <Input label="Username" value={username} onChange={(e) => setUsername(e.target.value.toLowerCase())} autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} maxLength={24} placeholder="e.g. neon_spinner" error={avail.state === "bad" ? avail.message : null} hint={avail.state === "ok" ? undefined : "3–24 lowercase letters, numbers, _ or -"} aria-describedby="username-status" />
133 + <span id="username-status" className={cn("pointer-events-none absolute right-4 top-[38px] flex items-center gap-1 text-[12px] font-medium", avail.state === "ok" && "text-success", avail.state === "bad" && "text-danger", avail.state === "checking" && "text-fg-3")} aria-live="polite">
134 + {avail.state === "checking" ? <Loader2 className="h-4 w-4 animate-spin" /> : avail.state === "ok" ? (
135 + <>
136 + <Check className="h-4 w-4" /> Available
137 + </>
138 + ) : avail.state === "bad" ? (
139 + <X className="h-4 w-4" />
140 + ) : null}
141 + </span>
142 + </div>
143 + <Input label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" placeholder="At least 8 characters" error={pwError} />
144 + <Input label="Confirm password" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" placeholder="Repeat your password" error={confirmError} />
145 +
146 + <label className="flex cursor-pointer items-start gap-3 rounded-md border border-line p-3.5 tap hover:bg-surface">
147 + <input type="checkbox" checked={age} onChange={(e) => setAge(e.target.checked)} className="mt-0.5 h-5 w-5 shrink-0 accent-[#c9a961]" />
148 + <span className="text-[14px] leading-snug">I confirm that I am 18 years of age or older.</span>
149 + </label>
150 +
151 + {formError ? (
152 + <p className="rounded-md border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger" role="alert">
153 + {formError}
154 + </p>
155 + ) : null}
156 +
157 + <Button type="submit" size="lg" variant="accent" className="w-full" disabled={!canSubmit} loading={submitting}>
158 + Create account
159 + </Button>
160 +
161 + <p className="text-center text-[12px] leading-relaxed text-fg-4">
162 + By continuing you accept the{" "}
163 + <Link href="/legal/terms" className="text-fg-3 underline-offset-4 hover:underline">
164 + Terms
165 + </Link>{" "}
166 + and{" "}
167 + <Link href="/legal/privacy" className="text-fg-3 underline-offset-4 hover:underline">
168 + Privacy Policy
169 + </Link>
170 + . Spinza Credits are fictional and cannot be purchased or withdrawn.
171 + </p>
172 + </form>
173 +
174 + <p className="mt-6 text-center text-sm text-fg-3">
175 + Already have an account?{" "}
176 + <Link href="/login" className="font-medium text-accent-2 underline-offset-4 hover:underline">
177 + Sign in
178 + </Link>
179 + </p>
180 + </div>
181 + );
182 +}
added apps/web/src/app/admin/achievements/page.tsx +64 −0
@@ -0,0 +1,64 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import { useAdminQuery } from "@/components/admin/use-query";
5 +import type { AchievementsResponse } from "@/components/admin/types";
6 +import { PageHeader, RefreshButton, Panel, ErrorState, TableSkeleton, StatGrid, StatTile } from "@/components/admin/primitives";
7 +import { ProgressionTable, type ProgressionItem } from "@/components/admin/progression-table";
8 +import { int, num, sc } from "@/components/admin/format";
9 +import { cn } from "@/lib/utils";
10 +
11 +export default function AdminAchievementsPage() {
12 + const q = useAdminQuery<AchievementsResponse>("/api/admin/achievements");
13 + const d = q.data;
14 +
15 + const items = React.useMemo<ProgressionItem[]>(() => {
16 + if (!d) return [];
17 + const u = new Map(d.unlocks.map((r) => [r.achievement_key, num(r.n)]));
18 + return d.achievements.map((a) => {
19 + const unlocks = u.get(a.key) ?? 0;
20 + return {
21 + key: a.key,
22 + name: a.name,
23 + description: a.description,
24 + target: num(a.target),
25 + rewardCredits: a.rewardCredits,
26 + rewardXp: a.rewardXp,
27 + enabled: a.enabled,
28 + metric: a.metric,
29 + tag: a.category,
30 + stats: [
31 + { label: "Unlocks", value: int(unlocks) },
32 + { label: "SC paid", value: sc(unlocks * a.rewardCredits) },
33 + ],
34 + };
35 + });
36 + }, [d]);
37 +
38 + const totals = d ? { total: d.achievements.length, enabled: d.achievements.filter((a) => a.enabled).length, unlocks: d.unlocks.reduce((a, r) => a + num(r.n), 0), paid: d.achievements.reduce((a, r) => a + r.rewardCredits * num(d.unlocks.find((x) => x.achievement_key === r.key)?.n ?? 0), 0) } : null;
39 +
40 + return (
41 + <>
42 + <PageHeader title="Achievements" description="Lifetime achievements. Edit inline, then save each row; toggles apply immediately." actions={<RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />} />
43 + {totals ? (
44 + <StatGrid cols={4} className="mb-4">
45 + <StatTile label="Achievements" value={totals.total} compact />
46 + <StatTile label="Enabled" value={`${totals.enabled} / ${totals.total}`} compact tone={totals.enabled === 0 ? "danger" : "neutral"} />
47 + <StatTile label="Total unlocks" value={int(totals.unlocks)} compact tone="accent" />
48 + <StatTile label="Credits paid out" value={sc(totals.paid)} compact />
49 + </StatGrid>
50 + ) : null}
51 + <Panel padded={false} className={cn(q.stale && "opacity-70")}>
52 + {q.error && !d ? (
53 + <div className="p-4">
54 + <ErrorState error={q.error} onRetry={() => void q.refresh()} />
55 + </div>
56 + ) : !d ? (
57 + <TableSkeleton rows={10} cols={8} />
58 + ) : (
59 + <ProgressionTable items={items} endpoint="/api/admin/achievements" onSaved={() => void q.refresh()} statHeaders={["Unlocks", "SC paid"]} />
60 + )}
61 + </Panel>
62 + </>
63 + );
64 +}
added apps/web/src/app/admin/analytics/games/page.tsx +71 −0
@@ -0,0 +1,71 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import Link from "next/link";
5 +import { useAdminQuery } from "@/components/admin/use-query";
6 +import type { GameAnalyticsResponse, GameAnalyticsRow } from "@/components/admin/types";
7 +import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, ChartSkeleton, SegmentedControl, useSort, type Column } from "@/components/admin/primitives";
8 +import { Bars, ChartFrame, CHART } from "@/components/admin/charts";
9 +import { compact, int, ms, multiplier, num, pct, sc } from "@/components/admin/format";
10 +import { cn } from "@/lib/utils";
11 +
12 +const DAYS = [7, 30, 90] as const;
13 +
14 +const cols: Column<GameAnalyticsRow>[] = [
15 + { key: "game", header: "Game", sortValue: (r) => r.game_slug, render: (r) => <Link href={`/admin/games/${r.game_slug}`} className="font-medium text-fg hover:text-accent-2">{r.game_slug}</Link> },
16 + { key: "spins", header: "Spins", align: "right", sortValue: (r) => num(r.spins), render: (r) => int(r.spins) },
17 + { key: "players", header: "Players", align: "right", sortValue: (r) => num(r.players), render: (r) => int(r.players) },
18 + { key: "launches", header: "Launches", align: "right", sortValue: (r) => num(r.launches), render: (r) => int(r.launches) },
19 + { key: "wagered", header: "Wagered", align: "right", sortValue: (r) => num(r.wagered), render: (r) => sc(r.wagered) },
20 + { key: "won", header: "Won", align: "right", sortValue: (r) => num(r.won), render: (r) => sc(r.won) },
21 + { key: "rtp", header: "RTP", align: "right", sortValue: (r) => r.rtp, render: (r) => <span className={cn(r.rtp !== null && (r.rtp > 1.02 || r.rtp < 0.9) && "text-[#ffc46b]")}>{pct(r.rtp)}</span> },
22 + { key: "avg_bet", header: "Avg bet", align: "right", sortValue: (r) => num(r.avg_bet), render: (r) => sc(Math.round(num(r.avg_bet))) },
23 + { key: "bonuses", header: "Bonus rounds", align: "right", sortValue: (r) => num(r.bonuses), render: (r) => <span>{int(r.bonuses)} <span className="text-fg-4">({pct(num(r.spins) ? num(r.bonuses) / num(r.spins) : null, 1)})</span></span> },
24 + { key: "big_wins", header: "Big wins ≥20×", align: "right", sortValue: (r) => num(r.big_wins), render: (r) => int(r.big_wins) },
25 + { key: "max", header: "Max win", align: "right", sortValue: (r) => num(r.max_multiplier), render: (r) => <span>{multiplier(r.max_multiplier ?? 0)} <span className="text-fg-4">· {sc(r.max_win)}</span></span> },
26 + { key: "favorites", header: "♥", align: "right", sortValue: (r) => num(r.favorites), render: (r) => int(r.favorites) },
27 + { key: "avg_ms", header: "Avg ms", align: "right", sortValue: (r) => num(r.avg_ms), render: (r) => ms(num(r.avg_ms)) },
28 +];
29 +
30 +export default function AdminGameAnalyticsPage() {
31 + const [days, setDays] = React.useState<(typeof DAYS)[number]>(30);
32 + const q = useAdminQuery<GameAnalyticsResponse>(`/api/admin/analytics/games?days=${days}`);
33 + const d = q.data;
34 + const { sorted, sort, toggle } = useSort(d?.games, cols, { key: "spins", dir: "desc" });
35 +
36 + const chartData = React.useMemo(() => (d?.games ?? []).slice().sort((a, b) => num(b.spins) - num(a.spins)).map((g) => ({ game: g.game_slug, spins: num(g.spins), players: num(g.players) })), [d]);
37 +
38 + return (
39 + <>
40 + <PageHeader
41 + title="Game Analytics"
42 + description="Per-game engagement and payout over the selected window. Launch and favourite counts are lifetime."
43 + actions={
44 + <>
45 + <SegmentedControl value={days} onChange={setDays} items={DAYS.map((v) => ({ value: v, label: `${v}d` }))} />
46 + <RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />
47 + </>
48 + }
49 + />
50 +
51 + {q.error && !d ? (
52 + <ErrorState error={q.error} onRetry={() => void q.refresh()} />
53 + ) : (
54 + <div className={cn("space-y-4", q.stale && "opacity-70")}>
55 + <Panel title={`Spins by game · last ${days} days`}>
56 + {!d ? (
57 + <ChartSkeleton height={Math.max(160, 26 * 8)} />
58 + ) : (
59 + <ChartFrame height={Math.max(160, 26 * chartData.length + 30)}>
60 + <Bars data={chartData} x="game" layout="vertical" series={[{ key: "spins", label: "Spins", color: CHART.accent }]} yFormat={(v) => compact(v)} tooltipLabel={(l, row) => `${String(l)} · ${int(row?.players ?? 0)} players`} />
61 + </ChartFrame>
62 + )}
63 + </Panel>
64 + <Panel title="Per-game table" description="Click a column header to sort" padded={false}>
65 + {!d ? <TableSkeleton rows={10} cols={8} /> : <DataTable columns={cols} rows={sorted} rowKey={(r) => r.game_slug} dense sort={sort} onSort={toggle} empty={`No rounds recorded in the last ${days} days.`} />}
66 + </Panel>
67 + </div>
68 + )}
69 + </>
70 + );
71 +}
added apps/web/src/app/admin/analytics/players/page.tsx +103 −0
@@ -0,0 +1,103 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import { useAdminQuery } from "@/components/admin/use-query";
5 +import type { PlayerAnalyticsResponse } from "@/components/admin/types";
6 +import { PageHeader, RefreshButton, Panel, ErrorState, TileSkeleton, ChartSkeleton, StatGrid, StatTile, SegmentedControl } from "@/components/admin/primitives";
7 +import { Bars, ChartFrame, CHART, Histogram } from "@/components/admin/charts";
8 +import { compact, int, isoDay, num, pct, shortDate } from "@/components/admin/format";
9 +import { cn } from "@/lib/utils";
10 +
11 +const DAYS = [7, 30, 90] as const;
12 +
13 +function dayRange(days: number, anchor: number): string[] {
14 + return Array.from({ length: days }, (_, i) => new Date(anchor - (days - 1 - i) * 86400_000).toISOString().slice(0, 10));
15 +}
16 +
17 +export default function AdminPlayerAnalyticsPage() {
18 + const [days, setDays] = React.useState<(typeof DAYS)[number]>(30);
19 + const q = useAdminQuery<PlayerAnalyticsResponse>(`/api/admin/analytics/players?days=${days}`);
20 + const d = q.data;
21 +
22 + const anchor = q.updatedAt;
23 + const series = React.useMemo(() => {
24 + const signups = new Map((d?.signups ?? []).map((r) => [isoDay(r.day), num(r.n)]));
25 + const act = new Map((d?.activity ?? []).map((r) => [isoDay(r.day), r]));
26 + return dayRange(days, anchor).map((day) => ({ day, signups: signups.get(day) ?? 0, players: num(act.get(day)?.players ?? 0), spins: num(act.get(day)?.spins ?? 0) }));
27 + }, [d, days, anchor]);
28 +
29 + const levels = React.useMemo(() => {
30 + const m = new Map((d?.levels ?? []).map((r) => [num(r.bucket), num(r.n)]));
31 + return Array.from({ length: 10 }, (_, i) => {
32 + const b = i + 1;
33 + const lo = 1 + (b - 1) * 10;
34 + const hi = b * 10;
35 + return { bucket: b, label: `${lo}–${hi}`, n: (m.get(b) ?? 0) + (b === 10 ? (m.get(11) ?? 0) : 0) };
36 + });
37 + }, [d]);
38 +
39 + const totalSignups = series.reduce((a, r) => a + r.signups, 0);
40 + const totalSpins = series.reduce((a, r) => a + r.spins, 0);
41 + const peakPlayers = series.reduce((a, r) => Math.max(a, r.players), 0);
42 + const barSize = days > 30 ? 6 : days > 7 ? 12 : 24;
43 +
44 + return (
45 + <>
46 + <PageHeader
47 + title="Player Analytics"
48 + description="Acquisition, activity, level progression and simple recency retention."
49 + actions={
50 + <>
51 + <SegmentedControl value={days} onChange={setDays} items={DAYS.map((v) => ({ value: v, label: `${v}d` }))} />
52 + <RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />
53 + </>
54 + }
55 + />
56 +
57 + {q.error && !d ? (
58 + <ErrorState error={q.error} onRetry={() => void q.refresh()} />
59 + ) : !d ? (
60 + <div className="space-y-4">
61 + <TileSkeleton count={5} cols={5} />
62 + <Panel>
63 + <ChartSkeleton />
64 + </Panel>
65 + </div>
66 + ) : (
67 + <div className={cn("space-y-4", q.stale && "opacity-70")}>
68 + <StatGrid cols={5}>
69 + <StatTile label={`Signups · ${days}d`} value={int(totalSignups)} sub={`${(totalSignups / days).toFixed(1)} per day`} />
70 + <StatTile label={`Spins · ${days}d`} value={compact(totalSpins)} sub={int(totalSpins)} />
71 + <StatTile label="Peak daily players" value={int(peakPlayers)} />
72 + <StatTile label="Retention d1 / d7" value={<span>{pct(d.retention.total ? d.retention.d1 / d.retention.total : null, 1)} <span className="text-fg-4">/</span> {pct(d.retention.total ? d.retention.d7 / d.retention.total : null, 1)}</span>} sub={`${int(d.retention.d1)} / ${int(d.retention.d7)} of ${int(d.retention.total)} signed in`} tone="accent" />
73 + <StatTile label="Total accounts" value={int(d.retention.total)} />
74 + </StatGrid>
75 +
76 + <div className="grid gap-4 xl:grid-cols-3">
77 + <Panel title="Signups per day">
78 + <ChartFrame height={200}>
79 + <Bars data={series} x="day" series={[{ key: "signups", label: "Signups", color: CHART.accent }]} xFormat={(v) => shortDate(String(v))} barSize={barSize} yFormat={(v) => int(v)} />
80 + </ChartFrame>
81 + </Panel>
82 + <Panel title="Active players per day" description="Distinct players with at least one round">
83 + <ChartFrame height={200}>
84 + <Bars data={series} x="day" series={[{ key: "players", label: "Players", color: CHART.accent }]} xFormat={(v) => shortDate(String(v))} barSize={barSize} yFormat={(v) => int(v)} />
85 + </ChartFrame>
86 + </Panel>
87 + <Panel title="Spins per day">
88 + <ChartFrame height={200}>
89 + <Bars data={series} x="day" series={[{ key: "spins", label: "Spins", color: CHART.grey }]} xFormat={(v) => shortDate(String(v))} barSize={barSize} yFormat={(v) => compact(v)} />
90 + </ChartFrame>
91 + </Panel>
92 + </div>
93 +
94 + <Panel title="Level distribution" description="Accounts per 10-level band (all time)">
95 + <ChartFrame height={200}>
96 + <Histogram data={levels} x="label" y="n" yFormat={(v) => int(v)} />
97 + </ChartFrame>
98 + </Panel>
99 + </div>
100 + )}
101 + </>
102 + );
103 +}
added apps/web/src/app/admin/economy/page.tsx +158 −0
@@ -0,0 +1,158 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import { CheckCircle2, XCircle } from "lucide-react";
5 +import { TRANSACTION_TYPES } from "@spinza/shared";
6 +import { useAdminQuery } from "@/components/admin/use-query";
7 +import type { EconomyResponse } from "@/components/admin/types";
8 +import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TileSkeleton, TableSkeleton, StatGrid, StatTile, SegmentedControl, Pill, type Column } from "@/components/admin/primitives";
9 +import { Bars, ChartFrame, CHART, Histogram, type Series } from "@/components/admin/charts";
10 +import { compact, int, isoDay, num, pct, sc, shortDate, signedSC } from "@/components/admin/format";
11 +import { cn } from "@/lib/utils";
12 +
13 +const DAYS = [7, 30, 90] as const;
14 +
15 +/** 9 ledger types folded into 5 legible series (the table below keeps every type). */
16 +const GROUPS: { key: string; label: string; color: string; types: string[] }[] = [
17 + { key: "bet", label: "Bets", color: CHART.grey2, types: ["BET"] },
18 + { key: "win", label: "Wins", color: CHART.accent, types: ["WIN"] },
19 + { key: "rewards", label: "Rewards", color: CHART.info, types: ["DAILY_REWARD", "ACHIEVEMENT", "MISSION", "LEVEL_UP"] },
20 + { key: "grants", label: "Grants", color: CHART.success, types: ["INITIAL_GRANT", "RESCUE_CREDITS"] },
21 + { key: "admin", label: "Admin", color: CHART.danger, types: ["ADMIN_ADJUSTMENT"] },
22 +];
23 +const SERIES: Series[] = GROUPS.map((g) => ({ key: g.key, label: g.label, color: g.color }));
24 +const groupOf = (type: string) => GROUPS.find((g) => g.types.includes(type))?.key ?? "grants";
25 +
26 +const TYPE_TONE: Record<string, "muted" | "accent" | "info" | "success" | "warn" | "neutral"> = { BET: "muted", WIN: "accent", DAILY_REWARD: "info", ACHIEVEMENT: "info", MISSION: "info", LEVEL_UP: "info", INITIAL_GRANT: "success", RESCUE_CREDITS: "success", ADMIN_ADJUSTMENT: "warn" };
27 +
28 +interface TypeRow {
29 + type: string;
30 + n: number;
31 + total: number;
32 +}
33 +
34 +const typeCols: Column<TypeRow>[] = [
35 + { key: "type", header: "Type", render: (r) => <Pill tone={TYPE_TONE[r.type] ?? "neutral"}>{r.type}</Pill> },
36 + { key: "group", header: "Group", render: (r) => <span className="text-fg-3">{GROUPS.find((g) => g.key === groupOf(r.type))?.label}</span> },
37 + { key: "n", header: "Transactions", align: "right", render: (r) => int(r.n) },
38 + { key: "total", header: "Signed total", align: "right", render: (r) => <span className={cn("font-medium", r.total > 0 ? "text-success" : r.total < 0 ? "text-fg-2" : "text-fg-3")}>{signedSC(r.total)}</span> },
39 + { key: "avg", header: "Avg / txn", align: "right", render: (r) => (r.n ? signedSC(Math.round(r.total / r.n)) : "—") },
40 +];
41 +
42 +export default function AdminEconomyPage() {
43 + const [days, setDays] = React.useState<(typeof DAYS)[number]>(30);
44 + const q = useAdminQuery<EconomyResponse>(`/api/admin/economy?days=${days}`);
45 + const d = q.data;
46 +
47 + const byType = React.useMemo<TypeRow[]>(() => {
48 + const m = new Map((d?.byType ?? []).map((r) => [r.type, { type: r.type, n: num(r.n), total: num(r.total) }]));
49 + return TRANSACTION_TYPES.map((t) => m.get(t) ?? { type: t, n: 0, total: 0 });
50 + }, [d]);
51 +
52 + const anchor = q.updatedAt;
53 + const daily = React.useMemo(() => {
54 + const rows = new Map<string, Record<string, number | string>>();
55 + for (let i = days - 1; i >= 0; i--) {
56 + const day = new Date(anchor - i * 86400_000).toISOString().slice(0, 10);
57 + rows.set(day, { day, bet: 0, win: 0, rewards: 0, grants: 0, admin: 0 });
58 + }
59 + for (const r of d?.daily ?? []) {
60 + const day = isoDay(r.day);
61 + const row = rows.get(day);
62 + if (!row) continue;
63 + const k = groupOf(r.type);
64 + row[k] = num(row[k]) + num(r.total);
65 + }
66 + return Array.from(rows.values());
67 + }, [d, days, anchor]);
68 +
69 + const net = byType.reduce((a, r) => a + r.total, 0);
70 + const mismatches = d ? num(d.invariant.mismatches) : 0;
71 +
72 + const distribution = React.useMemo(() => {
73 + const m = new Map((d?.balanceDistribution ?? []).map((r) => [num(r.bucket), num(r.n)]));
74 + return Array.from({ length: 11 }, (_, i) => {
75 + const b = i + 1;
76 + const label = b === 11 ? "100K+" : `${(b - 1) * 10}K–${b * 10}K`;
77 + return { bucket: b, label, n: m.get(b) ?? 0 };
78 + });
79 + }, [d]);
80 +
81 + return (
82 + <>
83 + <PageHeader
84 + title="Economy"
85 + description="Supply of Spinza Credits, ledger flows by type and the wallet invariant. Every figure is fictional currency."
86 + actions={
87 + <>
88 + <SegmentedControl value={days} onChange={setDays} items={DAYS.map((v) => ({ value: v, label: `${v}d` }))} />
89 + <RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />
90 + </>
91 + }
92 + />
93 +
94 + {q.error && !d ? (
95 + <ErrorState error={q.error} onRetry={() => void q.refresh()} />
96 + ) : !d ? (
97 + <div className="space-y-4">
98 + <TileSkeleton count={5} cols={5} />
99 + <Panel>
100 + <TableSkeleton rows={9} cols={5} />
101 + </Panel>
102 + </div>
103 + ) : (
104 + <div className={cn("space-y-4", q.stale && "opacity-70")}>
105 + <StatGrid cols={5}>
106 + <StatTile label="Circulating supply" value={compact(d.supply.circulating)} sub={sc(d.supply.circulating)} tone="accent" />
107 + <StatTile label="Lifetime granted" value={compact(d.supply.granted)} sub={sc(d.supply.granted)} />
108 + <StatTile label="Lifetime wagered" value={compact(d.supply.wagered)} sub={sc(d.supply.wagered)} />
109 + <StatTile label="Lifetime won" value={compact(d.supply.won)} sub={`${sc(d.supply.won)} · RTP ${pct(num(d.supply.wagered) ? num(d.supply.won) / num(d.supply.wagered) : null)}`} />
110 + <StatTile
111 + label="Wallet invariant"
112 + value={
113 + <span className="inline-flex items-center gap-1.5">
114 + {mismatches === 0 ? <CheckCircle2 className="h-5 w-5" /> : <XCircle className="h-5 w-5" />}
115 + {mismatches === 0 ? "OK" : `${int(mismatches)} off`}
116 + </span>
117 + }
118 + tone={mismatches === 0 ? "success" : "danger"}
119 + sub={mismatches === 0 ? "Σ ledger = balance for every wallet" : "wallets whose ledger ≠ balance"}
120 + />
121 + </StatGrid>
122 +
123 + <div className="grid gap-4 xl:grid-cols-[1.4fr_1fr]">
124 + <Panel title={`Daily ledger flow · last ${days} days`} description="Signed SC per day; bets sit below the baseline">
125 + <ChartFrame series={SERIES} height={260}>
126 + <Bars data={daily} x="day" series={SERIES} stacked xFormat={(v) => shortDate(String(v))} yFormat={(v) => compact(v)} barSize={days > 30 ? 6 : 14} tooltipLabel={(l) => shortDate(String(l))} />
127 + </ChartFrame>
128 + </Panel>
129 + <Panel title="Balance distribution" description="Wallets per 10K SC band">
130 + <ChartFrame height={260}>
131 + <Histogram data={distribution} x="label" y="n" yFormat={(v) => int(v)} />
132 + </ChartFrame>
133 + </Panel>
134 + </div>
135 +
136 + <Panel title={`Ledger by type · last ${days} days`} description={`Net flow ${signedSC(net)} across ${int(byType.reduce((a, r) => a + r.n, 0))} transactions`} padded={false}>
137 + <DataTable
138 + columns={typeCols}
139 + rows={byType}
140 + rowKey={(r) => r.type}
141 + dense
142 + footer={
143 + <tr className="border-t border-line text-[13px] font-semibold">
144 + <td className="px-3 py-2" colSpan={2}>
145 + Net
146 + </td>
147 + <td className="px-3 py-2 text-right tabular">{int(byType.reduce((a, r) => a + r.n, 0))}</td>
148 + <td className={cn("px-3 py-2 text-right tabular", net >= 0 ? "text-success" : "text-danger")}>{signedSC(net)}</td>
149 + <td />
150 + </tr>
151 + }
152 + />
153 + </Panel>
154 + </div>
155 + )}
156 + </>
157 + );
158 +}
added apps/web/src/app/admin/games/[slug]/page.tsx +284 −0
@@ -0,0 +1,284 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import Link from "next/link";
5 +import { useParams } from "next/navigation";
6 +import { ArrowLeft, FlaskConical } from "lucide-react";
7 +import type { GameDefinition, GameSymbol } from "@spinza/game-core";
8 +import { Button } from "@/components/ui";
9 +import { useAdminQuery } from "@/components/admin/use-query";
10 +import type { GameDailyRow, GameDetailResponse, GameVersionRow, SimulationRunRow } from "@/components/admin/types";
11 +import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, TileSkeleton, KV, Pill, LifecyclePill, PassFail, StatGrid, StatTile, Mono, type Column } from "@/components/admin/primitives";
12 +import { Bars, ChartFrame, CHART, Lines } from "@/components/admin/charts";
13 +import { CertificationBlock } from "@/components/admin/simulation-results";
14 +import { dateTime, int, isoDay, multiplier, num, pct, sc, shortDate, signedPct, titleCase } from "@/components/admin/format";
15 +import { VOLATILITY_LABEL, cn } from "@/lib/utils";
16 +
17 +export default function AdminGameDetailPage() {
18 + const { slug } = useParams<{ slug: string }>();
19 + const q = useAdminQuery<GameDetailResponse>(slug ? `/api/admin/games/${slug}` : null);
20 + const d = q.data;
21 + const [certVersion, setCertVersion] = React.useState<string | null>(null);
22 +
23 + const anchor = q.updatedAt;
24 + const daily = React.useMemo(() => (d ? fillDays(d.daily, anchor) : []), [d, anchor]);
25 + const currentVersion = d?.versions.find((v) => v.version === d.game.version) ?? d?.versions[0] ?? null;
26 + const shownCert = d?.versions.find((v) => v.version === (certVersion ?? currentVersion?.version))?.certification ?? null;
27 +
28 + return (
29 + <>
30 + <PageHeader
31 + eyebrow="Game"
32 + title={d ? d.game.name : "Game detail"}
33 + description={
34 + d ? (
35 + <span className="inline-flex flex-wrap items-center gap-2">
36 + <Mono>
37 + {d.game.slug} · v{d.game.version}
38 + </Mono>
39 + <LifecyclePill lifecycle={d.game.lifecycle} />
40 + {currentVersion?.certification ? <PassFail pass={currentVersion.certification.status === "PASS"} label={`cert ${currentVersion.certification.status}`} /> : <Pill tone="warn">not certified</Pill>}
41 + </span>
42 + ) : undefined
43 + }
44 + actions={
45 + <>
46 + <Button variant="ghost" size="sm" href="/admin/games">
47 + <ArrowLeft className="h-3.5 w-3.5" /> All games
48 + </Button>
49 + <RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />
50 + <Button variant="accent" size="sm" href={`/admin/simulator?slug=${slug}`}>
51 + <FlaskConical className="h-3.5 w-3.5" /> Run simulation
52 + </Button>
53 + </>
54 + }
55 + />
56 +
57 + {q.error && !d ? (
58 + <ErrorState error={q.error} onRetry={() => void q.refresh()} title={q.error.status === 404 ? "Game not found" : undefined} />
59 + ) : !d ? (
60 + <div className="space-y-4">
61 + <TileSkeleton count={6} cols={6} />
62 + <Panel>
63 + <TableSkeleton rows={8} cols={6} />
64 + </Panel>
65 + </div>
66 + ) : (
67 + <div className={cn("space-y-4", q.stale && "opacity-70")}>
68 + {/* Stats */}
69 + <StatGrid cols={6}>
70 + <StatTile label="Launches" value={int(d.stats?.launches ?? 0)} compact />
71 + <StatTile label="Spins" value={int(d.stats?.spins ?? 0)} compact />
72 + <StatTile label="Wagered / won" value={<span>{sc(d.stats?.wagered ?? 0)} <span className="text-fg-4">/</span> {sc(d.stats?.won ?? 0)}</span>} compact />
73 + <StatTile label="Effective RTP" value={pct(effRtp(d.stats?.wagered, d.stats?.won))} sub={d.definition ? `target ${pct(d.definition.rtp)}` : undefined} tone="accent" compact />
74 + <StatTile label="Hit rate" value={d.stats?.spins ? pct(num(d.stats.wins) / num(d.stats.spins)) : "—"} sub={`${int(d.stats?.bonuses ?? 0)} bonuses · ${int(d.stats?.freeSpins ?? 0)} FS`} compact />
75 + <StatTile label="Max win" value={multiplier(d.stats?.maxMultiplier ?? 0)} sub={`${sc(d.stats?.maxWin ?? 0)} · ${int(d.stats?.bigWins ?? 0)} big wins · ${int(d.stats?.favorites ?? 0)} ♥`} compact />
76 + </StatGrid>
77 +
78 + <div className="grid gap-4 xl:grid-cols-[1fr_1.4fr]">
79 + {/* Definition */}
80 + <Panel title="Definition" description={d.definition ? d.definition.tagline : "Definition missing from the game library"}>
81 + {d.definition ? <DefinitionSummary def={d.definition} /> : <p className="text-[13px] text-danger">The slug is in the database but no definition is registered in @spinza/games.</p>}
82 + </Panel>
83 +
84 + {/* Daily charts */}
85 + <Panel title="Last 30 days" description="Daily spins, distinct players and effective RTP">
86 + <div className="grid gap-5 md:grid-cols-3">
87 + <ChartFrame title="Spins" height={150}>
88 + <Bars data={daily} x="day" series={[{ key: "spins", label: "Spins", color: CHART.accent }]} xFormat={(v) => shortDate(String(v))} barSize={8} />
89 + </ChartFrame>
90 + <ChartFrame title="Players" height={150}>
91 + <Bars data={daily} x="day" series={[{ key: "players", label: "Players", color: CHART.grey }]} xFormat={(v) => shortDate(String(v))} barSize={8} />
92 + </ChartFrame>
93 + <ChartFrame title="RTP" height={150}>
94 + <Lines data={daily.filter((r) => r.rtp !== null)} x="day" series={[{ key: "rtp", label: "RTP", color: CHART.accent }]} xFormat={(v) => shortDate(String(v))} yFormat={(v) => `${Math.round(v * 100)}%`} reference={d.definition ? { y: d.definition.rtp } : undefined} dots />
95 + </ChartFrame>
96 + </div>
97 + {daily.every((r) => r.spins === 0) ? <p className="mt-3 text-center text-[12px] text-fg-4">No rounds recorded in the last 30 days.</p> : null}
98 + </Panel>
99 + </div>
100 +
101 + {/* Symbols */}
102 + {d.definition ? (
103 + <Panel title="Symbols & pays" description={`Pays are multiples of ${d.definition.payModel.type === "ways" ? "a bet unit (total bet ÷ " + d.definition.betDivisor + ")" : "the line bet"}; scatter pays are × total bet`} padded={false}>
104 + <SymbolsTable def={d.definition} />
105 + </Panel>
106 + ) : null}
107 +
108 + <div className="grid gap-4 xl:grid-cols-[1fr_1.4fr]">
109 + {/* Versions */}
110 + <Panel title="Versions" description="Click a version to view its certification" padded={false}>
111 + <DataTable<GameVersionRow>
112 + columns={[
113 + { key: "version", header: "Version", render: (v) => <span className={cn("font-mono text-[12px]", v.version === d.game.version ? "text-fg" : "text-fg-3")}>v{v.version}{v.version === d.game.version ? <span className="ml-1.5 text-[10px] uppercase text-accent-2">current</span> : null}</span> },
114 + { key: "status", header: "Status", render: (v) => <Pill tone={v.status === "published" ? "success" : v.status === "certified" ? "accent" : "muted"}>{v.status}</Pill> },
115 + { key: "cert", header: "Certification", render: (v) => (v.certification ? <PassFail pass={v.certification.status === "PASS"} /> : <span className="text-fg-4">none</span>) },
116 + { key: "created", header: "Created", render: (v) => <span className="text-fg-3">{dateTime(v.createdAt)}</span> },
117 + { key: "hash", header: "Hash", mono: true, render: (v) => <Mono title={v.definitionHash}>{v.definitionHash.slice(0, 12)}</Mono> },
118 + ]}
119 + rows={d.versions}
120 + rowKey={(v) => v.version}
121 + dense
122 + onRowClick={(v) => setCertVersion(v.version)}
123 + rowClassName={(v) => (v.version === (certVersion ?? currentVersion?.version) ? "bg-surface-2" : undefined)}
124 + empty="No versions recorded."
125 + />
126 + </Panel>
127 +
128 + {/* Simulation runs */}
129 + <Panel title="Recent simulation runs" description="Latest 10 for this game" padded={false}>
130 + <DataTable<SimulationRunRow>
131 + columns={runCols}
132 + rows={d.simulationRuns}
133 + rowKey={(r) => r.id}
134 + dense
135 + empty="No simulations yet — run one from the simulator."
136 + />
137 + </Panel>
138 + </div>
139 +
140 + {shownCert ? (
141 + <CertificationBlock cert={shownCert} title={`Certification · v${certVersion ?? currentVersion?.version}`} />
142 + ) : (
143 + <Panel title="Certification" tone="danger">
144 + <p className="text-[13px] text-fg-2">
145 + No certification report for v{certVersion ?? d.game.version}. Publishing is blocked until a simulation with <span className="font-semibold">certify</span> enabled passes for the current version.
146 + </p>
147 + <Button variant="outline" size="sm" className="mt-3" href={`/admin/simulator?slug=${slug}&certify=1`}>
148 + <FlaskConical className="h-3.5 w-3.5" /> Certify now
149 + </Button>
150 + </Panel>
151 + )}
152 + </div>
153 + )}
154 + </>
155 + );
156 +}
157 +
158 +const runCols: Column<SimulationRunRow>[] = [
159 + { key: "created", header: "Started", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },
160 + { key: "spins", header: "Spins", align: "right", render: (r) => int(r.spins) },
161 + { key: "status", header: "Status", render: (r) => <Pill tone={r.status === "done" ? "success" : r.status === "failed" ? "danger" : "info"}>{r.status === "running" ? `${Math.round((num(r.progress) / Math.max(1, num(r.spins))) * 100)}%` : r.status}</Pill> },
162 + { key: "rtp", header: "Observed RTP", align: "right", render: (r) => (r.result?.simulation ? <span>{pct(r.result.simulation.observedRtp, 3)} <span className="text-fg-4">({signedPct(r.result.simulation.deviation, 2)})</span></span> : "—") },
163 + { key: "cert", header: "Cert", render: (r) => (r.result?.certification ? <PassFail pass={r.result.certification.status === "PASS"} /> : <span className="text-fg-4">—</span>) },
164 + { key: "open", header: "", align: "right", render: (r) => <Link href={`/admin/simulator?run=${r.id}`} className="text-[12px] text-fg-3 hover:text-fg">Open →</Link> },
165 +];
166 +
167 +function effRtp(wagered: unknown, won: unknown): number | null {
168 + const w = num(wagered);
169 + return w ? num(won) / w : null;
170 +}
171 +
172 +function fillDays(rows: GameDailyRow[], anchor: number) {
173 + const byDay = new Map(rows.map((r) => [isoDay(r.day), r]));
174 + const out: { day: string; spins: number; players: number; rtp: number | null }[] = [];
175 + for (let i = 29; i >= 0; i--) {
176 + const d = new Date(anchor - i * 86400_000).toISOString().slice(0, 10);
177 + const r = byDay.get(d);
178 + out.push({ day: d, spins: r ? num(r.spins) : 0, players: r ? num(r.players) : 0, rtp: r && num(r.wagered) ? num(r.won) / num(r.wagered) : null });
179 + }
180 + return out;
181 +}
182 +
183 +const FEATURE_KEYS: (keyof GameDefinition)[] = ["wild", "scatter", "cascades", "spinCollect", "holdRespin", "pickBonuses", "meter", "heat", "mystery", "randomFeatures", "quantum", "dynamicGrid", "jackpot"];
184 +
185 +function DefinitionSummary({ def }: { def: GameDefinition }) {
186 + const mechanics = FEATURE_KEYS.filter((k) => def[k] !== undefined);
187 + return (
188 + <div className="space-y-4">
189 + <KV
190 + cols={3}
191 + items={[
192 + { label: "Grid", value: `${def.grid.reels} × ${def.grid.rows}` },
193 + { label: "Pay model", value: def.payModel.type === "ways" ? "Ways" : `${def.payModel.lines.length} lines` },
194 + { label: "Volatility", value: `${VOLATILITY_LABEL[def.volatility] ?? def.volatility}` },
195 + { label: "Target RTP", value: pct(def.rtp) },
196 + { label: "Max multiplier", value: `${int(def.maxMultiplier)}×` },
197 + { label: "Bets", value: `${sc(def.minBet)} – ${sc(def.maxBet)}` },
198 + { label: "Pay scale", value: def.payScale.toFixed(4), mono: true },
199 + { label: "Bet divisor", value: String(def.betDivisor), mono: true },
200 + { label: "Theme", value: def.theme },
201 + ]}
202 + />
203 + <div>
204 + <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-fg-4">Features</div>
205 + <div className="flex flex-wrap gap-1.5">
206 + {def.featureNames.map((f) => (
207 + <Pill key={f} tone="accent">
208 + {f}
209 + </Pill>
210 + ))}
211 + {def.featureNames.length === 0 ? <span className="text-[12px] text-fg-4">—</span> : null}
212 + </div>
213 + </div>
214 + <div>
215 + <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-fg-4">Mechanics configured</div>
216 + <div className="flex flex-wrap gap-1.5">
217 + {mechanics.map((m) => (
218 + <Pill key={m} tone="neutral">
219 + {titleCase(String(m))}
220 + </Pill>
221 + ))}
222 + {def.freeSpinsPersistentMultiplier ? <Pill tone="neutral">Persistent FS multiplier</Pill> : null}
223 + </div>
224 + </div>
225 + <div>
226 + <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-fg-4">Presentation</div>
227 + <div className="flex flex-wrap items-center gap-2 text-[12px] text-fg-2">
228 + {(["primary", "secondary", "glow", "bg", "surface"] as const).map((k) => (
229 + <span key={k} className="inline-flex items-center gap-1">
230 + <span className="inline-block h-3 w-3 rounded-[3px] border border-line" style={{ background: def.presentation.palette[k] }} />
231 + {k}
232 + </span>
233 + ))}
234 + <span className="text-fg-4">·</span>
235 + <span>{def.presentation.particles}</span>
236 + <span className="text-fg-4">·</span>
237 + <span>{def.presentation.frame}</span>
238 + <span className="text-fg-4">·</span>
239 + <span>{def.presentation.backdrop}</span>
240 + </div>
241 + </div>
242 + {def.tags.length ? <div className="text-[12px] text-fg-4">{def.tags.map((t) => `#${t}`).join(" ")}</div> : null}
243 + </div>
244 + );
245 +}
246 +
247 +function SymbolsTable({ def }: { def: GameDefinition }) {
248 + const counts = Array.from({ length: Math.max(0, def.grid.reels - 2) }, (_, i) => i + 3);
249 + const fmtWeight = (w: number | number[] | undefined) => (w === undefined ? "—" : Array.isArray(w) ? w.join(" / ") : String(w));
250 + const cols: Column<GameSymbol>[] = [
251 + {
252 + key: "id",
253 + header: "Symbol",
254 + render: (s) => (
255 + <span className="inline-flex items-center gap-2">
256 + <span className="inline-block h-3.5 w-3.5 rounded-[4px] border border-line" style={{ background: s.style.color, boxShadow: s.style.glow ? `0 0 ${Math.round(s.style.glow * 8)}px ${s.style.accent ?? s.style.color}` : undefined }} />
257 + <span className="font-medium text-fg">{s.name}</span>
258 + <Mono className="text-fg-4">{s.id}</Mono>
259 + </span>
260 + ),
261 + },
262 + { key: "kind", header: "Kind", render: (s) => <Pill tone={s.kind === "regular" ? "muted" : s.kind === "wild" ? "accent" : s.kind === "scatter" ? "info" : "warn"}>{s.kind}</Pill> },
263 + { key: "tier", header: "Tier", render: (s) => <span className="text-fg-3">{s.tier}</span> },
264 + { key: "weight", header: "Weight", align: "right", mono: true, render: (s) => fmtWeight(s.weight) },
265 + { key: "fsweight", header: "FS weight", align: "right", mono: true, render: (s) => (s.freeSpinWeight !== undefined ? fmtWeight(s.freeSpinWeight) : <span className="text-fg-4">—</span>) },
266 + ...counts.map<Column<GameSymbol>>((c) => ({
267 + key: `pay${c}`,
268 + header: `${c}×`,
269 + align: "right",
270 + render: (s) => {
271 + const p = s.pays?.[c];
272 + const sp = s.scatterPays?.[c];
273 + if (p === undefined && sp === undefined) return <span className="text-fg-4">·</span>;
274 + return (
275 + <span className="tabular">
276 + {p !== undefined ? p : null}
277 + {sp !== undefined ? <span className="text-info">{p !== undefined ? " / " : ""}{sp}×bet</span> : null}
278 + </span>
279 + );
280 + },
281 + })),
282 + ];
283 + return <DataTable columns={cols} rows={def.symbols} rowKey={(s) => s.id} dense />;
284 +}
added apps/web/src/app/admin/games/page.tsx +199 −0
@@ -0,0 +1,199 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import Link from "next/link";
5 +import { AlertTriangle, FlaskConical } from "lucide-react";
6 +import { Button } from "@/components/ui";
7 +import { api, ApiClientError } from "@/lib/api";
8 +import { toast } from "@/lib/store";
9 +import { GAME_LIFECYCLE, type GameLifecycle } from "@spinza/shared";
10 +import { useAdminQuery } from "@/components/admin/use-query";
11 +import type { GameListRow, GamesResponse } from "@/components/admin/types";
12 +import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, LifecyclePill, Toggle, DenseSelect, Pill, StatGrid, StatTile, InlineError, type Column } from "@/components/admin/primitives";
13 +import { describeError, int, pct } from "@/components/admin/format";
14 +import { cn } from "@/lib/utils";
15 +
16 +export default function AdminGamesPage() {
17 + const q = useAdminQuery<GamesResponse>("/api/admin/games");
18 + const d = q.data;
19 + const [busyKey, setBusyKey] = React.useState<string | null>(null);
20 + const [lifecycleError, setLifecycleError] = React.useState<{ slug: string; message: string } | null>(null);
21 +
22 + const patchGame = React.useCallback(
23 + (slug: string, patch: Partial<GameListRow>) => {
24 + q.mutate((prev) => ({ games: prev.games.map((g) => (g.slug === slug ? { ...g, ...patch } : g)) }));
25 + },
26 + [q],
27 + );
28 +
29 + async function setEnabled(g: GameListRow, enabled: boolean) {
30 + setBusyKey(`${g.slug}:enabled`);
31 + patchGame(g.slug, { enabled });
32 + try {
33 + await api(`/api/admin/flags/game.${g.slug}.enabled`, { json: { enabled } });
34 + toast({ title: `${g.name} ${enabled ? "enabled" : "disabled"}`, tone: enabled ? "success" : "default" });
35 + } catch (e) {
36 + patchGame(g.slug, { enabled: !enabled });
37 + toast({ title: "Flag update failed", description: describeError(e), tone: "danger" });
38 + } finally {
39 + setBusyKey(null);
40 + }
41 + }
42 +
43 + async function setGameFlag(g: GameListRow, key: "isFeatured" | "isNew", value: boolean) {
44 + setBusyKey(`${g.slug}:${key}`);
45 + patchGame(g.slug, { [key]: value });
46 + try {
47 + await api(`/api/admin/games/${g.slug}/flags`, { json: { [key]: value } });
48 + } catch (e) {
49 + patchGame(g.slug, { [key]: !value });
50 + toast({ title: "Update failed", description: describeError(e), tone: "danger" });
51 + } finally {
52 + setBusyKey(null);
53 + }
54 + }
55 +
56 + async function setLifecycle(g: GameListRow, lifecycle: GameLifecycle) {
57 + if (lifecycle === g.lifecycle) return;
58 + setBusyKey(`${g.slug}:lifecycle`);
59 + setLifecycleError(null);
60 + const prev = g.lifecycle;
61 + patchGame(g.slug, { lifecycle });
62 + try {
63 + await api(`/api/admin/games/${g.slug}/lifecycle`, { json: { lifecycle } });
64 + toast({ title: `${g.name} → ${lifecycle}`, tone: lifecycle === "published" ? "success" : "default" });
65 + void q.refresh();
66 + } catch (e) {
67 + patchGame(g.slug, { lifecycle: prev });
68 + const msg = e instanceof ApiClientError && e.code === "NOT_CERTIFIED" ? `NOT_CERTIFIED — ${e.message}` : describeError(e);
69 + setLifecycleError({ slug: g.slug, message: msg });
70 + toast({ title: "Lifecycle change rejected", description: msg, tone: "danger" });
71 + } finally {
72 + setBusyKey(null);
73 + }
74 + }
75 +
76 + const cols: Column<GameListRow>[] = [
77 + {
78 + key: "name",
79 + header: "Game",
80 + render: (g) => (
81 + <div className="min-w-0">
82 + <Link href={`/admin/games/${g.slug}`} className="font-medium text-fg hover:text-accent-2">
83 + {g.name}
84 + </Link>
85 + <div className="font-mono text-[11px] text-fg-4">
86 + {g.slug} · v{g.version}
87 + </div>
88 + </div>
89 + ),
90 + },
91 + {
92 + key: "lifecycle",
93 + header: "Lifecycle",
94 + render: (g) => (
95 + <div className="flex items-center gap-2">
96 + <LifecyclePill lifecycle={g.lifecycle} />
97 + <DenseSelect value={g.lifecycle} disabled={busyKey === `${g.slug}:lifecycle`} onChange={(e) => void setLifecycle(g, e.target.value as GameLifecycle)} aria-label={`Lifecycle of ${g.name}`} className="w-[130px]">
98 + {GAME_LIFECYCLE.map((l) => (
99 + <option key={l} value={l}>
100 + {l}
101 + </option>
102 + ))}
103 + </DenseSelect>
104 + </div>
105 + ),
106 + },
107 + { key: "enabled", header: "Enabled", align: "center", render: (g) => <Toggle checked={g.enabled} disabled={busyKey === `${g.slug}:enabled`} onChange={(v) => void setEnabled(g, v)} label={`Enable ${g.name}`} /> },
108 + { key: "featured", header: "Featured", align: "center", render: (g) => <Toggle checked={g.isFeatured} disabled={busyKey === `${g.slug}:isFeatured`} onChange={(v) => void setGameFlag(g, "isFeatured", v)} label={`Feature ${g.name}`} /> },
109 + { key: "new", header: "New", align: "center", render: (g) => <Toggle checked={g.isNew} disabled={busyKey === `${g.slug}:isNew`} onChange={(v) => void setGameFlag(g, "isNew", v)} label={`Mark ${g.name} as new`} /> },
110 + {
111 + key: "rtp",
112 + header: "RTP target / effective",
113 + align: "right",
114 + render: (g) => {
115 + const eff = g.stats?.effectiveRtp ?? null;
116 + const dev = eff !== null && g.rtp !== null ? eff - g.rtp : null;
117 + return (
118 + <span className="tabular">
119 + {pct(g.rtp)} <span className="text-fg-4">/</span> <span className={cn(dev !== null && Math.abs(dev) > 0.03 ? "text-[#ffc46b]" : "")}>{pct(eff)}</span>
120 + </span>
121 + );
122 + },
123 + },
124 + { key: "spins", header: "Spins", align: "right", render: (g) => int(g.stats?.spins ?? 0) },
125 + {
126 + key: "validation",
127 + header: "Validation",
128 + align: "center",
129 + render: (g) => {
130 + const errs = g.validation.filter((v) => v.level === "error").length;
131 + const warns = g.validation.length - errs;
132 + if (!g.validation.length) return <Pill tone="success">ok</Pill>;
133 + return (
134 + <span className="inline-flex gap-1" title={g.validation.map((v) => `${v.level}: ${v.message}`).join("\n")}>
135 + {errs ? <Pill tone="danger">{errs} err</Pill> : null}
136 + {warns ? <Pill tone="warn">{warns} warn</Pill> : null}
137 + </span>
138 + );
139 + },
140 + },
141 + {
142 + key: "actions",
143 + header: "",
144 + align: "right",
145 + render: (g) => (
146 + <Button variant="ghost" size="sm" href={`/admin/simulator?slug=${g.slug}`} aria-label={`Simulate ${g.name}`}>
147 + <FlaskConical className="h-3.5 w-3.5" /> Simulate
148 + </Button>
149 + ),
150 + },
151 + ];
152 +
153 + const summary = d
154 + ? {
155 + total: d.games.length,
156 + published: d.games.filter((g) => g.lifecycle === "published").length,
157 + disabled: d.games.filter((g) => !g.enabled || g.lifecycle === "disabled").length,
158 + issues: d.games.filter((g) => g.validation.some((v) => v.level === "error")).length,
159 + }
160 + : null;
161 +
162 + return (
163 + <>
164 + <PageHeader title="Games" description="Library lifecycle, kill-switches and catalogue flags. Publishing requires a PASS certification for the current version." actions={<RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />} />
165 +
166 + {summary ? (
167 + <StatGrid cols={4} className="mb-4">
168 + <StatTile label="Games in library" value={summary.total} compact />
169 + <StatTile label="Published" value={summary.published} tone="success" compact />
170 + <StatTile label="Disabled / off" value={summary.disabled} tone={summary.disabled ? "danger" : "neutral"} compact />
171 + <StatTile label="Definition errors" value={summary.issues} tone={summary.issues ? "danger" : "success"} compact />
172 + </StatGrid>
173 + ) : null}
174 +
175 + {lifecycleError ? (
176 + <div className="mb-3">
177 + <InlineError>
178 + <span className="font-semibold">{lifecycleError.slug}</span>: {lifecycleError.message}
179 + </InlineError>
180 + </div>
181 + ) : null}
182 +
183 + <Panel padded={false}>
184 + {q.error && !d ? (
185 + <div className="p-4">
186 + <ErrorState error={q.error} onRetry={() => void q.refresh()} />
187 + </div>
188 + ) : !d ? (
189 + <TableSkeleton rows={10} cols={8} />
190 + ) : (
191 + <DataTable columns={cols} rows={d.games} rowKey={(g) => g.id} dense stale={q.stale} empty="No games synced. Run the database seed." rowClassName={(g) => (lifecycleError?.slug === g.slug ? "bg-danger/5" : undefined)} />
192 + )}
193 + </Panel>
194 + <p className="mt-3 flex items-center gap-1.5 text-[12px] text-fg-4">
195 + <AlertTriangle className="h-3 w-3" /> “Enabled” is the runtime kill-switch (feature flag <code className="font-mono">game.&lt;slug&gt;.enabled</code>); lifecycle controls catalogue visibility.
196 + </p>
197 + </>
198 + );
199 +}
added apps/web/src/app/admin/layout.tsx +18 −0
@@ -0,0 +1,18 @@
1 +import type { Metadata } from "next";
2 +import { AdminGate } from "@/components/admin/gate";
3 +import { AdminShell } from "@/components/admin/shell";
4 +
5 +export const metadata: Metadata = {
6 + title: { default: "Admin", template: "%s · Spinza Admin" },
7 + description: "Spinza internal administration console.",
8 + robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false } },
9 +};
10 +
11 +/** The admin console has its own shell (dense dark console) and never uses the player AppShell. */
12 +export default function AdminLayout({ children }: { children: React.ReactNode }) {
13 + return (
14 + <AdminGate>
15 + <AdminShell>{children}</AdminShell>
16 + </AdminGate>
17 + );
18 +}
added apps/web/src/app/admin/missions/page.tsx +67 −0
@@ -0,0 +1,67 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import { useAdminQuery } from "@/components/admin/use-query";
5 +import type { MissionsResponse } from "@/components/admin/types";
6 +import { PageHeader, RefreshButton, Panel, ErrorState, TableSkeleton, StatGrid, StatTile } from "@/components/admin/primitives";
7 +import { ProgressionTable, type ProgressionItem } from "@/components/admin/progression-table";
8 +import { int, num, pct } from "@/components/admin/format";
9 +import { cn } from "@/lib/utils";
10 +
11 +export default function AdminMissionsPage() {
12 + const q = useAdminQuery<MissionsResponse>("/api/admin/missions");
13 + const d = q.data;
14 +
15 + const items = React.useMemo<ProgressionItem[]>(() => {
16 + if (!d) return [];
17 + const c = new Map(d.completions.map((r) => [r.mission_key, r]));
18 + return d.missions.map((m) => {
19 + const s = c.get(m.key);
20 + const started = num(s?.started ?? 0);
21 + const completed = num(s?.completed ?? 0);
22 + return {
23 + key: m.key,
24 + name: m.name,
25 + description: m.description,
26 + target: num(m.target),
27 + rewardCredits: m.rewardCredits,
28 + rewardXp: m.rewardXp,
29 + enabled: m.enabled,
30 + metric: m.metric,
31 + tag: m.period,
32 + stats: [
33 + { label: "Started", value: int(started) },
34 + { label: "Completed", value: int(completed) },
35 + { label: "Rate", value: started ? pct(completed / started, 0) : "—" },
36 + ],
37 + };
38 + });
39 + }, [d]);
40 +
41 + const totals = d ? { daily: d.missions.filter((m) => m.period === "daily").length, weekly: d.missions.filter((m) => m.period === "weekly").length, enabled: d.missions.filter((m) => m.enabled).length, completed: d.completions.reduce((a, r) => a + num(r.completed), 0) } : null;
42 +
43 + return (
44 + <>
45 + <PageHeader title="Missions" description="Daily and weekly missions. Edit inline, then save each row; toggles apply immediately. Completion counts cover periods active in the last 7 days." actions={<RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />} />
46 + {totals ? (
47 + <StatGrid cols={4} className="mb-4">
48 + <StatTile label="Daily missions" value={totals.daily} compact />
49 + <StatTile label="Weekly missions" value={totals.weekly} compact />
50 + <StatTile label="Enabled" value={`${totals.enabled} / ${totals.daily + totals.weekly}`} compact tone={totals.enabled === 0 ? "danger" : "neutral"} />
51 + <StatTile label="Completions · 7d" value={int(totals.completed)} compact tone="accent" />
52 + </StatGrid>
53 + ) : null}
54 + <Panel padded={false} className={cn(q.stale && "opacity-70")}>
55 + {q.error && !d ? (
56 + <div className="p-4">
57 + <ErrorState error={q.error} onRetry={() => void q.refresh()} />
58 + </div>
59 + ) : !d ? (
60 + <TableSkeleton rows={8} cols={8} />
61 + ) : (
62 + <ProgressionTable items={items} endpoint="/api/admin/missions" onSaved={() => void q.refresh()} statHeaders={["Started", "Completed", "Rate"]} />
63 + )}
64 + </Panel>
65 + </>
66 + );
67 +}
added apps/web/src/app/admin/page.tsx +103 −0
@@ -0,0 +1,103 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { Activity, Database, ShieldAlert, Timer } from "lucide-react";
5 +import { useAdminQuery } from "@/components/admin/use-query";
6 +import type { DashboardResponse } from "@/components/admin/types";
7 +import { PageHeader, RefreshButton, StatTile, StatGrid, Panel, DataTable, ErrorState, TileSkeleton, type Column, Mono } from "@/components/admin/primitives";
8 +import { compact, dateTime, duration, int, ms, multiplier, pct, sc } from "@/components/admin/format";
9 +import { cn } from "@/lib/utils";
10 +
11 +type TopGame = DashboardResponse["topGames"][number];
12 +type HighWin = DashboardResponse["highestWins"][number];
13 +
14 +const topGameCols: Column<TopGame>[] = [
15 + { key: "slug", header: "Game", render: (r) => <Link href={`/admin/games/${r.slug}`} className="font-medium text-fg hover:text-accent-2">{r.slug}</Link> },
16 + { key: "spins", header: "Spins", align: "right", render: (r) => int(r.spins) },
17 + { key: "wagered", header: "Wagered", align: "right", render: (r) => sc(r.wagered) },
18 + { key: "won", header: "Won", align: "right", render: (r) => sc(r.won) },
19 + { key: "rtp", header: "RTP", align: "right", render: (r) => <span className={cn(r.rtp !== null && (r.rtp > 1.02 || r.rtp < 0.9) ? "text-[#ffc46b]" : "")}>{pct(r.rtp)}</span> },
20 +];
21 +
22 +const highWinCols: Column<HighWin>[] = [
23 + { key: "at", header: "When", render: (r) => <span className="text-fg-3">{dateTime(r.at)}</span> },
24 + { key: "username", header: "Player", render: (r) => r.username },
25 + { key: "game", header: "Game", render: (r) => <Link href={`/admin/games/${r.game}`} className="hover:text-accent-2">{r.game}</Link> },
26 + { key: "bet", header: "Bet", align: "right", render: (r) => sc(r.bet) },
27 + { key: "win", header: "Win", align: "right", render: (r) => <span className="text-credit">{sc(r.win)}</span> },
28 + { key: "multiplier", header: "×", align: "right", render: (r) => multiplier(r.multiplier) },
29 + { key: "roundId", header: "Round", mono: true, render: (r) => <Mono>{r.roundId}</Mono> },
30 +];
31 +
32 +export default function AdminDashboardPage() {
33 + const q = useAdminQuery<DashboardResponse>("/api/admin/dashboard", { refreshMs: 30_000 });
34 + const d = q.data;
35 +
36 + return (
37 + <>
38 + <PageHeader title="Dashboard" description="Live platform overview. Figures refresh every 30 seconds." actions={<RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />} />
39 +
40 + {q.error && !d ? (
41 + <ErrorState error={q.error} onRetry={() => void q.refresh()} />
42 + ) : !d ? (
43 + <div className="space-y-4">
44 + <TileSkeleton count={6} cols={6} />
45 + <TileSkeleton count={6} cols={6} />
46 + </div>
47 + ) : (
48 + <div className={cn("space-y-5", q.stale && "opacity-70")}>
49 + <StatGrid cols={6}>
50 + <StatTile label="Registered users" value={int(d.users.registered)} />
51 + <StatTile label="DAU / WAU / MAU" value={<span>{compact(d.users.dau)} <span className="text-fg-4">/</span> {compact(d.users.wau)} <span className="text-fg-4">/</span> {compact(d.users.mau)}</span>} sub="Distinct players with rounds" />
52 + <StatTile label="Active sessions" value={int(d.users.activeSessions)} sub="Seen in the last 30 min" />
53 + <StatTile label="Live players" value={int(d.users.livePlayers)} tone="accent" sub="Spinning in the last 5 min" />
54 + <StatTile label="Spins today" value={int(d.today.spins)} sub="UTC day" />
55 + <StatTile label="Avg session" value={duration(d.averageSessionSec)} sub="Sessions started this week" />
56 + </StatGrid>
57 + <StatGrid cols={6}>
58 + <StatTile label="Credits wagered today" value={compact(d.today.wagered)} sub={sc(d.today.wagered)} />
59 + <StatTile label="Credits won today" value={compact(d.today.won)} sub={sc(d.today.won)} />
60 + <StatTile label="Effective RTP today" value={pct(d.today.effectiveRtp)} tone={d.today.effectiveRtp === null ? "neutral" : d.today.effectiveRtp > 1.02 || d.today.effectiveRtp < 0.9 ? "danger" : "success"} sub="won ÷ wagered" />
61 + <StatTile label="Avg spin latency" value={ms(d.today.avgSpinMs)} icon={<Timer className="h-3.5 w-3.5" />} />
62 + <StatTile label="p95 spin latency" value={ms(d.today.p95SpinMs)} tone={d.today.p95SpinMs > 250 ? "danger" : "neutral"} />
63 + <StatTile label="High-severity events" value={int(d.health.highSeverityEvents24h)} tone={d.health.highSeverityEvents24h > 0 ? "danger" : "success"} sub="Last 24 h" icon={<ShieldAlert className="h-3.5 w-3.5" />} />
64 + </StatGrid>
65 +
66 + <div className="grid gap-4 xl:grid-cols-2">
67 + <Panel title="Top games" description="Last 7 days, by spins" padded={false} actions={<Link href="/admin/analytics/games" className="text-[12px] text-fg-3 hover:text-fg">All analytics →</Link>}>
68 + <DataTable columns={topGameCols} rows={d.topGames} rowKey={(r) => r.slug} dense empty="No rounds recorded in the last 7 days." />
69 + </Panel>
70 + <Panel title="Highest wins" description="All time, top 10 rounds" padded={false}>
71 + <DataTable columns={highWinCols} rows={d.highestWins} rowKey={(r) => r.roundId} dense empty="No rounds yet." />
72 + </Panel>
73 + </div>
74 +
75 + <Panel title="Health" description="API process and database" padded={false}>
76 + <div className="grid grid-cols-2 divide-y divide-line text-[13px] sm:grid-cols-5 sm:divide-x sm:divide-y-0">
77 + <HealthCell icon={<Database className="h-3.5 w-3.5" />} label="DB latency" value={d.health.dbLatencyMs < 0 ? "down" : ms(d.health.dbLatencyMs)} tone={d.health.dbLatencyMs < 0 ? "danger" : d.health.dbLatencyMs > 50 ? "warn" : "ok"} />
78 + <HealthCell icon={<ShieldAlert className="h-3.5 w-3.5" />} label="High-severity 24 h" value={int(d.health.highSeverityEvents24h)} tone={d.health.highSeverityEvents24h > 0 ? "danger" : "ok"} />
79 + <HealthCell icon={<Activity className="h-3.5 w-3.5" />} label="Uptime" value={duration(d.health.uptimeSec)} tone="ok" />
80 + <HealthCell label="Version" value={<Mono>v{d.health.version}</Mono>} />
81 + <HealthCell label="Node" value={<Mono>{d.health.node}</Mono>} />
82 + </div>
83 + </Panel>
84 + </div>
85 + )}
86 + </>
87 + );
88 +}
89 +
90 +function HealthCell({ icon, label, value, tone }: { icon?: React.ReactNode; label: string; value: React.ReactNode; tone?: "ok" | "warn" | "danger" }) {
91 + return (
92 + <div className="flex items-center gap-3 px-4 py-3">
93 + {tone ? <span className={cn("h-2 w-2 shrink-0 rounded-full", tone === "ok" ? "bg-success" : tone === "warn" ? "bg-[#ffb454]" : "bg-danger")} /> : <span className="h-2 w-2 shrink-0" />}
94 + <div className="min-w-0">
95 + <div className="flex items-center gap-1 text-[11px] uppercase tracking-wider text-fg-4">
96 + {icon}
97 + {label}
98 + </div>
99 + <div className="truncate font-medium">{value}</div>
100 + </div>
101 + </div>
102 + );
103 +}
added apps/web/src/app/admin/rewards/page.tsx +202 −0
@@ -0,0 +1,202 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import { Plus, RotateCcw, Save, Trash2 } from "lucide-react";
5 +import { DAILY_REWARDS, RESCUE_CREDITS_AMOUNT, RESCUE_CREDITS_COOLDOWN_HOURS, RESCUE_CREDITS_THRESHOLD } from "@spinza/shared";
6 +import { Button } from "@/components/ui";
7 +import { api } from "@/lib/api";
8 +import { toast } from "@/lib/store";
9 +import { cn } from "@/lib/utils";
10 +import { useAdminQuery } from "@/components/admin/use-query";
11 +import type { DailyRewardsSetting, RescueSetting, SettingsResponse } from "@/components/admin/types";
12 +import { PageHeader, RefreshButton, Panel, ErrorState, TableSkeleton, DenseInput, FieldLabel, InlineError, Pill } from "@/components/admin/primitives";
13 +import { describeError, int, sc } from "@/components/admin/format";
14 +
15 +function readSchedule(s: Record<string, unknown> | undefined): number[] {
16 + const v = s?.["dailyRewards"] as Partial<DailyRewardsSetting> | undefined;
17 + return Array.isArray(v?.schedule) && v.schedule.length ? v.schedule.map((n) => Math.max(0, Math.trunc(Number(n) || 0))) : [...DAILY_REWARDS];
18 +}
19 +
20 +function readRescue(s: Record<string, unknown> | undefined): RescueSetting {
21 + const v = s?.["rescue"] as Partial<RescueSetting> | undefined;
22 + return { amount: Number(v?.amount ?? RESCUE_CREDITS_AMOUNT), cooldownHours: Number(v?.cooldownHours ?? RESCUE_CREDITS_COOLDOWN_HOURS), threshold: Number(v?.threshold ?? RESCUE_CREDITS_THRESHOLD) };
23 +}
24 +
25 +export default function AdminRewardsPage() {
26 + const q = useAdminQuery<SettingsResponse>("/api/admin/settings");
27 + const d = q.data;
28 + const flags = d?.flags ?? {};
29 +
30 + return (
31 + <>
32 + <PageHeader title="Daily Rewards" description="Streak schedule and rescue credits. Changes apply to the next claim; existing streaks are preserved." actions={<RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />} />
33 + {q.error && !d ? (
34 + <ErrorState error={q.error} onRetry={() => void q.refresh()} />
35 + ) : !d ? (
36 + <div className="grid gap-4 xl:grid-cols-[1.3fr_1fr]">
37 + <Panel>
38 + <TableSkeleton rows={7} cols={3} />
39 + </Panel>
40 + <Panel>
41 + <TableSkeleton rows={3} cols={2} />
42 + </Panel>
43 + </div>
44 + ) : (
45 + <div className={cn("grid gap-4 xl:grid-cols-[1.3fr_1fr]", q.stale && "opacity-70")}>
46 + <ScheduleEditor key={`s-${q.updatedAt}`} initial={readSchedule(d.settings)} enabled={flags["dailyRewards.enabled"] ?? true} onSaved={() => void q.refresh()} />
47 + <RescueEditor key={`r-${q.updatedAt}`} initial={readRescue(d.settings)} enabled={flags["rescue.enabled"] ?? true} onSaved={() => void q.refresh()} />
48 + </div>
49 + )}
50 + </>
51 + );
52 +}
53 +
54 +function ScheduleEditor({ initial, enabled, onSaved }: { initial: number[]; enabled: boolean; onSaved: () => void }) {
55 + const [schedule, setSchedule] = React.useState<number[]>(initial);
56 + const [busy, setBusy] = React.useState(false);
57 + const [error, setError] = React.useState<string | null>(null);
58 + const dirty = JSON.stringify(schedule) !== JSON.stringify(initial);
59 + const valid = schedule.length >= 1 && schedule.length <= 30 && schedule.every((n) => Number.isInteger(n) && n >= 0);
60 + const total = schedule.reduce((a, n) => a + n, 0);
61 +
62 + async function save() {
63 + if (!valid) return;
64 + setBusy(true);
65 + setError(null);
66 + try {
67 + await api("/api/admin/settings/dailyRewards", { json: { value: { schedule } } });
68 + toast({ title: "Daily reward schedule saved", description: `${schedule.length}-day streak · ${sc(total)} per full cycle`, tone: "success" });
69 + onSaved();
70 + } catch (e) {
71 + setError(describeError(e));
72 + } finally {
73 + setBusy(false);
74 + }
75 + }
76 +
77 + return (
78 + <Panel
79 + title="Streak schedule"
80 + description="Credits granted on each consecutive day; the last day repeats until the streak breaks."
81 + actions={
82 + <>
83 + <Pill tone={enabled ? "success" : "danger"} dot>
84 + {enabled ? "flag on" : "flag off"}
85 + </Pill>
86 + <Button size="sm" variant="ghost" onClick={() => setSchedule(initial)} disabled={!dirty || busy} aria-label="Reset">
87 + <RotateCcw className="h-3.5 w-3.5" />
88 + </Button>
89 + <Button size="sm" variant="accent" onClick={() => void save()} disabled={!dirty || !valid} loading={busy}>
90 + <Save className="h-3.5 w-3.5" /> Save
91 + </Button>
92 + </>
93 + }
94 + >
95 + <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
96 + {schedule.map((v, i) => (
97 + <div key={i} className="flex items-center gap-2 rounded-sm border border-line bg-bg-1/60 px-2.5 py-2">
98 + <span className="w-12 shrink-0 text-[11px] font-semibold uppercase tracking-wider text-fg-4">Day {i + 1}</span>
99 + <DenseInput inputMode="numeric" className="h-8 flex-1 text-right font-mono" value={String(v)} onChange={(e) => setSchedule((s) => s.map((x, j) => (j === i ? Number(e.target.value.replace(/\D/g, "") || 0) : x)))} aria-label={`Day ${i + 1} reward`} />
100 + <span className="text-[11px] text-fg-4">SC</span>
101 + <button type="button" onClick={() => setSchedule((s) => s.filter((_, j) => j !== i))} disabled={schedule.length <= 1} className="grid h-7 w-7 place-items-center rounded-xs text-fg-4 hover:text-danger disabled:opacity-30" aria-label={`Remove day ${i + 1}`}>
102 + <Trash2 className="h-3.5 w-3.5" />
103 + </button>
104 + </div>
105 + ))}
106 + {schedule.length < 30 ? (
107 + <button type="button" onClick={() => setSchedule((s) => [...s, s[s.length - 1] ?? 1000])} className="flex h-[46px] items-center justify-center gap-1.5 rounded-sm border border-dashed border-line-2 text-[12px] text-fg-3 hover:border-accent/50 hover:text-fg">
108 + <Plus className="h-3.5 w-3.5" /> Add day {schedule.length + 1}
109 + </button>
110 + ) : null}
111 + </div>
112 + <div className="mt-4 flex flex-wrap items-center gap-x-6 gap-y-1 border-t border-line pt-3 text-[12px] text-fg-3">
113 + <span>
114 + <span className="text-fg-4">days</span> {schedule.length}
115 + </span>
116 + <span>
117 + <span className="text-fg-4">full cycle</span> {sc(total)}
118 + </span>
119 + <span>
120 + <span className="text-fg-4">avg / day</span> {sc(Math.round(total / Math.max(1, schedule.length)))}
121 + </span>
122 + <button type="button" className="ml-auto text-fg-3 underline-offset-2 hover:text-fg hover:underline" onClick={() => setSchedule([...DAILY_REWARDS])}>
123 + Load defaults
124 + </button>
125 + </div>
126 + <FieldLabel className="mt-4">Preview</FieldLabel>
127 + <div className="flex items-end gap-1" aria-hidden>
128 + {schedule.map((v, i) => (
129 + <div key={i} className="flex flex-1 flex-col items-center gap-1">
130 + <div className="w-full rounded-t-[3px] bg-accent/70" style={{ height: `${Math.max(4, (v / Math.max(1, ...schedule)) * 56)}px` }} title={`Day ${i + 1}: ${sc(v)}`} />
131 + <span className="text-[9px] text-fg-4">{i + 1}</span>
132 + </div>
133 + ))}
134 + </div>
135 + {!valid ? <div className="mt-3"><InlineError>Schedule must have 1–30 non-negative integer amounts.</InlineError></div> : null}
136 + {error ? <div className="mt-3"><InlineError>{error}</InlineError></div> : null}
137 + </Panel>
138 + );
139 +}
140 +
141 +function RescueEditor({ initial, enabled, onSaved }: { initial: RescueSetting; enabled: boolean; onSaved: () => void }) {
142 + const [v, setV] = React.useState<RescueSetting>(initial);
143 + const [busy, setBusy] = React.useState(false);
144 + const [error, setError] = React.useState<string | null>(null);
145 + const dirty = JSON.stringify(v) !== JSON.stringify(initial);
146 + const valid = Number.isInteger(v.amount) && v.amount >= 0 && Number.isFinite(v.cooldownHours) && v.cooldownHours >= 0 && Number.isInteger(v.threshold) && v.threshold >= 0;
147 +
148 + async function save() {
149 + if (!valid) return;
150 + setBusy(true);
151 + setError(null);
152 + try {
153 + await api("/api/admin/settings/rescue", { json: { value: v } });
154 + toast({ title: "Rescue credits saved", description: `${sc(v.amount)} every ${v.cooldownHours} h under ${sc(v.threshold)}`, tone: "success" });
155 + onSaved();
156 + } catch (e) {
157 + setError(describeError(e));
158 + } finally {
159 + setBusy(false);
160 + }
161 + }
162 +
163 + return (
164 + <Panel
165 + title="Rescue credits"
166 + description="Granted to players who run out of credits."
167 + actions={
168 + <>
169 + <Pill tone={enabled ? "success" : "danger"} dot>
170 + {enabled ? "flag on" : "flag off"}
171 + </Pill>
172 + <Button size="sm" variant="ghost" onClick={() => setV(initial)} disabled={!dirty || busy} aria-label="Reset">
173 + <RotateCcw className="h-3.5 w-3.5" />
174 + </Button>
175 + <Button size="sm" variant="accent" onClick={() => void save()} disabled={!dirty || !valid} loading={busy}>
176 + <Save className="h-3.5 w-3.5" /> Save
177 + </Button>
178 + </>
179 + }
180 + >
181 + <div className="space-y-3">
182 + <div>
183 + <FieldLabel hint={`default ${int(RESCUE_CREDITS_AMOUNT)}`}>Amount (SC)</FieldLabel>
184 + <DenseInput inputMode="numeric" className="font-mono" value={String(v.amount)} onChange={(e) => setV({ ...v, amount: Number(e.target.value.replace(/\D/g, "") || 0) })} />
185 + </div>
186 + <div>
187 + <FieldLabel hint={`default ${RESCUE_CREDITS_COOLDOWN_HOURS} h`}>Cooldown (hours)</FieldLabel>
188 + <DenseInput inputMode="decimal" className="font-mono" value={String(v.cooldownHours)} onChange={(e) => setV({ ...v, cooldownHours: Number(e.target.value.replace(/[^\d.]/g, "") || 0) })} />
189 + </div>
190 + <div>
191 + <FieldLabel hint="balance at or below which rescue is offered">Threshold (SC)</FieldLabel>
192 + <DenseInput inputMode="numeric" className="font-mono" value={String(v.threshold)} onChange={(e) => setV({ ...v, threshold: Number(e.target.value.replace(/\D/g, "") || 0) })} />
193 + </div>
194 + </div>
195 + <p className="mt-4 rounded-sm border border-line bg-bg-1/60 px-3 py-2 text-[12px] text-fg-3">
196 + A player with <span className="text-fg">≤ {sc(v.threshold)}</span> can claim <span className="text-credit">{sc(v.amount)}</span> at most once every <span className="text-fg">{v.cooldownHours} h</span>.
197 + </p>
198 + {!valid ? <div className="mt-3"><InlineError>Amount and threshold must be non-negative integers; cooldown a non-negative number.</InlineError></div> : null}
199 + {error ? <div className="mt-3"><InlineError>{error}</InlineError></div> : null}
200 + </Panel>
201 + );
202 +}
added apps/web/src/app/admin/security/page.tsx +99 −0
@@ -0,0 +1,99 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import Link from "next/link";
5 +import { X } from "lucide-react";
6 +import { useAdminQuery } from "@/components/admin/use-query";
7 +import type { SecurityEventRow, SecurityEventsResponse } from "@/components/admin/types";
8 +import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, DenseInput, DenseSelect, SeverityPill, JsonToggle, Mono, type Column } from "@/components/admin/primitives";
9 +import { dateTime, int, num } from "@/components/admin/format";
10 +import { cn } from "@/lib/utils";
11 +
12 +const SEVERITIES = ["", "info", "warn", "high"] as const;
13 +const LIMITS = [50, 100, 200] as const;
14 +
15 +const cols: Column<SecurityEventRow>[] = [
16 + { key: "at", header: "Time", render: (e) => <span className="whitespace-nowrap text-fg-3">{dateTime(e.createdAt)}</span> },
17 + { key: "type", header: "Type", mono: true, render: (e) => <Mono className="text-fg">{e.type}</Mono> },
18 + { key: "sev", header: "Severity", render: (e) => <SeverityPill severity={e.severity} /> },
19 + { key: "user", header: "User", render: (e) => (e.userId ? <Link href={`/admin/users/${e.userId}`} className="hover:text-accent-2">{e.username ?? e.userId.slice(0, 8)}</Link> : e.adminId ? <span className="text-fg-3">admin</span> : <span className="text-fg-4">—</span>) },
20 + { key: "ip", header: "IP", mono: true, render: (e) => <Mono>{e.ip ?? "—"}</Mono> },
21 + { key: "ua", header: "Agent", render: (e) => <span className="block max-w-[220px] truncate text-fg-3" title={e.userAgent ?? ""}>{e.userAgent ?? "—"}</span> },
22 + { key: "meta", header: "Meta", render: (e) => <JsonToggle value={e.meta} /> },
23 +];
24 +
25 +export default function AdminSecurityPage() {
26 + const [type, setType] = React.useState("");
27 + const [severity, setSeverity] = React.useState<(typeof SEVERITIES)[number]>("");
28 + const [limit, setLimit] = React.useState<(typeof LIMITS)[number]>(100);
29 +
30 + const params = new URLSearchParams({ limit: String(limit) });
31 + if (type) params.set("type", type);
32 + if (severity) params.set("severity", severity);
33 + const q = useAdminQuery<SecurityEventsResponse>(`/api/admin/security/events?${params.toString()}`, { refreshMs: 30_000 });
34 + const d = q.data;
35 +
36 + const high = d?.events.filter((e) => e.severity === "high").length ?? 0;
37 + const total24h = d?.summary24h.reduce((a, s) => a + num(s.n), 0) ?? 0;
38 +
39 + return (
40 + <>
41 + <PageHeader title="Security" description="Authentication, admin actions, CSRF rejections and rate-limit hits. Newest first." actions={<RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />} />
42 +
43 + {d ? (
44 + <div className="mb-3 flex flex-wrap items-center gap-1.5">
45 + <span className="mr-1 text-[11px] font-semibold uppercase tracking-wider text-fg-4">Last 24 h · {int(total24h)}</span>
46 + {d.summary24h.length === 0 ? <span className="text-[12px] text-fg-4">quiet — no events</span> : null}
47 + {d.summary24h.map((s) => (
48 + <button key={s.type} type="button" onClick={() => setType((t) => (t === s.type ? "" : s.type))} className={cn("inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 font-mono text-[11px] transition-colors focus-ring", type === s.type ? "border-accent/50 bg-accent-soft text-accent-2" : "border-line bg-surface text-fg-2 hover:bg-surface-2")}>
49 + {s.type} <span className="text-fg-4">{int(s.n)}</span>
50 + </button>
51 + ))}
52 + </div>
53 + ) : null}
54 +
55 + <div className="mb-3 flex flex-wrap items-center gap-2">
56 + <div className="relative w-full sm:w-72">
57 + <DenseInput placeholder="Filter by exact type (e.g. login.failed)" value={type} onChange={(e) => setType(e.target.value.trim())} aria-label="Event type" list="security-types" className={type ? "pr-8" : undefined} />
58 + {type ? (
59 + <button type="button" onClick={() => setType("")} className="absolute right-2 top-1/2 -translate-y-1/2 text-fg-4 hover:text-fg" aria-label="Clear type filter">
60 + <X className="h-3.5 w-3.5" />
61 + </button>
62 + ) : null}
63 + <datalist id="security-types">{d?.summary24h.map((s) => <option key={s.type} value={s.type} />)}</datalist>
64 + </div>
65 + <DenseSelect value={severity} onChange={(e) => setSeverity(e.target.value as (typeof SEVERITIES)[number])} aria-label="Severity">
66 + {SEVERITIES.map((s) => (
67 + <option key={s} value={s}>
68 + {s ? s : "All severities"}
69 + </option>
70 + ))}
71 + </DenseSelect>
72 + <DenseSelect value={limit} onChange={(e) => setLimit(Number(e.target.value) as (typeof LIMITS)[number])} aria-label="Limit">
73 + {LIMITS.map((l) => (
74 + <option key={l} value={l}>
75 + Last {l}
76 + </option>
77 + ))}
78 + </DenseSelect>
79 + {d ? (
80 + <span className="ml-auto text-[12px] text-fg-3 tabular">
81 + {int(d.events.length)} shown · <span className={high ? "text-danger" : ""}>{int(high)} high</span>
82 + </span>
83 + ) : null}
84 + </div>
85 +
86 + <Panel padded={false}>
87 + {q.error && !d ? (
88 + <div className="p-4">
89 + <ErrorState error={q.error} onRetry={() => void q.refresh()} />
90 + </div>
91 + ) : !d ? (
92 + <TableSkeleton rows={10} cols={7} />
93 + ) : (
94 + <DataTable columns={cols} rows={d.events} rowKey={(e) => e.id} dense stale={q.stale} empty={type || severity ? "No events match these filters." : "No security events recorded."} rowClassName={(e) => (e.severity === "high" ? "bg-danger/5" : undefined)} />
95 + )}
96 + </Panel>
97 + </>
98 + );
99 +}
added apps/web/src/app/admin/settings/page.tsx +213 −0
@@ -0,0 +1,213 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import { AlertTriangle, Save, Wrench } from "lucide-react";
5 +import { Button } from "@/components/ui";
6 +import { api } from "@/lib/api";
7 +import { toast } from "@/lib/store";
8 +import { cn } from "@/lib/utils";
9 +import { useAdmin } from "@/components/admin/store";
10 +import { useAdminQuery } from "@/components/admin/use-query";
11 +import type { MaintenanceSetting, ProfanitySetting, SettingsResponse } from "@/components/admin/types";
12 +import { PageHeader, RefreshButton, Panel, ErrorState, TableSkeleton, Toggle, DenseTextarea, FieldLabel, InlineError, ConfirmDialog, Pill } from "@/components/admin/primitives";
13 +import { describeError, int } from "@/components/admin/format";
14 +
15 +const FLAG_LABELS: Record<string, string> = {
16 + "registration.enabled": "New account creation",
17 + "dailyRewards.enabled": "Daily reward claims",
18 + "leaderboards.enabled": "Public leaderboards",
19 + "missions.enabled": "Daily & weekly missions",
20 + "achievements.enabled": "Achievements",
21 + "rescue.enabled": "Rescue credits at zero balance",
22 +};
23 +
24 +export default function AdminSettingsPage() {
25 + const q = useAdminQuery<SettingsResponse>("/api/admin/settings");
26 + const d = q.data;
27 + const [busyFlag, setBusyFlag] = React.useState<string | null>(null);
28 +
29 + async function setFlag(key: string, enabled: boolean) {
30 + setBusyFlag(key);
31 + q.mutate((prev) => ({ ...prev, flags: { ...prev.flags, [key]: enabled } }));
32 + try {
33 + await api(`/api/admin/flags/${encodeURIComponent(key)}`, { json: { enabled } });
34 + toast({ title: `${FLAG_LABELS[key] ?? key} ${enabled ? "enabled" : "disabled"}`, tone: enabled ? "success" : "default" });
35 + } catch (e) {
36 + q.mutate((prev) => ({ ...prev, flags: { ...prev.flags, [key]: !enabled } }));
37 + toast({ title: "Flag update failed", description: describeError(e), tone: "danger" });
38 + } finally {
39 + setBusyFlag(null);
40 + }
41 + }
42 +
43 + const platformFlags = Object.entries(d?.flags ?? {}).filter(([k]) => !k.startsWith("game.")).sort(([a], [b]) => a.localeCompare(b));
44 + const gameFlags = Object.entries(d?.flags ?? {}).filter(([k]) => k.startsWith("game.")).sort(([a], [b]) => a.localeCompare(b));
45 + const gamesOff = gameFlags.filter(([, v]) => !v).length;
46 +
47 + return (
48 + <>
49 + <PageHeader title="Settings" description="Feature flags, maintenance mode and the username profanity list." actions={<RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />} />
50 + {q.error && !d ? (
51 + <ErrorState error={q.error} onRetry={() => void q.refresh()} />
52 + ) : !d ? (
53 + <div className="grid gap-4 xl:grid-cols-2">
54 + <Panel>
55 + <TableSkeleton rows={6} cols={2} />
56 + </Panel>
57 + <Panel>
58 + <TableSkeleton rows={4} cols={2} />
59 + </Panel>
60 + </div>
61 + ) : (
62 + <div className={cn("grid gap-4 xl:grid-cols-2", q.stale && "opacity-70")}>
63 + <div className="space-y-4">
64 + <Panel title="Platform feature flags" description="Runtime switches read by the API within 10 s" padded={false}>
65 + <ul className="divide-y divide-line/60">
66 + {platformFlags.map(([key, enabled]) => (
67 + <li key={key} className="flex items-center justify-between gap-4 px-4 py-2.5">
68 + <div className="min-w-0">
69 + <div className="text-[13px] font-medium text-fg">{FLAG_LABELS[key] ?? key}</div>
70 + <div className="font-mono text-[11px] text-fg-4">{key}</div>
71 + </div>
72 + <Toggle checked={enabled} disabled={busyFlag === key} onChange={(v) => void setFlag(key, v)} label={FLAG_LABELS[key] ?? key} />
73 + </li>
74 + ))}
75 + {platformFlags.length === 0 ? <li className="px-4 py-6 text-center text-[13px] text-fg-4">No flags defined. Run the database seed.</li> : null}
76 + </ul>
77 + </Panel>
78 + <Panel title="Game kill-switches" description={`${gameFlags.length} games · ${gamesOff ? `${gamesOff} disabled` : "all enabled"}`} padded={false}>
79 + <ul className="grid divide-y divide-line/60 sm:grid-cols-2 sm:divide-y-0">
80 + {gameFlags.map(([key, enabled]) => (
81 + <li key={key} className="flex items-center justify-between gap-3 border-b border-line/60 px-4 py-2 sm:[&:nth-last-child(-n+2)]:border-b-0">
82 + <span className="truncate font-mono text-[12px] text-fg-2">{key.replace(/^game\./, "").replace(/\.enabled$/, "")}</span>
83 + <Toggle checked={enabled} disabled={busyFlag === key} onChange={(v) => void setFlag(key, v)} label={key} />
84 + </li>
85 + ))}
86 + </ul>
87 + </Panel>
88 + </div>
89 + <div className="space-y-4">
90 + <MaintenanceCard key={`m-${q.updatedAt}`} initial={(d.settings["maintenance"] as MaintenanceSetting | undefined) ?? { enabled: false, message: "Spinza is getting an upgrade. Your credits and progress are safe." }} onSaved={() => void q.refresh()} />
91 + <ProfanityCard key={`p-${q.updatedAt}`} initial={((d.settings["profanity"] as ProfanitySetting | undefined)?.words ?? []).filter((w) => typeof w === "string")} onSaved={() => void q.refresh()} />
92 + </div>
93 + </div>
94 + )}
95 + </>
96 + );
97 +}
98 +
99 +function MaintenanceCard({ initial, onSaved }: { initial: MaintenanceSetting; onSaved: () => void }) {
100 + const setMaintenance = useAdmin((s) => s.setMaintenance);
101 + const [enabled, setEnabled] = React.useState(!!initial.enabled);
102 + const [message, setMessage] = React.useState(initial.message ?? "");
103 + const [confirm, setConfirm] = React.useState(false);
104 + const [busy, setBusy] = React.useState(false);
105 + const [error, setError] = React.useState<string | null>(null);
106 + const dirty = enabled !== !!initial.enabled || message !== (initial.message ?? "");
107 + const valid = message.trim().length >= 3;
108 +
109 + async function save() {
110 + setBusy(true);
111 + setError(null);
112 + try {
113 + const value = { enabled, message: message.trim() };
114 + await api("/api/admin/settings/maintenance", { json: { value } });
115 + setMaintenance(value);
116 + toast({ title: enabled ? "Maintenance mode ON" : "Maintenance mode OFF", description: enabled ? "Players now see the maintenance message." : "The platform is live again.", tone: enabled ? "danger" : "success" });
117 + setConfirm(false);
118 + onSaved();
119 + } catch (e) {
120 + setError(describeError(e));
121 + setConfirm(false);
122 + } finally {
123 + setBusy(false);
124 + }
125 + }
126 +
127 + return (
128 + <Panel
129 + title={
130 + <span className="inline-flex items-center gap-2">
131 + <Wrench className="h-3.5 w-3.5 text-fg-3" /> Maintenance mode
132 + <Pill tone={initial.enabled ? "danger" : "success"} dot>
133 + {initial.enabled ? "active" : "off"}
134 + </Pill>
135 + </span>
136 + }
137 + description="When enabled every player request returns 503 with this message. Admin routes stay reachable."
138 + tone={initial.enabled ? "danger" : undefined}
139 + >
140 + <div className="flex items-center justify-between gap-4 rounded-sm border border-line bg-bg-1/60 px-3 py-2.5">
141 + <div>
142 + <div className="text-[13px] font-medium">Maintenance enabled</div>
143 + <div className="text-[12px] text-fg-3">Blocks gameplay, sign-in and registration.</div>
144 + </div>
145 + <Toggle checked={enabled} onChange={setEnabled} label="Maintenance enabled" size="md" />
146 + </div>
147 + <div className="mt-3">
148 + <FieldLabel hint={`${message.trim().length} chars · min 3`}>Player-facing message</FieldLabel>
149 + <DenseTextarea rows={3} value={message} onChange={(e) => setMessage(e.target.value)} />
150 + </div>
151 + {error ? <div className="mt-3"><InlineError>{error}</InlineError></div> : null}
152 + <div className="mt-4 flex justify-end gap-2">
153 + <Button size="sm" variant="ghost" onClick={() => { setEnabled(!!initial.enabled); setMessage(initial.message ?? ""); }} disabled={!dirty || busy}>
154 + Reset
155 + </Button>
156 + <Button size="sm" variant={enabled ? "danger" : "primary"} onClick={() => setConfirm(true)} disabled={!dirty || !valid}>
157 + <Save className="h-3.5 w-3.5" /> {enabled && !initial.enabled ? "Enable maintenance" : !enabled && initial.enabled ? "Disable maintenance" : "Save"}
158 + </Button>
159 + </div>
160 + <ConfirmDialog open={confirm} onClose={() => setConfirm(false)} onConfirm={() => void save()} title={enabled ? "Enable maintenance mode?" : "Update maintenance settings?"} confirmLabel={enabled ? "Yes, take Spinza offline" : "Save"} danger={enabled} loading={busy}
161 + description={
162 + enabled ? (
163 + <span className="inline-flex items-start gap-2">
164 + <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-danger" />
165 + <span>All player traffic will receive a 503 immediately. Active spins finish, no credits are lost. This is logged as a high-severity event.</span>
166 + </span>
167 + ) : (
168 + "Players will see the platform again as soon as the API cache refreshes (≤ 10 s)."
169 + )
170 + }
171 + >
172 + <blockquote className="rounded-sm border border-line bg-bg-1 px-3 py-2 text-[13px] italic text-fg-2">“{message.trim()}”</blockquote>
173 + </ConfirmDialog>
174 + </Panel>
175 + );
176 +}
177 +
178 +function ProfanityCard({ initial, onSaved }: { initial: string[]; onSaved: () => void }) {
179 + const [text, setText] = React.useState(initial.join("\n"));
180 + const [busy, setBusy] = React.useState(false);
181 + const [error, setError] = React.useState<string | null>(null);
182 + const words = React.useMemo(() => Array.from(new Set(text.split(/[\n,]+/).map((w) => w.trim().toLowerCase()).filter(Boolean))), [text]);
183 + const dirty = JSON.stringify(words) !== JSON.stringify(initial.map((w) => w.trim().toLowerCase()).filter(Boolean));
184 +
185 + async function save() {
186 + setBusy(true);
187 + setError(null);
188 + try {
189 + await api("/api/admin/settings/profanity", { json: { value: { words } } });
190 + toast({ title: "Profanity list saved", description: `${int(words.length)} custom words`, tone: "success" });
191 + onSaved();
192 + } catch (e) {
193 + setError(describeError(e));
194 + } finally {
195 + setBusy(false);
196 + }
197 + }
198 +
199 + return (
200 + <Panel title="Username profanity list" description="Extends the built-in list; one word per line (or comma-separated). Matching is case-insensitive." actions={<span className="text-[12px] text-fg-3 tabular">{int(words.length)} words</span>}>
201 + <DenseTextarea rows={8} value={text} onChange={(e) => setText(e.target.value)} placeholder={"one word per line"} className="font-mono" spellCheck={false} />
202 + {error ? <div className="mt-3"><InlineError>{error}</InlineError></div> : null}
203 + <div className="mt-3 flex justify-end gap-2">
204 + <Button size="sm" variant="ghost" onClick={() => setText(initial.join("\n"))} disabled={!dirty || busy}>
205 + Reset
206 + </Button>
207 + <Button size="sm" variant="accent" onClick={() => void save()} disabled={!dirty} loading={busy}>
208 + <Save className="h-3.5 w-3.5" /> Save list
209 + </Button>
210 + </div>
211 + </Panel>
212 + );
213 +}
added apps/web/src/app/admin/simulator/page.tsx +333 −0
@@ -0,0 +1,333 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import Link from "next/link";
5 +import { useRouter, useSearchParams } from "next/navigation";
6 +import { CheckCircle2, FlaskConical, Play, XCircle } from "lucide-react";
7 +import { BET_LEVELS } from "@spinza/shared";
8 +import { Button, Progress, Spinner } from "@/components/ui";
9 +import { api, ApiClientError } from "@/lib/api";
10 +import { toast } from "@/lib/store";
11 +import { cn, timeAgo } from "@/lib/utils";
12 +import { useAdminQuery } from "@/components/admin/use-query";
13 +import type { GamesResponse, SimulationRunRow, SimulatorRunResponse, SimulatorRunsResponse } from "@/components/admin/types";
14 +import { PageHeader, RefreshButton, Panel, ErrorState, TableSkeleton, DenseSelect, DenseInput, FieldLabel, Toggle, SegmentedControl, Pill, PassFail, InlineError, EmptyState, LifecyclePill, Mono } from "@/components/admin/primitives";
15 +import { SimulationResults } from "@/components/admin/simulation-results";
16 +import { dateTime, describeError, durationMs, int, num, pct, signedPct } from "@/components/admin/format";
17 +
18 +const PRESETS = [10_000, 100_000, 1_000_000, 10_000_000] as const;
19 +const POLL_MS = 1500;
20 +
21 +export default function AdminSimulatorPage() {
22 + return (
23 + <React.Suspense fallback={<PageHeader title="Game Simulator" />}>
24 + <Simulator />
25 + </React.Suspense>
26 + );
27 +}
28 +
29 +function Simulator() {
30 + const params = useSearchParams();
31 + const router = useRouter();
32 + const games = useAdminQuery<GamesResponse>("/api/admin/games");
33 + const runs = useAdminQuery<SimulatorRunsResponse>("/api/admin/simulator/runs?limit=20");
34 +
35 + const initialSlug = params.get("slug") ?? "";
36 + const initialRun = params.get("run");
37 + const [slug, setSlug] = React.useState(initialSlug);
38 + const [spins, setSpins] = React.useState<number>(100_000);
39 + const [customSpins, setCustomSpins] = React.useState("");
40 + const [bet, setBet] = React.useState<number>(100);
41 + const [certify, setCertify] = React.useState(params.get("certify") === "1");
42 + const [starting, setStarting] = React.useState(false);
43 + const [startError, setStartError] = React.useState<string | null>(null);
44 +
45 + const [activeRunId, setActiveRunId] = React.useState<string | null>(initialRun);
46 + const [run, setRun] = React.useState<SimulationRunRow | null>(null);
47 + const [runError, setRunError] = React.useState<string | null>(null);
48 +
49 + // Default to the first game once the library loads (when no slug was preselected).
50 + const firstSlug = games.data?.games[0]?.slug ?? "";
51 + const effectiveSlug = slug || firstSlug;
52 + const selected = games.data?.games.find((g) => g.slug === effectiveSlug) ?? null;
53 +
54 + const effectiveSpins = customSpins ? Number(customSpins) : spins;
55 + const spinsOk = Number.isInteger(effectiveSpins) && effectiveSpins >= 1000 && effectiveSpins <= 10_000_000;
56 +
57 + /* ---- polling ---- */
58 + const refreshRuns = runs.refresh;
59 + React.useEffect(() => {
60 + if (!activeRunId) return;
61 + let cancelled = false;
62 + let timer: ReturnType<typeof setTimeout> | null = null;
63 + const tick = async () => {
64 + try {
65 + const r = await api<SimulatorRunResponse>(`/api/admin/simulator/runs/${activeRunId}`);
66 + if (cancelled) return;
67 + setRun(r.run);
68 + setRunError(null);
69 + if (r.run.status === "running") timer = setTimeout(tick, POLL_MS);
70 + else void refreshRuns();
71 + } catch (e) {
72 + if (cancelled) return;
73 + setRunError(describeError(e));
74 + if (!(e instanceof ApiClientError && (e.status === 404 || e.status === 401))) timer = setTimeout(tick, POLL_MS * 2);
75 + }
76 + };
77 + void tick();
78 + return () => {
79 + cancelled = true;
80 + if (timer) clearTimeout(timer);
81 + };
82 + }, [activeRunId, refreshRuns]);
83 +
84 + async function start() {
85 + if (!effectiveSlug || !spinsOk) return;
86 + setStarting(true);
87 + setStartError(null);
88 + try {
89 + const r = await api<{ runId: string }>("/api/admin/simulator/run", { json: { slug: effectiveSlug, spins: effectiveSpins, bet, certify } });
90 + setRun(null);
91 + setActiveRunId(r.runId);
92 + router.replace(`/admin/simulator?slug=${effectiveSlug}&run=${r.runId}`);
93 + toast({ title: "Simulation started", description: `${selected?.name ?? effectiveSlug} · ${int(effectiveSpins)} spins${certify ? " · certify" : ""}` });
94 + void runs.refresh();
95 + } catch (e) {
96 + const msg = e instanceof ApiClientError && e.code === "BUSY" ? e.message : describeError(e);
97 + setStartError(msg);
98 + } finally {
99 + setStarting(false);
100 + }
101 + }
102 +
103 + function openRun(r: SimulationRunRow) {
104 + setRun(null);
105 + setRunError(null);
106 + setActiveRunId(r.id);
107 + if (r.gameSlug !== slug) setSlug(r.gameSlug);
108 + router.replace(`/admin/simulator?slug=${r.gameSlug}&run=${r.id}`);
109 + }
110 +
111 + const busy = run?.status === "running";
112 +
113 + return (
114 + <>
115 + <PageHeader title="Game Simulator" description="Run Monte-Carlo simulations of any game definition in worker threads, watch RTP converge and issue internal certifications." actions={<RefreshButton onClick={() => void runs.refresh()} loading={runs.refreshing} />} />
116 +
117 + <div className="grid gap-4 xl:grid-cols-[360px_1fr]">
118 + {/* Left column: configuration + recent runs */}
119 + <div className="space-y-4">
120 + <Panel title="Configuration" description="Spins ≥ 1,000 · up to 10M">
121 + {games.error && !games.data ? (
122 + <ErrorState error={games.error} onRetry={() => void games.refresh()} />
123 + ) : (
124 + <div className="space-y-4">
125 + <div>
126 + <FieldLabel hint={selected ? <LifecyclePill lifecycle={selected.lifecycle} /> : undefined}>Game</FieldLabel>
127 + <DenseSelect className="w-full" value={effectiveSlug} onChange={(e) => setSlug(e.target.value)} disabled={!games.data} aria-label="Game">
128 + {!games.data ? <option>Loading…</option> : null}
129 + {games.data?.games.map((g) => (
130 + <option key={g.slug} value={g.slug}>
131 + {g.name} · v{g.version}
132 + </option>
133 + ))}
134 + </DenseSelect>
135 + {selected ? (
136 + <div className="mt-1.5 flex flex-wrap gap-x-3 text-[12px] text-fg-3">
137 + <span>target RTP {pct(selected.rtp)}</span>
138 + <span>payScale {selected.payScale?.toFixed(4) ?? "—"}</span>
139 + <Link href={`/admin/games/${selected.slug}`} className="text-fg-3 underline-offset-2 hover:text-fg hover:underline">
140 + details →
141 + </Link>
142 + </div>
143 + ) : null}
144 + </div>
145 +
146 + <div>
147 + <FieldLabel hint={spinsOk ? `${int(effectiveSpins)} spins` : "1,000 – 10,000,000"}>Spins</FieldLabel>
148 + <SegmentedControl
149 + className="w-full [&>button]:flex-1"
150 + value={customSpins ? 0 : spins}
151 + onChange={(v) => {
152 + setSpins(v);
153 + setCustomSpins("");
154 + }}
155 + items={PRESETS.map((p) => ({ value: p, label: p >= 1_000_000 ? `${p / 1_000_000}M` : `${p / 1000}K` }))}
156 + />
157 + <DenseInput className="mt-2 font-mono" inputMode="numeric" placeholder="Custom spin count" value={customSpins} onChange={(e) => setCustomSpins(e.target.value.replace(/\D/g, ""))} aria-label="Custom spin count" />
158 + </div>
159 +
160 + <div className="grid grid-cols-2 gap-3">
161 + <div>
162 + <FieldLabel>Bet (SC)</FieldLabel>
163 + <DenseSelect className="w-full" value={bet} onChange={(e) => setBet(Number(e.target.value))} aria-label="Bet">
164 + {BET_LEVELS.map((b) => (
165 + <option key={b} value={b}>
166 + {b.toLocaleString("en-US")} SC
167 + </option>
168 + ))}
169 + </DenseSelect>
170 + </div>
171 + <div>
172 + <FieldLabel>Certify</FieldLabel>
173 + <div className="flex h-9 items-center gap-2">
174 + <Toggle checked={certify} onChange={setCertify} label="Produce a certification report" />
175 + <span className="text-[12px] text-fg-3">{certify ? "report + checks" : "stats only"}</span>
176 + </div>
177 + </div>
178 + </div>
179 + {certify && effectiveSpins < 1_000_000 ? <p className="text-[12px] text-[#ffc46b]">A production certification needs 1M spins; smaller runs relax the minimum-spins rule and are for exploration only.</p> : null}
180 +
181 + {startError ? <InlineError>{startError}</InlineError> : null}
182 +
183 + <Button variant="accent" className="w-full" onClick={() => void start()} loading={starting} disabled={!effectiveSlug || !spinsOk || busy}>
184 + <Play className="h-4 w-4" /> {busy ? "Simulation running…" : "Run simulation"}
185 + </Button>
186 + </div>
187 + )}
188 + </Panel>
189 +
190 + <Panel title="Recent runs" description="Click to load a result" padded={false}>
191 + {runs.error && !runs.data ? (
192 + <div className="p-3">
193 + <ErrorState error={runs.error} onRetry={() => void runs.refresh()} />
194 + </div>
195 + ) : !runs.data ? (
196 + <TableSkeleton rows={6} cols={3} />
197 + ) : runs.data.runs.length === 0 ? (
198 + <EmptyState title="No runs yet" description="Configure a simulation above and press Run." />
199 + ) : (
200 + <ul className={cn("divide-y divide-line/60", runs.stale && "opacity-60")}>
201 + {runs.data.runs.map((r) => {
202 + const active = r.id === activeRunId;
203 + return (
204 + <li key={r.id}>
205 + <button type="button" onClick={() => openRun(r)} className={cn("flex w-full items-center gap-3 px-3 py-2 text-left text-[12px] transition-colors hover:bg-surface-2 focus-ring", active && "bg-surface-2")}>
206 + <RunStatusIcon status={r.status} />
207 + <div className="min-w-0 flex-1">
208 + <div className="flex items-center gap-2">
209 + <span className="truncate font-medium text-fg">{r.gameSlug}</span>
210 + <Mono className="text-fg-4">v{r.gameVersion}</Mono>
211 + </div>
212 + <div className="text-fg-3">
213 + {int(r.spins)} spins · {timeAgo(r.createdAt)}
214 + {r.status === "running" ? ` · ${Math.round((num(r.progress) / Math.max(1, num(r.spins))) * 100)}%` : ""}
215 + </div>
216 + </div>
217 + </button>
218 + </li>
219 + );
220 + })}
221 + </ul>
222 + )}
223 + </Panel>
224 + </div>
225 +
226 + {/* Right column: run status + results */}
227 + <div className="space-y-4">
228 + {!activeRunId ? (
229 + <Panel className="min-h-[320px]">
230 + <EmptyState title="No simulation selected" description="Pick a game, choose a spin count and press Run — or open a recent run from the list." action={<FlaskConical className="h-5 w-5 text-fg-4" />} />
231 + </Panel>
232 + ) : (
233 + <>
234 + <RunStatus run={run} error={runError} />
235 + {run?.status === "done" && run.result?.simulation ? <SimulationResults sim={run.result.simulation} cert={run.result.certification ?? null} /> : null}
236 + {run?.status === "done" && !run.result?.simulation ? <InlineError>This run finished but stored no result payload.</InlineError> : null}
237 + </>
238 + )}
239 + </div>
240 + </div>
241 + </>
242 + );
243 +}
244 +
245 +function RunStatusIcon({ status }: { status: string }) {
246 + if (status === "done") return <CheckCircle2 className="h-4 w-4 shrink-0 text-success" />;
247 + if (status === "failed") return <XCircle className="h-4 w-4 shrink-0 text-danger" />;
248 + return <Spinner className="h-4 w-4 shrink-0 text-info" />;
249 +}
250 +
251 +function RunStatus({ run, error }: { run: SimulationRunRow | null; error: string | null }) {
252 + // Wall clock for the elapsed/ETA readout: ticks every second while the run is in progress.
253 + const [now, setNow] = React.useState<number | null>(null);
254 + React.useEffect(() => {
255 + if (run?.status !== "running") return;
256 + const tick = () => setNow(Date.now());
257 + const first = setTimeout(tick, 0);
258 + const t = setInterval(tick, 1000);
259 + return () => {
260 + clearTimeout(first);
261 + clearInterval(t);
262 + };
263 + }, [run?.status]);
264 +
265 + if (!run) {
266 + return (
267 + <Panel>
268 + {error ? (
269 + <InlineError>{error}</InlineError>
270 + ) : (
271 + <div className="flex items-center gap-2 text-[13px] text-fg-3">
272 + <Spinner className="h-4 w-4" /> Loading run…
273 + </div>
274 + )}
275 + </Panel>
276 + );
277 + }
278 +
279 + const progress = num(run.progress);
280 + const total = Math.max(1, num(run.spins));
281 + const ratio = Math.min(1, progress / total);
282 + const createdMs = new Date(run.createdAt).getTime();
283 + const elapsedMs = run.finishedAt ? new Date(run.finishedAt).getTime() - createdMs : now !== null ? Math.max(0, now - createdMs) : null;
284 + const eta = elapsedMs !== null && ratio > 0.02 && run.status === "running" ? (elapsedMs / ratio) * (1 - ratio) : null;
285 + const sim = run.result?.simulation;
286 + const cert = run.result?.certification ?? null;
287 +
288 + return (
289 + <Panel
290 + title={
291 + <span className="inline-flex items-center gap-2">
292 + {run.gameSlug} <Mono className="text-fg-4">v{run.gameVersion}</Mono>
293 + <Pill tone={run.status === "done" ? "success" : run.status === "failed" ? "danger" : "info"}>{run.status}</Pill>
294 + {cert ? <PassFail pass={cert.status === "PASS"} label={`cert ${cert.status}`} /> : null}
295 + </span>
296 + }
297 + description={`Run ${run.id.slice(0, 8)} · started ${dateTime(run.createdAt)}${run.finishedAt ? ` · finished ${dateTime(run.finishedAt)}` : ""}`}
298 + tone={run.status === "failed" ? "danger" : undefined}
299 + >
300 + {run.status === "running" ? (
301 + <div>
302 + <div className="mb-2 flex items-baseline justify-between text-[13px]">
303 + <span className="text-fg-2">
304 + <span className="font-semibold text-fg tabular">{int(progress)}</span> <span className="text-fg-4">/</span> {int(total)} spins
305 + </span>
306 + <span className="tabular text-fg-3">
307 + {(ratio * 100).toFixed(1)}%{elapsedMs !== null ? ` · ${durationMs(elapsedMs)} elapsed` : ""}{eta !== null ? ` · ~${durationMs(eta)} left` : ""}
308 + </span>
309 + </div>
310 + <Progress value={ratio} max={1} className="h-2" />
311 + <p className="mt-2 text-[12px] text-fg-4">Polling every 1.5 s. Worker threads keep the API responsive; you can leave this page and re-open the run later.</p>
312 + </div>
313 + ) : run.status === "failed" ? (
314 + <InlineError>{run.error ?? "The simulation failed without an error message."}</InlineError>
315 + ) : sim ? (
316 + <div className="flex flex-wrap gap-x-6 gap-y-1 text-[13px] text-fg-2">
317 + <span>
318 + <span className="text-fg-4">spins</span> {int(sim.spins)}
319 + </span>
320 + <span>
321 + <span className="text-fg-4">bet</span> {int(sim.bet)} SC
322 + </span>
323 + <span>
324 + <span className="text-fg-4">observed</span> {pct(sim.observedRtp, 3)} <span className="text-fg-4">({signedPct(sim.deviation, 3)})</span>
325 + </span>
326 + <span>
327 + <span className="text-fg-4">duration</span> {durationMs(sim.durationMs)}
328 + </span>
329 + </div>
330 + ) : null}
331 + </Panel>
332 + );
333 +}
added apps/web/src/app/admin/system/page.tsx +96 −0
@@ -0,0 +1,96 @@
1 +"use client";
2 +
3 +import { Cpu, Database, MemoryStick, Server } from "lucide-react";
4 +import { useAdminQuery } from "@/components/admin/use-query";
5 +import type { SystemResponse } from "@/components/admin/types";
6 +import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, TileSkeleton, StatGrid, StatTile, KV, Pill, Mono, type Column } from "@/components/admin/primitives";
7 +import { bytes, duration, int, ms, num } from "@/components/admin/format";
8 +import { cn } from "@/lib/utils";
9 +
10 +type Service = SystemResponse["services"][number];
11 +type TableRow = SystemResponse["tables"][number] & { __max: number };
12 +
13 +const serviceCols: Column<Service>[] = [
14 + { key: "status", header: "Status", render: (s) => <Pill tone={s.status === "ok" ? "success" : "danger"} dot>{s.status}</Pill> },
15 + { key: "name", header: "Service", render: (s) => <span className="font-medium text-fg">{s.name}</span> },
16 + { key: "node", header: "Node", mono: true, render: (s) => <Mono>{s.node}</Mono> },
17 + { key: "version", header: "Version", mono: true, render: (s) => <Mono>v{s.version}</Mono> },
18 + { key: "uptime", header: "Uptime", align: "right", render: (s) => (s.uptimeSec === null ? <span className="text-fg-4">—</span> : duration(s.uptimeSec)) },
19 + { key: "latency", header: "Latency", align: "right", render: (s) => (s.latencyMs < 0 ? <span className="text-danger">unreachable</span> : ms(s.latencyMs, 1)) },
20 +];
21 +
22 +const tableCols: Column<TableRow>[] = [
23 + { key: "table", header: "Table", mono: true, render: (t) => <Mono className="text-fg">{t.table}</Mono> },
24 + { key: "rows", header: "Live rows", align: "right", render: (t) => int(t.rows) },
25 + { key: "bytes", header: "Total size", align: "right", render: (t) => bytes(t.bytes) },
26 + {
27 + key: "share",
28 + header: "",
29 + render: (t) => <span className="block h-1.5 w-full max-w-[160px] overflow-hidden rounded-full bg-surface-3"><span className="block h-full rounded-full bg-accent/70" style={{ width: `${Math.min(100, (num(t.bytes) / num(t.__max)) * 100)}%` }} /></span>,
30 + },
31 +];
32 +
33 +export default function AdminSystemPage() {
34 + const q = useAdminQuery<SystemResponse>("/api/admin/system", { refreshMs: 15_000 });
35 + const d = q.data;
36 + const maxBytes = d ? Math.max(1, ...d.tables.map((t) => num(t.bytes))) : 1;
37 + const tables = (d?.tables ?? []).map((t) => ({ ...t, __max: maxBytes }));
38 + const down = d?.services.filter((s) => s.status !== "ok").length ?? 0;
39 + const memPct = d ? 1 - d.process.freeMemMb / Math.max(1, d.process.totalMemMb) : 0;
40 +
41 + return (
42 + <>
43 + <PageHeader title="System Health" description="API process, dependencies and storage. Refreshes every 15 seconds." actions={<RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />} />
44 + {q.error && !d ? (
45 + <ErrorState error={q.error} onRetry={() => void q.refresh()} />
46 + ) : !d ? (
47 + <div className="space-y-4">
48 + <TileSkeleton count={4} cols={4} />
49 + <Panel>
50 + <TableSkeleton rows={3} cols={6} />
51 + </Panel>
52 + </div>
53 + ) : (
54 + <div className={cn("space-y-4", q.stale && "opacity-70")}>
55 + <StatGrid cols={4}>
56 + <StatTile label="Services" value={down === 0 ? "All healthy" : `${down} down`} tone={down === 0 ? "success" : "danger"} sub={`${d.services.length} monitored · ${d.games} games loaded`} icon={<Server className="h-3.5 w-3.5" />} />
57 + <StatTile label="Spin latency · 1 h" value={<span>{ms(d.spins.avg)} <span className="text-fg-4">avg</span></span>} sub={`p95 ${ms(d.spins.p95)} · n=${int(d.spins.n)}`} tone={num(d.spins.p95) > 250 ? "danger" : "neutral"} />
58 + <StatTile label="Process memory" value={`${int(d.process.rssMb)} MB`} sub={`heap ${int(d.process.heapMb)} MB · Node ${d.process.nodeVersion}`} icon={<MemoryStick className="h-3.5 w-3.5" />} />
59 + <StatTile label="Load average" value={d.process.load.map((l) => l.toFixed(2)).join(" / ")} sub={`${d.process.cpus} CPUs · ${d.process.platform}`} tone={d.process.load[0] > d.process.cpus ? "danger" : "neutral"} icon={<Cpu className="h-3.5 w-3.5" />} />
60 + </StatGrid>
61 +
62 + <Panel title="Services" padded={false}>
63 + <DataTable columns={serviceCols} rows={d.services} rowKey={(s) => s.name} dense />
64 + </Panel>
65 +
66 + <div className="grid gap-4 xl:grid-cols-[1fr_1.4fr]">
67 + <Panel title="Host" description="Node running the API process">
68 + <KV
69 + items={[
70 + { label: "Platform", value: d.process.platform, mono: true },
71 + { label: "Node.js", value: d.process.nodeVersion, mono: true },
72 + { label: "CPUs", value: String(d.process.cpus) },
73 + { label: "Load 1 / 5 / 15", value: d.process.load.map((l) => l.toFixed(2)).join(" / "), mono: true },
74 + { label: "Memory used", value: `${int(d.process.totalMemMb - d.process.freeMemMb)} / ${int(d.process.totalMemMb)} MB` },
75 + { label: "RSS / heap", value: `${int(d.process.rssMb)} / ${int(d.process.heapMb)} MB` },
76 + ]}
77 + />
78 + <div className="mt-4">
79 + <div className="mb-1 flex justify-between text-[11px] text-fg-4">
80 + <span>Host memory</span>
81 + <span>{Math.round(memPct * 100)}%</span>
82 + </div>
83 + <div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-3">
84 + <div className={cn("h-full rounded-full", memPct > 0.9 ? "bg-danger" : memPct > 0.75 ? "bg-[#ffb454]" : "bg-accent")} style={{ width: `${Math.round(memPct * 100)}%` }} />
85 + </div>
86 + </div>
87 + </Panel>
88 + <Panel title="Largest tables" description="pg_stat_user_tables, top 15 by total relation size" padded={false} actions={<Database className="h-3.5 w-3.5 text-fg-4" />}>
89 + <DataTable columns={tableCols} rows={tables} rowKey={(t) => t.table} dense empty="No table statistics available." />
90 + </Panel>
91 + </div>
92 + </div>
93 + )}
94 + </>
95 + );
96 +}
added apps/web/src/app/admin/users/[id]/page.tsx +349 −0
@@ -0,0 +1,349 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import Link from "next/link";
5 +import { useParams } from "next/navigation";
6 +import { ArrowLeft, Ban, CheckCircle2, Scale, UserCheck, XCircle } from "lucide-react";
7 +import { Button, Tabs } from "@/components/ui";
8 +import { api } from "@/lib/api";
9 +import { toast } from "@/lib/store";
10 +import { timeAgo } from "@/lib/utils";
11 +import { useAdminQuery } from "@/components/admin/use-query";
12 +import type { LedgerRow, RoundRow, SecurityEventRow, SessionRow, UserDetailResponse } from "@/components/admin/types";
13 +import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, KV, Pill, UserStatusPill, SeverityPill, ConfirmDialog, DenseInput, DenseTextarea, FieldLabel, InlineError, JsonToggle, Mono, StatTile, StatGrid, type Column } from "@/components/admin/primitives";
14 +import { dateTime, describeError, int, multiplier, sc, signedSC } from "@/components/admin/format";
15 +import { cn } from "@/lib/utils";
16 +
17 +type Tab = "ledger" | "rounds" | "sessions" | "events";
18 +
19 +const ledgerCols: Column<LedgerRow>[] = [
20 + { key: "at", header: "When", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },
21 + { key: "type", header: "Type", render: (r) => <Pill tone={r.type === "ADMIN_ADJUSTMENT" ? "warn" : r.type === "BET" ? "muted" : r.type === "WIN" ? "accent" : "neutral"}>{r.type}</Pill> },
22 + { key: "amount", header: "Amount", align: "right", render: (r) => <span className={cn(r.amount > 0 ? "text-success" : r.amount < 0 ? "text-fg-2" : "text-fg-3")}>{signedSC(r.amount)}</span> },
23 + { key: "after", header: "Balance after", align: "right", render: (r) => sc(r.balanceAfter) },
24 + { key: "ref", header: "Reference", mono: true, render: (r) => <Mono>{r.reference ?? "—"}</Mono> },
25 + { key: "meta", header: "Meta", render: (r) => <JsonToggle value={r.meta} /> },
26 +];
27 +
28 +const roundCols: Column<RoundRow>[] = [
29 + { key: "at", header: "When", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },
30 + { key: "game", header: "Game", render: (r) => <Link href={`/admin/games/${r.gameSlug}`} className="hover:text-accent-2">{r.gameSlug}</Link> },
31 + { key: "bet", header: "Bet", align: "right", render: (r) => sc(r.bet) },
32 + { key: "win", header: "Win", align: "right", render: (r) => <span className={r.win > 0 ? "text-credit" : "text-fg-3"}>{sc(r.win)}</span> },
33 + { key: "mult", header: "×", align: "right", render: (r) => multiplier(r.multiplier) },
34 + { key: "features", header: "Features", render: (r) => (r.features.length ? <span className="text-fg-2">{r.features.join(", ")}</span> : <span className="text-fg-4">—</span>) },
35 + { key: "flags", header: "", render: (r) => <span className="flex gap-1">{r.freeSpins ? <Pill tone="info">FS</Pill> : null}{r.bonus ? <Pill tone="accent">Bonus</Pill> : null}{r.jackpotTier ? <Pill tone="warn">{r.jackpotTier}</Pill> : null}</span> },
36 + { key: "after", header: "Balance after", align: "right", render: (r) => sc(r.balanceAfter) },
37 + { key: "ms", header: "ms", align: "right", render: (r) => r.durationMs ?? "—" },
38 + { key: "id", header: "Round", mono: true, render: (r) => <Mono>{r.roundId}</Mono> },
39 +];
40 +
41 +const sessionCols: Column<SessionRow>[] = [
42 + { key: "created", header: "Started", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },
43 + { key: "seen", header: "Last seen", render: (r) => timeAgo(r.lastSeenAt) },
44 + { key: "expires", header: "Expires", render: (r) => <span className="text-fg-3">{dateTime(r.expiresAt)}</span> },
45 + { key: "ip", header: "IP", mono: true, render: (r) => <Mono>{r.ip ?? "—"}</Mono> },
46 + { key: "ua", header: "User agent", render: (r) => <span className="block max-w-[420px] truncate text-fg-3" title={r.userAgent ?? ""}>{r.userAgent ?? "—"}</span> },
47 +];
48 +
49 +const eventCols: Column<SecurityEventRow>[] = [
50 + { key: "at", header: "When", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },
51 + { key: "type", header: "Type", mono: true, render: (r) => <Mono className="text-fg">{r.type}</Mono> },
52 + { key: "sev", header: "Severity", render: (r) => <SeverityPill severity={r.severity} /> },
53 + { key: "ip", header: "IP", mono: true, render: (r) => <Mono>{r.ip ?? "—"}</Mono> },
54 + { key: "meta", header: "Meta", render: (r) => <JsonToggle value={r.meta} /> },
55 +];
56 +
57 +export default function AdminUserDetailPage() {
58 + const { id } = useParams<{ id: string }>();
59 + const q = useAdminQuery<UserDetailResponse>(id ? `/api/admin/users/${id}` : null);
60 + const d = q.data;
61 + const [tab, setTab] = React.useState<Tab>("ledger");
62 + const [adjustOpen, setAdjustOpen] = React.useState(false);
63 + const [statusOpen, setStatusOpen] = React.useState(false);
64 +
65 + return (
66 + <>
67 + <PageHeader
68 + eyebrow="User"
69 + title={d ? d.user.username : "User detail"}
70 + description={d ? <span className="font-mono text-[12px]">{d.user.id}</span> : undefined}
71 + actions={
72 + <>
73 + <Button variant="ghost" size="sm" href="/admin/users">
74 + <ArrowLeft className="h-3.5 w-3.5" /> All users
75 + </Button>
76 + <RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />
77 + </>
78 + }
79 + />
80 +
81 + {q.error && !d ? (
82 + <ErrorState error={q.error} onRetry={() => void q.refresh()} title={q.error.status === 404 ? "User not found" : undefined} />
83 + ) : !d ? (
84 + <div className="grid gap-4 lg:grid-cols-3">
85 + <Panel>
86 + <TableSkeleton rows={4} cols={2} />
87 + </Panel>
88 + <Panel>
89 + <TableSkeleton rows={4} cols={2} />
90 + </Panel>
91 + <Panel>
92 + <TableSkeleton rows={4} cols={2} />
93 + </Panel>
94 + </div>
95 + ) : (
96 + <div className={cn("space-y-4", q.stale && "opacity-70")}>
97 + <div className="grid gap-4 lg:grid-cols-3">
98 + {/* Profile */}
99 + <Panel title="Profile" actions={<UserStatusPill status={d.user.status} />}>
100 + <KV
101 + items={[
102 + { label: "Username", value: d.user.username },
103 + { label: "Level", value: `${d.user.level} · ${int(d.user.xp)} XP` },
104 + { label: "Created", value: dateTime(d.user.createdAt) },
105 + { label: "Last login", value: d.user.lastLoginAt ? `${timeAgo(d.user.lastLoginAt)} · ${dateTime(d.user.lastLoginAt)}` : "never" },
106 + { label: "Age confirmed", value: d.user.ageConfirmedAt ? dateTime(d.user.ageConfirmedAt) : "—" },
107 + { label: "Last rescue", value: d.user.lastRescueAt ? dateTime(d.user.lastRescueAt) : "—" },
108 + { label: "Total spins", value: int(d.user.totalSpins) },
109 + { label: "Games played", value: int(d.user.gamesPlayed) },
110 + { label: "Biggest win", value: sc(d.user.biggestWin) },
111 + { label: "Biggest multiplier", value: multiplier(d.user.biggestMultiplier) },
112 + ]}
113 + />
114 + <div className="mt-4 flex flex-wrap gap-2 border-t border-line pt-4">
115 + {d.user.status === "suspended" ? (
116 + <Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
117 + <UserCheck className="h-3.5 w-3.5" /> Reactivate account
118 + </Button>
119 + ) : (
120 + <Button variant="danger" size="sm" onClick={() => setStatusOpen(true)} disabled={d.user.status === "deleted"}>
121 + <Ban className="h-3.5 w-3.5" /> Suspend account
122 + </Button>
123 + )}
124 + </div>
125 + </Panel>
126 +
127 + {/* Wallet */}
128 + <Panel
129 + title="Wallet"
130 + actions={
131 + d.invariant.ok ? (
132 + <Pill tone="success">
133 + <CheckCircle2 className="h-3 w-3" /> ledger = balance
134 + </Pill>
135 + ) : (
136 + <Pill tone="danger">
137 + <XCircle className="h-3 w-3" /> ledger ≠ balance
138 + </Pill>
139 + )
140 + }
141 + >
142 + <div className="text-[11px] font-medium uppercase tracking-wider text-fg-4">Balance</div>
143 + <div className="text-3xl font-semibold tracking-tight text-credit">{sc(d.wallet?.balance ?? 0)}</div>
144 + <KV
145 + className="mt-4"
146 + items={[
147 + { label: "Lifetime granted", value: sc(d.wallet?.lifetimeGranted ?? 0) },
148 + { label: "Lifetime wagered", value: sc(d.wallet?.lifetimeWagered ?? 0) },
149 + { label: "Lifetime won", value: sc(d.wallet?.lifetimeWon ?? 0) },
150 + { label: "Updated", value: d.wallet ? timeAgo(d.wallet.updatedAt) : "—" },
151 + ]}
152 + />
153 + <div className={cn("mt-4 rounded-sm border px-3 py-2 text-[12px]", d.invariant.ok ? "border-success/30 bg-success/5 text-fg-2" : "border-danger/40 bg-danger/10 text-danger")}>
154 + <div className="flex items-center gap-1.5 font-semibold uppercase tracking-wider">
155 + <Scale className="h-3 w-3" /> Ledger invariant
156 + </div>
157 + <div className="mt-1 flex justify-between tabular">
158 + <span>Σ ledger amounts</span>
159 + <span>{sc(d.invariant.ledgerTotal)}</span>
160 + </div>
161 + <div className="flex justify-between tabular">
162 + <span>Wallet balance</span>
163 + <span>{sc(d.invariant.balance)}</span>
164 + </div>
165 + {!d.invariant.ok ? <div className="mt-1 font-medium">Mismatch of {signedSC(d.invariant.balance - d.invariant.ledgerTotal)} — investigate before adjusting.</div> : null}
166 + </div>
167 + <div className="mt-4 border-t border-line pt-4">
168 + <Button variant="outline" size="sm" onClick={() => setAdjustOpen(true)}>
169 + Adjust wallet
170 + </Button>
171 + </div>
172 + </Panel>
173 +
174 + {/* Activity snapshot */}
175 + <div className="grid content-start gap-3">
176 + <StatGrid cols={3} className="grid-cols-2">
177 + <StatTile label="Ledger rows" value={int(d.ledger.length)} sub="latest 50" compact />
178 + <StatTile label="Rounds" value={int(d.rounds.length)} sub="latest 30" compact />
179 + <StatTile label="Sessions" value={int(d.sessions.length)} sub="active + expired" compact />
180 + </StatGrid>
181 + <StatGrid cols={3} className="grid-cols-2">
182 + <StatTile label="Security events" value={int(d.events.length)} tone={d.events.some((e) => e.severity === "high") ? "danger" : "neutral"} sub="latest 30" compact />
183 + <StatTile label="Round RTP" value={roundRtp(d.rounds)} sub="latest rounds" compact />
184 + <StatTile label="Bonus rounds" value={int(d.rounds.filter((r) => r.bonus || r.freeSpins).length)} compact />
185 + </StatGrid>
186 + </div>
187 + </div>
188 +
189 + <Panel
190 + padded={false}
191 + title={
192 + <Tabs<Tab>
193 + value={tab}
194 + onChange={setTab}
195 + items={[
196 + { value: "ledger", label: `Ledger (${d.ledger.length})` },
197 + { value: "rounds", label: `Rounds (${d.rounds.length})` },
198 + { value: "sessions", label: `Sessions (${d.sessions.length})` },
199 + { value: "events", label: `Security (${d.events.length})` },
200 + ]}
201 + />
202 + }
203 + >
204 + {tab === "ledger" ? <DataTable columns={ledgerCols} rows={d.ledger} rowKey={(r) => r.id} dense empty="No ledger entries." /> : null}
205 + {tab === "rounds" ? <DataTable columns={roundCols} rows={d.rounds} rowKey={(r) => r.id} dense empty="No rounds played." /> : null}
206 + {tab === "sessions" ? <DataTable columns={sessionCols} rows={d.sessions} rowKey={(r) => r.id} dense empty="No sessions." /> : null}
207 + {tab === "events" ? <DataTable columns={eventCols} rows={d.events} rowKey={(r) => r.id} dense empty="No security events for this user." /> : null}
208 + </Panel>
209 +
210 + <AdjustDialog open={adjustOpen} onClose={() => setAdjustOpen(false)} userId={d.user.id} username={d.user.username} balance={d.wallet?.balance ?? 0} onDone={() => void q.refresh()} />
211 + <StatusDialog open={statusOpen} onClose={() => setStatusOpen(false)} userId={d.user.id} username={d.user.username} suspended={d.user.status === "suspended"} onDone={() => void q.refresh()} />
212 + </div>
213 + )}
214 + </>
215 + );
216 +}
217 +
218 +function roundRtp(rounds: RoundRow[]): string {
219 + const bet = rounds.reduce((a, r) => a + r.bet, 0);
220 + const win = rounds.reduce((a, r) => a + r.win, 0);
221 + return bet ? `${((win / bet) * 100).toFixed(1)}%` : "—";
222 +}
223 +
224 +function AdjustDialog({ open, onClose, userId, username, balance, onDone }: { open: boolean; onClose: () => void; userId: string; username: string; balance: number; onDone: () => void }) {
225 + const [amount, setAmount] = React.useState("");
226 + const [note, setNote] = React.useState("");
227 + const [busy, setBusy] = React.useState(false);
228 + const [error, setError] = React.useState<string | null>(null);
229 + const [confirm, setConfirm] = React.useState(false);
230 +
231 + const n = Number(amount);
232 + const amountOk = /^-?\d+$/.test(amount.trim()) && n !== 0 && Math.abs(n) <= 1_000_000_000;
233 + const noteOk = note.trim().length >= 3;
234 + const after = balance + (amountOk ? n : 0);
235 +
236 + function reset() {
237 + setAmount("");
238 + setNote("");
239 + setError(null);
240 + setConfirm(false);
241 + }
242 +
243 + async function submit() {
244 + if (!amountOk || !noteOk) return;
245 + setBusy(true);
246 + setError(null);
247 + try {
248 + const r = await api<{ balance: number }>(`/api/admin/users/${userId}/adjust`, { json: { amount: n, note: note.trim() } });
249 + toast({ title: "Wallet adjusted", description: `${username} · ${signedSC(n)} → ${sc(r.balance)}`, tone: "success" });
250 + reset();
251 + onClose();
252 + onDone();
253 + } catch (e) {
254 + setError(describeError(e));
255 + setConfirm(false);
256 + } finally {
257 + setBusy(false);
258 + }
259 + }
260 +
261 + return (
262 + <ConfirmDialog
263 + open={open}
264 + onClose={() => {
265 + if (busy) return;
266 + reset();
267 + onClose();
268 + }}
269 + onConfirm={() => (confirm ? void submit() : setConfirm(true))}
270 + title={confirm ? "Confirm wallet adjustment" : `Adjust wallet · ${username}`}
271 + description={confirm ? undefined : "Signed amount in Spinza Credits (SC). Positive grants, negative removes. Logged as a high-severity security event."}
272 + confirmLabel={confirm ? `Apply ${signedSC(amountOk ? n : 0)}` : "Review"}
273 + danger={amountOk && n < 0}
274 + loading={busy}
275 + disabled={!amountOk || !noteOk}
276 + >
277 + {confirm ? (
278 + <div className="rounded-sm border border-line bg-bg-1 p-3 text-[13px]">
279 + <div className="flex justify-between">
280 + <span className="text-fg-3">Player</span>
281 + <span className="font-medium">{username}</span>
282 + </div>
283 + <div className="mt-1 flex justify-between">
284 + <span className="text-fg-3">Adjustment</span>
285 + <span className={cn("font-semibold tabular", n > 0 ? "text-success" : "text-danger")}>{signedSC(n)}</span>
286 + </div>
287 + <div className="mt-1 flex justify-between">
288 + <span className="text-fg-3">Balance</span>
289 + <span className="tabular">
290 + {sc(balance)} → <span className="text-credit">{sc(after)}</span>
291 + </span>
292 + </div>
293 + <div className="mt-2 border-t border-line pt-2 text-fg-2">“{note.trim()}”</div>
294 + {after < 0 ? <InlineError>Resulting balance would be negative.</InlineError> : null}
295 + </div>
296 + ) : (
297 + <div className="space-y-3">
298 + <div>
299 + <FieldLabel hint={amountOk ? `→ ${sc(after)}` : "integer, non-zero"}>Amount (SC)</FieldLabel>
300 + <DenseInput inputMode="numeric" placeholder="e.g. 5000 or -2500" value={amount} onChange={(e) => setAmount(e.target.value.replace(/[^\d-]/g, ""))} className="font-mono" />
301 + </div>
302 + <div>
303 + <FieldLabel hint={`${note.trim().length}/200 · min 3`}>Note</FieldLabel>
304 + <DenseTextarea rows={2} maxLength={200} placeholder="Why is this adjustment being made?" value={note} onChange={(e) => setNote(e.target.value)} />
305 + </div>
306 + <div className="flex gap-1.5">
307 + {[1000, 5000, 10000, -1000].map((v) => (
308 + <button key={v} type="button" onClick={() => setAmount(String(v))} className="rounded-xs border border-line px-2 py-1 text-[11px] text-fg-3 hover:text-fg">
309 + {signedSC(v)}
310 + </button>
311 + ))}
312 + </div>
313 + </div>
314 + )}
315 + {error ? (
316 + <div className="mt-3">
317 + <InlineError>{error}</InlineError>
318 + </div>
319 + ) : null}
320 + </ConfirmDialog>
321 + );
322 +}
323 +
324 +function StatusDialog({ open, onClose, userId, username, suspended, onDone }: { open: boolean; onClose: () => void; userId: string; username: string; suspended: boolean; onDone: () => void }) {
325 + const [busy, setBusy] = React.useState(false);
326 + const [error, setError] = React.useState<string | null>(null);
327 + const next = suspended ? "active" : "suspended";
328 +
329 + async function submit() {
330 + setBusy(true);
331 + setError(null);
332 + try {
333 + await api(`/api/admin/users/${userId}/status`, { json: { status: next } });
334 + toast({ title: next === "suspended" ? "Account suspended" : "Account reactivated", description: username, tone: next === "suspended" ? "danger" : "success" });
335 + onClose();
336 + onDone();
337 + } catch (e) {
338 + setError(describeError(e));
339 + } finally {
340 + setBusy(false);
341 + }
342 + }
343 +
344 + return (
345 + <ConfirmDialog open={open} onClose={onClose} onConfirm={() => void submit()} title={suspended ? `Reactivate ${username}?` : `Suspend ${username}?`} description={suspended ? "The player will be able to sign in and play again." : "All active sessions are revoked immediately and the player cannot sign in until reactivated. Credits and progress are kept."} confirmLabel={suspended ? "Reactivate" : "Suspend"} danger={!suspended} loading={busy}>
346 + {error ? <InlineError>{error}</InlineError> : null}
347 + </ConfirmDialog>
348 + );
349 +}
added apps/web/src/app/admin/users/page.tsx +106 −0
@@ -0,0 +1,106 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import { useRouter } from "next/navigation";
5 +import { Search } from "lucide-react";
6 +import { useAdminQuery } from "@/components/admin/use-query";
7 +import type { UserListRow, UsersResponse } from "@/components/admin/types";
8 +import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, Pagination, DenseInput, DenseSelect, UserStatusPill, type Column } from "@/components/admin/primitives";
9 +import { dateTime, int, sc } from "@/components/admin/format";
10 +import { timeAgo } from "@/lib/utils";
11 +
12 +const SORTS = [
13 + { value: "created", label: "Newest" },
14 + { value: "spins", label: "Most spins" },
15 + { value: "balance", label: "Highest balance" },
16 + { value: "level", label: "Highest level" },
17 +] as const;
18 +type Sort = (typeof SORTS)[number]["value"];
19 +
20 +const LIMIT = 50;
21 +
22 +const cols: Column<UserListRow>[] = [
23 + { key: "username", header: "Username", render: (r) => <span className="font-medium text-fg">{r.username}</span> },
24 + { key: "level", header: "Level", align: "right", render: (r) => r.level },
25 + { key: "balance", header: "Balance", align: "right", render: (r) => <span className="text-credit">{sc(r.balance)}</span> },
26 + { key: "spins", header: "Spins", align: "right", render: (r) => int(r.totalSpins) },
27 + { key: "wagered", header: "Wagered", align: "right", render: (r) => sc(r.wagered) },
28 + { key: "won", header: "Won", align: "right", render: (r) => sc(r.won) },
29 + { key: "status", header: "Status", render: (r) => <UserStatusPill status={r.status} /> },
30 + { key: "created", header: "Created", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },
31 + { key: "lastLogin", header: "Last login", render: (r) => <span className="text-fg-3">{r.lastLoginAt ? timeAgo(r.lastLoginAt) : "never"}</span> },
32 +];
33 +
34 +function useDebounced<T>(value: T, delay = 300): T {
35 + const [v, setV] = React.useState(value);
36 + React.useEffect(() => {
37 + const t = setTimeout(() => setV(value), delay);
38 + return () => clearTimeout(t);
39 + }, [value, delay]);
40 + return v;
41 +}
42 +
43 +export default function AdminUsersPage() {
44 + const router = useRouter();
45 + const [q, setQ] = React.useState("");
46 + const [sort, setSort] = React.useState<Sort>("created");
47 + const [offset, setOffset] = React.useState(0);
48 + const dq = useDebounced(q.trim());
49 +
50 + const params = new URLSearchParams({ sort, limit: String(LIMIT), offset: String(offset) });
51 + if (dq) params.set("q", dq);
52 + const query = useAdminQuery<UsersResponse>(`/api/admin/users?${params.toString()}`);
53 + const data = query.data;
54 +
55 + return (
56 + <>
57 + <PageHeader title="Users" description="Search, inspect and moderate player accounts." actions={<RefreshButton onClick={() => void query.refresh()} loading={query.refreshing} />} />
58 +
59 + <div className="mb-3 flex flex-wrap items-center gap-2">
60 + <div className="relative w-full sm:w-72">
61 + <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-4" />
62 + <DenseInput
63 + className="pl-8"
64 + placeholder="Search username…"
65 + value={q}
66 + onChange={(e) => {
67 + setQ(e.target.value);
68 + setOffset(0);
69 + }}
70 + aria-label="Search users"
71 + />
72 + </div>
73 + <DenseSelect
74 + value={sort}
75 + onChange={(e) => {
76 + setSort(e.target.value as Sort);
77 + setOffset(0);
78 + }}
79 + aria-label="Sort users"
80 + >
81 + {SORTS.map((s) => (
82 + <option key={s.value} value={s.value}>
83 + {s.label}
84 + </option>
85 + ))}
86 + </DenseSelect>
87 + {data ? <span className="ml-auto text-[12px] text-fg-3 tabular">{int(data.total)} accounts</span> : null}
88 + </div>
89 +
90 + <Panel padded={false}>
91 + {query.error && !data ? (
92 + <div className="p-4">
93 + <ErrorState error={query.error} onRetry={() => void query.refresh()} />
94 + </div>
95 + ) : !data ? (
96 + <TableSkeleton rows={10} cols={9} />
97 + ) : (
98 + <>
99 + <DataTable columns={cols} rows={data.users} rowKey={(r) => r.id} dense stale={query.stale} onRowClick={(r) => router.push(`/admin/users/${r.id}`)} empty={dq ? `No users match "${dq}".` : "No registered users yet."} />
100 + <Pagination offset={offset} limit={LIMIT} total={data.total} onChange={setOffset} />
101 + </>
102 + )}
103 + </Panel>
104 + </>
105 + );
106 +}
added apps/web/src/app/error.tsx +40 −0
@@ -0,0 +1,40 @@
1 +"use client";
2 +
3 +import { useEffect } from "react";
4 +import Link from "next/link";
5 +import { RefreshCw, TriangleAlert } from "lucide-react";
6 +import { Button } from "@/components/ui";
7 +import { SpinzaWordmark } from "@/components/brand/logo";
8 +
9 +/** Route-level error boundary. Keeps the brand, offers a retry, reassures about progress. */
10 +export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
11 + useEffect(() => {
12 + console.error(error);
13 + }, [error]);
14 +
15 + return (
16 + <div className="relative flex min-h-dvh flex-col items-center justify-center px-4 py-16 text-center">
17 + <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_50%_20%,rgba(255,92,122,0.10),transparent_55%)]" />
18 + <Link href="/" className="relative mb-10 focus-ring rounded-md" aria-label="Spinza home">
19 + <SpinzaWordmark />
20 + </Link>
21 + <div className="relative" role="alert">
22 + <div className="mx-auto mb-6 grid h-16 w-16 place-items-center rounded-full metal text-danger">
23 + <TriangleAlert className="h-7 w-7" />
24 + </div>
25 + <h1 className="text-3xl font-semibold tracking-[-0.03em] sm:text-4xl">Something went wrong.</h1>
26 + <p className="mx-auto mt-3 max-w-md text-[15px] text-fg-3">An unexpected error interrupted this screen. Your credits, streak and progress are safe on the server — try again or head back to the lobby.</p>
27 + {error.digest ? <p className="mt-2 font-mono text-[11px] text-fg-4">Reference {error.digest}</p> : null}
28 + <div className="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
29 + <Button size="lg" onClick={reset}>
30 + <RefreshCw className="h-4 w-4" /> Try again
31 + </Button>
32 + <Button href="/" variant="secondary" size="lg">
33 + Back to the lobby
34 + </Button>
35 + </div>
36 + </div>
37 + <p className="relative mt-16 text-[12px] text-fg-4">Virtual credits only. No deposits. No withdrawals. No cash value. 18+.</p>
38 + </div>
39 + );
40 +}
added apps/web/src/app/games/[slug]/page.tsx +22 −0
@@ -0,0 +1,22 @@
1 +import type { Metadata } from "next";
2 +import { notFound } from "next/navigation";
3 +import { apiServer } from "@/lib/api-server";
4 +import type { GameInfo } from "@spinza/shared";
5 +import type { ClientDefinition } from "@/components/game/types";
6 +import { GameClient } from "@/components/game/game-client";
7 +
8 +type Params = { params: Promise<{ slug: string }> };
9 +
10 +export async function generateMetadata({ params }: Params): Promise<Metadata> {
11 + const { slug } = await params;
12 + const data = await apiServer<{ game: GameInfo }>(`/api/games/${slug}`);
13 + if (!data) return { title: "Game not found" };
14 + return { title: `${data.game.name} — play free`, description: data.game.description, robots: { index: false } };
15 +}
16 +
17 +export default async function GamePage({ params }: Params) {
18 + const { slug } = await params;
19 + const data = await apiServer<{ game: GameInfo; definition: ClientDefinition }>(`/api/games/${slug}`);
20 + if (!data) notFound();
21 + return <GameClient game={data.game} definition={data.definition} />;
22 +}
added apps/web/src/app/games/page.tsx +42 −0
@@ -0,0 +1,42 @@
1 +import { Suspense } from "react";
2 +import type { Metadata } from "next";
3 +import type { GameCard } from "@spinza/shared";
4 +import { apiServer } from "@/lib/api-server";
5 +import { AppShell } from "@/components/shell/app-shell";
6 +import { ServerUnavailable } from "@/components/shell/api-error";
7 +import { GamesBrowser } from "@/components/lobby/games-browser";
8 +import { GameCardSkeleton } from "@/components/lobby/game-card";
9 +
10 +export const metadata: Metadata = {
11 + title: "Games",
12 + description: "Browse all twenty original Spinza games — cascades, expanding wilds, hold-and-respin jackpots and more. Free fictional credits, no cash value.",
13 + alternates: { canonical: "/games" },
14 +};
15 +
16 +export default async function GamesPage() {
17 + const data = await apiServer<{ games: GameCard[] }>("/api/games");
18 + return (
19 + <AppShell>
20 + <div className="mb-6">
21 + <div className="eyebrow mb-1">Library</div>
22 + <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">All games</h1>
23 + <p className="mt-2 max-w-xl text-sm text-fg-3">Every title is a Spinza original, certified against its published math. Volatility tells you how the ride feels; open Game Info inside any game for the full details.</p>
24 + </div>
25 + {data === null ? (
26 + <ServerUnavailable />
27 + ) : (
28 + <Suspense
29 + fallback={
30 + <div className="grid grid-cols-2 gap-x-3 gap-y-6 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5" aria-busy>
31 + {Array.from({ length: 10 }).map((_, i) => (
32 + <GameCardSkeleton key={i} className="w-full sm:w-full" />
33 + ))}
34 + </div>
35 + }
36 + >
37 + <GamesBrowser games={data.games} />
38 + </Suspense>
39 + )}
40 + </AppShell>
41 + );
42 +}
added apps/web/src/app/globals.css +229 −0
@@ -0,0 +1,229 @@
1 +@import "tailwindcss";
2 +
3 +/* ------------------------------------------------------------------------
4 + Spinza design system — dark luxury. Deep charcoal, subtle metallic
5 + surfaces, high contrast, restrained accent. Game artwork supplies colour.
6 + ------------------------------------------------------------------------ */
7 +
8 +@theme {
9 + --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
10 + --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
11 +
12 + --color-bg: #07080c;
13 + --color-bg-1: #0c0e14;
14 + --color-bg-2: #12141c;
15 + --color-bg-3: #191c26;
16 + --color-surface: rgb(255 255 255 / 0.035);
17 + --color-surface-2: rgb(255 255 255 / 0.06);
18 + --color-surface-3: rgb(255 255 255 / 0.09);
19 + --color-line: rgb(255 255 255 / 0.08);
20 + --color-line-2: rgb(255 255 255 / 0.14);
21 + --color-fg: #f3f4f8;
22 + --color-fg-2: #b6bac6;
23 + --color-fg-3: #7b8090;
24 + --color-fg-4: #4f5464;
25 + --color-accent: #c9a961;
26 + --color-accent-2: #e8cf8f;
27 + --color-accent-soft: rgb(201 169 97 / 0.14);
28 + --color-credit: #ffd66b;
29 + --color-success: #3ddc97;
30 + --color-danger: #ff5c7a;
31 + --color-info: #6ea8ff;
32 +
33 + --radius-xs: 6px;
34 + --radius-sm: 10px;
35 + --radius-md: 14px;
36 + --radius-lg: 20px;
37 + --radius-xl: 28px;
38 +
39 + --shadow-card: 0 1px 0 rgb(255 255 255 / 0.04) inset, 0 20px 50px -30px rgb(0 0 0 / 0.9);
40 + --shadow-glow: 0 0 0 1px rgb(201 169 97 / 0.25), 0 10px 40px -10px rgb(201 169 97 / 0.35);
41 +
42 + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
43 + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
44 +
45 + --animate-fade-in: fade-in 0.35s var(--ease-out-expo) both;
46 + --animate-fade-up: fade-up 0.5s var(--ease-out-expo) both;
47 + --animate-shimmer: shimmer 2.6s linear infinite;
48 + --animate-pulse-soft: pulse-soft 2.4s ease-in-out infinite;
49 + --animate-float: float 6s ease-in-out infinite;
50 + --animate-spin-slow: spin 14s linear infinite;
51 +
52 + @keyframes fade-in {
53 + from { opacity: 0; }
54 + to { opacity: 1; }
55 + }
56 + @keyframes fade-up {
57 + from { opacity: 0; transform: translateY(10px); }
58 + to { opacity: 1; transform: translateY(0); }
59 + }
60 + @keyframes shimmer {
61 + from { background-position: -200% 0; }
62 + to { background-position: 200% 0; }
63 + }
64 + @keyframes pulse-soft {
65 + 0%, 100% { opacity: 1; }
66 + 50% { opacity: 0.55; }
67 + }
68 + @keyframes float {
69 + 0%, 100% { transform: translateY(0); }
70 + 50% { transform: translateY(-10px); }
71 + }
72 +}
73 +
74 +:root {
75 + color-scheme: dark;
76 + --safe-top: env(safe-area-inset-top, 0px);
77 + --safe-bottom: env(safe-area-inset-bottom, 0px);
78 + --z-nav: 40;
79 + --z-sheet: 60;
80 + --z-modal: 80;
81 + --z-toast: 100;
82 +}
83 +
84 +html {
85 + background: var(--color-bg);
86 + -webkit-tap-highlight-color: transparent;
87 + text-size-adjust: 100%;
88 +}
89 +
90 +body {
91 + @apply bg-bg text-fg font-sans antialiased;
92 + min-height: 100dvh;
93 + overscroll-behavior-y: none;
94 + font-feature-settings: "ss01", "cv11", "tnum";
95 +}
96 +
97 +::selection {
98 + background: rgb(201 169 97 / 0.35);
99 +}
100 +
101 +/* Prevent iOS zoom on inputs. */
102 +input,
103 +select,
104 +textarea {
105 + font-size: 16px;
106 +}
107 +
108 +@media (prefers-reduced-motion: reduce) {
109 + *,
110 + *::before,
111 + *::after {
112 + animation-duration: 0.01ms !important;
113 + animation-iteration-count: 1 !important;
114 + transition-duration: 0.01ms !important;
115 + }
116 +}
117 +
118 +/* ----------------------------------------------------------------- utils */
119 +
120 +@utility surface {
121 + background: linear-gradient(180deg, rgb(255 255 255 / 0.05), rgb(255 255 255 / 0.02));
122 + border: 1px solid var(--color-line);
123 + box-shadow: var(--shadow-card);
124 +}
125 +
126 +@utility surface-2 {
127 + background: linear-gradient(180deg, rgb(255 255 255 / 0.08), rgb(255 255 255 / 0.035));
128 + border: 1px solid var(--color-line-2);
129 + box-shadow: var(--shadow-card);
130 +}
131 +
132 +@utility metal {
133 + background:
134 + linear-gradient(135deg, rgb(255 255 255 / 0.1), rgb(255 255 255 / 0) 40%, rgb(255 255 255 / 0.04) 60%, rgb(255 255 255 / 0) 100%),
135 + linear-gradient(180deg, #1a1d27, #0e1017);
136 + border: 1px solid rgb(255 255 255 / 0.12);
137 +}
138 +
139 +@utility text-credit {
140 + color: var(--color-credit);
141 + font-variant-numeric: tabular-nums;
142 +}
143 +
144 +@utility tabular {
145 + font-variant-numeric: tabular-nums;
146 +}
147 +
148 +@utility hairline {
149 + border-color: var(--color-line);
150 +}
151 +
152 +@utility scrollbar-none {
153 + scrollbar-width: none;
154 + &::-webkit-scrollbar {
155 + display: none;
156 + }
157 +}
158 +
159 +@utility snap-row {
160 + display: flex;
161 + gap: 0.75rem;
162 + overflow-x: auto;
163 + scroll-snap-type: x mandatory;
164 + padding-bottom: 4px;
165 + scrollbar-width: none;
166 + &::-webkit-scrollbar {
167 + display: none;
168 + }
169 + & > * {
170 + scroll-snap-align: start;
171 + flex: 0 0 auto;
172 + }
173 +}
174 +
175 +@utility shimmer-text {
176 + background: linear-gradient(90deg, var(--color-accent) 0%, var(--color-accent-2) 40%, #fff7dc 50%, var(--color-accent-2) 60%, var(--color-accent) 100%);
177 + background-size: 200% auto;
178 + -webkit-background-clip: text;
179 + background-clip: text;
180 + color: transparent;
181 + animation: shimmer 3s linear infinite;
182 +}
183 +
184 +@utility glass {
185 + background: rgb(12 14 20 / 0.72);
186 + backdrop-filter: blur(18px) saturate(1.3);
187 + -webkit-backdrop-filter: blur(18px) saturate(1.3);
188 + border: 1px solid var(--color-line);
189 +}
190 +
191 +@utility tap {
192 + min-height: 44px;
193 + min-width: 44px;
194 +}
195 +
196 +@utility page {
197 + width: 100%;
198 + max-width: 1320px;
199 + margin-inline: auto;
200 + padding-inline: 1rem;
201 + @media (min-width: 640px) {
202 + padding-inline: 1.5rem;
203 + }
204 + @media (min-width: 1024px) {
205 + padding-inline: 2rem;
206 + }
207 +}
208 +
209 +@utility eyebrow {
210 + font-size: 0.6875rem;
211 + letter-spacing: 0.18em;
212 + text-transform: uppercase;
213 + color: var(--color-fg-3);
214 + font-weight: 600;
215 +}
216 +
217 +@utility focus-ring {
218 + outline: none;
219 + &:focus-visible {
220 + box-shadow: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-accent);
221 + }
222 +}
223 +
224 +/* game canvas fills its container */
225 +.spz-canvas canvas {
226 + display: block;
227 + width: 100% !important;
228 + height: 100% !important;
229 +}
added apps/web/src/app/how-it-works/page.tsx +127 −0
@@ -0,0 +1,127 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { DAILY_REWARDS, RESCUE_CREDITS_AMOUNT, RESCUE_CREDITS_COOLDOWN_HOURS, STARTING_BALANCE, BET_LEVELS, formatSC } from "@spinza/shared";
4 +import { Button } from "@/components/ui";
5 +import { ProsePage, Section, Callout, Bullets } from "@/components/lobby/prose";
6 +
7 +export const metadata: Metadata = {
8 + title: "How it works",
9 + description: "How Spinza works: a username, 10,000 fictional credits, twenty original games, daily rewards and levels. No deposits, no withdrawals, no cash value.",
10 + alternates: { canonical: "/how-it-works" },
11 +};
12 +
13 +const TOC = [
14 + { id: "credits", label: "Spinza Credits" },
15 + { id: "account", label: "Your account" },
16 + { id: "games", label: "The games" },
17 + { id: "refills", label: "Daily rewards & refills" },
18 + { id: "progression", label: "Levels, missions, achievements" },
19 + { id: "fairness", label: "Fairness & certification" },
20 + { id: "faq", label: "Questions" },
21 +];
22 +
23 +export default function HowItWorksPage() {
24 + return (
25 + <ProsePage eyebrow="Guide" title="How Spinza works" lead="Spinza is a social casino built around one idea: all the craft of great slot design, none of the money. Here is everything you need to know before your first spin." toc={TOC}>
26 + <Section id="credits" title="Spinza Credits are fictional — completely">
27 + <p>Everything in Spinza is played with Spinza Credits (SC). They are a made-up in-game currency. They have no monetary value, they cannot be bought, they cannot be sold, transferred, gifted or withdrawn, and they cannot be exchanged for prizes, goods or anything else.</p>
28 + <p>Because nothing of value is ever at stake, Spinza is not gambling. It is a game about games: the reels, the features, the near-misses and the big moments, without a wallet attached.</p>
29 + <Callout>
30 + <strong>Virtual credits only. No deposits. No withdrawals. No cash value.</strong> There is no shop, no checkout and no way to add credits with money — by design, forever.
31 + </Callout>
32 + </Section>
33 +
34 + <Section id="account" title="An account is just a username">
35 + <p>To create an account you choose a username (3 to 24 lowercase letters, numbers, underscores or hyphens) and a password of at least eight characters, and confirm you are 18 or older. That is the whole form. Spinza never asks for an email address, a phone number or a payment method.</p>
36 + <p>
37 + Because there is no email on file, Spinza gives you a <strong>recovery code</strong> when you register — the format is <code className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[13px]">SPZ-XXXX-XXXX-XXXX</code>. It is the only way to reset a forgotten password. Save it somewhere safe. Every time it is used, a fresh code is issued. You can also rotate it at any time from{" "}
38 + <Link href="/settings#security" className="text-accent-2 underline-offset-4 hover:underline">
39 + Settings → Security
40 + </Link>
41 + .
42 + </p>
43 + <p>Every new account receives {formatSC(STARTING_BALANCE)} immediately.</p>
44 + </Section>
45 +
46 + <Section id="games" title="Twenty original games">
47 + <p>Every Spinza game is designed and built in-house — symbols, artwork, rules and math. You will find classic five-reel layouts alongside cascading grids, expanding wilds, hold-and-respin jackpots, collect-and-multiply meters, mystery symbols and pick bonuses.</p>
48 + <Bullets
49 + items={[
50 + <>
51 + <strong>Bet levels</strong> are the same everywhere: {BET_LEVELS.map((b) => b.toLocaleString("en-US")).join(", ")} SC per spin (some games narrow the range).
52 + </>,
53 + <>
54 + <strong>Volatility</strong> tells you how a game feels. Relaxed games pay small and often; extreme games stay quiet for long stretches and then explode.
55 + </>,
56 + <>
57 + <strong>Game Info</strong>, inside every game, shows the full rules, paytable, features and the certified return figures.
58 + </>,
59 + <>
60 + <strong>Outcomes are decided on the server</strong> for every round, recorded with a round ID, and listed in your history.
61 + </>,
62 + ]}
63 + />
64 + </Section>
65 +
66 + <Section id="refills" title="Running low is never the end">
67 + <p>Spinza is designed so nobody gets stuck at zero.</p>
68 + <Bullets
69 + items={[
70 + <>
71 + <strong>Daily reward.</strong> Claim once a day. The amount grows with your streak: {DAILY_REWARDS.map((d) => d.toLocaleString("en-US")).join(" → ")} SC over seven consecutive days, then the cycle starts again. Miss a day and you fall back one step; miss longer and the streak resets.
72 + </>,
73 + <>
74 + <strong>Rescue credits.</strong> If your balance reaches 0 SC, claim {formatSC(RESCUE_CREDITS_AMOUNT)} from the Rewards page. Available again {RESCUE_CREDITS_COOLDOWN_HOURS} hours after each use.
75 + </>,
76 + <>
77 + <strong>Level-ups, missions and achievements</strong> all pay credits too (see below).
78 + </>,
79 + ]}
80 + />
81 + </Section>
82 +
83 + <Section id="progression" title="Levels, missions and achievements">
84 + <p>Every spin earns experience points, with bonuses for triggering features and for trying a game for the first time. XP fills a level bar; each new level grants credits, with larger bonuses every fifth, tenth and twenty-fifth level.</p>
85 + <p>Missions are short daily and weekly challenges — spin a number of rounds, trigger a bonus, land a big multiplier. Achievements are permanent milestones across your whole Spinza history. Both pay credits and XP the moment they complete.</p>
86 + <p>Leaderboards rank players by biggest win, biggest multiplier, most spins and highest level. They are for bragging rights only, and you can opt out entirely in Settings.</p>
87 + </Section>
88 +
89 + <Section id="fairness" title="Fairness and certification">
90 + <p>Before a game is published, it is run through millions of simulated rounds. The observed return, hit rate, bonus frequency and maximum win are compared with the game&apos;s design targets, and the game only opens to players if it passes. The certification summary is shown in each game&apos;s Game Info panel.</p>
91 + <p>Outcomes are generated server-side with a cryptographically secure random source. The client only animates what the server has already decided.</p>
92 + </Section>
93 +
94 + <Section id="faq" title="Questions">
95 + <dl className="space-y-4">
96 + {[
97 + ["Can I buy credits?", "No. There is no store and never will be. Credits come only from daily rewards, rescue credits, missions, achievements and levelling up."],
98 + ["Can I win real money or prizes?", "No. Spinza Credits have no value outside Spinza and cannot be converted into anything."],
99 + ["Do I need to download anything?", "No. Spinza runs in your browser on phone, tablet and desktop. You can add it to your home screen for a full-screen experience."],
100 + ["What data do you keep?", "Your username, a hash of your password, your gameplay statistics and your settings. No email, no phone, no trackers, no ads."],
101 + ["I lost my password and my recovery code.", "Unfortunately the account cannot be recovered — there is no email on file to verify you. You are welcome to start again with a new username."],
102 + ].map(([q, a]) => (
103 + <div key={q} className="rounded-md border border-line p-4">
104 + <dt className="font-semibold text-fg">{q}</dt>
105 + <dd className="mt-1 text-fg-2">{a}</dd>
106 + </div>
107 + ))}
108 + </dl>
109 + </Section>
110 +
111 + <div className="flex flex-col gap-3 rounded-xl border border-accent/25 p-6 sm:flex-row sm:items-center sm:justify-between">
112 + <div>
113 + <div className="text-lg font-semibold tracking-tight">Ready to spin?</div>
114 + <p className="text-sm text-fg-3">A username is all it takes. Your first {formatSC(STARTING_BALANCE)} are waiting.</p>
115 + </div>
116 + <div className="flex gap-2">
117 + <Button variant="accent" href="/register">
118 + Play free
119 + </Button>
120 + <Button variant="ghost" href="/responsible-play">
121 + Responsible play
122 + </Button>
123 + </div>
124 + </div>
125 + </ProsePage>
126 + );
127 +}
added apps/web/src/app/layout.tsx +42 −0
@@ -0,0 +1,42 @@
1 +import type { Metadata, Viewport } from "next";
2 +import { Geist, Geist_Mono } from "next/font/google";
3 +import "./globals.css";
4 +import { Providers } from "@/components/shell/providers";
5 +import { apiServer } from "@/lib/api-server";
6 +import type { PublicUser, UserSettings, WalletView } from "@spinza/shared";
7 +
8 +const geist = Geist({ subsets: ["latin"], variable: "--font-geist-sans", display: "swap" });
9 +const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono", display: "swap" });
10 +
11 +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.spinza.dev";
12 +
13 +export const metadata: Metadata = {
14 + metadataBase: new URL(siteUrl),
15 + title: { default: "Spinza — The Virtual Casino Playground", template: "%s · Spinza" },
16 + description: "Play original Spinza games using free fictional credits. No deposits. No withdrawals. No cash value.",
17 + applicationName: "Spinza",
18 + openGraph: { title: "Spinza — The Virtual Casino Playground", description: "Play original Spinza games using free fictional credits. No deposits. No withdrawals. No cash value.", url: siteUrl, siteName: "Spinza", type: "website", images: ["/og.png"] },
19 + twitter: { card: "summary_large_image", title: "Spinza — The Virtual Casino Playground", description: "Original games. Fictional credits. No cash value." },
20 + icons: { icon: "/icon.svg", apple: "/apple-icon.png" },
21 + manifest: "/manifest.webmanifest",
22 + robots: { index: true, follow: true },
23 +};
24 +
25 +export const viewport: Viewport = {
26 + themeColor: "#07080c",
27 + width: "device-width",
28 + initialScale: 1,
29 + viewportFit: "cover",
30 + maximumScale: 1,
31 +};
32 +
33 +export default async function RootLayout({ children }: { children: React.ReactNode }) {
34 + const session = await apiServer<{ user: PublicUser; wallet: WalletView; settings: UserSettings }>("/api/user");
35 + return (
36 + <html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
37 + <body>
38 + <Providers session={session}>{children}</Providers>
39 + </body>
40 + </html>
41 + );
42 +}
added apps/web/src/app/leaderboard/page.tsx +179 −0
@@ -0,0 +1,179 @@
1 +"use client";
2 +
3 +import { useState } from "react";
4 +import Link from "next/link";
5 +import { Crown, Trophy, EyeOff, Info } from "lucide-react";
6 +import type { LeaderboardView, LeaderboardEntry } from "@spinza/shared";
7 +import { formatMultiplier, formatSC } from "@spinza/shared";
8 +import { useSession } from "@/lib/store";
9 +import { useApi } from "@/lib/use-api";
10 +import { cn, timeAgo } from "@/lib/utils";
11 +import { Card, Empty, Skeleton, Tabs } from "@/components/ui";
12 +import { AppShell } from "@/components/shell/app-shell";
13 +import { ApiErrorState } from "@/components/shell/api-error";
14 +
15 +const CATEGORIES = [
16 + { value: "biggest_win_today", label: "Biggest Win Today", short: "Today" },
17 + { value: "biggest_win_week", label: "Biggest Win This Week", short: "This Week" },
18 + { value: "biggest_multiplier", label: "Biggest Multiplier", short: "Multiplier" },
19 + { value: "most_spins", label: "Most Spins", short: "Spins" },
20 + { value: "highest_level", label: "Highest Level", short: "Level" },
21 +] as const;
22 +type Category = (typeof CATEGORIES)[number]["value"];
23 +
24 +function formatValue(cat: Category, v: number): string {
25 + switch (cat) {
26 + case "biggest_multiplier":
27 + return formatMultiplier(v);
28 + case "most_spins":
29 + return `${Math.round(v).toLocaleString("en-US")} spins`;
30 + case "highest_level":
31 + return `Level ${Math.round(v)}`;
32 + default:
33 + return formatSC(v);
34 + }
35 +}
36 +
37 +function Avatar({ name, rank, size = "md" }: { name: string; rank: number; size?: "md" | "lg" }) {
38 + const ring = rank === 1 ? "ring-2 ring-accent shadow-glow" : rank === 2 ? "ring-2 ring-[#c7ccd8]" : rank === 3 ? "ring-2 ring-[#b87b4b]" : "ring-1 ring-line-2";
39 + return (
40 + <span className={cn("grid shrink-0 place-items-center rounded-full metal font-bold uppercase", ring, size === "lg" ? "h-16 w-16 text-xl" : "h-10 w-10 text-sm")} aria-hidden>
41 + {name.slice(0, 1)}
42 + </span>
43 + );
44 +}
45 +
46 +function Podium({ entries, cat }: { entries: LeaderboardEntry[]; cat: Category }) {
47 + const order = [entries[1], entries[0], entries[2]].filter(Boolean) as LeaderboardEntry[];
48 + if (!entries.length) return null;
49 + return (
50 + <div className="grid grid-cols-3 items-end gap-2 sm:gap-4">
51 + {order.map((e) => {
52 + const first = e.rank === 1;
53 + return (
54 + <div key={e.rank} className={cn("relative flex flex-col items-center rounded-lg border p-3 pt-6 text-center sm:p-4 sm:pt-8", first ? "surface-2 border-accent/40 bg-[radial-gradient(ellipse_at_top,rgba(201,169,97,0.18),transparent_60%)] min-h-[200px] sm:min-h-[230px]" : "surface min-h-[170px] sm:min-h-[190px]", e.isYou && "outline outline-2 outline-accent/60")}>
55 + {first ? <Crown className="absolute -top-3 h-6 w-6 text-accent drop-shadow-[0_0_8px_rgba(201,169,97,0.7)]" /> : null}
56 + <span className={cn("absolute left-2 top-2 grid h-6 w-6 place-items-center rounded-full text-[11px] font-bold", first ? "bg-accent text-bg" : "bg-surface-3 text-fg-2")}>{e.rank}</span>
57 + <Avatar name={e.username} rank={e.rank} size={first ? "lg" : "md"} />
58 + <div className="mt-3 w-full truncate text-[14px] font-semibold tracking-tight sm:text-[15px]">{e.isYou ? "You" : e.username}</div>
59 + <div className="text-[11px] text-fg-3">Level {e.level}</div>
60 + <div className={cn("mt-2 font-semibold tabular", first ? "text-credit text-base sm:text-lg" : "text-fg text-sm")}>{formatValue(cat, e.value)}</div>
61 + {e.game ? <div className="mt-0.5 truncate text-[11px] text-fg-4">{e.game}</div> : null}
62 + </div>
63 + );
64 + })}
65 + </div>
66 + );
67 +}
68 +
69 +export default function LeaderboardPage() {
70 + const [cat, setCat] = useState<Category>("biggest_win_today");
71 + const { data, error, loading, reload } = useApi<LeaderboardView>(`/api/leaderboards?category=${cat}`);
72 + const status = useSession((s) => s.status);
73 + const optIn = useSession((s) => s.settings?.leaderboardOptIn ?? true);
74 + const entries = data?.entries ?? [];
75 + const rest = entries.slice(3);
76 +
77 + return (
78 + <AppShell>
79 + <div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
80 + <div>
81 + <div className="eyebrow mb-1">Leaderboard</div>
82 + <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">{data?.label || CATEGORIES.find((c) => c.value === cat)?.label}</h1>
83 + <p className="mt-2 max-w-xl text-sm text-fg-3">Bragging rights only. All values are in fictional Spinza Credits or gameplay stats — nothing is ever paid out.</p>
84 + </div>
85 + <div className="-mx-4 overflow-x-auto px-4 scrollbar-none sm:mx-0 sm:px-0">
86 + <Tabs<Category> value={cat} onChange={setCat} items={CATEGORIES.map((c) => ({ value: c.value, label: c.short }))} />
87 + </div>
88 + </div>
89 +
90 + {status === "authenticated" && !optIn ? (
91 + <div className="mb-4 flex items-start gap-3 rounded-md border border-line-2 bg-surface-2 px-4 py-3 text-sm text-fg-2" role="status">
92 + <EyeOff className="mt-0.5 h-4 w-4 shrink-0 text-fg-3" />
93 + <p>
94 + You have opted out of leaderboards, so your results are hidden from other players.{" "}
95 + <Link href="/settings#leaderboard" className="font-medium text-accent-2 underline-offset-4 hover:underline">
96 + Change in settings
97 + </Link>
98 + </p>
99 + </div>
100 + ) : null}
101 +
102 + {error && !data ? (
103 + <ApiErrorState error={error} retry={reload} />
104 + ) : loading && !data ? (
105 + <div aria-busy>
106 + <div className="grid grid-cols-3 items-end gap-2 sm:gap-4">
107 + <Skeleton className="h-[170px] rounded-lg" />
108 + <Skeleton className="h-[200px] rounded-lg" />
109 + <Skeleton className="h-[170px] rounded-lg" />
110 + </div>
111 + <div className="mt-4 space-y-2">
112 + {Array.from({ length: 6 }).map((_, i) => (
113 + <Skeleton key={i} className="h-14 rounded-md" />
114 + ))}
115 + </div>
116 + </div>
117 + ) : !entries.length ? (
118 + <Empty title="No entries yet" description={cat.startsWith("biggest_win") ? "Nobody has landed a win in this period yet. Yours could be the first." : "This board fills up as players spin."} icon={<Trophy className="h-5 w-5" />} />
119 + ) : (
120 + <div className={cn("transition-opacity", loading && "opacity-60")}>
121 + <Podium entries={entries.slice(0, 3)} cat={cat} />
122 + {rest.length ? (
123 + <Card as="ol" className="mt-4 divide-y divide-line overflow-hidden">
124 + {rest.map((e) => (
125 + <li key={`${e.rank}-${e.username}`} className={cn("flex items-center gap-3 px-3 py-2.5 sm:px-4", e.isYou && "bg-accent-soft")}>
126 + <span className="w-8 shrink-0 text-center text-sm font-semibold tabular text-fg-3">{e.rank}</span>
127 + <Avatar name={e.username} rank={e.rank} />
128 + <div className="min-w-0 flex-1">
129 + <div className="truncate text-[15px] font-medium">{e.isYou ? `${e.username} (you)` : e.username}</div>
130 + <div className="text-[12px] text-fg-3">
131 + Level {e.level}
132 + {e.game ? ` · ${e.game}` : ""}
133 + </div>
134 + </div>
135 + <div className="shrink-0 text-right text-sm font-semibold tabular">{formatValue(cat, e.value)}</div>
136 + </li>
137 + ))}
138 + </Card>
139 + ) : null}
140 +
141 + {status === "authenticated" ? (
142 + data?.you && !entries.some((e) => e.isYou) ? (
143 + <Card className="mt-4 flex items-center gap-3 border-accent/40 px-3 py-3 sm:px-4">
144 + <span className="w-8 shrink-0 text-center text-sm font-semibold tabular text-accent-2">{data.you.rank}</span>
145 + <Avatar name={data.you.username} rank={data.you.rank} />
146 + <div className="min-w-0 flex-1">
147 + <div className="truncate text-[15px] font-medium">{data.you.username} (you)</div>
148 + <div className="text-[12px] text-fg-3">Level {data.you.level}</div>
149 + </div>
150 + <div className="shrink-0 text-right text-sm font-semibold tabular">{formatValue(cat, data.you.value)}</div>
151 + </Card>
152 + ) : !data?.you ? (
153 + <p className="mt-4 text-center text-[13px] text-fg-3">{optIn ? "You are not on this board yet. Play a few rounds to enter." : "Opt back in to appear on leaderboards."}</p>
154 + ) : null
155 + ) : (
156 + <p className="mt-4 text-center text-[13px] text-fg-3">
157 + <Link href="/login?next=%2Fleaderboard" className="font-medium text-accent-2 underline-offset-4 hover:underline">
158 + Sign in
159 + </Link>{" "}
160 + to see where you rank.
161 + </p>
162 + )}
163 + {data?.updatedAt ? <p className="mt-3 text-center text-[11px] text-fg-4">Updated {timeAgo(data.updatedAt)}</p> : null}
164 + </div>
165 + )}
166 +
167 + <div className="mt-10 flex items-start gap-3 rounded-md border border-line p-4 text-[13px] leading-relaxed text-fg-3">
168 + <Info className="mt-0.5 h-4 w-4 shrink-0" />
169 + <p>
170 + Privacy: leaderboards show your username, level and result only. You can leave all leaderboards at any time from{" "}
171 + <Link href="/settings#leaderboard" className="font-medium text-fg-2 underline-offset-4 hover:underline">
172 + Settings → Leaderboard participation
173 + </Link>
174 + . Spinza never shares data with third parties.
175 + </p>
176 + </div>
177 + </AppShell>
178 + );
179 +}
added apps/web/src/app/legal/privacy/page.tsx +132 −0
@@ -0,0 +1,132 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { ProsePage, Section, Callout, Bullets } from "@/components/lobby/prose";
4 +
5 +export const metadata: Metadata = {
6 + title: "Privacy Policy",
7 + description: "Spinza's privacy policy: we store a username, a password hash and gameplay statistics. No email, no trackers, no ads, no data sales.",
8 + alternates: { canonical: "/legal/privacy" },
9 +};
10 +
11 +const TOC = [
12 + { id: "summary", label: "In one paragraph" },
13 + { id: "collect", label: "What we collect" },
14 + { id: "not", label: "What we do not collect" },
15 + { id: "use", label: "How we use it" },
16 + { id: "cookies", label: "Cookies" },
17 + { id: "sharing", label: "Sharing" },
18 + { id: "retention", label: "Retention & deletion" },
19 + { id: "security", label: "Security" },
20 + { id: "rights", label: "Your choices" },
21 + { id: "changes", label: "Changes" },
22 +];
23 +
24 +const ROWS: [string, string][] = [
25 + ["Username", "Identifies your account and appears on leaderboards (unless you opt out)."],
26 + ["Password hash", "A one-way Argon2id hash. We never store or see your actual password."],
27 + ["Recovery code hash", "A one-way hash of your recovery code, used only to verify it when you reset your password."],
28 + ["Age confirmation", "The time you confirmed you are 18 or older."],
29 + ["Gameplay records", "Each round: game, bet, win, multiplier, features, balance after and a round ID; plus aggregate statistics (spins, wins, biggest win, level, XP)."],
30 + ["Credit ledger", "Every movement of fictional Spinza Credits, so your balance is always auditable."],
31 + ["Rewards & progression", "Daily-reward streak, missions, achievements, favourites."],
32 + ["Settings", "Sound, animation, reminder and leaderboard preferences."],
33 + ["Session data", "A session token (as a hash), the time of sign-in and last activity, your browser's user-agent string and a truncated IP address, so you can review and revoke devices."],
34 + ["Security log", "Sign-in successes and failures, password and recovery-code changes, and rate-limit events, kept to protect accounts."],
35 +];
36 +
37 +export default function PrivacyPage() {
38 + return (
39 + <ProsePage eyebrow="Legal" title="Privacy Policy" lead="Spinza is built to know as little about you as possible. This page lists exactly what we store, why, and what we deliberately never touch." updated="September 7, 2026" toc={TOC}>
40 + <Section id="summary" title="In one paragraph">
41 + <Callout>
42 + We store your <strong>username</strong>, a <strong>hash of your password</strong>, and your <strong>gameplay statistics and settings</strong>. We do not collect your email address, phone number, real name, location, payment details or contacts. We use <strong>no advertising, no analytics trackers and no third-party scripts</strong>. We never sell, rent or share your data.
43 + </Callout>
44 + </Section>
45 +
46 + <Section id="collect" title="What we collect">
47 + <p>Everything below is created by you using Spinza. Nothing is gathered from other sources.</p>
48 + <div className="overflow-hidden rounded-md border border-line">
49 + <table className="w-full text-left text-[14px]">
50 + <thead className="bg-surface text-[11px] uppercase tracking-wider text-fg-3">
51 + <tr>
52 + <th className="px-4 py-2.5 font-semibold">Data</th>
53 + <th className="px-4 py-2.5 font-semibold">Purpose</th>
54 + </tr>
55 + </thead>
56 + <tbody className="divide-y divide-line">
57 + {ROWS.map(([k, v]) => (
58 + <tr key={k}>
59 + <td className="whitespace-nowrap px-4 py-2.5 align-top font-medium text-fg">{k}</td>
60 + <td className="px-4 py-2.5 text-fg-2">{v}</td>
61 + </tr>
62 + ))}
63 + </tbody>
64 + </table>
65 + </div>
66 + </Section>
67 +
68 + <Section id="not" title="What we do not collect">
69 + <Bullets items={["Email address, phone number or any other contact detail.", "Real name, date of birth, address or government identifiers.", "Payment information of any kind — Spinza has no payments.", "Precise location. IP addresses are truncated before being shown to you and used only for security rate-limiting.", "Advertising identifiers, cross-site cookies or browsing history.", "Anything from your device beyond what your browser sends with every web request."]} />
70 + </Section>
71 +
72 + <Section id="use" title="How we use it">
73 + <Bullets items={["To run the game: keep your balance, history, progress and settings in sync across your devices.", "To keep accounts secure: sign you in, let you review and revoke sessions, detect brute-force attempts and abuse.", "To operate leaderboards and the wins feed — only if you have not opted out.", "To keep the games fair: aggregate, anonymous game statistics (spins, returns, hit rates) are used to monitor that each game behaves as certified."]} />
74 + <p>We do not profile you, make automated decisions that affect you, or use your data for marketing.</p>
75 + </Section>
76 +
77 + <Section id="cookies" title="Cookies">
78 + <p>
79 + Spinza sets one strictly necessary cookie, <code className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-[13px]">spinza_session</code>, which keeps you signed in. It is HttpOnly, Secure and SameSite, and expires after 30 days or when you sign out. There are no analytics, advertising or third-party cookies, so no cookie banner is needed.
80 + </p>
81 + </Section>
82 +
83 + <Section id="sharing" title="Sharing">
84 + <p>We do not sell, rent, trade or share personal data with third parties. Other players can see only your username, level and leaderboard results, and only if you participate in leaderboards. The only circumstance in which data would leave Spinza is a valid legal obligation, in which case we disclose the minimum required.</p>
85 + <p>Spinza is hosted on infrastructure we operate. No third-party analytics, advertising, font, CDN or social-media services load on the player pages.</p>
86 + </Section>
87 +
88 + <Section id="retention" title="Retention and deletion">
89 + <p>Your data is kept while your account exists so your history and progress remain intact. Sessions expire after 30 days of inactivity. Security-log entries are kept for a limited period for abuse prevention. When an account is closed, its personal data is deleted; anonymous, aggregate game statistics that cannot identify you are retained.</p>
90 + </Section>
91 +
92 + <Section id="security" title="Security">
93 + <p>Passwords and recovery codes are hashed with Argon2id and never stored in plain text. Sessions are random tokens stored as hashes. All traffic is encrypted in transit. Sign-in, registration and recovery endpoints are rate-limited. You can review every active session and sign any of them out from Settings → Security.</p>
94 + </Section>
95 +
96 + <Section id="rights" title="Your choices">
97 + <Bullets
98 + items={[
99 + <>
100 + <strong>Leaderboards:</strong> opt out at any time in{" "}
101 + <Link href="/settings#leaderboard" className="text-accent-2 underline-offset-4 hover:underline">
102 + Settings
103 + </Link>
104 + ; your entries disappear immediately.
105 + </>,
106 + <>
107 + <strong>Sessions:</strong> revoke any device from Settings → Security.
108 + </>,
109 + <>
110 + <strong>Access:</strong> your profile, round history and credit ledger pages show you everything we hold about your play.
111 + </>,
112 + <>
113 + <strong>Deletion:</strong> you may request closure of your account and deletion of its data through the channels listed on this site.
114 + </>,
115 + ]}
116 + />
117 + <p>Depending on where you live you may have additional rights under local law (such as access, correction, portability or objection). We honour them regardless of jurisdiction.</p>
118 + </Section>
119 +
120 + <Section id="changes" title="Changes to this policy">
121 + <p>If we change what we collect or how we use it, we will update this page and its “last updated” date before the change takes effect. We will never start collecting email addresses, adding trackers or selling data without saying so here first — and we have no plans to.</p>
122 + <p>
123 + Read alongside our{" "}
124 + <Link href="/legal/terms" className="text-accent-2 underline-offset-4 hover:underline">
125 + Terms of Service
126 + </Link>
127 + .
128 + </p>
129 + </Section>
130 + </ProsePage>
131 + );
132 +}
added apps/web/src/app/legal/terms/page.tsx +96 −0
@@ -0,0 +1,96 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { ProsePage, Section, Callout, Bullets } from "@/components/lobby/prose";
4 +
5 +export const metadata: Metadata = {
6 + title: "Terms of Service",
7 + description: "Terms of Service for Spinza, a fictional social casino. Virtual credits only, no purchases, no withdrawals, no cash value, 18+.",
8 + alternates: { canonical: "/legal/terms" },
9 +};
10 +
11 +const TOC = [
12 + { id: "nature", label: "What Spinza is" },
13 + { id: "eligibility", label: "Eligibility" },
14 + { id: "account", label: "Accounts & recovery codes" },
15 + { id: "credits", label: "Spinza Credits" },
16 + { id: "conduct", label: "Fair play & conduct" },
17 + { id: "content", label: "Games & intellectual property" },
18 + { id: "availability", label: "Availability & changes" },
19 + { id: "liability", label: "Disclaimers & liability" },
20 + { id: "termination", label: "Termination" },
21 + { id: "misc", label: "General" },
22 +];
23 +
24 +export default function TermsPage() {
25 + return (
26 + <ProsePage eyebrow="Legal" title="Terms of Service" lead="These terms govern your use of Spinza. They are written to be read. The short version: Spinza is a free game with fictional credits, for adults, and we ask you to play fair." updated="September 7, 2026" toc={TOC}>
27 + <Section id="nature" title="1. What Spinza is">
28 + <p>Spinza (“Spinza”, “we”, “us”) is an online social casino: a collection of original casino-style games played exclusively with a fictional in-game currency called Spinza Credits (“SC”). Spinza is provided for entertainment only.</p>
29 + <Callout>
30 + <strong>Spinza is not a gambling service.</strong> No money or anything of monetary value is ever wagered, won or lost. Spinza Credits cannot be purchased, sold, transferred, redeemed or withdrawn, and have no cash value. Spinza offers no prizes, sweepstakes or rewards of monetary value.
31 + </Callout>
32 + </Section>
33 +
34 + <Section id="eligibility" title="2. Eligibility">
35 + <p>You must be at least 18 years old (or the age of majority where you live, if higher) to create an account or play. By registering you confirm that you meet this requirement. We may suspend or close accounts we reasonably believe belong to minors.</p>
36 + <p>You are responsible for making sure that using Spinza is lawful where you are.</p>
37 + </Section>
38 +
39 + <Section id="account" title="3. Accounts and recovery codes">
40 + <Bullets
41 + items={[
42 + "One account per person. Usernames must not impersonate others, contain offensive language or infringe anyone's rights; we may refuse or rename usernames that do.",
43 + "You are responsible for keeping your password and recovery code confidential and for all activity on your account.",
44 + "Spinza does not collect email addresses or phone numbers. Your recovery code is the only way to regain access if you forget your password. If you lose both, the account cannot be recovered and we cannot restore it.",
45 + "You may delete your access at any time by signing out of all sessions; you may request account closure through the channels listed on this site.",
46 + ]}
47 + />
48 + </Section>
49 +
50 + <Section id="credits" title="4. Spinza Credits">
51 + <p>Spinza Credits are a limited, revocable, non-transferable licence to use a fictional game feature. They are not property, not currency and not a stored-value product. You acknowledge that:</p>
52 + <Bullets items={["Credits have no monetary value and cannot be exchanged for money, goods, services or any other consideration, inside or outside Spinza.", "Credits cannot be bought. Spinza does not sell credits or anything else, and does not accept payments.", "Credits are granted only through gameplay features (starting balance, daily rewards, rescue credits, missions, achievements, level-ups) at amounts we may adjust at any time.", "We may correct, reset or remove credits that were obtained through bugs, exploits or violations of these terms."]} />
53 + </Section>
54 +
55 + <Section id="conduct" title="5. Fair play and conduct">
56 + <p>To keep Spinza fair for everyone, you agree not to:</p>
57 + <Bullets items={["Use bots, scripts, automation or multiple accounts to play or claim rewards.", "Probe, reverse-engineer, tamper with or attempt to manipulate game outcomes, the client, the API or other players' accounts.", "Exploit bugs rather than reporting them.", "Harass other players or use usernames that are abusive, hateful or misleading.", "Use Spinza for any commercial purpose, or represent Spinza Credits as having value."]} />
58 + <p>We may investigate suspicious activity, adjust balances, remove leaderboard entries and suspend or terminate accounts that breach these rules.</p>
59 + </Section>
60 +
61 + <Section id="content" title="6. Games and intellectual property">
62 + <p>All Spinza games, artwork, names, symbols, sounds, text, software and the Spinza mark are original works owned by Spinza or its licensors and protected by copyright, trademark and other laws. You receive a personal, non-exclusive, non-transferable, revocable licence to play them through the Spinza website for your own entertainment. You may not copy, modify, distribute, sell or create derivative works from them.</p>
63 + <p>Game outcomes are generated on our servers using a secure random source. Each game is simulated and certified against its published mathematics before release; the certification summary is shown in the game&apos;s information panel. Published return figures are long-run statistical expectations and do not predict individual sessions.</p>
64 + </Section>
65 +
66 + <Section id="availability" title="7. Availability and changes">
67 + <p>Spinza is provided free of charge and we aim to keep it available, but we do not guarantee uninterrupted access. We may add, change, pause or remove games, features, reward amounts and these terms at any time. Material changes to the terms will be signposted on this page with a new “last updated” date. Continued use after a change means you accept the updated terms.</p>
68 + <p>During maintenance your credits and progress are preserved.</p>
69 + </Section>
70 +
71 + <Section id="liability" title="8. Disclaimers and limitation of liability">
72 + <p>Spinza is provided “as is” and “as available”, without warranties of any kind, express or implied, including fitness for a particular purpose and non-infringement. To the fullest extent permitted by law, Spinza and its operators are not liable for any indirect, incidental, consequential or special damages, or for loss of data, credits, progress or access, arising from your use of Spinza. Because Spinza involves no money, there is no financial loss that can result from play.</p>
73 + <p>Nothing in these terms limits rights that cannot be limited under applicable law.</p>
74 + </Section>
75 +
76 + <Section id="termination" title="9. Termination">
77 + <p>You may stop using Spinza at any time. We may suspend or terminate your account, with or without notice, if you breach these terms, if required by law, or if we discontinue the service. On termination your licence to use Spinza Credits and content ends immediately; because credits have no value, no compensation is due.</p>
78 + </Section>
79 +
80 + <Section id="misc" title="10. General">
81 + <p>These terms are the entire agreement between you and Spinza about the service. If any part is found unenforceable, the rest remains in effect. Our failure to enforce a provision is not a waiver. You may not assign your account or these terms; we may assign them in connection with a transfer of the service. These terms are governed by the laws of the jurisdiction in which the service is operated, without regard to conflict-of-law rules.</p>
82 + <p>
83 + See also our{" "}
84 + <Link href="/legal/privacy" className="text-accent-2 underline-offset-4 hover:underline">
85 + Privacy Policy
86 + </Link>{" "}
87 + and{" "}
88 + <Link href="/responsible-play" className="text-accent-2 underline-offset-4 hover:underline">
89 + Responsible Play
90 + </Link>{" "}
91 + pages.
92 + </p>
93 + </Section>
94 + </ProsePage>
95 + );
96 +}
added apps/web/src/app/maintenance/page.tsx +28 −0
@@ -0,0 +1,28 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { Wrench } from "lucide-react";
4 +import { SpinzaWordmark } from "@/components/brand/logo";
5 +import { MaintenanceRetry } from "./retry";
6 +
7 +export const metadata: Metadata = { title: "Maintenance", robots: { index: false } };
8 +
9 +export default function MaintenancePage() {
10 + return (
11 + <div className="relative flex min-h-dvh flex-col items-center justify-center px-4 py-16 text-center">
12 + <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_50%_20%,rgba(201,169,97,0.14),transparent_55%)]" />
13 + <Link href="/" className="relative mb-10 focus-ring rounded-md" aria-label="Spinza home">
14 + <SpinzaWordmark />
15 + </Link>
16 + <div className="relative" role="status">
17 + <div className="mx-auto mb-6 grid h-16 w-16 place-items-center rounded-full metal text-accent-2 animate-float">
18 + <Wrench className="h-7 w-7" />
19 + </div>
20 + <div className="eyebrow mb-2 text-accent">Scheduled maintenance</div>
21 + <h1 className="text-3xl font-semibold tracking-[-0.03em] sm:text-4xl">Spinza is getting an upgrade.</h1>
22 + <p className="mx-auto mt-3 max-w-md text-[15px] text-fg-3">Your credits and progress are safe. We are polishing the reels and will be back shortly — this page checks automatically.</p>
23 + <MaintenanceRetry />
24 + </div>
25 + <p className="relative mt-16 text-[12px] text-fg-4">Virtual credits only. No deposits. No withdrawals. No cash value. 18+.</p>
26 + </div>
27 + );
28 +}
added apps/web/src/app/maintenance/retry.tsx +54 −0
@@ -0,0 +1,54 @@
1 +"use client";
2 +
3 +import { useEffect, useState } from "react";
4 +import { useRouter } from "next/navigation";
5 +import { RefreshCw } from "lucide-react";
6 +import { Button } from "@/components/ui";
7 +
8 +const INTERVAL = 30;
9 +
10 +/** Polls the API every 30 s and returns to the lobby as soon as it answers. */
11 +export function MaintenanceRetry() {
12 + const router = useRouter();
13 + const [left, setLeft] = useState(INTERVAL);
14 + const [checking, setChecking] = useState(false);
15 +
16 + const check = async () => {
17 + setChecking(true);
18 + try {
19 + const res = await fetch("/api/games", { cache: "no-store" });
20 + if (res.ok) {
21 + router.replace("/");
22 + return;
23 + }
24 + } catch {
25 + // still down
26 + } finally {
27 + setChecking(false);
28 + setLeft(INTERVAL);
29 + }
30 + };
31 +
32 + useEffect(() => {
33 + const t = setInterval(() => {
34 + setLeft((n) => {
35 + if (n <= 1) {
36 + void check();
37 + return INTERVAL;
38 + }
39 + return n - 1;
40 + });
41 + }, 1000);
42 + return () => clearInterval(t);
43 + // eslint-disable-next-line react-hooks/exhaustive-deps
44 + }, []);
45 +
46 + return (
47 + <div className="mt-8 flex flex-col items-center gap-3">
48 + <Button size="lg" variant="secondary" onClick={check} loading={checking}>
49 + <RefreshCw className="h-4 w-4" /> Check now
50 + </Button>
51 + <span className="text-[12px] tabular text-fg-4">Next automatic check in {left}s</span>
52 + </div>
53 + );
54 +}
added apps/web/src/app/not-found.tsx +35 −0
@@ -0,0 +1,35 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { Compass } from "lucide-react";
4 +import { Button } from "@/components/ui";
5 +import { SpinzaWordmark } from "@/components/brand/logo";
6 +
7 +export const metadata: Metadata = { title: "Page not found", robots: { index: false } };
8 +
9 +export default function NotFound() {
10 + return (
11 + <div className="relative flex min-h-dvh flex-col items-center justify-center px-4 py-16 text-center">
12 + <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_50%_20%,rgba(201,169,97,0.12),transparent_55%)]" />
13 + <Link href="/" className="relative mb-10 focus-ring rounded-md" aria-label="Spinza home">
14 + <SpinzaWordmark />
15 + </Link>
16 + <div className="relative">
17 + <div className="mx-auto mb-6 grid h-16 w-16 place-items-center rounded-full metal text-accent-2">
18 + <Compass className="h-7 w-7" />
19 + </div>
20 + <div className="font-mono text-[12px] tracking-[0.3em] text-fg-4">404</div>
21 + <h1 className="mt-2 text-3xl font-semibold tracking-[-0.03em] sm:text-4xl">This reel doesn&apos;t exist.</h1>
22 + <p className="mx-auto mt-3 max-w-md text-[15px] text-fg-3">The page you were looking for has moved or was never here. Your credits and progress are unaffected.</p>
23 + <div className="mt-8 flex flex-col justify-center gap-3 sm:flex-row">
24 + <Button href="/" size="lg">
25 + Back to the lobby
26 + </Button>
27 + <Button href="/games" variant="secondary" size="lg">
28 + Browse games
29 + </Button>
30 + </div>
31 + </div>
32 + <p className="relative mt-16 text-[12px] text-fg-4">Virtual credits only. No deposits. No withdrawals. No cash value. 18+.</p>
33 + </div>
34 + );
35 +}
added apps/web/src/app/page.tsx +50 −0
@@ -0,0 +1,50 @@
1 +import type { Metadata } from "next";
2 +import type { GameCard, PublicUser, UserSettings, WalletView } from "@spinza/shared";
3 +import { apiServer } from "@/lib/api-server";
4 +import { AppShell } from "@/components/shell/app-shell";
5 +import { Landing } from "@/components/lobby/landing";
6 +import { Lobby } from "@/components/lobby/lobby";
7 +
8 +export const metadata: Metadata = {
9 + title: "Spinza — The Virtual Casino Playground",
10 + description: "Twenty original casino-style games played with free fictional credits. No deposits. No withdrawals. No cash value. 18+.",
11 + alternates: { canonical: "/" },
12 +};
13 +
14 +interface GamesResponse {
15 + games: GameCard[];
16 + livePlayers: number;
17 + recommendations: string[];
18 +}
19 +
20 +export default async function HomePage() {
21 + const [session, lobby] = await Promise.all([apiServer<{ user: PublicUser; wallet: WalletView; settings: UserSettings }>("/api/user"), apiServer<GamesResponse>("/api/games")]);
22 +
23 + if (!session) {
24 + return (
25 + <>
26 + <AppShell wide>
27 + <Landing games={lobby?.games ?? []} apiDown={lobby === null} />
28 + </AppShell>
29 + <script
30 + type="application/ld+json"
31 + dangerouslySetInnerHTML={{
32 + __html: JSON.stringify({
33 + "@context": "https://schema.org",
34 + "@type": "WebSite",
35 + name: "Spinza",
36 + url: process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.spinza.dev",
37 + description: "Original casino-style games played with fictional credits. No deposits, no withdrawals, no cash value.",
38 + }),
39 + }}
40 + />
41 + </>
42 + );
43 + }
44 +
45 + return (
46 + <AppShell>
47 + <Lobby user={session.user} games={lobby?.games ?? null} livePlayers={lobby?.livePlayers ?? 0} recommendations={lobby?.recommendations ?? []} />
48 + </AppShell>
49 + );
50 +}
added apps/web/src/app/profile/history/page.tsx +303 −0
@@ -0,0 +1,303 @@
1 +"use client";
2 +
3 +import { useCallback, useEffect, useState } from "react";
4 +import Link from "next/link";
5 +import { ArrowLeft, History, Copy, Check, ChevronRight } from "lucide-react";
6 +import type { RoundHistoryEntry } from "@spinza/shared";
7 +import { formatMultiplier, formatSC, classifyWin, WIN_CLASSES } from "@spinza/shared";
8 +import { api, ApiClientError } from "@/lib/api";
9 +import { toast } from "@/lib/store";
10 +import { cn } from "@/lib/utils";
11 +import { Badge, Button, Card, Empty, Sheet, Skeleton } from "@/components/ui";
12 +import { AppShell } from "@/components/shell/app-shell";
13 +import { RequireAuth } from "@/components/shell/require-auth";
14 +import { ApiErrorState } from "@/components/shell/api-error";
15 +import { useRouter } from "next/navigation";
16 +
17 +type Entry = RoundHistoryEntry & { features: string[] };
18 +interface Page {
19 + entries: Entry[];
20 + nextBefore: string | null;
21 +}
22 +interface RoundDetail {
23 + roundId: string;
24 + game: string;
25 + version: string;
26 + bet: number;
27 + win: number;
28 + multiplier: number;
29 + balanceAfter: number;
30 + createdAt: string;
31 + result: unknown;
32 + features: string[];
33 +}
34 +
35 +const LIMIT = 40;
36 +
37 +function fmtDate(iso: string) {
38 + const d = new Date(iso);
39 + return d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
40 +}
41 +
42 +function WinTag({ multiplier }: { multiplier: number }) {
43 + const cls = classifyWin(multiplier);
44 + if (cls === "none" || cls === "regular") return null;
45 + const label = WIN_CLASSES.find((w) => w.id === cls)?.label ?? "";
46 + return <Badge tone={cls === "legendary" || cls === "epic" ? "accent" : cls === "mega" ? "new" : "success"}>{label}</Badge>;
47 +}
48 +
49 +function RoundSheet({ roundId, onClose }: { roundId: string | null; onClose: () => void }) {
50 + const [detail, setDetail] = useState<RoundDetail | null>(null);
51 + const [error, setError] = useState<unknown>(null);
52 + const [copied, setCopied] = useState(false);
53 +
54 + // Reset when a different round is opened (adjust-state-from-props), then fetch.
55 + const [seen, setSeen] = useState(roundId);
56 + if (roundId !== seen) {
57 + setSeen(roundId);
58 + setDetail(null);
59 + setError(null);
60 + }
61 + useEffect(() => {
62 + if (!roundId) return;
63 + let alive = true;
64 + api<RoundDetail>(`/api/user/rounds/${roundId}`)
65 + .then((d) => alive && setDetail(d))
66 + .catch((e: unknown) => alive && setError(e));
67 + return () => {
68 + alive = false;
69 + };
70 + }, [roundId]);
71 +
72 + const copy = async () => {
73 + if (!roundId) return;
74 + try {
75 + await navigator.clipboard.writeText(roundId);
76 + setCopied(true);
77 + setTimeout(() => setCopied(false), 1500);
78 + } catch {
79 + toast({ title: "Copy unavailable", tone: "danger" });
80 + }
81 + };
82 +
83 + const gameName = (detail?.game ?? "").replace(/-/g, " ");
84 + const resultObj = detail && detail.result && typeof detail.result === "object" ? (detail.result as Record<string, unknown>) : null;
85 + const summary = resultObj ? Object.entries(resultObj).filter(([, v]) => typeof v === "number" || typeof v === "string" || typeof v === "boolean") : [];
86 +
87 + return (
88 + <Sheet open={!!roundId} onClose={onClose} title="Round details">
89 + {error ? (
90 + <ApiErrorState error={error} compact />
91 + ) : !detail ? (
92 + <div className="space-y-3" aria-busy>
93 + <Skeleton className="h-6 w-1/2" />
94 + <Skeleton className="h-24" />
95 + <Skeleton className="h-16" />
96 + </div>
97 + ) : (
98 + <div className="space-y-5">
99 + <div>
100 + <div className="flex items-center gap-2">
101 + <Link href={`/games/${detail.game}`} className="text-lg font-semibold capitalize tracking-tight hover:text-accent-2" onClick={onClose}>
102 + {gameName}
103 + </Link>
104 + <WinTag multiplier={detail.multiplier} />
105 + </div>
106 + <div className="text-[13px] text-fg-3">
107 + {fmtDate(detail.createdAt)} · v{detail.version}
108 + </div>
109 + </div>
110 + <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
111 + {[
112 + { l: "Bet", v: formatSC(detail.bet) },
113 + { l: "Win", v: <span className={detail.win > 0 ? "text-credit" : ""}>{formatSC(detail.win)}</span> },
114 + { l: "Multiplier", v: formatMultiplier(detail.multiplier) },
115 + { l: "Balance after", v: formatSC(detail.balanceAfter) },
116 + ].map((x) => (
117 + <div key={x.l} className="surface rounded-md p-3">
118 + <div className="eyebrow">{x.l}</div>
119 + <div className="mt-1 text-[15px] font-semibold tabular">{x.v}</div>
120 + </div>
121 + ))}
122 + </div>
123 + <div>
124 + <div className="eyebrow mb-2">Features triggered</div>
125 + {detail.features.length ? (
126 + <div className="flex flex-wrap gap-1.5">
127 + {detail.features.map((f) => (
128 + <Badge key={f} tone="accent">
129 + {f.replace(/[_-]/g, " ")}
130 + </Badge>
131 + ))}
132 + </div>
133 + ) : (
134 + <p className="text-sm text-fg-3">Base game only — no features this round.</p>
135 + )}
136 + </div>
137 + {summary.length ? (
138 + <div>
139 + <div className="eyebrow mb-2">Outcome</div>
140 + <dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[13px]">
141 + {summary.slice(0, 12).map(([k, v]) => (
142 + <div key={k} className="flex justify-between gap-2 border-b border-line py-1">
143 + <dt className="truncate capitalize text-fg-3">{k.replace(/([A-Z])/g, " $1").replace(/[_-]/g, " ")}</dt>
144 + <dd className="tabular font-medium">{String(v)}</dd>
145 + </div>
146 + ))}
147 + </dl>
148 + </div>
149 + ) : null}
150 + <div>
151 + <div className="eyebrow mb-2">Round ID</div>
152 + <div className="flex items-center gap-2">
153 + <code className="flex-1 truncate rounded-md bg-bg-1 px-3 py-2 font-mono text-[12px] text-fg-2">{detail.roundId}</code>
154 + <Button variant="secondary" size="icon" onClick={copy} aria-label="Copy round ID">
155 + {copied ? <Check className="h-4 w-4 text-success" /> : <Copy className="h-4 w-4" />}
156 + </Button>
157 + </div>
158 + <p className="mt-2 text-[12px] text-fg-4">Every round is recorded server-side. Quote this ID if you ever need to reference a specific spin.</p>
159 + </div>
160 + </div>
161 + )}
162 + </Sheet>
163 + );
164 +}
165 +
166 +function fetchPage(before: string | null): Promise<Page> {
167 + const q = new URLSearchParams({ limit: String(LIMIT) });
168 + if (before) q.set("before", before);
169 + return api<Page>(`/api/user/history?${q}`);
170 +}
171 +
172 +function HistoryBody() {
173 + const router = useRouter();
174 + const [entries, setEntries] = useState<Entry[] | null>(null); // null = first page loading
175 + const [nextBefore, setNextBefore] = useState<string | null>(null);
176 + const [more, setMore] = useState(false);
177 + const [error, setError] = useState<unknown>(null);
178 + const [open, setOpen] = useState<string | null>(null);
179 + const [tick, setTick] = useState(0);
180 +
181 + const fail = useCallback(
182 + (e: unknown) => {
183 + if (e instanceof ApiClientError && e.status === 401) {
184 + router.replace("/login?next=%2Fprofile%2Fhistory&reason=expired");
185 + return;
186 + }
187 + setError(e);
188 + },
189 + [router],
190 + );
191 +
192 + useEffect(() => {
193 + let alive = true;
194 + fetchPage(null)
195 + .then((page) => {
196 + if (!alive) return;
197 + setEntries(page.entries);
198 + setNextBefore(page.nextBefore);
199 + setError(null);
200 + })
201 + .catch((e: unknown) => alive && fail(e));
202 + return () => {
203 + alive = false;
204 + };
205 + }, [tick, fail]);
206 +
207 + const retry = () => {
208 + setEntries(null);
209 + setError(null);
210 + setTick((t) => t + 1);
211 + };
212 +
213 + const loadMore = async () => {
214 + if (!nextBefore || more) return;
215 + setMore(true);
216 + try {
217 + const page = await fetchPage(nextBefore);
218 + setEntries((cur) => [...(cur ?? []), ...page.entries]);
219 + setNextBefore(page.nextBefore);
220 + } catch (e) {
221 + fail(e);
222 + toast({ title: "Could not load more", tone: "danger" });
223 + } finally {
224 + setMore(false);
225 + }
226 + };
227 +
228 + if (error && !entries?.length) return <ApiErrorState error={error} retry={retry} />;
229 + if (entries === null) {
230 + return (
231 + <div className="space-y-2" aria-busy>
232 + {Array.from({ length: 8 }).map((_, i) => (
233 + <Skeleton key={i} className="h-14 rounded-md" />
234 + ))}
235 + </div>
236 + );
237 + }
238 + if (!entries.length) return <Empty title="No rounds yet" description="Your spins will be listed here with bet, win, multiplier and balance." icon={<History className="h-5 w-5" />} action={<Button href="/games">Play a game</Button>} />;
239 +
240 + return (
241 + <>
242 + <Card className="overflow-hidden">
243 + <div className="hidden grid-cols-[150px_1fr_110px_120px_90px_130px_28px] gap-3 border-b border-line px-4 py-2.5 text-[11px] font-semibold uppercase tracking-wider text-fg-3 md:grid">
244 + <span>Time</span>
245 + <span>Game</span>
246 + <span className="text-right">Bet</span>
247 + <span className="text-right">Win</span>
248 + <span className="text-right">Multi</span>
249 + <span className="text-right">Balance after</span>
250 + <span />
251 + </div>
252 + <ul className="divide-y divide-line">
253 + {entries.map((e) => (
254 + <li key={e.roundId}>
255 + <button onClick={() => setOpen(e.roundId)} className="grid w-full grid-cols-[1fr_auto] items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-2 focus-ring md:grid-cols-[150px_1fr_110px_120px_90px_130px_28px]">
256 + <span className="text-[12px] tabular text-fg-3 md:text-[13px]">{fmtDate(e.createdAt)}</span>
257 + <span className="col-start-1 row-start-2 flex items-center gap-2 md:col-auto md:row-auto">
258 + <span className="truncate text-[15px] font-medium">{e.gameName}</span>
259 + <WinTag multiplier={e.multiplier} />
260 + </span>
261 + <span className="hidden text-right text-sm tabular text-fg-2 md:block">{formatSC(e.bet)}</span>
262 + <span className={cn("col-start-2 row-start-1 text-right text-sm font-semibold tabular md:col-auto md:row-auto", e.win > 0 ? "text-credit" : "text-fg-3")}>{e.win > 0 ? `+${formatSC(e.win)}` : formatSC(0)}</span>
263 + <span className="col-start-2 row-start-2 text-right text-[12px] tabular text-fg-3 md:col-auto md:row-auto md:text-sm">
264 + <span className="md:hidden">Bet {formatSC(e.bet, { unit: false })} · </span>
265 + {formatMultiplier(e.multiplier)}
266 + </span>
267 + <span className="hidden text-right text-sm tabular text-fg-2 md:block">{formatSC(e.balanceAfter)}</span>
268 + <ChevronRight className="hidden h-4 w-4 text-fg-4 md:block" />
269 + </button>
270 + </li>
271 + ))}
272 + </ul>
273 + </Card>
274 + {nextBefore ? (
275 + <div className="mt-4 flex justify-center">
276 + <Button variant="secondary" onClick={loadMore} loading={more}>
277 + Load more
278 + </Button>
279 + </div>
280 + ) : (
281 + <p className="mt-4 text-center text-[12px] text-fg-4">That&apos;s every round on record.</p>
282 + )}
283 + <RoundSheet roundId={open} onClose={() => setOpen(null)} />
284 + </>
285 + );
286 +}
287 +
288 +export default function HistoryPage() {
289 + return (
290 + <AppShell>
291 + <RequireAuth>
292 + <div className="mb-6">
293 + <Link href="/profile" className="tap -ml-1 inline-flex h-9 min-h-0 items-center gap-1 text-sm text-fg-3 hover:text-fg">
294 + <ArrowLeft className="h-4 w-4" /> Profile
295 + </Link>
296 + <h1 className="mt-1 text-3xl font-semibold tracking-tight sm:text-4xl">Round history</h1>
297 + <p className="mt-2 max-w-xl text-sm text-fg-3">Every spin, newest first. Tap a round for the full result and the features it triggered.</p>
298 + </div>
299 + <HistoryBody />
300 + </RequireAuth>
301 + </AppShell>
302 + );
303 +}
added apps/web/src/app/profile/ledger/page.tsx +194 −0
@@ -0,0 +1,194 @@
1 +"use client";
2 +
3 +import { useCallback, useEffect, useState } from "react";
4 +import Link from "next/link";
5 +import { useRouter } from "next/navigation";
6 +import { ArrowLeft, Gift, Sparkles, Target, Zap, LifeBuoy, Wrench, Dice5, Coins, ReceiptText, type LucideIcon } from "lucide-react";
7 +import type { LedgerEntry, TransactionType } from "@spinza/shared";
8 +import { formatSC } from "@spinza/shared";
9 +import { api, ApiClientError } from "@/lib/api";
10 +import { cn } from "@/lib/utils";
11 +import { Button, Card, Empty, Skeleton, Tabs } from "@/components/ui";
12 +import { AppShell } from "@/components/shell/app-shell";
13 +import { RequireAuth } from "@/components/shell/require-auth";
14 +import { ApiErrorState } from "@/components/shell/api-error";
15 +
16 +const TYPES: Record<TransactionType, { label: string; icon: LucideIcon; tone: string }> = {
17 + INITIAL_GRANT: { label: "Welcome credits", icon: Sparkles, tone: "text-accent-2 bg-accent-soft" },
18 + BET: { label: "Bet", icon: Dice5, tone: "text-fg-2 bg-surface-2" },
19 + WIN: { label: "Win", icon: Coins, tone: "text-credit bg-credit/10" },
20 + DAILY_REWARD: { label: "Daily reward", icon: Gift, tone: "text-accent-2 bg-accent-soft" },
21 + ACHIEVEMENT: { label: "Achievement", icon: Sparkles, tone: "text-accent-2 bg-accent-soft" },
22 + MISSION: { label: "Mission", icon: Target, tone: "text-info bg-info/10" },
23 + LEVEL_UP: { label: "Level up", icon: Zap, tone: "text-accent-2 bg-accent-soft" },
24 + RESCUE_CREDITS: { label: "Rescue credits", icon: LifeBuoy, tone: "text-success bg-success/10" },
25 + ADMIN_ADJUSTMENT: { label: "Adjustment", icon: Wrench, tone: "text-fg-2 bg-surface-2" },
26 +};
27 +
28 +const FILTERS = [
29 + { value: "", label: "All" },
30 + { value: "WIN", label: "Wins" },
31 + { value: "BET", label: "Bets" },
32 + { value: "rewards", label: "Rewards" },
33 +] as const;
34 +type Filter = (typeof FILTERS)[number]["value"];
35 +const REWARD_TYPES: TransactionType[] = ["DAILY_REWARD", "ACHIEVEMENT", "MISSION", "LEVEL_UP", "RESCUE_CREDITS", "INITIAL_GRANT"];
36 +
37 +interface Page {
38 + entries: LedgerEntry[];
39 + nextBefore: string | null;
40 +}
41 +const LIMIT = 50;
42 +
43 +function fmtDate(iso: string) {
44 + return new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
45 +}
46 +
47 +async function fetchPage(before: string | null, f: Filter): Promise<{ entries: LedgerEntry[]; nextBefore: string | null }> {
48 + const q = new URLSearchParams({ limit: String(LIMIT) });
49 + if (before) q.set("before", before);
50 + if (f && f !== "rewards") q.set("type", f);
51 + const page = await api<Page>(`/api/wallet/ledger?${q}`);
52 + // "Rewards" groups several types; the API filters by a single type, so refine client-side.
53 + return { entries: f === "rewards" ? page.entries.filter((e) => REWARD_TYPES.includes(e.type)) : page.entries, nextBefore: page.nextBefore };
54 +}
55 +
56 +function LedgerBody() {
57 + const router = useRouter();
58 + const [filter, setFilter] = useState<Filter>("");
59 + const [entries, setEntries] = useState<LedgerEntry[] | null>(null); // null = first page loading
60 + const [nextBefore, setNextBefore] = useState<string | null>(null);
61 + const [more, setMore] = useState(false);
62 + const [error, setError] = useState<unknown>(null);
63 + const [tick, setTick] = useState(0);
64 +
65 + // Switching filter restarts from the first page (adjust-state-from-props).
66 + const [prevFilter, setPrevFilter] = useState(filter);
67 + if (filter !== prevFilter) {
68 + setPrevFilter(filter);
69 + setEntries(null);
70 + setError(null);
71 + }
72 +
73 + const fail = useCallback(
74 + (e: unknown) => {
75 + if (e instanceof ApiClientError && e.status === 401) {
76 + router.replace("/login?next=%2Fprofile%2Fledger&reason=expired");
77 + return;
78 + }
79 + setError(e);
80 + },
81 + [router],
82 + );
83 +
84 + useEffect(() => {
85 + let alive = true;
86 + fetchPage(null, filter)
87 + .then((page) => {
88 + if (!alive) return;
89 + setEntries(page.entries);
90 + setNextBefore(page.nextBefore);
91 + setError(null);
92 + })
93 + .catch((e: unknown) => alive && fail(e));
94 + return () => {
95 + alive = false;
96 + };
97 + }, [filter, tick, fail]);
98 +
99 + const retry = () => {
100 + setEntries(null);
101 + setError(null);
102 + setTick((t) => t + 1);
103 + };
104 +
105 + const loadMore = async () => {
106 + if (!nextBefore || more) return;
107 + setMore(true);
108 + try {
109 + const page = await fetchPage(nextBefore, filter);
110 + setEntries((cur) => [...(cur ?? []), ...page.entries]);
111 + setNextBefore(page.nextBefore);
112 + } catch (e) {
113 + fail(e);
114 + } finally {
115 + setMore(false);
116 + }
117 + };
118 +
119 + return (
120 + <>
121 + <div className="mb-4">
122 + <Tabs<Filter> value={filter} onChange={setFilter} items={FILTERS.map((f) => ({ value: f.value, label: f.label }))} />
123 + </div>
124 + {error && !entries?.length ? (
125 + <ApiErrorState error={error} retry={retry} />
126 + ) : entries === null ? (
127 + <div className="space-y-2" aria-busy>
128 + {Array.from({ length: 8 }).map((_, i) => (
129 + <Skeleton key={i} className="h-16 rounded-md" />
130 + ))}
131 + </div>
132 + ) : !entries.length ? (
133 + <Empty title="Nothing here yet" description={filter ? "No transactions of this kind yet." : "Every credit movement — bets, wins, rewards — is recorded here."} icon={<ReceiptText className="h-5 w-5" />} action={filter ? <Button variant="secondary" onClick={() => setFilter("")}>Show all</Button> : <Button href="/games">Play a game</Button>} />
134 + ) : (
135 + <>
136 + <Card as="ul" className="divide-y divide-line overflow-hidden">
137 + {entries.map((e) => {
138 + const meta = TYPES[e.type] ?? { label: e.type, icon: Wrench, tone: "text-fg-2 bg-surface-2" };
139 + const Icon = meta.icon;
140 + const positive = e.amount > 0;
141 + return (
142 + <li key={e.id} className="flex items-center gap-3 px-4 py-3">
143 + <span className={cn("grid h-10 w-10 shrink-0 place-items-center rounded-md", meta.tone)}>
144 + <Icon className="h-[18px] w-[18px]" />
145 + </span>
146 + <div className="min-w-0 flex-1">
147 + <div className="flex items-center gap-2">
148 + <span className="truncate text-[15px] font-medium">{meta.label}</span>
149 + {e.reference && !/^(spz_rnd_|daily:|welcome$|rescue$)/.test(e.reference) ? <span className="hidden truncate text-[12px] text-fg-4 sm:inline">{e.reference.replace(/[:_]/g, " · ")}</span> : null}
150 + </div>
151 + <div className="text-[12px] tabular text-fg-3">{fmtDate(e.createdAt)}</div>
152 + </div>
153 + <div className="shrink-0 text-right">
154 + <div className={cn("text-[15px] font-semibold tabular", positive ? "text-credit" : "text-fg-2")}>
155 + {positive ? "+" : e.amount < 0 ? "−" : ""}
156 + {formatSC(Math.abs(e.amount))}
157 + </div>
158 + <div className="text-[11px] tabular text-fg-4">Balance {formatSC(e.balanceAfter, { unit: false })}</div>
159 + </div>
160 + </li>
161 + );
162 + })}
163 + </Card>
164 + {nextBefore ? (
165 + <div className="mt-4 flex justify-center">
166 + <Button variant="secondary" onClick={loadMore} loading={more}>
167 + Load more
168 + </Button>
169 + </div>
170 + ) : (
171 + <p className="mt-4 text-center text-[12px] text-fg-4">End of ledger.</p>
172 + )}
173 + </>
174 + )}
175 + </>
176 + );
177 +}
178 +
179 +export default function LedgerPage() {
180 + return (
181 + <AppShell>
182 + <RequireAuth>
183 + <div className="mb-6">
184 + <Link href="/profile" className="tap -ml-1 inline-flex h-9 min-h-0 items-center gap-1 text-sm text-fg-3 hover:text-fg">
185 + <ArrowLeft className="h-4 w-4" /> Profile
186 + </Link>
187 + <h1 className="mt-1 text-3xl font-semibold tracking-tight sm:text-4xl">Credit ledger</h1>
188 + <p className="mt-2 max-w-xl text-sm text-fg-3">A complete record of your Spinza Credits. All amounts are fictional and have no cash value.</p>
189 + </div>
190 + <LedgerBody />
191 + </RequireAuth>
192 + </AppShell>
193 + );
194 +}
added apps/web/src/app/profile/page.tsx +177 −0
@@ -0,0 +1,177 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { History, ReceiptText, Settings, Trophy, ChevronRight, Sparkles, Zap } from "lucide-react";
5 +import type { PublicUser } from "@spinza/shared";
6 +import { formatMultiplier, formatSC } from "@spinza/shared";
7 +import { useSession } from "@/lib/store";
8 +import { useApi } from "@/lib/use-api";
9 +import { Card, Credits, Progress, Skeleton } from "@/components/ui";
10 +import { AppShell } from "@/components/shell/app-shell";
11 +import { RequireAuth } from "@/components/shell/require-auth";
12 +import { ApiErrorState } from "@/components/shell/api-error";
13 +import { GameArt, type ArtPalette } from "@/components/lobby/game-art";
14 +
15 +interface Profile {
16 + user: PublicUser;
17 + wallet: { balance: number; lifetimeWagered: number; lifetimeWon: number };
18 + stats: { totalSpins: number; gamesPlayed: number; biggestWin: number; biggestMultiplier: number; bonuses: number; achievements: number };
19 + favoriteGame: { slug: string; name: string; spins: number; palette: ArtPalette } | null;
20 +}
21 +
22 +function Stat({ label, value, sub }: { label: string; value: React.ReactNode; sub?: string }) {
23 + return (
24 + <Card className="p-4">
25 + <div className="eyebrow">{label}</div>
26 + <div className="mt-2 text-xl font-semibold tabular tracking-tight sm:text-2xl">{value}</div>
27 + {sub ? <div className="mt-0.5 text-[12px] text-fg-3">{sub}</div> : null}
28 + </Card>
29 + );
30 +}
31 +
32 +const LINKS = [
33 + { href: "/profile/history", label: "Round history", desc: "Every spin, with full results", icon: History },
34 + { href: "/profile/ledger", label: "Credit ledger", desc: "All credit movements", icon: ReceiptText },
35 + { href: "/rewards", label: "Achievements", desc: "Milestones and rewards", icon: Trophy },
36 + { href: "/settings", label: "Settings", desc: "Sound, motion, security", icon: Settings },
37 +];
38 +
39 +function ProfileBody() {
40 + const { data, error, loading, reload } = useApi<Profile>("/api/user/profile");
41 + const wallet = useSession((s) => s.wallet);
42 +
43 + if (error && !data) return <ApiErrorState error={error} retry={reload} />;
44 + if (loading && !data) {
45 + return (
46 + <div className="space-y-4" aria-busy>
47 + <Skeleton className="h-40 rounded-xl" />
48 + <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
49 + {Array.from({ length: 8 }).map((_, i) => (
50 + <Skeleton key={i} className="h-24 rounded-lg" />
51 + ))}
52 + </div>
53 + </div>
54 + );
55 + }
56 + if (!data) return null;
57 + const { user, stats, favoriteGame } = data;
58 + const balance = wallet?.balance ?? data.wallet.balance;
59 + const joined = new Date(user.createdAt).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
60 +
61 + return (
62 + <div className="space-y-8">
63 + {/* identity */}
64 + <Card className="relative overflow-hidden p-6 sm:p-8">
65 + <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top_left,rgba(201,169,97,0.14),transparent_55%)]" />
66 + <div className="relative flex flex-col gap-6 sm:flex-row sm:items-center">
67 + <span className="grid h-20 w-20 shrink-0 place-items-center rounded-2xl metal text-3xl font-bold uppercase text-accent-2 shadow-glow">{user.username.slice(0, 1)}</span>
68 + <div className="min-w-0 flex-1">
69 + <div className="flex flex-wrap items-center gap-2">
70 + <h1 className="truncate text-2xl font-semibold tracking-tight sm:text-3xl">{user.username}</h1>
71 + <span className="inline-flex items-center gap-1 rounded-full bg-accent-soft px-2.5 py-1 text-[12px] font-bold text-accent-2">
72 + <Zap className="h-3 w-3" /> LVL {user.level}
73 + </span>
74 + </div>
75 + <p className="mt-1 text-[13px] text-fg-3">Member since {joined}</p>
76 + <div className="mt-4 max-w-md">
77 + <Progress value={user.xpIntoLevel} max={user.xpForNext || 1} />
78 + <div className="mt-1.5 flex justify-between text-[12px] tabular text-fg-3">
79 + <span>{user.xpIntoLevel.toLocaleString("en-US")} XP</span>
80 + <span>{user.xpForNext ? `${user.xpForNext.toLocaleString("en-US")} XP to level ${user.level + 1}` : "Max level"}</span>
81 + </div>
82 + </div>
83 + </div>
84 + <div className="sm:text-right">
85 + <div className="eyebrow">Balance</div>
86 + <Credits amount={balance} size="lg" />
87 + </div>
88 + </div>
89 + </Card>
90 +
91 + {/* stats */}
92 + <section aria-labelledby="stats-title">
93 + <h2 id="stats-title" className="mb-3 text-lg font-semibold tracking-tight">
94 + Lifetime stats
95 + </h2>
96 + <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
97 + <Stat label="Total spins" value={stats.totalSpins.toLocaleString("en-US")} sub={`${stats.gamesPlayed} ${stats.gamesPlayed === 1 ? "game" : "games"} played`} />
98 + <Stat label="Total wagered" value={formatSC(data.wallet.lifetimeWagered)} />
99 + <Stat label="Total won" value={formatSC(data.wallet.lifetimeWon)} sub={data.wallet.lifetimeWagered ? `${((data.wallet.lifetimeWon / data.wallet.lifetimeWagered) * 100).toFixed(1)}% returned` : undefined} />
100 + <Stat label="Biggest win" value={<span className="text-credit">{formatSC(stats.biggestWin)}</span>} />
101 + <Stat label="Biggest multiplier" value={formatMultiplier(stats.biggestMultiplier)} />
102 + <Stat label="Bonus rounds" value={stats.bonuses.toLocaleString("en-US")} />
103 + <Stat label="Achievements" value={stats.achievements.toLocaleString("en-US")} sub="unlocked" />
104 + <Stat label="Level" value={user.level} sub={`${user.xp.toLocaleString("en-US")} XP total`} />
105 + </div>
106 + </section>
107 +
108 + <div className="grid gap-4 lg:grid-cols-[1fr_1.2fr]">
109 + {/* favourite game */}
110 + <section aria-labelledby="fav-title">
111 + <h2 id="fav-title" className="mb-3 text-lg font-semibold tracking-tight">
112 + Most played
113 + </h2>
114 + {favoriteGame ? (
115 + <Link href={`/games/${favoriteGame.slug}`} className="group relative block overflow-hidden rounded-lg border border-line focus-ring">
116 + <div className="aspect-[3/1] sm:aspect-[3/1]">
117 + <GameArt slug={favoriteGame.slug} name={favoriteGame.name} palette={favoriteGame.palette} variant="banner" />
118 + </div>
119 + <div className="absolute inset-x-0 bottom-0 flex items-center justify-between p-4">
120 + <span className="rounded-full glass px-3 py-1 text-[12px] font-semibold tabular">{favoriteGame.spins.toLocaleString("en-US")} spins</span>
121 + <span className="inline-flex items-center gap-1 rounded-full bg-fg px-3 py-1.5 text-[13px] font-semibold text-bg">Play again</span>
122 + </div>
123 + </Link>
124 + ) : (
125 + <Card className="flex items-center gap-4 p-5">
126 + <span className="grid h-11 w-11 place-items-center rounded-md bg-surface-2 text-fg-3">
127 + <Sparkles className="h-5 w-5" />
128 + </span>
129 + <div>
130 + <div className="font-semibold">No favourite yet</div>
131 + <p className="text-[13px] text-fg-3">
132 + Play a few rounds and your most-played game appears here.{" "}
133 + <Link href="/games" className="text-accent-2 underline-offset-4 hover:underline">
134 + Browse games
135 + </Link>
136 + </p>
137 + </div>
138 + </Card>
139 + )}
140 + </section>
141 +
142 + {/* shortcuts */}
143 + <section aria-labelledby="more-title">
144 + <h2 id="more-title" className="mb-3 text-lg font-semibold tracking-tight">
145 + Account
146 + </h2>
147 + <Card as="ul" className="divide-y divide-line overflow-hidden">
148 + {LINKS.map((l) => (
149 + <li key={l.href}>
150 + <Link href={l.href} className="flex items-center gap-3 px-4 py-3.5 transition-colors hover:bg-surface-2 focus-ring">
151 + <span className="grid h-10 w-10 place-items-center rounded-md bg-surface-2 text-fg-2">
152 + <l.icon className="h-[18px] w-[18px]" />
153 + </span>
154 + <span className="min-w-0 flex-1">
155 + <span className="block text-[15px] font-medium">{l.label}</span>
156 + <span className="block text-[12px] text-fg-3">{l.desc}</span>
157 + </span>
158 + <ChevronRight className="h-4 w-4 text-fg-4" />
159 + </Link>
160 + </li>
161 + ))}
162 + </Card>
163 + </section>
164 + </div>
165 + </div>
166 + );
167 +}
168 +
169 +export default function ProfilePage() {
170 + return (
171 + <AppShell>
172 + <RequireAuth>
173 + <ProfileBody />
174 + </RequireAuth>
175 + </AppShell>
176 + );
177 +}
added apps/web/src/app/responsible-play/page.tsx +93 −0
@@ -0,0 +1,93 @@
1 +import type { Metadata } from "next";
2 +import Link from "next/link";
3 +import { Clock, Coffee, Sparkles, EyeOff, LogOut, Info } from "lucide-react";
4 +import { Button } from "@/components/ui";
5 +import { ProsePage, Section, Callout, Bullets } from "@/components/lobby/prose";
6 +
7 +export const metadata: Metadata = {
8 + title: "Responsible play",
9 + description: "Spinza uses fictional credits with no cash value, but time still matters. Learn about the built-in reminders, controls and how to take a break.",
10 + alternates: { canonical: "/responsible-play" },
11 +};
12 +
13 +const TOOLS = [
14 + { icon: Clock, title: "Session reminder", body: "Choose 30, 60, 90 or 120 minutes and Spinza tells you how long you have been playing at that interval, every time.", href: "/settings#time" },
15 + { icon: Coffee, title: "Break reminder", body: "A gentle prompt to step away after long stretches of continuous play.", href: "/settings#time" },
16 + { icon: Sparkles, title: "Animation intensity & reduce motion", body: "Turn down particles, flashes and celebratory motion if they feel like too much.", href: "/settings#motion" },
17 + { icon: EyeOff, title: "Leaderboard opt-out", body: "Remove yourself from every public board and the wins feed with one switch.", href: "/settings#leaderboard" },
18 + { icon: LogOut, title: "Sign out anywhere", body: "Review your active sessions and sign out any device from Settings → Security.", href: "/settings#security" },
19 +];
20 +
21 +export default function ResponsiblePlayPage() {
22 + return (
23 + <ProsePage eyebrow="Responsible play" title="Fictional credits. Real time." lead="Nothing you do on Spinza can cost you money — there is simply no way to spend any. But casino-style games are designed to be absorbing, and we want your time here to stay fun. These are the tools and the principles behind them.">
24 + <Callout>
25 + <strong>Spinza is not gambling.</strong> Spinza Credits have no monetary value, cannot be purchased and cannot be withdrawn. No outcome on Spinza can win or lose you money. Spinza is for adults 18 and over.
26 + </Callout>
27 +
28 + <Section id="principles" title="Our commitments">
29 + <Bullets
30 + items={[
31 + <>
32 + <strong>No purchases, ever.</strong> There is no shop, no in-app payment and no premium currency. Running out of credits is never a reason to spend money, because you cannot.
33 + </>,
34 + <>
35 + <strong>No pressure mechanics.</strong> No countdown offers, no “last chance” pop-ups, no notifications urging you back. The daily reward waits for you; it does not chase you.
36 + </>,
37 + <>
38 + <strong>Honest presentation.</strong> Wins are celebrated in proportion to their size, losses are shown plainly, and every game publishes its certified return and volatility in Game Info.
39 + </>,
40 + <>
41 + <strong>Your data stays yours.</strong> We store a username, a password hash and gameplay statistics. No email, no advertising identifiers, no third-party trackers.
42 + </>,
43 + ]}
44 + />
45 + </Section>
46 +
47 + <Section id="tools" title="Built-in tools">
48 + <div className="grid gap-3 sm:grid-cols-2">
49 + {TOOLS.map((t) => (
50 + <Link key={t.title} href={t.href} className="surface group flex gap-3 rounded-lg p-4 transition-colors hover:bg-surface-2 focus-ring">
51 + <span className="grid h-10 w-10 shrink-0 place-items-center rounded-md metal text-accent-2">
52 + <t.icon className="h-[18px] w-[18px]" />
53 + </span>
54 + <span>
55 + <span className="block font-semibold text-fg group-hover:text-accent-2">{t.title}</span>
56 + <span className="mt-0.5 block text-[13px] leading-snug text-fg-3">{t.body}</span>
57 + </span>
58 + </Link>
59 + ))}
60 + </div>
61 + <p className="text-[13px] text-fg-3">All tools live in Settings and apply instantly across every device you sign in on.</p>
62 + </Section>
63 +
64 + <Section id="signs" title="Keeping it healthy">
65 + <p>Even without money involved, it is worth noticing how you play. A few honest questions:</p>
66 + <Bullets items={["Am I playing longer than I meant to, more often than I would like?", "Am I playing to escape a mood rather than for enjoyment?", "Is Spinza getting in the way of sleep, work, study or people I care about?", "Do I feel irritable when I stop, or when I cannot play?"]} />
67 + <p>If any of these ring true, set a shorter session reminder, turn on break reminders, or simply sign out for a while. Your credits, streaks and progress will be exactly where you left them whenever you return — there is no penalty for taking time away.</p>
68 + </Section>
69 +
70 + <Section id="minors" title="Adults only">
71 + <p>Spinza is for players aged 18 and over. Age is confirmed at registration and we ask that adults keep their sign-in details away from minors. If you believe a minor is using Spinza, sign out of the account from Settings → Security and revoke its other sessions.</p>
72 + </Section>
73 +
74 + <Section id="help" title="If real-money gambling is a concern">
75 + <p>Spinza never involves money, but if you or someone close to you struggles with real-money gambling, confidential help is available in most countries through national gambling-support helplines and services such as Gamblers Anonymous. Please reach out to a local service — they are free, confidential and used to hearing from people at every stage.</p>
76 + <div className="flex items-start gap-3 rounded-md border border-line p-4 text-[13px] text-fg-3">
77 + <Info className="mt-0.5 h-4 w-4 shrink-0" />
78 + <p>Spinza does not link to real-money gambling sites, does not advertise them and does not accept advertising from them.</p>
79 + </div>
80 + </Section>
81 +
82 + <div className="flex flex-col gap-3 rounded-xl border border-line p-6 sm:flex-row sm:items-center sm:justify-between">
83 + <div>
84 + <div className="text-lg font-semibold tracking-tight">Set your reminders</div>
85 + <p className="text-sm text-fg-3">Takes ten seconds and follows your account everywhere.</p>
86 + </div>
87 + <Button variant="secondary" href="/settings#time">
88 + Open settings
89 + </Button>
90 + </div>
91 + </ProsePage>
92 + );
93 +}
added apps/web/src/app/rewards/page.tsx +388 −0
@@ -0,0 +1,388 @@
1 +"use client";
2 +
3 +import { useEffect, useState } from "react";
4 +import { Gift, LifeBuoy, Target, Trophy, Lock, Check, Flame, Star, Sparkles, Zap } from "lucide-react";
5 +import type { AchievementView, DailyRewardStatus, MissionView, RescueStatus } from "@spinza/shared";
6 +import { RESCUE_CREDITS_AMOUNT, RESCUE_CREDITS_COOLDOWN_HOURS, formatSC } from "@spinza/shared";
7 +import { api } from "@/lib/api";
8 +import { toast, useSession } from "@/lib/store";
9 +import { useApi } from "@/lib/use-api";
10 +import { cn, countdown } from "@/lib/utils";
11 +import { Button, Card, Credits, Progress, Skeleton, SectionHead, Empty, Badge } from "@/components/ui";
12 +import { AppShell } from "@/components/shell/app-shell";
13 +import { RequireAuth } from "@/components/shell/require-auth";
14 +import { ApiErrorState } from "@/components/shell/api-error";
15 +
16 +/* ---------------------------------------------------------------- helpers */
17 +
18 +function useTick(active: boolean) {
19 + const [, set] = useState(0);
20 + useEffect(() => {
21 + if (!active) return;
22 + const t = setInterval(() => set((n) => n + 1), 1000);
23 + return () => clearInterval(t);
24 + }, [active]);
25 +}
26 +
27 +/* ------------------------------------------------------------ daily card */
28 +
29 +interface DailyClaim {
30 + amount: number;
31 + streakDay: number;
32 + balance: number;
33 + nextAvailableAt: string;
34 + xp: { xp: number; level: number } | null;
35 +}
36 +
37 +function DailyCard() {
38 + const { data, error, reload, setData } = useApi<DailyRewardStatus>("/api/rewards/daily");
39 + const setBalance = useSession((s) => s.setBalance);
40 + const setUserXp = useSession((s) => s.setUserXp);
41 + const [busy, setBusy] = useState(false);
42 + useTick(!!data && !data.available);
43 +
44 + const claim = async () => {
45 + if (busy) return;
46 + setBusy(true);
47 + try {
48 + const res = await api<DailyClaim>("/api/rewards/daily/claim", { method: "POST" });
49 + setBalance(res.balance);
50 + if (res.xp) setUserXp(res.xp.xp, res.xp.level);
51 + toast({ title: `+${res.amount.toLocaleString("en-US")} SC`, description: `Day ${res.streakDay} claimed. Come back tomorrow to keep the streak.`, tone: "credit" });
52 + setData((d) => (d ? { ...d, available: false, claimedToday: true, streakDay: res.streakDay, nextAvailableAt: res.nextAvailableAt } : d));
53 + } catch (e) {
54 + toast({ title: "Could not claim", description: e instanceof Error ? e.message : undefined, tone: "danger" });
55 + void reload();
56 + } finally {
57 + setBusy(false);
58 + }
59 + };
60 +
61 + if (error && !data) return <ApiErrorState error={error} retry={reload} compact />;
62 + if (!data) {
63 + return (
64 + <Card className="p-5">
65 + <Skeleton className="h-5 w-40" />
66 + <div className="mt-5 grid grid-cols-7 gap-2">
67 + {Array.from({ length: 7 }).map((_, i) => (
68 + <Skeleton key={i} className="h-20" />
69 + ))}
70 + </div>
71 + <Skeleton className="mt-5 h-12 w-full" />
72 + </Card>
73 + );
74 + }
75 +
76 + // Day the next claim will land on (or the last claimed day when waiting).
77 + const targetDay = data.available ? Math.min((data.streakDay % data.schedule.length) + 1, data.schedule.length) : data.streakDay;
78 +
79 + return (
80 + <Card className={cn("relative overflow-hidden p-5 sm:p-6", data.available && "border-accent/40 shadow-glow")}>
81 + <div className="flex items-start justify-between gap-4">
82 + <div className="flex items-center gap-3">
83 + <span className={cn("grid h-11 w-11 place-items-center rounded-md", data.available ? "bg-accent text-bg" : "metal text-accent-2")}>
84 + <Gift className="h-5 w-5" />
85 + </span>
86 + <div>
87 + <h2 className="text-lg font-semibold tracking-tight">Daily reward</h2>
88 + <p className="text-[13px] text-fg-3">Claim once a day. Seven days in a row unlock the big one.</p>
89 + </div>
90 + </div>
91 + {data.streakDay > 0 ? (
92 + <span className="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2.5 py-1 text-[12px] font-bold text-fg-2">
93 + <Flame className="h-3.5 w-3.5 text-accent" /> {data.streakDay}-day streak
94 + </span>
95 + ) : null}
96 + </div>
97 +
98 + <ol className="mt-5 grid grid-cols-7 gap-1.5 sm:gap-2" aria-label="Seven-day schedule">
99 + {data.schedule.map((amt, i) => {
100 + const day = i + 1;
101 + const claimed = day <= data.streakDay && !(data.available && day === targetDay);
102 + const isNext = data.available && day === targetDay;
103 + return (
104 + <li key={day} className={cn("flex flex-col items-center justify-between rounded-md border px-1 py-2.5 text-center", isNext ? "border-accent bg-accent-soft" : claimed ? "border-success/30 bg-success/5" : "border-line bg-surface", day === 7 && !isNext && !claimed && "border-line-2")} aria-current={isNext ? "step" : undefined}>
105 + <span className="text-[10px] font-bold uppercase tracking-wider text-fg-3">Day {day}</span>
106 + <span className={cn("my-1.5 grid h-7 w-7 place-items-center rounded-full", claimed ? "bg-success text-bg" : isNext ? "bg-accent text-bg" : "bg-surface-2 text-fg-4")}>{claimed ? <Check className="h-4 w-4" /> : day === 7 ? <Star className="h-3.5 w-3.5" /> : <Gift className="h-3.5 w-3.5" />}</span>
107 + <span className={cn("text-[11px] font-semibold tabular sm:text-[12px]", claimed ? "text-fg-3" : "text-credit")}>{amt >= 1000 ? `${(amt / 1000).toFixed(amt % 1000 ? 2 : 0).replace(/\.?0+$/, "")}K` : amt}</span>
108 + </li>
109 + );
110 + })}
111 + </ol>
112 +
113 + <div className="mt-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
114 + <div className="text-sm text-fg-2">
115 + {data.available ? (
116 + <>
117 + Ready now: <Credits amount={data.nextAmount} size="md" />
118 + </>
119 + ) : (
120 + <>
121 + Next reward <Credits amount={data.nextAmount} size="sm" /> in <span className="font-semibold tabular text-fg">{countdown(data.nextAvailableAt)}</span>
122 + </>
123 + )}
124 + </div>
125 + <Button variant="accent" size="lg" onClick={claim} disabled={!data.available} loading={busy} className="sm:min-w-[180px]">
126 + {data.available ? `Claim ${formatSC(data.nextAmount)}` : "Claimed today"}
127 + </Button>
128 + </div>
129 + </Card>
130 + );
131 +}
132 +
133 +/* ----------------------------------------------------------- rescue card */
134 +
135 +function RescueCard() {
136 + const { data, error, reload, setData } = useApi<RescueStatus>("/api/rewards/rescue");
137 + const setBalance = useSession((s) => s.setBalance);
138 + const balance = useSession((s) => s.wallet?.balance ?? 0);
139 + const [busy, setBusy] = useState(false);
140 + useTick(!!data?.nextAvailableAt);
141 +
142 + const claim = async () => {
143 + if (busy) return;
144 + setBusy(true);
145 + try {
146 + const res = await api<{ amount: number; balance: number; nextAvailableAt: string }>("/api/rewards/rescue/claim", { method: "POST" });
147 + setBalance(res.balance);
148 + toast({ title: `+${res.amount.toLocaleString("en-US")} SC`, description: "Rescue credits added. Back in the game.", tone: "credit" });
149 + setData((d) => (d ? { ...d, eligible: false, balance: res.balance, nextAvailableAt: res.nextAvailableAt } : d));
150 + } catch (e) {
151 + toast({ title: "Not available", description: e instanceof Error ? e.message : undefined, tone: "danger" });
152 + void reload();
153 + } finally {
154 + setBusy(false);
155 + }
156 + };
157 +
158 + if (error && !data) return <ApiErrorState error={error} retry={reload} compact />;
159 + const amount = data?.amount ?? RESCUE_CREDITS_AMOUNT;
160 + return (
161 + <Card className={cn("p-5 sm:p-6", data?.eligible && "border-success/40")}>
162 + <div className="flex items-center gap-3">
163 + <span className={cn("grid h-11 w-11 place-items-center rounded-md", data?.eligible ? "bg-success text-bg" : "metal text-fg-2")}>
164 + <LifeBuoy className="h-5 w-5" />
165 + </span>
166 + <div>
167 + <h2 className="text-lg font-semibold tracking-tight">Rescue credits</h2>
168 + <p className="text-[13px] text-fg-3">
169 + {formatSC(amount)} every {RESCUE_CREDITS_COOLDOWN_HOURS} hours whenever you reach 0 SC.
170 + </p>
171 + </div>
172 + </div>
173 + <p className="mt-4 text-sm leading-relaxed text-fg-2">Spinza Credits are fictional, so nobody ever gets stuck. If your balance hits zero, claim a free top-up here. The cooldown resets {RESCUE_CREDITS_COOLDOWN_HOURS} hours after each rescue.</p>
174 + <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
175 + <div className="text-sm text-fg-3">
176 + {!data ? (
177 + <Skeleton className="h-4 w-40" />
178 + ) : data.eligible ? (
179 + <span className="text-success">Available now</span>
180 + ) : data.nextAvailableAt ? (
181 + <>
182 + Recharging · ready in <span className="font-semibold tabular text-fg">{countdown(data.nextAvailableAt)}</span>
183 + </>
184 + ) : (
185 + <>
186 + Balance <Credits amount={balance} size="sm" /> — unlocks at 0 SC
187 + </>
188 + )}
189 + </div>
190 + <Button variant={data?.eligible ? "primary" : "secondary"} onClick={claim} disabled={!data?.eligible} loading={busy}>
191 + {data?.eligible ? `Claim ${formatSC(amount)}` : "Not needed yet"}
192 + </Button>
193 + </div>
194 + </Card>
195 + );
196 +}
197 +
198 +/* ------------------------------------------------------------ level card */
199 +
200 +function LevelCard() {
201 + const user = useSession((s) => s.user);
202 + if (!user) return null;
203 + const max = user.xpForNext || 1;
204 + const pct = user.xpForNext ? user.xpIntoLevel / user.xpForNext : 1;
205 + return (
206 + <Card className="p-5 sm:p-6">
207 + <div className="flex items-center gap-3">
208 + <span className="grid h-11 w-11 place-items-center rounded-md bg-[linear-gradient(180deg,#e8cf8f,#c9a961)] text-bg">
209 + <Zap className="h-5 w-5" />
210 + </span>
211 + <div className="flex-1">
212 + <div className="flex items-baseline justify-between">
213 + <h2 className="text-lg font-semibold tracking-tight">Level {user.level}</h2>
214 + <span className="text-[12px] tabular text-fg-3">{user.xp.toLocaleString("en-US")} XP total</span>
215 + </div>
216 + <p className="text-[13px] text-fg-3">Every spin earns XP. Level-ups grant bonus credits.</p>
217 + </div>
218 + </div>
219 + <div className="mt-4">
220 + <Progress value={user.xpIntoLevel} max={max} />
221 + <div className="mt-2 flex justify-between text-[12px] tabular text-fg-3">
222 + <span>{user.xpIntoLevel.toLocaleString("en-US")} XP</span>
223 + <span>{user.xpForNext ? `${(user.xpForNext - user.xpIntoLevel).toLocaleString("en-US")} XP to level ${user.level + 1}` : "Max level reached"}</span>
224 + </div>
225 + <div className="mt-1 text-right text-[11px] text-fg-4">{Math.round(pct * 100)}%</div>
226 + </div>
227 + </Card>
228 + );
229 +}
230 +
231 +/* --------------------------------------------------------------- missions */
232 +
233 +function Missions() {
234 + const { data, error, loading, reload } = useApi<{ missions: MissionView[]; enabled: boolean }>("/api/missions");
235 + useTick(!!data);
236 + if (error && !data) return <ApiErrorState error={error} retry={reload} compact />;
237 + if (loading && !data) {
238 + return (
239 + <div className="grid gap-3 md:grid-cols-2">
240 + {Array.from({ length: 4 }).map((_, i) => (
241 + <Skeleton key={i} className="h-28 rounded-lg" />
242 + ))}
243 + </div>
244 + );
245 + }
246 + const missions = data?.missions ?? [];
247 + if (!data?.enabled) return <Empty title="Missions are paused" description="Daily and weekly missions will return shortly." icon={<Target className="h-5 w-5" />} />;
248 + if (!missions.length) return <Empty title="No missions right now" description="Check back soon for new daily and weekly challenges." icon={<Target className="h-5 w-5" />} />;
249 +
250 + const groups: { period: "daily" | "weekly"; label: string; items: MissionView[] }[] = [
251 + { period: "daily", label: "Daily", items: missions.filter((m) => m.period === "daily") },
252 + { period: "weekly", label: "Weekly", items: missions.filter((m) => m.period === "weekly") },
253 + ].filter((g) => g.items.length) as { period: "daily" | "weekly"; label: string; items: MissionView[] }[];
254 +
255 + return (
256 + <div className="space-y-6">
257 + {groups.map((g) => (
258 + <div key={g.period}>
259 + <div className="mb-2 flex items-center justify-between text-[12px] text-fg-3">
260 + <span className="eyebrow">{g.label}</span>
261 + <span className="tabular">Resets in {countdown(g.items[0].expiresAt)}</span>
262 + </div>
263 + <div className="grid gap-3 md:grid-cols-2">
264 + {g.items.map((m) => {
265 + const done = !!m.completedAt;
266 + return (
267 + <Card key={m.key} className={cn("p-4", done && "border-success/30")}>
268 + <div className="flex items-start justify-between gap-3">
269 + <div className="min-w-0">
270 + <div className="flex items-center gap-2">
271 + <h3 className="truncate text-[15px] font-semibold tracking-tight">{m.name}</h3>
272 + {done ? <Badge tone="success">Done</Badge> : null}
273 + </div>
274 + <p className="mt-0.5 text-[13px] text-fg-3">{m.description}</p>
275 + </div>
276 + <div className="shrink-0 text-right">
277 + <Credits amount={m.rewardCredits} size="sm" sign />
278 + <div className="text-[11px] text-fg-3">+{m.rewardXp} XP</div>
279 + </div>
280 + </div>
281 + <div className="mt-3 flex items-center gap-3">
282 + <Progress value={m.progress} max={m.target} tone={done ? "success" : "accent"} className="flex-1" />
283 + <span className="text-[12px] tabular text-fg-2">
284 + {m.progress.toLocaleString("en-US")}/{m.target.toLocaleString("en-US")}
285 + </span>
286 + </div>
287 + </Card>
288 + );
289 + })}
290 + </div>
291 + </div>
292 + ))}
293 + <p className="text-[12px] text-fg-4">Mission rewards are credited automatically the moment a mission completes.</p>
294 + </div>
295 + );
296 +}
297 +
298 +/* ----------------------------------------------------------- achievements */
299 +
300 +function Achievements() {
301 + const { data, error, loading, reload } = useApi<{ achievements: AchievementView[]; unlocked: number; total: number }>("/api/achievements");
302 + if (error && !data) return <ApiErrorState error={error} retry={reload} compact />;
303 + if (loading && !data) {
304 + return (
305 + <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
306 + {Array.from({ length: 6 }).map((_, i) => (
307 + <Skeleton key={i} className="h-32 rounded-lg" />
308 + ))}
309 + </div>
310 + );
311 + }
312 + const list = data?.achievements ?? [];
313 + if (!list.length) return <Empty title="No achievements yet" description="Achievements unlock as you play." icon={<Trophy className="h-5 w-5" />} />;
314 + const sorted = [...list].sort((a, b) => Number(!!b.unlockedAt) - Number(!!a.unlockedAt) || b.progress / b.target - a.progress / a.target);
315 + return (
316 + <div className="space-y-3">
317 + <div className="flex items-center justify-between text-sm text-fg-3">
318 + <span>
319 + <span className="font-semibold text-fg">{data?.unlocked ?? 0}</span> of {data?.total ?? list.length} unlocked
320 + </span>
321 + <Progress value={data?.unlocked ?? 0} max={(data?.total ?? list.length) || 1} className="w-32" tone="credit" />
322 + </div>
323 + <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
324 + {sorted.map((a) => {
325 + const unlocked = !!a.unlockedAt;
326 + return (
327 + <Card key={a.key} className={cn("relative p-4", unlocked ? "border-accent/35 bg-[linear-gradient(180deg,rgba(201,169,97,0.10),rgba(255,255,255,0.02))]" : "opacity-90")}>
328 + <div className="flex items-start gap-3">
329 + <span className={cn("grid h-11 w-11 shrink-0 place-items-center rounded-md", unlocked ? "bg-accent text-bg shadow-glow" : "bg-surface-2 text-fg-4")} aria-hidden>
330 + {unlocked ? <Sparkles className="h-5 w-5" /> : <Lock className="h-4 w-4" />}
331 + </span>
332 + <div className="min-w-0 flex-1">
333 + <h3 className="truncate text-[15px] font-semibold tracking-tight">{a.name}</h3>
334 + <p className="mt-0.5 text-[13px] leading-snug text-fg-3">{a.description}</p>
335 + <div className="mt-2 flex items-center gap-3 text-[12px]">
336 + <span className="inline-flex items-center gap-1 text-credit tabular">+{a.rewardCredits.toLocaleString("en-US")} SC</span>
337 + <span className="text-fg-3">+{a.rewardXp} XP</span>
338 + </div>
339 + </div>
340 + </div>
341 + {unlocked ? (
342 + <div className="mt-3 text-[11px] text-fg-3">Unlocked {new Date(a.unlockedAt!).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</div>
343 + ) : (
344 + <div className="mt-3 flex items-center gap-3">
345 + <Progress value={a.progress} max={a.target} className="flex-1" />
346 + <span className="text-[11px] tabular text-fg-3">
347 + {a.progress.toLocaleString("en-US")}/{a.target.toLocaleString("en-US")}
348 + </span>
349 + </div>
350 + )}
351 + </Card>
352 + );
353 + })}
354 + </div>
355 + </div>
356 + );
357 +}
358 +
359 +/* ------------------------------------------------------------------- page */
360 +
361 +export default function RewardsPage() {
362 + return (
363 + <AppShell>
364 + <RequireAuth>
365 + <div className="mb-6">
366 + <div className="eyebrow mb-1">Rewards</div>
367 + <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">Keep the credits flowing.</h1>
368 + <p className="mt-2 max-w-xl text-sm text-fg-3">Daily rewards, missions, achievements and level-ups all pay in Spinza Credits — fictional, free and never for sale.</p>
369 + </div>
370 + <div className="grid gap-4 lg:grid-cols-[1.4fr_1fr]">
371 + <DailyCard />
372 + <div className="grid gap-4">
373 + <LevelCard />
374 + <RescueCard />
375 + </div>
376 + </div>
377 + <section className="mt-12" aria-labelledby="missions-title">
378 + <SectionHead eyebrow="Challenges" title="Missions" />
379 + <Missions />
380 + </section>
381 + <section className="mt-12" aria-labelledby="achievements-title">
382 + <SectionHead eyebrow="Milestones" title="Achievements" />
383 + <Achievements />
384 + </section>
385 + </RequireAuth>
386 + </AppShell>
387 + );
388 +}
added apps/web/src/app/settings/page.tsx +354 −0
@@ -0,0 +1,354 @@
1 +"use client";
2 +
3 +import { useEffect, useRef, useState } from "react";
4 +import { useRouter } from "next/navigation";
5 +import { Volume2, VolumeX, Sparkles, Clock, Trophy, KeyRound, ShieldCheck, LogOut, Smartphone, Monitor, Trash2 } from "lucide-react";
6 +import type { UserSettings } from "@spinza/shared";
7 +import { SESSION_REMINDER_OPTIONS } from "@spinza/shared";
8 +import { api } from "@/lib/api";
9 +import { toast, useSession } from "@/lib/store";
10 +import { useApi } from "@/lib/use-api";
11 +import { cn, timeAgo } from "@/lib/utils";
12 +import { Button, Card, Input, Sheet, Skeleton, Switch } from "@/components/ui";
13 +import { AppShell } from "@/components/shell/app-shell";
14 +import { RequireAuth } from "@/components/shell/require-auth";
15 +import { RecoveryCodePanel } from "@/components/shell/recovery-code";
16 +import { ApiErrorState } from "@/components/shell/api-error";
17 +
18 +/* ---------------------------------------------------------------- pieces */
19 +
20 +function Section({ id, icon: Icon, title, description, children }: { id: string; icon: React.ElementType; title: string; description?: string; children: React.ReactNode }) {
21 + return (
22 + <Card as="section" id={id} className="p-5 sm:p-6" aria-labelledby={`${id}-title`}>
23 + <div className="mb-4 flex items-center gap-3">
24 + <span className="grid h-10 w-10 place-items-center rounded-md metal text-accent-2">
25 + <Icon className="h-[18px] w-[18px]" />
26 + </span>
27 + <div>
28 + <h2 id={`${id}-title`} className="text-lg font-semibold tracking-tight">
29 + {title}
30 + </h2>
31 + {description ? <p className="text-[13px] text-fg-3">{description}</p> : null}
32 + </div>
33 + </div>
34 + {children}
35 + </Card>
36 + );
37 +}
38 +
39 +/** Range input with local state; commits (PATCH) at most every 300 ms while dragging. */
40 +function Slider({ label, value, onChange, disabled }: { label: string; value: number; onChange: (v: number) => void; disabled?: boolean }) {
41 + // Settings are loaded before this renders, and `value` only changes through this control,
42 + // so the initial local state is always in sync.
43 + const [local, setLocal] = useState(Math.round(value * 100));
44 + const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
45 + useEffect(
46 + () => () => {
47 + if (timer.current) clearTimeout(timer.current);
48 + },
49 + [],
50 + );
51 + const change = (n: number) => {
52 + setLocal(n);
53 + if (timer.current) clearTimeout(timer.current);
54 + timer.current = setTimeout(() => onChange(n / 100), 300);
55 + };
56 + return (
57 + <label className={cn("block py-2", disabled && "opacity-50")}>
58 + <span className="mb-1.5 flex items-center justify-between text-[15px] font-medium">
59 + {label}
60 + <span className="text-[13px] tabular text-fg-3">{local}%</span>
61 + </span>
62 + <input type="range" min={0} max={100} step={1} value={local} disabled={disabled} onChange={(e) => change(Number(e.target.value))} className="h-11 w-full cursor-pointer accent-[#c9a961]" aria-label={label} />
63 + </label>
64 + );
65 +}
66 +
67 +function Segmented<T extends string | number>({ value, onChange, options, label }: { value: T; onChange: (v: T) => void; options: { value: T; label: string }[]; label: string }) {
68 + return (
69 + <div className="flex flex-wrap gap-1.5" role="radiogroup" aria-label={label}>
70 + {options.map((o) => (
71 + <button key={String(o.value)} type="button" role="radio" aria-checked={value === o.value} onClick={() => onChange(o.value)} className={cn("tap h-10 min-w-[44px] rounded-md border px-3.5 text-[13px] font-semibold transition-colors focus-ring", value === o.value ? "border-accent/50 bg-accent-soft text-accent-2" : "border-line text-fg-3 hover:border-line-2 hover:text-fg")}>
72 + {o.label}
73 + </button>
74 + ))}
75 + </div>
76 + );
77 +}
78 +
79 +/* ------------------------------------------------------------- security */
80 +
81 +function ChangePassword() {
82 + const [cur, setCur] = useState("");
83 + const [next, setNext] = useState("");
84 + const [confirm, setConfirm] = useState("");
85 + const [busy, setBusy] = useState(false);
86 + const [error, setError] = useState<string | null>(null);
87 + const can = cur && next.length >= 8 && next === confirm && !busy;
88 + const submit = async (e: React.FormEvent) => {
89 + e.preventDefault();
90 + if (!can) return;
91 + setBusy(true);
92 + setError(null);
93 + try {
94 + await api("/api/auth/password", { json: { currentPassword: cur, newPassword: next } });
95 + toast({ title: "Password updated", tone: "success" });
96 + setCur("");
97 + setNext("");
98 + setConfirm("");
99 + } catch (err) {
100 + setError(err instanceof Error ? err.message : "Could not change password.");
101 + } finally {
102 + setBusy(false);
103 + }
104 + };
105 + return (
106 + <form onSubmit={submit} className="space-y-3" noValidate>
107 + <Input label="Current password" type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" />
108 + <div className="grid gap-3 sm:grid-cols-2">
109 + <Input label="New password" type="password" value={next} onChange={(e) => setNext(e.target.value)} autoComplete="new-password" error={next && next.length < 8 ? "At least 8 characters." : null} />
110 + <Input label="Confirm new password" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" error={confirm && confirm !== next ? "Passwords do not match." : null} />
111 + </div>
112 + {error ? (
113 + <p className="text-sm text-danger" role="alert">
114 + {error}
115 + </p>
116 + ) : null}
117 + <Button type="submit" variant="secondary" disabled={!can} loading={busy}>
118 + Update password
119 + </Button>
120 + </form>
121 + );
122 +}
123 +
124 +function RotateRecovery() {
125 + const [open, setOpen] = useState(false);
126 + const [password, setPassword] = useState("");
127 + const [busy, setBusy] = useState(false);
128 + const [error, setError] = useState<string | null>(null);
129 + const [code, setCode] = useState<string | null>(null);
130 +
131 + const close = () => {
132 + setOpen(false);
133 + setPassword("");
134 + setError(null);
135 + setCode(null);
136 + };
137 + const rotate = async (e: React.FormEvent) => {
138 + e.preventDefault();
139 + if (!password || busy) return;
140 + setBusy(true);
141 + setError(null);
142 + try {
143 + const res = await api<{ recoveryCode: string }>("/api/auth/recovery-code/rotate", { json: { password } });
144 + setCode(res.recoveryCode);
145 + } catch (err) {
146 + setError(err instanceof Error ? err.message : "Could not rotate the code.");
147 + } finally {
148 + setBusy(false);
149 + }
150 + };
151 + return (
152 + <>
153 + <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
154 + <p className="text-sm text-fg-2">Your recovery code is the only way to reset a forgotten password. Rotate it if you think it was exposed — the old code stops working immediately.</p>
155 + <Button variant="secondary" onClick={() => setOpen(true)} className="shrink-0">
156 + <KeyRound className="h-4 w-4" /> Rotate recovery code
157 + </Button>
158 + </div>
159 + <Sheet open={open} onClose={code ? () => undefined : close} side="center" title={code ? "New recovery code" : "Rotate recovery code"}>
160 + {code ? (
161 + <RecoveryCodePanel hideHeader code={code} notice="Your previous recovery code no longer works. Save this one now. Spinza does not collect your email address — if you lose your password and this code, your account cannot be recovered." continueLabel="Done" onContinue={close} />
162 + ) : (
163 + <form onSubmit={rotate} className="space-y-4" noValidate>
164 + <p className="text-sm text-fg-2">Confirm your password to generate a new recovery code.</p>
165 + <Input label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" autoFocus />
166 + {error ? (
167 + <p className="text-sm text-danger" role="alert">
168 + {error}
169 + </p>
170 + ) : null}
171 + <div className="flex gap-3">
172 + <Button type="button" variant="secondary" className="flex-1" onClick={close}>
173 + Cancel
174 + </Button>
175 + <Button type="submit" className="flex-1" disabled={!password} loading={busy}>
176 + Generate new code
177 + </Button>
178 + </div>
179 + </form>
180 + )}
181 + </Sheet>
182 + </>
183 + );
184 +}
185 +
186 +interface SessionRow {
187 + id: string;
188 + current: boolean;
189 + createdAt: string;
190 + lastSeenAt: string;
191 + userAgent: string | null;
192 + ip: string | null;
193 +}
194 +
195 +function describeUa(ua: string | null): { label: string; mobile: boolean } {
196 + if (!ua) return { label: "Unknown device", mobile: false };
197 + const mobile = /iPhone|Android|Mobile/i.test(ua);
198 + const os = /iPhone|iPad/i.test(ua) ? "iOS" : /Android/i.test(ua) ? "Android" : /Mac OS X/i.test(ua) ? "macOS" : /Windows/i.test(ua) ? "Windows" : /Linux/i.test(ua) ? "Linux" : "Unknown OS";
199 + const browser = /Edg\//i.test(ua) ? "Edge" : /Chrome\//i.test(ua) ? "Chrome" : /Firefox\//i.test(ua) ? "Firefox" : /Safari\//i.test(ua) ? "Safari" : "Browser";
200 + return { label: `${browser} · ${os}`, mobile };
201 +}
202 +
203 +function Sessions() {
204 + const { data, error, loading, reload, setData } = useApi<{ sessions: SessionRow[] }>("/api/user/sessions");
205 + const [busy, setBusy] = useState<string | null>(null);
206 + const revoke = async (id: string) => {
207 + setBusy(id);
208 + try {
209 + await api(`/api/user/sessions/${id}`, { method: "DELETE" });
210 + setData((d) => (d ? { sessions: d.sessions.filter((s) => s.id !== id) } : d));
211 + toast({ title: "Session signed out", tone: "success" });
212 + } catch (e) {
213 + toast({ title: "Could not revoke", description: e instanceof Error ? e.message : undefined, tone: "danger" });
214 + } finally {
215 + setBusy(null);
216 + }
217 + };
218 + if (error && !data) return <ApiErrorState error={error} retry={reload} compact />;
219 + if (loading && !data) return <Skeleton className="h-24" />;
220 + const list = data?.sessions ?? [];
221 + return (
222 + <ul className="divide-y divide-line rounded-md border border-line">
223 + {list.map((s) => {
224 + const d = describeUa(s.userAgent);
225 + const Icon = d.mobile ? Smartphone : Monitor;
226 + return (
227 + <li key={s.id} className="flex items-center gap-3 px-3 py-3">
228 + <span className="grid h-10 w-10 shrink-0 place-items-center rounded-md bg-surface-2 text-fg-2">
229 + <Icon className="h-[18px] w-[18px]" />
230 + </span>
231 + <div className="min-w-0 flex-1">
232 + <div className="flex items-center gap-2 text-[15px] font-medium">
233 + <span className="truncate">{d.label}</span>
234 + {s.current ? <span className="rounded-full bg-success/15 px-2 py-0.5 text-[11px] font-bold text-success">This device</span> : null}
235 + </div>
236 + <div className="text-[12px] text-fg-3">
237 + Active {timeAgo(s.lastSeenAt)}
238 + {s.ip ? ` · ${s.ip}` : ""} · signed in {timeAgo(s.createdAt)}
239 + </div>
240 + </div>
241 + {!s.current ? (
242 + <Button variant="ghost" size="sm" onClick={() => revoke(s.id)} loading={busy === s.id} aria-label="Sign out this session">
243 + <Trash2 className="h-4 w-4" /> Revoke
244 + </Button>
245 + ) : null}
246 + </li>
247 + );
248 + })}
249 + </ul>
250 + );
251 +}
252 +
253 +/* ------------------------------------------------------------------ page */
254 +
255 +function SettingsBody() {
256 + const router = useRouter();
257 + const settings = useSession((s) => s.settings);
258 + const setSettings = useSession((s) => s.setSettings);
259 + const signOut = useSession((s) => s.signOut);
260 + const [signingOut, setSigningOut] = useState(false);
261 + if (!settings) return <Skeleton className="h-64" />;
262 + const set = (patch: Partial<UserSettings>) => setSettings(patch);
263 + const muted = !settings.soundEnabled;
264 +
265 + return (
266 + <div className="grid gap-4 lg:grid-cols-2">
267 + <Section id="sound" icon={muted ? VolumeX : Volume2} title="Sound" description="Music and effects inside games.">
268 + <Switch checked={settings.soundEnabled} onChange={(v) => set({ soundEnabled: v })} label="Sound" description={muted ? "All game audio is muted." : "Game audio is on."} />
269 + <div className="mt-1 border-t border-line pt-2">
270 + <Slider label="Master volume" value={settings.masterVolume} onChange={(v) => set({ masterVolume: v })} disabled={muted} />
271 + <Slider label="Music" value={settings.musicVolume} onChange={(v) => set({ musicVolume: v })} disabled={muted} />
272 + <Slider label="Effects" value={settings.effectsVolume} onChange={(v) => set({ effectsVolume: v })} disabled={muted} />
273 + </div>
274 + </Section>
275 +
276 + <Section id="motion" icon={Sparkles} title="Animation" description="Tune how cinematic the games feel.">
277 + <div className="py-2">
278 + <div className="mb-2 text-[15px] font-medium">Animation intensity</div>
279 + <Segmented<UserSettings["animationIntensity"]> label="Animation intensity" value={settings.animationIntensity} onChange={(v) => set({ animationIntensity: v })} options={[{ value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }]} />
280 + <p className="mt-2 text-[13px] text-fg-3">Low keeps particles and screen effects to a minimum. High turns everything up.</p>
281 + </div>
282 + <div className="border-t border-line">
283 + <Switch checked={settings.reduceMotion} onChange={(v) => set({ reduceMotion: v })} label="Reduce motion" description="Shorten transitions and disable celebratory motion across Spinza." />
284 + </div>
285 + </Section>
286 +
287 + <Section id="time" icon={Clock} title="Time & breaks" description="Responsible-play reminders. Credits are fictional; your time is not.">
288 + <div className="py-2">
289 + <div className="mb-2 text-[15px] font-medium">Session reminder</div>
290 + <Segmented<number> label="Session reminder" value={settings.sessionReminderMinutes} onChange={(v) => set({ sessionReminderMinutes: v })} options={SESSION_REMINDER_OPTIONS.map((m) => ({ value: m, label: m === 0 ? "Off" : `${m} min` }))} />
291 + <p className="mt-2 text-[13px] text-fg-3">Shows how long you have been playing at the chosen interval.</p>
292 + </div>
293 + <div className="border-t border-line">
294 + <Switch checked={settings.breakReminder} onChange={(v) => set({ breakReminder: v })} label="Break reminder" description="Suggest a short break after long stretches of continuous play." />
295 + </div>
296 + </Section>
297 +
298 + <Section id="leaderboard" icon={Trophy} title="Leaderboards" description="Control what other players can see.">
299 + <Switch checked={settings.leaderboardOptIn} onChange={(v) => set({ leaderboardOptIn: v })} label="Leaderboard participation" description={settings.leaderboardOptIn ? "Your username, level and results appear on public boards and the wins feed." : "You are hidden from all leaderboards and the wins feed."} />
300 + </Section>
301 +
302 + <div className="lg:col-span-2">
303 + <Section id="security" icon={ShieldCheck} title="Security" description="Spinza stores only your username and a password hash — keep both safe.">
304 + <div className="grid gap-8 lg:grid-cols-2">
305 + <div>
306 + <h3 className="mb-3 text-[15px] font-semibold">Change password</h3>
307 + <ChangePassword />
308 + </div>
309 + <div className="space-y-8">
310 + <div>
311 + <h3 className="mb-3 text-[15px] font-semibold">Recovery code</h3>
312 + <RotateRecovery />
313 + </div>
314 + <div>
315 + <h3 className="mb-3 text-[15px] font-semibold">Active sessions</h3>
316 + <Sessions />
317 + </div>
318 + </div>
319 + </div>
320 + <div className="mt-8 flex flex-col gap-3 border-t border-line pt-6 sm:flex-row sm:items-center sm:justify-between">
321 + <p className="text-sm text-fg-3">Signing out only affects this device. Your credits and progress stay on your account.</p>
322 + <Button
323 + variant="danger"
324 + loading={signingOut}
325 + onClick={async () => {
326 + setSigningOut(true);
327 + await signOut();
328 + router.push("/");
329 + router.refresh();
330 + }}
331 + >
332 + <LogOut className="h-4 w-4" /> Sign out
333 + </Button>
334 + </div>
335 + </Section>
336 + </div>
337 + </div>
338 + );
339 +}
340 +
341 +export default function SettingsPage() {
342 + return (
343 + <AppShell>
344 + <RequireAuth>
345 + <div className="mb-6">
346 + <div className="eyebrow mb-1">Settings</div>
347 + <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">Make Spinza yours.</h1>
348 + <p className="mt-2 max-w-xl text-sm text-fg-3">Preferences save instantly and follow your account across devices.</p>
349 + </div>
350 + <SettingsBody />
351 + </RequireAuth>
352 + </AppShell>
353 + );
354 +}
added apps/web/src/components/admin/charts.tsx +212 −0
@@ -0,0 +1,212 @@
1 +"use client";
2 +
3 +/**
4 + * Recharts wrappers tuned to the Spinza tokens: one gold accent, muted greys,
5 + * hairline solid grid, 2px lines, thin rounded bars, tooltips in a glass card.
6 + * Text never wears the series colour — identity comes from the swatch beside it.
7 + */
8 +import * as React from "react";
9 +import { Bar, BarChart, CartesianGrid, Cell, Line, LineChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
10 +import { cn } from "@/lib/utils";
11 +
12 +export const CHART = {
13 + accent: "#c9a961",
14 + accent2: "#e8cf8f",
15 + grey: "#7b8090",
16 + grey2: "#4f5464",
17 + info: "#6ea8ff",
18 + success: "#3ddc97",
19 + danger: "#ff5c7a",
20 + grid: "rgba(255,255,255,0.07)",
21 + axis: "rgba(255,255,255,0.12)",
22 + tick: "#7b8090",
23 +} as const;
24 +
25 +export type Row = Record<string, unknown>;
26 +
27 +export interface Series {
28 + key: string;
29 + label: string;
30 + color: string;
31 + /** Stacked bars share a stackId. */
32 + stackId?: string;
33 +}
34 +
35 +const tickStyle = { fill: CHART.tick, fontSize: 11 } as const;
36 +
37 +export type Formatter = (v: number) => string;
38 +
39 +const defaultFmt: Formatter = (v) => (Math.abs(v) >= 1_000_000 ? `${(v / 1_000_000).toFixed(1).replace(/\.0$/, "")}M` : Math.abs(v) >= 1000 ? `${(v / 1000).toFixed(1).replace(/\.0$/, "")}K` : v.toLocaleString("en-US", { maximumFractionDigits: 2 }));
40 +
41 +/* ----------------------------------------------------------- tooltip */
42 +
43 +interface TooltipEntry {
44 + name?: string | number;
45 + value?: number | string | ReadonlyArray<number | string>;
46 + color?: string;
47 + fill?: string;
48 + dataKey?: string | number;
49 + payload?: Record<string, unknown>;
50 +}
51 +
52 +interface AdminTooltipProps {
53 + active?: boolean;
54 + payload?: ReadonlyArray<TooltipEntry>;
55 + label?: unknown;
56 + labelFormatter?: (label: unknown, row?: Record<string, unknown>) => string;
57 + valueFormatter?: Formatter;
58 + series?: Series[];
59 +}
60 +
61 +export function AdminTooltip({ active, payload, label, labelFormatter, valueFormatter = defaultFmt, series }: AdminTooltipProps) {
62 + if (!active || !payload || payload.length === 0) return null;
63 + const row = payload[0]?.payload;
64 + const title = labelFormatter ? labelFormatter(label, row) : label === undefined ? "" : String(label);
65 + return (
66 + <div className="glass min-w-[140px] rounded-sm px-3 py-2 text-[12px] shadow-2xl">
67 + {title ? <div className="mb-1 font-semibold text-fg">{title}</div> : null}
68 + <div className="space-y-0.5">
69 + {payload.map((p, i) => {
70 + const s = series?.find((x) => x.key === p.dataKey);
71 + const v = typeof p.value === "number" ? valueFormatter(p.value) : String(p.value ?? "—");
72 + return (
73 + <div key={i} className="flex items-center justify-between gap-4">
74 + <span className="inline-flex items-center gap-1.5 text-fg-3">
75 + <span className="inline-block h-2 w-2 rounded-[2px]" style={{ background: s?.color ?? p.color ?? p.fill ?? CHART.accent }} />
76 + {s?.label ?? String(p.name ?? p.dataKey ?? "")}
77 + </span>
78 + <span className="tabular font-medium text-fg">{v}</span>
79 + </div>
80 + );
81 + })}
82 + </div>
83 + </div>
84 + );
85 +}
86 +
87 +/* ------------------------------------------------------------ legend */
88 +
89 +export function ChartLegend({ series, className }: { series: Series[]; className?: string }) {
90 + if (series.length < 2) return null;
91 + return (
92 + <div className={cn("flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-fg-3", className)}>
93 + {series.map((s) => (
94 + <span key={s.key} className="inline-flex items-center gap-1.5">
95 + <span className="inline-block h-2 w-2 rounded-[2px]" style={{ background: s.color }} />
96 + {s.label}
97 + </span>
98 + ))}
99 + </div>
100 + );
101 +}
102 +
103 +/* ------------------------------------------------------------- frame */
104 +
105 +export function ChartFrame({ title, subtitle, series, children, height = 220, right, className }: { title?: React.ReactNode; subtitle?: React.ReactNode; series?: Series[]; children: React.ReactNode; height?: number; right?: React.ReactNode; className?: string }) {
106 + return (
107 + <div className={cn("min-w-0", className)}>
108 + {title || right ? (
109 + <div className="mb-2 flex flex-wrap items-start justify-between gap-2">
110 + <div>
111 + {title ? <div className="text-[13px] font-semibold tracking-tight">{title}</div> : null}
112 + {subtitle ? <div className="text-[12px] text-fg-3">{subtitle}</div> : null}
113 + </div>
114 + {right}
115 + </div>
116 + ) : null}
117 + {series ? <ChartLegend series={series} className="mb-2" /> : null}
118 + <div style={{ height }} className="w-full">
119 + {children}
120 + </div>
121 + </div>
122 + );
123 +}
124 +
125 +export function ChartEmpty({ message = "No data for this period." }: { message?: string }) {
126 + return <div className="grid h-full place-items-center rounded-sm border border-dashed border-line text-[12px] text-fg-4">{message}</div>;
127 +}
128 +
129 +/* ------------------------------------------------------------- bars */
130 +
131 +export function Bars({ data, x, series, xFormat, yFormat = defaultFmt, stacked, barSize = 18, tooltipLabel, yDomain, layout = "horizontal", height, hideXTicks, referenceY }: { data: Row[]; x: string; series: Series[]; xFormat?: (v: unknown) => string; yFormat?: Formatter; stacked?: boolean; barSize?: number; tooltipLabel?: (label: unknown, row?: Record<string, unknown>) => string; yDomain?: [number | "auto" | "dataMin" | "dataMax", number | "auto" | "dataMin" | "dataMax"]; layout?: "horizontal" | "vertical"; height?: number; hideXTicks?: boolean; referenceY?: { y: number; label?: string } }) {
132 + if (data.length === 0) return <ChartEmpty />;
133 + const vertical = layout === "vertical";
134 + return (
135 + <ResponsiveContainer width="100%" height={height ?? "100%"}>
136 + <BarChart data={data} layout={layout} stackOffset={stacked ? "sign" : "none"} margin={{ top: 6, right: 8, bottom: 0, left: vertical ? 8 : -12 }} barGap={2} barCategoryGap={vertical ? 6 : "24%"}>
137 + <CartesianGrid stroke={CHART.grid} vertical={vertical} horizontal={!vertical} />
138 + {vertical ? (
139 + <>
140 + <XAxis type="number" tick={tickStyle} tickFormatter={yFormat} axisLine={{ stroke: CHART.axis }} tickLine={false} domain={yDomain} allowDecimals={false} />
141 + <YAxis type="category" dataKey={x} tick={tickStyle} tickFormatter={xFormat} axisLine={false} tickLine={false} width={120} interval={0} />
142 + </>
143 + ) : (
144 + <>
145 + <XAxis dataKey={x} tick={hideXTicks ? false : tickStyle} tickFormatter={xFormat} axisLine={{ stroke: CHART.axis }} tickLine={false} minTickGap={18} />
146 + <YAxis tick={tickStyle} tickFormatter={yFormat} axisLine={false} tickLine={false} width={48} domain={yDomain} allowDecimals={false} />
147 + </>
148 + )}
149 + <Tooltip cursor={{ fill: "rgba(255,255,255,0.04)" }} content={<AdminTooltip series={series} valueFormatter={yFormat} labelFormatter={tooltipLabel ?? (xFormat ? (l) => xFormat(l) : undefined)} />} />
150 + {referenceY ? <ReferenceLine y={referenceY.y} stroke={CHART.grey} strokeDasharray="4 4" label={referenceY.label ? { value: referenceY.label, fill: CHART.tick, fontSize: 10, position: "insideTopRight" } : undefined} /> : null}
151 + {series.map((s, i) => {
152 + const last = i === series.length - 1;
153 + return <Bar key={s.key} dataKey={s.key} name={s.label} fill={s.color} stackId={stacked ? (s.stackId ?? "a") : s.stackId} barSize={barSize} radius={!stacked || last ? (vertical ? [0, 4, 4, 0] : [4, 4, 0, 0]) : 0} stroke={stacked ? "#0c0e14" : undefined} strokeWidth={stacked ? 1 : 0} isAnimationActive={false} />;
154 + })}
155 + </BarChart>
156 + </ResponsiveContainer>
157 + );
158 +}
159 +
160 +/** Single-series histogram with optional highlighted bucket. */
161 +export function Histogram({ data, x, y, xFormat, yFormat = defaultFmt, logScale, highlight, color = CHART.accent, height }: { data: Row[]; x: string; y: string; xFormat?: (v: unknown) => string; yFormat?: Formatter; logScale?: boolean; highlight?: (row: Row) => boolean; color?: string; height?: number }) {
162 + if (data.length === 0) return <ChartEmpty />;
163 + const rows = logScale ? data.map((r) => ({ ...r, [y]: (r[y] as number) > 0 ? (r[y] as number) : null })) : data;
164 + return (
165 + <ResponsiveContainer width="100%" height={height ?? "100%"}>
166 + <BarChart data={rows} margin={{ top: 6, right: 8, bottom: 0, left: -8 }} barCategoryGap="20%">
167 + <CartesianGrid stroke={CHART.grid} vertical={false} />
168 + <XAxis dataKey={x} tick={tickStyle} tickFormatter={xFormat} axisLine={{ stroke: CHART.axis }} tickLine={false} interval={0} angle={data.length > 8 ? -30 : 0} textAnchor={data.length > 8 ? "end" : "middle"} height={data.length > 8 ? 42 : 24} />
169 + <YAxis tick={tickStyle} tickFormatter={yFormat} axisLine={false} tickLine={false} width={52} scale={logScale ? "log" : "auto"} domain={logScale ? [1, "auto"] : [0, "auto"]} allowDataOverflow={logScale} allowDecimals={false} />
170 + <Tooltip cursor={{ fill: "rgba(255,255,255,0.04)" }} content={<AdminTooltip valueFormatter={yFormat} labelFormatter={xFormat ? (l) => xFormat(l) : undefined} />} />
171 + <Bar dataKey={y} name={String(y)} fill={color} radius={[4, 4, 0, 0]} barSize={22} isAnimationActive={false}>
172 + {highlight ? data.map((r, i) => <Cell key={i} fill={highlight(r) ? CHART.accent2 : color} />) : null}
173 + </Bar>
174 + </BarChart>
175 + </ResponsiveContainer>
176 + );
177 +}
178 +
179 +/* ------------------------------------------------------------- lines */
180 +
181 +export function Lines({ data, x, series, xFormat, yFormat = defaultFmt, yDomain, reference, tooltipLabel, height, dots }: { data: Row[]; x: string; series: Series[]; xFormat?: (v: unknown) => string; yFormat?: Formatter; yDomain?: [number | "auto" | "dataMin" | "dataMax", number | "auto" | "dataMin" | "dataMax"]; reference?: { y: number; label?: string }; tooltipLabel?: (label: unknown, row?: Record<string, unknown>) => string; height?: number; dots?: boolean }) {
182 + if (data.length === 0) return <ChartEmpty />;
183 + return (
184 + <ResponsiveContainer width="100%" height={height ?? "100%"}>
185 + <LineChart data={data} margin={{ top: 6, right: 12, bottom: 0, left: -8 }}>
186 + <CartesianGrid stroke={CHART.grid} vertical={false} />
187 + <XAxis dataKey={x} tick={tickStyle} tickFormatter={xFormat} axisLine={{ stroke: CHART.axis }} tickLine={false} minTickGap={24} />
188 + <YAxis tick={tickStyle} tickFormatter={yFormat} axisLine={false} tickLine={false} width={56} domain={yDomain ?? ["auto", "auto"]} />
189 + <Tooltip cursor={{ stroke: CHART.axis }} content={<AdminTooltip series={series} valueFormatter={yFormat} labelFormatter={tooltipLabel ?? (xFormat ? (l) => xFormat(l) : undefined)} />} />
190 + {reference ? <ReferenceLine y={reference.y} stroke={CHART.grey} strokeDasharray="4 4" label={reference.label ? { value: reference.label, fill: CHART.tick, fontSize: 10, position: "insideTopRight" } : undefined} /> : null}
191 + {series.map((s) => (
192 + <Line key={s.key} type="monotone" dataKey={s.key} name={s.label} stroke={s.color} strokeWidth={2} dot={dots ? { r: 3, fill: s.color, stroke: "#0c0e14", strokeWidth: 2 } : false} activeDot={{ r: 4, fill: s.color, stroke: "#0c0e14", strokeWidth: 2 }} isAnimationActive={false} connectNulls />
193 + ))}
194 + </LineChart>
195 + </ResponsiveContainer>
196 + );
197 +}
198 +
199 +/* ------------------------------------------------------- sparkline */
200 +
201 +export function Sparkline({ values, color = CHART.accent, height = 28, width = 96 }: { values: number[]; color?: string; height?: number; width?: number }) {
202 + if (values.length < 2) return <span className="text-fg-4">—</span>;
203 + const min = Math.min(...values);
204 + const max = Math.max(...values);
205 + const span = max - min || 1;
206 + const pts = values.map((v, i) => `${(i / (values.length - 1)) * width},${height - ((v - min) / span) * (height - 4) - 2}`).join(" ");
207 + return (
208 + <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden>
209 + <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />
210 + </svg>
211 + );
212 +}
added apps/web/src/components/admin/format.ts +110 −0
@@ -0,0 +1,110 @@
1 +import { formatCompact, formatSC } from "@spinza/shared";
2 +import { ApiClientError } from "@/lib/api";
3 +
4 +/** Coerce Postgres bigint strings / nulls to a plain number. */
5 +export function num(v: unknown): number {
6 + if (typeof v === "number") return Number.isFinite(v) ? v : 0;
7 + if (typeof v === "string" && v.trim() !== "") {
8 + const n = Number(v);
9 + return Number.isFinite(n) ? n : 0;
10 + }
11 + if (typeof v === "bigint") return Number(v);
12 + return 0;
13 +}
14 +
15 +export function pct(v: number | null | undefined, digits = 2): string {
16 + if (v === null || v === undefined || !Number.isFinite(v)) return "—";
17 + return `${(v * 100).toFixed(digits)}%`;
18 +}
19 +
20 +export function signedPct(v: number | null | undefined, digits = 2): string {
21 + if (v === null || v === undefined || !Number.isFinite(v)) return "—";
22 + return `${v > 0 ? "+" : ""}${(v * 100).toFixed(digits)}%`;
23 +}
24 +
25 +export function int(v: unknown): string {
26 + return Math.trunc(num(v)).toLocaleString("en-US");
27 +}
28 +
29 +export function compact(v: unknown): string {
30 + return formatCompact(num(v));
31 +}
32 +
33 +/** Signed credits: +1,250 SC / −400 SC. Never a currency sign. */
34 +export function signedSC(v: unknown): string {
35 + const n = num(v);
36 + const s = formatSC(Math.abs(n));
37 + return n < 0 ? `−${s}` : n > 0 ? `+${s}` : s;
38 +}
39 +
40 +export function sc(v: unknown): string {
41 + return formatSC(num(v));
42 +}
43 +
44 +export function ms(v: number | null | undefined, digits = 0): string {
45 + if (v === null || v === undefined || !Number.isFinite(v)) return "—";
46 + return `${v.toFixed(digits)} ms`;
47 +}
48 +
49 +/** 93784 -> "1d 2h 3m" */
50 +export function duration(sec: number | null | undefined): string {
51 + if (sec === null || sec === undefined || !Number.isFinite(sec)) return "—";
52 + const s = Math.max(0, Math.round(sec));
53 + const d = Math.floor(s / 86400);
54 + const h = Math.floor((s % 86400) / 3600);
55 + const m = Math.floor((s % 3600) / 60);
56 + if (d > 0) return `${d}d ${h}h ${m}m`;
57 + if (h > 0) return `${h}h ${m}m`;
58 + if (m > 0) return `${m}m ${s % 60}s`;
59 + return `${s}s`;
60 +}
61 +
62 +export function durationMs(v: number | null | undefined): string {
63 + if (v === null || v === undefined || !Number.isFinite(v)) return "—";
64 + if (v < 1000) return `${Math.round(v)} ms`;
65 + return duration(v / 1000);
66 +}
67 +
68 +export function bytes(v: unknown): string {
69 + const n = num(v);
70 + if (n < 1024) return `${n} B`;
71 + if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`;
72 + if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`;
73 + return `${(n / 1024 ** 3).toFixed(2)} GB`;
74 +}
75 +
76 +export function dateTime(iso: string | null | undefined): string {
77 + if (!iso) return "—";
78 + const d = new Date(iso);
79 + if (Number.isNaN(d.getTime())) return "—";
80 + return d.toLocaleString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
81 +}
82 +
83 +export function shortDate(iso: string | null | undefined): string {
84 + if (!iso) return "—";
85 + const d = new Date(iso);
86 + if (Number.isNaN(d.getTime())) return "—";
87 + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
88 +}
89 +
90 +export function isoDay(iso: string): string {
91 + const d = new Date(iso);
92 + return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 10);
93 +}
94 +
95 +export function multiplier(v: unknown): string {
96 + const n = num(v);
97 + if (n >= 100) return `${Math.round(n).toLocaleString("en-US")}×`;
98 + if (n >= 10) return `${n.toFixed(1).replace(/\.0$/, "")}×`;
99 + return `${n.toFixed(2).replace(/\.?0+$/, "")}×`;
100 +}
101 +
102 +export function describeError(e: unknown): string {
103 + if (e instanceof ApiClientError) return e.code && e.code !== `HTTP_${e.status}` ? `${e.message} (${e.code})` : e.message;
104 + if (e instanceof Error) return e.message;
105 + return "Unexpected error.";
106 +}
107 +
108 +export function titleCase(s: string): string {
109 + return s.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
110 +}
added apps/web/src/components/admin/gate.tsx +140 −0
@@ -0,0 +1,140 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import { KeyRound, Lock, ShieldAlert, ShieldCheck } from "lucide-react";
5 +import { Button, Input, Spinner } from "@/components/ui";
6 +import { SpinzaMark } from "@/components/brand/logo";
7 +import { api, ApiClientError } from "@/lib/api";
8 +import { useAdmin } from "./store";
9 +import type { AdminIdentity } from "./types";
10 +import { ErrorState } from "./primitives";
11 +
12 +/** Gate: GET /api/admin/me → login form (401), restricted notice (403), or the console. */
13 +export function AdminGate({ children }: { children: React.ReactNode }) {
14 + const status = useAdmin((s) => s.status);
15 + const error = useAdmin((s) => s.error);
16 + const check = useAdmin((s) => s.check);
17 +
18 + React.useEffect(() => {
19 + void check();
20 + }, [check]);
21 +
22 + if (status === "loading") {
23 + return (
24 + <div className="grid min-h-dvh place-items-center bg-bg text-fg-3">
25 + <div className="flex items-center gap-3 text-sm">
26 + <Spinner className="h-4 w-4" /> Checking admin session…
27 + </div>
28 + </div>
29 + );
30 + }
31 + if (status === "forbidden") return <Restricted message={error} />;
32 + if (status === "error") {
33 + return (
34 + <Frame>
35 + <ErrorState title="Admin API unreachable" error={error} onRetry={() => void check()} />
36 + </Frame>
37 + );
38 + }
39 + if (status === "unauthed") return <LoginScreen />;
40 + return <>{children}</>;
41 +}
42 +
43 +function Frame({ children }: { children: React.ReactNode }) {
44 + return (
45 + <div className="grid min-h-dvh place-items-center bg-bg px-4 py-10">
46 + <div className="w-full max-w-sm">
47 + <div className="mb-6 flex items-center justify-center gap-2 text-fg">
48 + <SpinzaMark className="h-7 w-7" />
49 + <span className="text-lg font-semibold tracking-tight">
50 + SPIN<span className="text-accent">ZA</span> <span className="ml-1 text-fg-3">Admin</span>
51 + </span>
52 + </div>
53 + {children}
54 + </div>
55 + </div>
56 + );
57 +}
58 +
59 +function Restricted({ message }: { message: string | null }) {
60 + return (
61 + <Frame>
62 + <div className="surface rounded-lg px-6 py-10 text-center">
63 + <div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-danger/10 text-danger">
64 + <ShieldAlert className="h-6 w-6" />
65 + </div>
66 + <h1 className="text-lg font-semibold tracking-tight">Admin access is restricted</h1>
67 + <p className="mt-2 text-[13px] text-fg-3">{message ?? "This console is only reachable from allow-listed addresses."}</p>
68 + </div>
69 + </Frame>
70 + );
71 +}
72 +
73 +function LoginScreen() {
74 + const signedIn = useAdmin((s) => s.signedIn);
75 + const [username, setUsername] = React.useState("");
76 + const [password, setPassword] = React.useState("");
77 + const [totp, setTotp] = React.useState("");
78 + const [busy, setBusy] = React.useState(false);
79 + const [error, setError] = React.useState<string | null>(null);
80 + const [forbidden, setForbidden] = React.useState<string | null>(null);
81 +
82 + const totpOk = /^\d{6}$/.test(totp);
83 + const canSubmit = username.trim().length > 0 && password.length > 0 && totpOk && !busy;
84 +
85 + async function submit(e: React.FormEvent) {
86 + e.preventDefault();
87 + if (!canSubmit) return;
88 + setBusy(true);
89 + setError(null);
90 + try {
91 + const r = await api<{ admin: AdminIdentity }>("/api/admin/auth/login", { json: { username: username.trim(), password, totp } });
92 + signedIn(r.admin);
93 + } catch (err) {
94 + if (err instanceof ApiClientError) {
95 + if (err.status === 403) setForbidden(err.message);
96 + else if (err.status === 429) setError("Too many attempts. Wait a few minutes and try again.");
97 + else if (err.status === 401) setError("Invalid credentials.");
98 + else setError(err.message);
99 + } else setError("Unexpected error.");
100 + setPassword("");
101 + setTotp("");
102 + } finally {
103 + setBusy(false);
104 + }
105 + }
106 +
107 + if (forbidden) return <Restricted message={forbidden} />;
108 +
109 + return (
110 + <Frame>
111 + <form onSubmit={submit} className="surface rounded-lg p-6" noValidate>
112 + <div className="mb-5 flex items-center gap-2">
113 + <div className="grid h-8 w-8 place-items-center rounded-md bg-accent-soft text-accent-2">
114 + <Lock className="h-4 w-4" />
115 + </div>
116 + <div>
117 + <h1 className="text-[15px] font-semibold tracking-tight">Admin sign-in</h1>
118 + <p className="text-[12px] text-fg-3">Username, password and a 6-digit authenticator code.</p>
119 + </div>
120 + </div>
121 + <div className="space-y-3">
122 + <Input label="Username" name="username" autoComplete="username" autoCapitalize="none" spellCheck={false} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
123 + <Input label="Password" name="password" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
124 + <Input label="Authenticator code" name="totp" inputMode="numeric" pattern="[0-9]*" maxLength={6} autoComplete="one-time-code" placeholder="000000" className="font-mono tracking-[0.3em]" value={totp} onChange={(e) => setTotp(e.target.value.replace(/\D/g, "").slice(0, 6))} error={totp.length > 0 && !totpOk ? "Enter the 6 digits from your authenticator app." : null} />
125 + </div>
126 + {error ? (
127 + <div role="alert" className="mt-4 flex items-start gap-2 rounded-sm border border-danger/30 bg-danger/10 px-3 py-2 text-[13px] text-danger">
128 + <ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" /> {error}
129 + </div>
130 + ) : null}
131 + <Button type="submit" variant="accent" className="mt-5 w-full" loading={busy} disabled={!canSubmit}>
132 + <KeyRound className="h-4 w-4" /> Sign in
133 + </Button>
134 + <p className="mt-4 flex items-center justify-center gap-1.5 text-[11px] text-fg-4">
135 + <ShieldCheck className="h-3 w-3" /> Sessions expire after 12 hours. All actions are logged.
136 + </p>
137 + </form>
138 + </Frame>
139 + );
140 +}
added apps/web/src/components/admin/primitives.tsx +443 −0
@@ -0,0 +1,443 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, ChevronRight, Inbox, RefreshCw } from "lucide-react";
5 +import { Button, Sheet, Skeleton } from "@/components/ui";
6 +import { cn } from "@/lib/utils";
7 +import type { ApiClientError } from "@/lib/api";
8 +import type { GameLifecycle } from "@spinza/shared";
9 +import { describeError } from "./format";
10 +
11 +/* ------------------------------------------------------------ page header */
12 +
13 +export function PageHeader({ title, description, actions, eyebrow }: { title: string; description?: React.ReactNode; actions?: React.ReactNode; eyebrow?: string }) {
14 + return (
15 + <div className="mb-5 flex flex-wrap items-end justify-between gap-3">
16 + <div className="min-w-0">
17 + {eyebrow ? <div className="eyebrow mb-1">{eyebrow}</div> : null}
18 + <h1 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h1>
19 + {description ? <p className="mt-1 max-w-2xl text-[13px] text-fg-3">{description}</p> : null}
20 + </div>
21 + {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
22 + </div>
23 + );
24 +}
25 +
26 +export function RefreshButton({ onClick, loading, label = "Refresh", size = "sm" }: { onClick: () => void; loading?: boolean; label?: string; size?: "sm" | "md" }) {
27 + return (
28 + <Button variant="outline" size={size} onClick={onClick} disabled={loading} aria-label={label} title={label}>
29 + <RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
30 + <span className="hidden sm:inline">{label}</span>
31 + </Button>
32 + );
33 +}
34 +
35 +/* ---------------------------------------------------------------- panel */
36 +
37 +export function Panel({ title, description, actions, children, className, padded = true, tone }: { title?: React.ReactNode; description?: React.ReactNode; actions?: React.ReactNode; children: React.ReactNode; className?: string; padded?: boolean; tone?: "danger" | "success" }) {
38 + return (
39 + <section className={cn("surface rounded-md", tone === "danger" && "border-danger/30", tone === "success" && "border-success/30", className)}>
40 + {title || actions ? (
41 + <header className="flex flex-wrap items-center justify-between gap-2 border-b border-line px-4 py-2.5">
42 + <div className="min-w-0">
43 + <h2 className="text-[13px] font-semibold tracking-tight">{title}</h2>
44 + {description ? <p className="text-[12px] text-fg-3">{description}</p> : null}
45 + </div>
46 + {actions ? <div className="flex items-center gap-2">{actions}</div> : null}
47 + </header>
48 + ) : null}
49 + <div className={cn(padded && "p-4")}>{children}</div>
50 + </section>
51 + );
52 +}
53 +
54 +/* ------------------------------------------------------------- stat tile */
55 +
56 +export function StatTile({ label, value, sub, tone = "neutral", icon, className, compact }: { label: string; value: React.ReactNode; sub?: React.ReactNode; tone?: "neutral" | "accent" | "success" | "danger" | "info"; icon?: React.ReactNode; className?: string; compact?: boolean }) {
57 + const valueTone = { neutral: "text-fg", accent: "text-accent-2", success: "text-success", danger: "text-danger", info: "text-info" }[tone];
58 + return (
59 + <div className={cn("surface rounded-md px-4 py-3", className)}>
60 + <div className="flex items-center justify-between gap-2">
61 + <div className="truncate text-[12px] font-medium text-fg-3">{label}</div>
62 + {icon ? <span className="text-fg-4">{icon}</span> : null}
63 + </div>
64 + <div className={cn("mt-1 font-semibold tracking-tight", compact ? "text-lg" : "text-2xl", valueTone)} style={{ fontVariantNumeric: "proportional-nums" }}>
65 + {value}
66 + </div>
67 + {sub ? <div className="mt-0.5 truncate text-[12px] text-fg-3">{sub}</div> : null}
68 + </div>
69 + );
70 +}
71 +
72 +export function StatGrid({ children, cols = 4, className }: { children: React.ReactNode; cols?: 3 | 4 | 5 | 6; className?: string }) {
73 + const c = { 3: "sm:grid-cols-3", 4: "sm:grid-cols-2 lg:grid-cols-4", 5: "sm:grid-cols-3 xl:grid-cols-5", 6: "sm:grid-cols-3 xl:grid-cols-6" }[cols];
74 + return <div className={cn("grid grid-cols-2 gap-3", c, className)}>{children}</div>;
75 +}
76 +
77 +/* ------------------------------------------------------------------ pills */
78 +
79 +type PillTone = "neutral" | "accent" | "success" | "danger" | "info" | "warn" | "muted";
80 +
81 +export function Pill({ tone = "neutral", children, className, dot }: { tone?: PillTone; children: React.ReactNode; className?: string; dot?: boolean }) {
82 + const tones: Record<PillTone, string> = {
83 + neutral: "bg-surface-2 text-fg-2 border-line",
84 + muted: "bg-transparent text-fg-3 border-line",
85 + accent: "bg-accent-soft text-accent-2 border-accent/30",
86 + success: "bg-success/10 text-success border-success/30",
87 + danger: "bg-danger/10 text-danger border-danger/30",
88 + info: "bg-info/10 text-info border-info/30",
89 + warn: "bg-[#ffb454]/10 text-[#ffc46b] border-[#ffb454]/30",
90 + };
91 + return (
92 + <span className={cn("inline-flex items-center gap-1.5 rounded-full border px-2 py-[2px] text-[11px] font-semibold uppercase tracking-wider whitespace-nowrap", tones[tone], className)}>
93 + {dot ? <span className="h-1.5 w-1.5 rounded-full bg-current" /> : null}
94 + {children}
95 + </span>
96 + );
97 +}
98 +
99 +export const LIFECYCLE_TONE: Record<GameLifecycle, PillTone> = { draft: "muted", simulation: "info", approved: "accent", staging: "warn", published: "success", disabled: "danger" };
100 +
101 +export function LifecyclePill({ lifecycle }: { lifecycle: GameLifecycle | string }) {
102 + const tone = (LIFECYCLE_TONE as Record<string, PillTone>)[lifecycle] ?? "neutral";
103 + return <Pill tone={tone}>{lifecycle}</Pill>;
104 +}
105 +
106 +export function UserStatusPill({ status }: { status: string }) {
107 + const tone: PillTone = status === "active" ? "success" : status === "suspended" ? "danger" : "muted";
108 + return (
109 + <Pill tone={tone} dot>
110 + {status}
111 + </Pill>
112 + );
113 +}
114 +
115 +export function SeverityPill({ severity }: { severity: string }) {
116 + const tone: PillTone = severity === "high" ? "danger" : severity === "warn" ? "warn" : "muted";
117 + return <Pill tone={tone}>{severity}</Pill>;
118 +}
119 +
120 +export function PassFail({ pass, label }: { pass: boolean; label?: string }) {
121 + return <Pill tone={pass ? "success" : "danger"}>{label ?? (pass ? "PASS" : "FAIL")}</Pill>;
122 +}
123 +
124 +/* ------------------------------------------------------------ data table */
125 +
126 +export interface Column<T> {
127 + key: string;
128 + header: React.ReactNode;
129 + render: (row: T) => React.ReactNode;
130 + align?: "left" | "right" | "center";
131 + width?: string;
132 + /** Provide to make the column sortable (client-side). */
133 + sortValue?: (row: T) => number | string | null;
134 + className?: string;
135 + mono?: boolean;
136 +}
137 +
138 +export interface SortState {
139 + key: string;
140 + dir: "asc" | "desc";
141 +}
142 +
143 +export function useSort<T>(rows: T[] | null | undefined, columns: Column<T>[], initial?: SortState) {
144 + const [sort, setSort] = React.useState<SortState | null>(initial ?? null);
145 + const sorted = React.useMemo(() => {
146 + if (!rows) return [];
147 + if (!sort) return rows;
148 + const col = columns.find((c) => c.key === sort.key);
149 + if (!col?.sortValue) return rows;
150 + const sv = col.sortValue;
151 + return rows
152 + .map((r, i) => ({ r, i, v: sv(r) }))
153 + .sort((a, b) => {
154 + const av = a.v;
155 + const bv = b.v;
156 + if (av === null || av === undefined) return 1;
157 + if (bv === null || bv === undefined) return -1;
158 + const c = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
159 + return (sort.dir === "asc" ? c : -c) || a.i - b.i;
160 + })
161 + .map((x) => x.r);
162 + }, [rows, sort, columns]);
163 + const toggle = React.useCallback((key: string) => {
164 + setSort((s) => (s?.key === key ? (s.dir === "desc" ? { key, dir: "asc" } : null) : { key, dir: "desc" }));
165 + }, []);
166 + return { sorted, sort, toggle };
167 +}
168 +
169 +export function DataTable<T>({ columns, rows, rowKey, onRowClick, empty, className, sort, onSort, stale, dense, footer, rowClassName }: { columns: Column<T>[]; rows: T[]; rowKey: (row: T) => string; onRowClick?: (row: T) => void; empty?: React.ReactNode; className?: string; sort?: SortState | null; onSort?: (key: string) => void; stale?: boolean; dense?: boolean; footer?: React.ReactNode; rowClassName?: (row: T) => string | undefined }) {
170 + const alignCls = (a?: Column<T>["align"]) => (a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left");
171 + return (
172 + <div className={cn("overflow-x-auto", stale && "opacity-60 transition-opacity", className)}>
173 + <table className="w-full min-w-[640px] border-collapse text-[13px]">
174 + <thead>
175 + <tr className="border-b border-line">
176 + {columns.map((c) => {
177 + const sortable = !!(c.sortValue && onSort);
178 + const active = sort?.key === c.key;
179 + return (
180 + <th key={c.key} scope="col" style={{ width: c.width }} className={cn("px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-fg-3 whitespace-nowrap", alignCls(c.align), sortable && "cursor-pointer select-none hover:text-fg-2")} onClick={sortable ? () => onSort?.(c.key) : undefined} aria-sort={active ? (sort?.dir === "asc" ? "ascending" : "descending") : undefined}>
181 + <span className={cn("inline-flex items-center gap-1", c.align === "right" && "flex-row-reverse")}>
182 + {c.header}
183 + {sortable ? active ? sort?.dir === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" /> : <ArrowUpDown className="h-3 w-3 opacity-40" /> : null}
184 + </span>
185 + </th>
186 + );
187 + })}
188 + </tr>
189 + </thead>
190 + <tbody>
191 + {rows.length === 0 ? (
192 + <tr>
193 + <td colSpan={columns.length} className="px-3 py-10 text-center text-[13px] text-fg-3">
194 + {empty ?? "Nothing to show."}
195 + </td>
196 + </tr>
197 + ) : (
198 + rows.map((r) => (
199 + <tr key={rowKey(r)} onClick={onRowClick ? () => onRowClick(r) : undefined} className={cn("border-b border-line/60 last:border-0", onRowClick && "cursor-pointer hover:bg-surface-2", rowClassName?.(r))}>
200 + {columns.map((c) => (
201 + <td key={c.key} className={cn("px-3 align-middle", dense ? "py-1.5" : "py-2", alignCls(c.align), c.mono && "font-mono text-[12px]", (c.align === "right" || c.mono) && "tabular", c.className)}>
202 + {c.render(r)}
203 + </td>
204 + ))}
205 + </tr>
206 + ))
207 + )}
208 + </tbody>
209 + {footer ? <tfoot>{footer}</tfoot> : null}
210 + </table>
211 + </div>
212 + );
213 +}
214 +
215 +export function TableSkeleton({ rows = 6, cols = 5 }: { rows?: number; cols?: number }) {
216 + return (
217 + <div className="space-y-2 p-3" aria-busy>
218 + <div className="flex gap-3">
219 + {Array.from({ length: cols }).map((_, i) => (
220 + <Skeleton key={i} className="h-3 flex-1" />
221 + ))}
222 + </div>
223 + {Array.from({ length: rows }).map((_, i) => (
224 + <div key={i} className="flex gap-3">
225 + {Array.from({ length: cols }).map((_, j) => (
226 + <Skeleton key={j} className="h-5 flex-1" />
227 + ))}
228 + </div>
229 + ))}
230 + </div>
231 + );
232 +}
233 +
234 +export function TileSkeleton({ count = 4, cols = 4 }: { count?: number; cols?: 3 | 4 | 5 | 6 }) {
235 + return (
236 + <StatGrid cols={cols}>
237 + {Array.from({ length: count }).map((_, i) => (
238 + <div key={i} className="surface rounded-md px-4 py-3">
239 + <Skeleton className="h-3 w-24" />
240 + <Skeleton className="mt-2 h-7 w-20" />
241 + </div>
242 + ))}
243 + </StatGrid>
244 + );
245 +}
246 +
247 +export function ChartSkeleton({ height = 220 }: { height?: number }) {
248 + return (
249 + <div style={{ height }} className="w-full">
250 + <Skeleton className="h-full w-full" />
251 + </div>
252 + );
253 +}
254 +
255 +/* --------------------------------------------------------- state blocks */
256 +
257 +export function ErrorState({ error, onRetry, title = "Couldn't load this view" }: { error: ApiClientError | Error | string | null; onRetry?: () => void; title?: string }) {
258 + const msg = typeof error === "string" ? error : error ? describeError(error) : "";
259 + return (
260 + <div className="surface flex flex-col items-center gap-3 rounded-md border-danger/30 px-6 py-10 text-center">
261 + <div className="grid h-10 w-10 place-items-center rounded-full bg-danger/10 text-danger">
262 + <AlertTriangle className="h-5 w-5" />
263 + </div>
264 + <div>
265 + <div className="text-[15px] font-semibold">{title}</div>
266 + {msg ? <div className="mt-1 text-[13px] text-fg-3">{msg}</div> : null}
267 + </div>
268 + {onRetry ? (
269 + <Button variant="outline" size="sm" onClick={onRetry}>
270 + <RefreshCw className="h-3.5 w-3.5" /> Try again
271 + </Button>
272 + ) : null}
273 + </div>
274 + );
275 +}
276 +
277 +export function EmptyState({ title, description, action, className }: { title: string; description?: string; action?: React.ReactNode; className?: string }) {
278 + return (
279 + <div className={cn("flex flex-col items-center gap-2 px-6 py-10 text-center", className)}>
280 + <div className="grid h-10 w-10 place-items-center rounded-full bg-surface-2 text-fg-3">
281 + <Inbox className="h-5 w-5" />
282 + </div>
283 + <div className="text-[14px] font-semibold">{title}</div>
284 + {description ? <p className="max-w-sm text-[13px] text-fg-3">{description}</p> : null}
285 + {action ? <div className="mt-2">{action}</div> : null}
286 + </div>
287 + );
288 +}
289 +
290 +export function InlineError({ children }: { children: React.ReactNode }) {
291 + return (
292 + <div className="flex items-start gap-2 rounded-sm border border-danger/30 bg-danger/10 px-3 py-2 text-[13px] text-danger">
293 + <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
294 + <span>{children}</span>
295 + </div>
296 + );
297 +}
298 +
299 +/* ------------------------------------------------------------- KV list */
300 +
301 +export function KV({ items, className, cols = 2 }: { items: { label: string; value: React.ReactNode; mono?: boolean }[]; className?: string; cols?: 1 | 2 | 3 }) {
302 + return (
303 + <dl className={cn("grid gap-x-6 gap-y-2.5", cols === 1 ? "grid-cols-1" : cols === 3 ? "grid-cols-2 sm:grid-cols-3" : "grid-cols-2", className)}>
304 + {items.map((it) => (
305 + <div key={it.label} className="min-w-0">
306 + <dt className="text-[11px] font-medium uppercase tracking-wider text-fg-4">{it.label}</dt>
307 + <dd className={cn("mt-0.5 truncate text-[13px] text-fg", it.mono && "font-mono text-[12px]")}>{it.value}</dd>
308 + </div>
309 + ))}
310 + </dl>
311 + );
312 +}
313 +
314 +/* ------------------------------------------------------- dense controls */
315 +
316 +export const denseControl = "h-9 rounded-sm border border-line-2 bg-bg-1 px-2.5 text-[13px] text-fg placeholder:text-fg-4 focus-ring focus:border-accent/60 disabled:opacity-50";
317 +
318 +export const DenseInput = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(function DenseInput({ className, ...props }, ref) {
319 + return <input ref={ref} className={cn(denseControl, "w-full", className)} style={{ fontSize: 13 }} {...props} />;
320 +});
321 +
322 +export function DenseSelect({ className, children, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
323 + return (
324 + <div className={cn("relative inline-flex", className)}>
325 + <select className={cn(denseControl, "w-full appearance-none pr-8")} style={{ fontSize: 13 }} {...props}>
326 + {children}
327 + </select>
328 + <ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-3" />
329 + </div>
330 + );
331 +}
332 +
333 +export function DenseTextarea({ className, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
334 + return <textarea className={cn(denseControl, "h-auto w-full py-2 leading-relaxed", className)} style={{ fontSize: 13 }} {...props} />;
335 +}
336 +
337 +export function FieldLabel({ children, hint, className }: { children: React.ReactNode; hint?: React.ReactNode; className?: string }) {
338 + return (
339 + <div className={cn("mb-1.5 flex items-baseline justify-between gap-2", className)}>
340 + <span className="text-[12px] font-medium text-fg-2">{children}</span>
341 + {hint ? <span className="text-[11px] text-fg-4">{hint}</span> : null}
342 + </div>
343 + );
344 +}
345 +
346 +/** Compact switch for table cells and setting rows. */
347 +export function Toggle({ checked, onChange, disabled, label, size = "sm" }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; label: string; size?: "sm" | "md" }) {
348 + const dims = size === "sm" ? { track: "h-5 w-9", knob: "h-4 w-4", on: "translate-x-[18px]", off: "translate-x-0.5" } : { track: "h-6 w-11", knob: "h-5 w-5", on: "translate-x-[22px]", off: "translate-x-0.5" };
349 + return (
350 + <button type="button" role="switch" aria-checked={checked} aria-label={label} disabled={disabled} onClick={() => onChange(!checked)} className={cn("relative inline-flex shrink-0 items-center rounded-full border transition-colors focus-ring disabled:opacity-50", dims.track, checked ? "border-accent bg-accent" : "border-line-2 bg-surface-3")}>
351 + <span className={cn("absolute top-1/2 -translate-y-1/2 rounded-full bg-white shadow transition-transform", dims.knob, checked ? dims.on : dims.off)} />
352 + </button>
353 + );
354 +}
355 +
356 +export function SegmentedControl<T extends string | number>({ value, onChange, items, className, size = "sm" }: { value: T; onChange: (v: T) => void; items: { value: T; label: string }[]; className?: string; size?: "sm" | "xs" }) {
357 + return (
358 + <div className={cn("inline-flex rounded-sm border border-line bg-surface p-0.5", className)} role="radiogroup">
359 + {items.map((it) => (
360 + <button key={String(it.value)} type="button" role="radio" aria-checked={value === it.value} onClick={() => onChange(it.value)} className={cn("rounded-[6px] font-semibold transition-colors focus-ring", size === "xs" ? "h-7 px-2 text-[11px]" : "h-8 px-3 text-[12px]", value === it.value ? "bg-surface-3 text-fg shadow" : "text-fg-3 hover:text-fg-2")}>
361 + {it.label}
362 + </button>
363 + ))}
364 + </div>
365 + );
366 +}
367 +
368 +/* --------------------------------------------------------- JSON toggle */
369 +
370 +export function JsonToggle({ value, label = "meta" }: { value: unknown; label?: string }) {
371 + const [open, setOpen] = React.useState(false);
372 + if (value === null || value === undefined || (typeof value === "object" && Object.keys(value as object).length === 0)) return <span className="text-fg-4">—</span>;
373 + const keys = typeof value === "object" ? Object.keys(value as object).length : 1;
374 + return (
375 + <div className="min-w-0">
376 + <button type="button" onClick={() => setOpen((o) => !o)} className="inline-flex items-center gap-1 text-[12px] text-fg-3 hover:text-fg focus-ring rounded-xs" aria-expanded={open}>
377 + <ChevronRight className={cn("h-3 w-3 transition-transform", open && "rotate-90")} />
378 + {label} <span className="text-fg-4">({keys})</span>
379 + </button>
380 + {open ? <pre className="mt-1 max-h-64 max-w-[520px] overflow-auto rounded-xs border border-line bg-bg-1 p-2 font-mono text-[11px] leading-relaxed text-fg-2">{JSON.stringify(value, null, 2)}</pre> : null}
381 + </div>
382 + );
383 +}
384 +
385 +/* ------------------------------------------------------ confirm dialog */
386 +
387 +export function ConfirmDialog({ open, onClose, onConfirm, title, description, confirmLabel = "Confirm", danger, loading, children, disabled }: { open: boolean; onClose: () => void; onConfirm: () => void; title: string; description?: React.ReactNode; confirmLabel?: string; danger?: boolean; loading?: boolean; children?: React.ReactNode; disabled?: boolean }) {
388 + return (
389 + <Sheet open={open} onClose={onClose} title={title} side="center">
390 + {description ? <p className="text-[13px] text-fg-2">{description}</p> : null}
391 + {children ? <div className="mt-4">{children}</div> : null}
392 + <div className="mt-6 flex justify-end gap-2">
393 + <Button variant="ghost" size="sm" onClick={onClose} disabled={loading}>
394 + Cancel
395 + </Button>
396 + <Button variant={danger ? "danger" : "primary"} size="sm" onClick={onConfirm} loading={loading} disabled={disabled}>
397 + {confirmLabel}
398 + </Button>
399 + </div>
400 + </Sheet>
401 + );
402 +}
403 +
404 +/* ---------------------------------------------------------- misc bits */
405 +
406 +export function Mono({ children, className, title }: { children: React.ReactNode; className?: string; title?: string }) {
407 + return (
408 + <span className={cn("font-mono text-[12px] text-fg-2", className)} title={title}>
409 + {children}
410 + </span>
411 + );
412 +}
413 +
414 +export function Delta({ value, digits = 2, invert }: { value: number | null | undefined; digits?: number; invert?: boolean }) {
415 + if (value === null || value === undefined || !Number.isFinite(value)) return <span className="text-fg-4">—</span>;
416 + const good = invert ? value <= 0 : value >= 0;
417 + return (
418 + <span className={cn("tabular", value === 0 ? "text-fg-3" : good ? "text-success" : "text-danger")}>
419 + {value > 0 ? "+" : ""}
420 + {(value * 100).toFixed(digits)}%
421 + </span>
422 + );
423 +}
424 +
425 +export function Pagination({ offset, limit, total, onChange }: { offset: number; limit: number; total: number; onChange: (offset: number) => void }) {
426 + const from = total === 0 ? 0 : offset + 1;
427 + const to = Math.min(total, offset + limit);
428 + return (
429 + <div className="flex items-center justify-between gap-3 border-t border-line px-3 py-2 text-[12px] text-fg-3">
430 + <span className="tabular">
431 + {from.toLocaleString("en-US")}–{to.toLocaleString("en-US")} of {total.toLocaleString("en-US")}
432 + </span>
433 + <div className="flex gap-1">
434 + <Button variant="ghost" size="sm" disabled={offset === 0} onClick={() => onChange(Math.max(0, offset - limit))}>
435 + Previous
436 + </Button>
437 + <Button variant="ghost" size="sm" disabled={offset + limit >= total} onClick={() => onChange(offset + limit)}>
438 + Next
439 + </Button>
440 + </div>
441 + </div>
442 + );
443 +}
added apps/web/src/components/admin/progression-table.tsx +176 −0
@@ -0,0 +1,176 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import { Check, RotateCcw } from "lucide-react";
5 +import { Button } from "@/components/ui";
6 +import { api } from "@/lib/api";
7 +import { toast } from "@/lib/store";
8 +import { cn } from "@/lib/utils";
9 +import type { ProgressionPatch } from "./types";
10 +import { DenseInput, Pill, Toggle } from "./primitives";
11 +import { describeError, int } from "./format";
12 +
13 +export interface ProgressionItem {
14 + key: string;
15 + name: string;
16 + description: string;
17 + target: number;
18 + rewardCredits: number;
19 + rewardXp: number;
20 + enabled: boolean;
21 + metric: string;
22 + /** Extra chip shown next to the key (period / category). */
23 + tag?: string;
24 + /** Right-hand statistics columns. */
25 + stats: { label: string; value: React.ReactNode }[];
26 +}
27 +
28 +type Draft = Partial<Pick<ProgressionItem, "name" | "description" | "target" | "rewardCredits" | "rewardXp">>;
29 +
30 +/** Inline-editable table shared by Missions and Achievements. Each row saves through PATCH `${endpoint}/${key}`. */
31 +export function ProgressionTable({ items, endpoint, onSaved, statHeaders }: { items: ProgressionItem[]; endpoint: string; onSaved: () => void; statHeaders: string[] }) {
32 + const [drafts, setDrafts] = React.useState<Record<string, Draft>>({});
33 + const [busy, setBusy] = React.useState<string | null>(null);
34 +
35 + function edit(key: string, patch: Draft) {
36 + setDrafts((d) => ({ ...d, [key]: { ...d[key], ...patch } }));
37 + }
38 + function reset(key: string) {
39 + setDrafts((d) => {
40 + const n = { ...d };
41 + delete n[key];
42 + return n;
43 + });
44 + }
45 +
46 + function validate(item: ProgressionItem, draft: Draft): string | null {
47 + const name = draft.name ?? item.name;
48 + const desc = draft.description ?? item.description;
49 + if (name.trim().length < 2) return "Name must be at least 2 characters.";
50 + if (desc.trim().length < 2) return "Description must be at least 2 characters.";
51 + const t = draft.target ?? item.target;
52 + if (!Number.isInteger(t) || t < 1) return "Target must be a positive integer.";
53 + const rc = draft.rewardCredits ?? item.rewardCredits;
54 + const rx = draft.rewardXp ?? item.rewardXp;
55 + if (!Number.isInteger(rc) || rc < 0 || !Number.isInteger(rx) || rx < 0) return "Rewards must be non-negative integers.";
56 + return null;
57 + }
58 +
59 + async function save(item: ProgressionItem) {
60 + const draft = drafts[item.key];
61 + if (!draft) return;
62 + const err = validate(item, draft);
63 + if (err) {
64 + toast({ title: "Invalid values", description: err, tone: "danger" });
65 + return;
66 + }
67 + const patch: ProgressionPatch = {};
68 + if (draft.name !== undefined && draft.name !== item.name) patch.name = draft.name.trim();
69 + if (draft.description !== undefined && draft.description !== item.description) patch.description = draft.description.trim();
70 + if (draft.target !== undefined && draft.target !== item.target) patch.target = draft.target;
71 + if (draft.rewardCredits !== undefined && draft.rewardCredits !== item.rewardCredits) patch.rewardCredits = draft.rewardCredits;
72 + if (draft.rewardXp !== undefined && draft.rewardXp !== item.rewardXp) patch.rewardXp = draft.rewardXp;
73 + if (Object.keys(patch).length === 0) {
74 + reset(item.key);
75 + return;
76 + }
77 + setBusy(item.key);
78 + try {
79 + await api(`${endpoint}/${encodeURIComponent(item.key)}`, { method: "PATCH", json: patch });
80 + toast({ title: `${item.name} saved`, tone: "success" });
81 + reset(item.key);
82 + onSaved();
83 + } catch (e) {
84 + toast({ title: "Save failed", description: describeError(e), tone: "danger" });
85 + } finally {
86 + setBusy(null);
87 + }
88 + }
89 +
90 + async function toggleEnabled(item: ProgressionItem, enabled: boolean) {
91 + setBusy(item.key);
92 + try {
93 + await api(`${endpoint}/${encodeURIComponent(item.key)}`, { method: "PATCH", json: { enabled } });
94 + onSaved();
95 + } catch (e) {
96 + toast({ title: "Update failed", description: describeError(e), tone: "danger" });
97 + } finally {
98 + setBusy(null);
99 + }
100 + }
101 +
102 + const numInput = (item: ProgressionItem, field: "target" | "rewardCredits" | "rewardXp", width: string) => {
103 + const draft = drafts[item.key];
104 + const v = draft?.[field] ?? item[field];
105 + return <DenseInput inputMode="numeric" className={cn(width, "text-right font-mono", draft?.[field] !== undefined && draft[field] !== item[field] && "border-accent/60")} value={String(v)} onChange={(e) => edit(item.key, { [field]: Number(e.target.value.replace(/\D/g, "") || 0) })} aria-label={`${field} of ${item.name}`} />;
106 + };
107 +
108 + return (
109 + <div className="overflow-x-auto">
110 + <table className="w-full min-w-[980px] border-collapse text-[13px]">
111 + <thead>
112 + <tr className="border-b border-line text-left text-[11px] font-semibold uppercase tracking-wider text-fg-3">
113 + <th className="px-3 py-2">On</th>
114 + <th className="px-3 py-2">Key</th>
115 + <th className="px-3 py-2">Name & description</th>
116 + <th className="px-3 py-2 text-right">Target</th>
117 + <th className="px-3 py-2 text-right">Reward SC</th>
118 + <th className="px-3 py-2 text-right">Reward XP</th>
119 + {statHeaders.map((h) => (
120 + <th key={h} className="px-3 py-2 text-right">
121 + {h}
122 + </th>
123 + ))}
124 + <th className="px-3 py-2" />
125 + </tr>
126 + </thead>
127 + <tbody>
128 + {items.map((item) => {
129 + const draft = drafts[item.key];
130 + const dirty = !!draft;
131 + return (
132 + <tr key={item.key} className={cn("border-b border-line/60 align-top last:border-0", !item.enabled && "opacity-60", dirty && "bg-accent-soft/30")}>
133 + <td className="px-3 py-2.5">
134 + <Toggle checked={item.enabled} disabled={busy === item.key} onChange={(v) => void toggleEnabled(item, v)} label={`Enable ${item.name}`} />
135 + </td>
136 + <td className="px-3 py-2.5">
137 + <div className="font-mono text-[12px] text-fg-2">{item.key}</div>
138 + <div className="mt-1 flex flex-wrap gap-1">
139 + {item.tag ? <Pill tone={item.tag === "weekly" ? "info" : "muted"}>{item.tag}</Pill> : null}
140 + <Pill tone="muted">{item.metric}</Pill>
141 + </div>
142 + </td>
143 + <td className="px-3 py-2.5">
144 + <DenseInput className={cn("mb-1.5 h-8 font-medium", draft?.name !== undefined && draft.name !== item.name && "border-accent/60")} value={draft?.name ?? item.name} onChange={(e) => edit(item.key, { name: e.target.value })} aria-label={`Name of ${item.key}`} />
145 + <DenseInput className={cn("h-8 text-fg-2", draft?.description !== undefined && draft.description !== item.description && "border-accent/60")} value={draft?.description ?? item.description} onChange={(e) => edit(item.key, { description: e.target.value })} aria-label={`Description of ${item.key}`} />
146 + </td>
147 + <td className="px-3 py-2.5 text-right">{numInput(item, "target", "w-24")}</td>
148 + <td className="px-3 py-2.5 text-right">{numInput(item, "rewardCredits", "w-24")}</td>
149 + <td className="px-3 py-2.5 text-right">{numInput(item, "rewardXp", "w-20")}</td>
150 + {item.stats.map((s) => (
151 + <td key={s.label} className="px-3 py-2.5 text-right tabular text-fg-2">
152 + {s.value}
153 + </td>
154 + ))}
155 + <td className="px-3 py-2.5 text-right">
156 + {dirty ? (
157 + <div className="inline-flex gap-1">
158 + <Button size="sm" variant="accent" onClick={() => void save(item)} loading={busy === item.key} aria-label="Save">
159 + <Check className="h-3.5 w-3.5" /> Save
160 + </Button>
161 + <Button size="sm" variant="ghost" onClick={() => reset(item.key)} disabled={busy === item.key} aria-label="Discard changes">
162 + <RotateCcw className="h-3.5 w-3.5" />
163 + </Button>
164 + </div>
165 + ) : (
166 + <span className="text-[11px] text-fg-4">{int(item.target)} target</span>
167 + )}
168 + </td>
169 + </tr>
170 + );
171 + })}
172 + </tbody>
173 + </table>
174 + </div>
175 + );
176 +}
added apps/web/src/components/admin/shell.tsx +189 −0
@@ -0,0 +1,189 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import Link from "next/link";
5 +import { usePathname } from "next/navigation";
6 +import { Activity, Award, BarChart3, Coins, FlaskConical, Gamepad2, Gift, HeartPulse, LayoutDashboard, LogOut, Menu, Settings, ShieldAlert, Target, Users, UsersRound, X } from "lucide-react";
7 +import { SpinzaMark } from "@/components/brand/logo";
8 +import { cn } from "@/lib/utils";
9 +import { useAdmin } from "./store";
10 +import { Pill } from "./primitives";
11 +
12 +interface NavItem {
13 + href: string;
14 + label: string;
15 + icon: React.ComponentType<{ className?: string }>;
16 +}
17 +
18 +const SECTIONS: { title: string; items: NavItem[] }[] = [
19 + { title: "Overview", items: [{ href: "/admin", label: "Dashboard", icon: LayoutDashboard }] },
20 + {
21 + title: "Operations",
22 + items: [
23 + { href: "/admin/users", label: "Users", icon: Users },
24 + { href: "/admin/games", label: "Games", icon: Gamepad2 },
25 + { href: "/admin/simulator", label: "Game Simulator", icon: FlaskConical },
26 + ],
27 + },
28 + {
29 + title: "Insights",
30 + items: [
31 + { href: "/admin/economy", label: "Economy", icon: Coins },
32 + { href: "/admin/analytics/games", label: "Game Analytics", icon: BarChart3 },
33 + { href: "/admin/analytics/players", label: "Player Analytics", icon: UsersRound },
34 + ],
35 + },
36 + {
37 + title: "Progression",
38 + items: [
39 + { href: "/admin/missions", label: "Missions", icon: Target },
40 + { href: "/admin/achievements", label: "Achievements", icon: Award },
41 + { href: "/admin/rewards", label: "Daily Rewards", icon: Gift },
42 + ],
43 + },
44 + {
45 + title: "Platform",
46 + items: [
47 + { href: "/admin/system", label: "System Health", icon: HeartPulse },
48 + { href: "/admin/security", label: "Security", icon: ShieldAlert },
49 + { href: "/admin/settings", label: "Settings", icon: Settings },
50 + ],
51 + },
52 +];
53 +
54 +const ALL_ITEMS = SECTIONS.flatMap((s) => s.items);
55 +
56 +function isActive(path: string, href: string) {
57 + return href === "/admin" ? path === "/admin" : path === href || path.startsWith(href + "/");
58 +}
59 +
60 +function currentTitle(path: string): string {
61 + const hit = ALL_ITEMS.filter((i) => isActive(path, i.href)).sort((a, b) => b.href.length - a.href.length)[0];
62 + if (!hit) return "Admin";
63 + if (path.startsWith("/admin/users/") && path !== "/admin/users") return "User detail";
64 + if (path.startsWith("/admin/games/") && path !== "/admin/games") return "Game detail";
65 + return hit.label;
66 +}
67 +
68 +export function AdminShell({ children }: { children: React.ReactNode }) {
69 + const path = usePathname() ?? "/admin";
70 + const [open, setOpen] = React.useState(false);
71 + const admin = useAdmin((s) => s.admin);
72 + const maintenance = useAdmin((s) => s.maintenance);
73 + const node = useAdmin((s) => s.node);
74 + const version = useAdmin((s) => s.version);
75 + const signOut = useAdmin((s) => s.signOut);
76 + const loadStatus = useAdmin((s) => s.loadStatus);
77 +
78 + React.useEffect(() => {
79 + const t = setInterval(() => {
80 + if (document.visibilityState === "visible") void loadStatus();
81 + }, 60_000);
82 + return () => clearInterval(t);
83 + }, [loadStatus]);
84 +
85 + const nav = (
86 + <nav className="flex flex-1 flex-col gap-4 overflow-y-auto px-3 py-3">
87 + {SECTIONS.map((s) => (
88 + <div key={s.title}>
89 + <div className="eyebrow mb-1 px-2 text-[10px]">{s.title}</div>
90 + <ul className="flex flex-col gap-0.5">
91 + {s.items.map((it) => {
92 + const active = isActive(path, it.href);
93 + return (
94 + <li key={it.href}>
95 + <Link href={it.href} onClick={() => setOpen(false)} aria-current={active ? "page" : undefined} className={cn("flex items-center gap-2.5 rounded-sm px-2 py-1.5 text-[13px] font-medium transition-colors", active ? "bg-surface-3 text-fg" : "text-fg-3 hover:bg-surface-2 hover:text-fg-2")}>
96 + <it.icon className={cn("h-4 w-4 shrink-0", active ? "text-accent-2" : "text-fg-4")} />
97 + {it.label}
98 + </Link>
99 + </li>
100 + );
101 + })}
102 + </ul>
103 + </div>
104 + ))}
105 + </nav>
106 + );
107 +
108 + return (
109 + <div className="min-h-dvh bg-bg lg:grid lg:grid-cols-[224px_1fr]">
110 + {/* Sidebar (desktop) */}
111 + <aside className="sticky top-0 hidden h-dvh flex-col border-r border-line bg-bg-1/70 lg:flex">
112 + <Link href="/admin" className="flex h-14 items-center gap-2 border-b border-line px-4">
113 + <SpinzaMark className="h-6 w-6" glow={false} />
114 + <span className="text-[15px] font-semibold tracking-tight">
115 + SPIN<span className="text-accent">ZA</span>
116 + </span>
117 + <span className="ml-1 rounded-xs border border-line px-1.5 py-px text-[10px] font-bold uppercase tracking-wider text-fg-3">Admin</span>
118 + </Link>
119 + {nav}
120 + <div className="border-t border-line px-4 py-3 text-[11px] leading-relaxed text-fg-4">
121 + Internal console. Virtual credits only — SC have no cash value.
122 + <div className="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-fg-4">
123 + <Activity className="h-3 w-3" /> {node ?? "—"} · v{version ?? "—"}
124 + </div>
125 + </div>
126 + </aside>
127 +
128 + {/* Mobile drawer */}
129 + {open ? (
130 + <div className="fixed inset-0 lg:hidden" style={{ zIndex: "var(--z-sheet)" }}>
131 + <div className="absolute inset-0 bg-black/70" onClick={() => setOpen(false)} />
132 + <div className="absolute inset-y-0 left-0 flex w-[260px] flex-col bg-bg-1 shadow-2xl">
133 + <div className="flex h-14 items-center justify-between border-b border-line px-4">
134 + <span className="text-[15px] font-semibold tracking-tight">
135 + SPIN<span className="text-accent">ZA</span> <span className="text-fg-3">Admin</span>
136 + </span>
137 + <button onClick={() => setOpen(false)} className="tap grid place-items-center rounded-md text-fg-3 hover:text-fg" aria-label="Close menu">
138 + <X className="h-5 w-5" />
139 + </button>
140 + </div>
141 + {nav}
142 + </div>
143 + </div>
144 + ) : null}
145 +
146 + <div className="flex min-h-dvh min-w-0 flex-col">
147 + <header className="sticky top-0 border-b border-line bg-bg-1/80 backdrop-blur" style={{ zIndex: "var(--z-nav)", paddingTop: "var(--safe-top)" }}>
148 + <div className="flex h-14 items-center justify-between gap-3 px-4 lg:px-6">
149 + <div className="flex min-w-0 items-center gap-3">
150 + <button onClick={() => setOpen(true)} className="tap -ml-2 grid place-items-center rounded-md text-fg-3 hover:text-fg lg:hidden" aria-label="Open menu">
151 + <Menu className="h-5 w-5" />
152 + </button>
153 + <div className="truncate text-[13px] font-semibold text-fg">{currentTitle(path)}</div>
154 + </div>
155 + <div className="flex items-center gap-2 sm:gap-3">
156 + {maintenance ? (
157 + maintenance.enabled ? (
158 + <Pill tone="danger" dot>
159 + Maintenance
160 + </Pill>
161 + ) : (
162 + <Pill tone="success" dot>
163 + Live
164 + </Pill>
165 + )
166 + ) : (
167 + <Pill tone="muted">…</Pill>
168 + )}
169 + <span className="hidden font-mono text-[11px] text-fg-3 md:inline">
170 + {node ?? "—"} · v{version ?? "—"}
171 + </span>
172 + <span className="hidden items-center gap-2 rounded-full border border-line bg-surface px-2.5 py-1 text-[12px] sm:inline-flex">
173 + <span className="grid h-5 w-5 place-items-center rounded-full bg-accent text-[10px] font-bold text-bg">{admin?.username.slice(0, 1).toUpperCase()}</span>
174 + <span className="font-medium">{admin?.username}</span>
175 + <span className="text-fg-4">{admin?.role}</span>
176 + </span>
177 + <button onClick={() => void signOut()} className="tap grid place-items-center rounded-md text-fg-3 hover:text-fg focus-ring" aria-label="Log out" title="Log out">
178 + <LogOut className="h-4 w-4" />
179 + </button>
180 + </div>
181 + </div>
182 + </header>
183 + <main className="flex-1 px-4 py-5 lg:px-6 lg:py-6">
184 + <div className="mx-auto w-full max-w-[1480px]">{children}</div>
185 + </main>
186 + </div>
187 + </div>
188 + );
189 +}
added apps/web/src/components/admin/simulation-results.tsx +123 −0
@@ -0,0 +1,123 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import type { CertificationReport, SimulationResult } from "@spinza/game-core";
5 +import { Bars, ChartFrame, CHART, Histogram, Lines } from "./charts";
6 +import { KV, Panel, PassFail, SegmentedControl, StatGrid, StatTile } from "./primitives";
7 +import { durationMs, int, multiplier, pct, sc, signedPct } from "./format";
8 +import { cn } from "@/lib/utils";
9 +
10 +/** KPI tiles + charts for a finished simulation. Shared by the simulator and the game detail page. */
11 +export function SimulationResults({ sim, cert, compactTiles }: { sim: SimulationResult; cert: CertificationReport | null; compactTiles?: boolean }) {
12 + const [scale, setScale] = React.useState<"log" | "linear">("log");
13 + const devTone = Math.abs(sim.deviation) <= 0.004 ? "success" : Math.abs(sim.deviation) <= 0.015 ? "neutral" : "danger";
14 + const features = Object.entries(sim.featureCounts)
15 + .map(([feature, count]) => ({ feature, count, rate: count / sim.spins }))
16 + .sort((a, b) => b.count - a.count);
17 + const variance = sim.stdDev * sim.stdDev;
18 + const convDomain: [number, number] = [Math.max(0, Math.min(sim.configuredRtp, ...sim.convergence.map((c) => c.rtp)) - 0.02), Math.max(sim.configuredRtp, ...sim.convergence.map((c) => c.rtp)) + 0.02];
19 +
20 + return (
21 + <div className="space-y-4">
22 + <StatGrid cols={6}>
23 + <StatTile label="Configured RTP" value={pct(sim.configuredRtp)} compact={compactTiles} />
24 + <StatTile label="Observed RTP" value={pct(sim.observedRtp, 3)} tone="accent" compact={compactTiles} />
25 + <StatTile label="Deviation" value={signedPct(sim.deviation, 3)} tone={devTone} sub="observed − configured" compact={compactTiles} />
26 + <StatTile label="Std dev (×bet)" value={sim.stdDev.toFixed(2)} sub={`variance ${variance.toFixed(2)}`} compact={compactTiles} />
27 + <StatTile label="Hit rate" value={pct(sim.hitRate)} compact={compactTiles} />
28 + <StatTile label="Max win" value={multiplier(sim.maxWinMultiplier)} sub={sc(sim.maxWin)} compact={compactTiles} />
29 + </StatGrid>
30 + <StatGrid cols={6}>
31 + <StatTile label="Bonus rate" value={pct(sim.bonusRate, 3)} sub={`1 in ${sim.bonusRate ? Math.round(1 / sim.bonusRate).toLocaleString("en-US") : "∞"}`} compact={compactTiles} />
32 + <StatTile label="Free-spin rate" value={pct(sim.freeSpinRate, 3)} sub={`1 in ${sim.freeSpinRate ? Math.round(1 / sim.freeSpinRate).toLocaleString("en-US") : "∞"}`} compact={compactTiles} />
33 + <StatTile label="Jackpot rate" value={pct(sim.jackpotRate, 4)} sub={`1 in ${sim.jackpotRate ? Math.round(1 / sim.jackpotRate).toLocaleString("en-US") : "∞"}`} compact={compactTiles} />
34 + <StatTile label="Median / avg win" value={<span>{sc(sim.medianWin)} <span className="text-fg-4">/</span> {sc(Math.round(sim.averageWin))}</span>} sub={`bet ${sc(sim.bet)}`} compact={compactTiles} />
35 + <StatTile label="Capped rounds" value={int(sim.cappedRounds)} tone={sim.cappedRounds / sim.spins > 0.0005 ? "danger" : "neutral"} sub={pct(sim.cappedRounds / sim.spins, 4)} compact={compactTiles} />
36 + <StatTile label="Duration" value={durationMs(sim.durationMs)} sub={`${int(sim.spins)} spins · ${Math.round(sim.spins / Math.max(1, sim.durationMs / 1000)).toLocaleString("en-US")} spins/s`} compact={compactTiles} />
37 + </StatGrid>
38 +
39 + <div className="grid gap-4 xl:grid-cols-2">
40 + <Panel>
41 + <ChartFrame title="Win distribution" subtitle="Rounds per win multiplier bucket" height={240} right={<SegmentedControl size="xs" value={scale} onChange={setScale} items={[{ value: "log", label: "Log" }, { value: "linear", label: "Linear" }]} />}>
42 + <Histogram data={sim.distribution.map((b) => ({ ...b }))} x="label" y="count" logScale={scale === "log"} yFormat={(v) => (v >= 1_000_000 ? `${(v / 1_000_000).toFixed(1)}M` : v >= 1000 ? `${(v / 1000).toFixed(0)}K` : String(Math.round(v)))} />
43 + </ChartFrame>
44 + <DistributionTable rows={sim.distribution} />
45 + </Panel>
46 + <Panel>
47 + <ChartFrame title="RTP convergence" subtitle={`Running RTP vs configured ${pct(sim.configuredRtp)}`} height={240}>
48 + <Lines data={sim.convergence.map((c) => ({ ...c }))} x="spins" series={[{ key: "rtp", label: "Observed RTP", color: CHART.accent }]} xFormat={(v) => shortNum(Number(v))} yFormat={(v) => `${(v * 100).toFixed(1)}%`} yDomain={convDomain} reference={{ y: sim.configuredRtp, label: `target ${pct(sim.configuredRtp)}` }} tooltipLabel={(l) => `${int(l)} spins`} />
49 + </ChartFrame>
50 + <ChartFrame title="Feature frequency" subtitle="Rounds in which each feature fired" height={Math.max(120, 28 * features.length + 30)} className="mt-6">
51 + {features.length ? <Bars data={features} x="feature" layout="vertical" series={[{ key: "count", label: "Rounds", color: CHART.accent }]} yFormat={(v) => shortNum(v)} tooltipLabel={(l, row) => `${String(l)} · ${pct(Number(row?.rate ?? 0), 3)} of rounds`} /> : <div className="grid h-full place-items-center text-[12px] text-fg-4">No feature fired during this run.</div>}
52 + </ChartFrame>
53 + </Panel>
54 + </div>
55 +
56 + {cert ? <CertificationBlock cert={cert} /> : null}
57 + </div>
58 + );
59 +}
60 +
61 +function DistributionTable({ rows }: { rows: SimulationResult["distribution"] }) {
62 + return (
63 + <details className="mt-3 text-[12px]">
64 + <summary className="cursor-pointer text-fg-3 hover:text-fg">Table view</summary>
65 + <table className="mt-2 w-full text-[12px] tabular">
66 + <thead>
67 + <tr className="text-left text-[10px] uppercase tracking-wider text-fg-4">
68 + <th className="py-1">Bucket</th>
69 + <th className="py-1 text-right">Rounds</th>
70 + <th className="py-1 text-right">Share</th>
71 + </tr>
72 + </thead>
73 + <tbody>
74 + {rows.map((b) => (
75 + <tr key={b.label} className="border-t border-line/60">
76 + <td className="py-1">{b.label}</td>
77 + <td className="py-1 text-right">{int(b.count)}</td>
78 + <td className="py-1 text-right text-fg-3">{pct(b.share, 3)}</td>
79 + </tr>
80 + ))}
81 + </tbody>
82 + </table>
83 + </details>
84 + );
85 +}
86 +
87 +export function CertificationBlock({ cert, title = "Certification report" }: { cert: CertificationReport; title?: string }) {
88 + return (
89 + <Panel title={title} description={`${cert.name} · ${cert.game}@${cert.version} · certified ${new Date(cert.certifiedAt).toLocaleString("en-US")}`} actions={<PassFail pass={cert.status === "PASS"} label={cert.status} />} tone={cert.status === "PASS" ? "success" : "danger"}>
90 + <KV
91 + cols={3}
92 + items={[
93 + { label: "Spins", value: int(cert.spins) },
94 + { label: "Configured RTP", value: pct(cert.configuredRtp) },
95 + { label: "Observed RTP", value: pct(cert.observedRtp, 3) },
96 + { label: "Deviation", value: signedPct(cert.deviation, 3) },
97 + { label: "Hit rate", value: pct(cert.hitRate) },
98 + { label: "Bonus / free spins", value: `${pct(cert.bonusRate, 3)} / ${pct(cert.freeSpinRate, 3)}` },
99 + { label: "Max win", value: multiplier(cert.maxWinMultiplier) },
100 + { label: "Std dev", value: cert.stdDev.toFixed(2) },
101 + { label: "Rules", value: `±${(cert.rules.maxDeviation * 100).toFixed(2)}% · min ${int(cert.rules.minSpins)} · band ${pct(cert.rules.rtpBand[0], 0)}–${pct(cert.rules.rtpBand[1], 0)}` },
102 + ]}
103 + />
104 + <ul className="mt-4 divide-y divide-line/60 rounded-sm border border-line">
105 + {cert.checks.map((c) => (
106 + <li key={c.name} className="flex items-start gap-3 px-3 py-2 text-[13px]">
107 + <PassFail pass={c.pass} />
108 + <div className="min-w-0">
109 + <div className="font-mono text-[12px] text-fg">{c.name}</div>
110 + <div className={cn("text-[12px]", c.pass ? "text-fg-3" : "text-danger")}>{c.detail}</div>
111 + </div>
112 + </li>
113 + ))}
114 + </ul>
115 + </Panel>
116 + );
117 +}
118 +
119 +function shortNum(v: number): string {
120 + if (Math.abs(v) >= 1_000_000) return `${(v / 1_000_000).toFixed(v % 1_000_000 === 0 ? 0 : 1)}M`;
121 + if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(v % 1000 === 0 ? 0 : 1)}K`;
122 + return String(Math.round(v));
123 +}
added apps/web/src/components/admin/store.ts +75 −0
@@ -0,0 +1,75 @@
1 +"use client";
2 +
3 +import { create } from "zustand";
4 +import { api, ApiClientError } from "@/lib/api";
5 +import type { AdminIdentity, MaintenanceSetting, SettingsResponse, SystemResponse } from "./types";
6 +
7 +export type GateStatus = "loading" | "authed" | "unauthed" | "forbidden" | "error";
8 +
9 +interface AdminState {
10 + status: GateStatus;
11 + admin: AdminIdentity | null;
12 + error: string | null;
13 + maintenance: MaintenanceSetting | null;
14 + node: string | null;
15 + version: string | null;
16 + /** GET /api/admin/me → decides which screen the gate renders. */
17 + check: () => Promise<void>;
18 + signedIn: (admin: AdminIdentity) => void;
19 + /** Called by the query hook on any 401. */
20 + signedOut: () => void;
21 + signOut: () => Promise<void>;
22 + /** Top-bar status: maintenance pill + node/version. */
23 + loadStatus: () => Promise<void>;
24 + setMaintenance: (m: MaintenanceSetting) => void;
25 +}
26 +
27 +export const useAdmin = create<AdminState>((set, get) => ({
28 + status: "loading",
29 + admin: null,
30 + error: null,
31 + maintenance: null,
32 + node: null,
33 + version: null,
34 + check: async () => {
35 + try {
36 + const r = await api<{ admin: AdminIdentity }>("/api/admin/me");
37 + set({ status: "authed", admin: r.admin, error: null });
38 + void get().loadStatus();
39 + } catch (e) {
40 + if (e instanceof ApiClientError) {
41 + if (e.status === 401) set({ status: "unauthed", admin: null, error: null });
42 + else if (e.status === 403) set({ status: "forbidden", admin: null, error: e.message });
43 + else set({ status: "error", admin: null, error: e.message });
44 + } else {
45 + set({ status: "error", admin: null, error: "Unexpected error." });
46 + }
47 + }
48 + },
49 + signedIn: (admin) => {
50 + set({ status: "authed", admin, error: null });
51 + void get().loadStatus();
52 + },
53 + signedOut: () => set({ status: "unauthed", admin: null, maintenance: null }),
54 + signOut: async () => {
55 + await api("/api/admin/auth/logout", { method: "POST" }).catch(() => {});
56 + get().signedOut();
57 + },
58 + loadStatus: async () => {
59 + const [settings, system] = await Promise.allSettled([api<SettingsResponse>("/api/admin/settings"), api<SystemResponse>("/api/admin/system")]);
60 + const patch: Partial<AdminState> = {};
61 + if (settings.status === "fulfilled") {
62 + const m = settings.value.settings["maintenance"] as MaintenanceSetting | undefined;
63 + patch.maintenance = m && typeof m.enabled === "boolean" ? m : { enabled: false, message: "" };
64 + }
65 + if (system.status === "fulfilled") {
66 + const svc = system.value.services.find((s) => s.name === "spinza-api") ?? system.value.services[0];
67 + if (svc) {
68 + patch.node = svc.node;
69 + patch.version = svc.version;
70 + }
71 + }
72 + set(patch);
73 + },
74 + setMaintenance: (m) => set({ maintenance: m }),
75 +}));
added apps/web/src/components/admin/types.ts +374 −0
@@ -0,0 +1,374 @@
1 +/**
2 + * Response shapes of `/api/admin/*` (see apps/api/src/routes/admin.ts).
3 + *
4 + * Raw SQL rows arrive with Postgres `bigint` columns serialised as strings and
5 + * `timestamp`/`date_trunc` columns as ISO strings — hence the `Num` alias.
6 + * Always pass those through `num()` (format.ts) before doing arithmetic.
7 + */
8 +import type { CertificationReport, GameDefinition, SimulationResult, ValidationIssue } from "@spinza/game-core";
9 +import type { GameLifecycle } from "@spinza/shared";
10 +
11 +export type Num = number | string;
12 +
13 +export interface AdminIdentity {
14 + id: string;
15 + username: string;
16 + role: string;
17 +}
18 +
19 +/* ---------------------------------------------------------------- dashboard */
20 +
21 +export interface DashboardResponse {
22 + users: { registered: number; dau: number; wau: number; mau: number; activeSessions: number; livePlayers: number };
23 + today: { spins: number; wagered: number; won: number; effectiveRtp: number | null; avgSpinMs: number; p95SpinMs: number };
24 + averageSessionSec: number;
25 + topGames: { slug: string; spins: number; wagered: number; won: number; rtp: number | null }[];
26 + highestWins: { roundId: string; game: string; bet: number; win: number; multiplier: number; at: string; username: string }[];
27 + health: { dbLatencyMs: number; highSeverityEvents24h: number; uptimeSec: number; version: string; node: string };
28 +}
29 +
30 +/* -------------------------------------------------------------------- users */
31 +
32 +export type UserStatus = "active" | "suspended" | "deleted";
33 +
34 +export interface UserListRow {
35 + id: string;
36 + username: string;
37 + level: number;
38 + xp: number;
39 + status: UserStatus | string;
40 + createdAt: string;
41 + lastLoginAt: string | null;
42 + totalSpins: number;
43 + biggestWin: number;
44 + balance: number;
45 + wagered: number;
46 + won: number;
47 +}
48 +
49 +export interface UsersResponse {
50 + users: UserListRow[];
51 + total: number;
52 +}
53 +
54 +export interface UserRecord {
55 + id: string;
56 + username: string;
57 + usernameNormalized: string;
58 + level: number;
59 + xp: number;
60 + status: UserStatus | string;
61 + ageConfirmedAt: string | null;
62 + createdAt: string;
63 + lastLoginAt: string | null;
64 + lastRescueAt: string | null;
65 + totalSpins: number;
66 + gamesPlayed: number;
67 + biggestWin: number;
68 + biggestMultiplier: Num;
69 +}
70 +
71 +export interface WalletRecord {
72 + userId: string;
73 + balance: number;
74 + lifetimeWagered: number;
75 + lifetimeWon: number;
76 + lifetimeGranted: number;
77 + updatedAt: string;
78 +}
79 +
80 +export interface LedgerRow {
81 + id: string;
82 + userId: string;
83 + type: string;
84 + amount: number;
85 + balanceAfter: number;
86 + reference: string | null;
87 + meta: Record<string, unknown> | null;
88 + createdAt: string;
89 +}
90 +
91 +export interface RoundRow {
92 + id: string;
93 + roundId: string;
94 + userId: string;
95 + gameId: string;
96 + gameSlug: string;
97 + gameVersion: string;
98 + clientRoundId: string;
99 + bet: number;
100 + win: number;
101 + multiplier: Num;
102 + balanceAfter: number;
103 + features: string[];
104 + freeSpins: boolean;
105 + bonus: boolean;
106 + jackpotTier: string | null;
107 + rngReference: string;
108 + durationMs: number | null;
109 + createdAt: string;
110 +}
111 +
112 +export interface SessionRow {
113 + id: string;
114 + userId: string;
115 + createdAt: string;
116 + expiresAt: string;
117 + lastSeenAt: string;
118 + userAgent: string | null;
119 + ip: string | null;
120 +}
121 +
122 +export interface SecurityEventRow {
123 + id: string;
124 + userId: string | null;
125 + adminId: string | null;
126 + type: string;
127 + severity: "info" | "warn" | "high" | string;
128 + ip: string | null;
129 + userAgent: string | null;
130 + meta: Record<string, unknown> | null;
131 + createdAt: string;
132 + username?: string | null;
133 +}
134 +
135 +export interface UserDetailResponse {
136 + user: UserRecord;
137 + wallet: WalletRecord | null;
138 + ledger: LedgerRow[];
139 + rounds: RoundRow[];
140 + sessions: SessionRow[];
141 + events: SecurityEventRow[];
142 + invariant: { ledgerTotal: number; balance: number; ok: boolean };
143 +}
144 +
145 +/* -------------------------------------------------------------------- games */
146 +
147 +export interface GameStatsRecord {
148 + gameId: string;
149 + launches: number;
150 + spins: number;
151 + wagered: number;
152 + won: number;
153 + wins: number;
154 + bonuses: number;
155 + freeSpins: number;
156 + bigWins: number;
157 + maxWin: number;
158 + maxMultiplier: Num;
159 + favorites: number;
160 + updatedAt: string;
161 + effectiveRtp?: number | null;
162 +}
163 +
164 +export interface GameRecord {
165 + id: string;
166 + slug: string;
167 + name: string;
168 + version: string;
169 + lifecycle: GameLifecycle;
170 + sortOrder: number;
171 + isFeatured: boolean;
172 + isNew: boolean;
173 + summary: Record<string, unknown>;
174 + createdAt: string;
175 + publishedAt: string | null;
176 + updatedAt: string;
177 +}
178 +
179 +export interface GameListRow extends GameRecord {
180 + enabled: boolean;
181 + rtp: number | null;
182 + payScale: number | null;
183 + stats: GameStatsRecord | null;
184 + validation: ValidationIssue[];
185 +}
186 +
187 +export interface GamesResponse {
188 + games: GameListRow[];
189 +}
190 +
191 +export interface GameVersionRow {
192 + version: string;
193 + status: string;
194 + createdAt: string;
195 + certification: CertificationReport | null;
196 + definitionHash: string;
197 +}
198 +
199 +export interface GameDailyRow {
200 + day: string;
201 + spins: number;
202 + wagered: Num;
203 + won: Num;
204 + players: number;
205 +}
206 +
207 +export type SimulationRunStatus = "running" | "done" | "failed";
208 +
209 +export interface SimulationRunResult {
210 + simulation: SimulationResult;
211 + certification: CertificationReport | null;
212 +}
213 +
214 +export interface SimulationRunRow {
215 + id: string;
216 + gameSlug: string;
217 + gameVersion: string;
218 + spins: number;
219 + status: SimulationRunStatus | string;
220 + progress: number;
221 + result?: SimulationRunResult | null;
222 + error: string | null;
223 + requestedBy?: string | null;
224 + createdAt: string;
225 + finishedAt: string | null;
226 +}
227 +
228 +export interface GameDetailResponse {
229 + game: GameRecord;
230 + definition: GameDefinition | null;
231 + versions: GameVersionRow[];
232 + stats: GameStatsRecord | null;
233 + daily: GameDailyRow[];
234 + simulationRuns: SimulationRunRow[];
235 +}
236 +
237 +export interface SimulatorRunsResponse {
238 + runs: SimulationRunRow[];
239 +}
240 +
241 +export interface SimulatorRunResponse {
242 + run: SimulationRunRow;
243 +}
244 +
245 +/* ------------------------------------------------------------------ economy */
246 +
247 +export interface EconomyResponse {
248 + byType: { type: string; n: number; total: Num }[];
249 + daily: { day: string; type: string; total: Num }[];
250 + supply: { circulating: Num; granted: Num; wagered: Num; won: Num };
251 + invariant: { mismatches: number };
252 + balanceDistribution: { bucket: number; n: number }[];
253 +}
254 +
255 +/* ---------------------------------------------------------------- analytics */
256 +
257 +export interface GameAnalyticsRow {
258 + game_slug: string;
259 + spins: number;
260 + players: number;
261 + wagered: number;
262 + won: number;
263 + avg_bet: number | null;
264 + bonuses: number;
265 + max_win: number;
266 + max_multiplier: number | null;
267 + big_wins: number;
268 + avg_ms: number | null;
269 + rtp: number | null;
270 + launches: number;
271 + favorites: number;
272 +}
273 +
274 +export interface GameAnalyticsResponse {
275 + games: GameAnalyticsRow[];
276 +}
277 +
278 +export interface PlayerAnalyticsResponse {
279 + signups: { day: string; n: number }[];
280 + activity: { day: string; players: number; spins: number }[];
281 + levels: { bucket: number; n: number }[];
282 + retention: { d1: number; d7: number; total: number };
283 +}
284 +
285 +/* --------------------------------------------------- missions / achievements */
286 +
287 +export interface MissionRow {
288 + key: string;
289 + name: string;
290 + description: string;
291 + period: "daily" | "weekly" | string;
292 + metric: string;
293 + target: number;
294 + rewardCredits: number;
295 + rewardXp: number;
296 + enabled: boolean;
297 + sortOrder: number;
298 +}
299 +
300 +export interface MissionsResponse {
301 + missions: MissionRow[];
302 + completions: { mission_key: string; completed: number; started: number }[];
303 +}
304 +
305 +export interface AchievementRow {
306 + key: string;
307 + name: string;
308 + description: string;
309 + category: string;
310 + metric: string;
311 + target: number;
312 + rewardCredits: number;
313 + rewardXp: number;
314 + icon: string;
315 + sortOrder: number;
316 + enabled: boolean;
317 +}
318 +
319 +export interface AchievementsResponse {
320 + achievements: AchievementRow[];
321 + unlocks: { achievement_key: string; n: number }[];
322 +}
323 +
324 +export interface ProgressionPatch {
325 + enabled?: boolean;
326 + target?: number;
327 + rewardCredits?: number;
328 + rewardXp?: number;
329 + name?: string;
330 + description?: string;
331 +}
332 +
333 +/* ---------------------------------------------------------------- settings */
334 +
335 +export interface MaintenanceSetting {
336 + enabled: boolean;
337 + message: string;
338 +}
339 +
340 +export interface RescueSetting {
341 + amount: number;
342 + cooldownHours: number;
343 + threshold: number;
344 +}
345 +
346 +export interface DailyRewardsSetting {
347 + schedule: number[];
348 +}
349 +
350 +export interface ProfanitySetting {
351 + words: string[];
352 +}
353 +
354 +export interface SettingsResponse {
355 + flags: Record<string, boolean>;
356 + settings: Record<string, unknown>;
357 +}
358 +
359 +/* ---------------------------------------------------------------- security */
360 +
361 +export interface SecurityEventsResponse {
362 + events: SecurityEventRow[];
363 + summary24h: { type: string; n: number }[];
364 +}
365 +
366 +/* ------------------------------------------------------------------ system */
367 +
368 +export interface SystemResponse {
369 + services: { name: string; node: string; version: string; uptimeSec: number | null; status: "ok" | "down" | string; latencyMs: number }[];
370 + process: { rssMb: number; heapMb: number; cpus: number; load: number[]; totalMemMb: number; freeMemMb: number; platform: string; nodeVersion: string };
371 + spins: { avg: number; p95: number; n: number };
372 + tables: { table: string; bytes: Num; rows: Num }[];
373 + games: number;
374 +}
added apps/web/src/components/admin/use-query.ts +87 −0
@@ -0,0 +1,87 @@
1 +"use client";
2 +
3 +import { useCallback, useEffect, useRef, useState } from "react";
4 +import { api, ApiClientError } from "@/lib/api";
5 +import { useAdmin } from "./store";
6 +
7 +interface QueryState<T> {
8 + path: string | null;
9 + data: T | null;
10 + error: ApiClientError | null;
11 + at: number;
12 +}
13 +
14 +export interface QueryResult<T> {
15 + /** Latest data — kept (and flagged `stale`) while a new path is loading, so tables never flash. */
16 + data: T | null;
17 + error: ApiClientError | null;
18 + /** True until the first response for the current path arrives. */
19 + loading: boolean;
20 + /** `data` belongs to a previous path. */
21 + stale: boolean;
22 + /** A user-triggered refresh is in flight. */
23 + refreshing: boolean;
24 + refresh: () => Promise<void>;
25 + /** Replace the cached data locally (optimistic updates). */
26 + mutate: (fn: (prev: T) => T) => void;
27 + updatedAt: number;
28 +}
29 +
30 +/** Client-side data hook for `/api/admin/*`. A 401 flips the admin gate back to the login form. */
31 +export function useAdminQuery<T>(path: string | null, opts: { refreshMs?: number } = {}): QueryResult<T> {
32 + const [state, setState] = useState<QueryState<T>>({ path: null, data: null, error: null, at: 0 });
33 + const [refreshing, setRefreshing] = useState(false);
34 + const seq = useRef(0);
35 + const refreshMs = opts.refreshMs ?? 0;
36 +
37 + const run = useCallback(async (p: string) => {
38 + const my = ++seq.current;
39 + try {
40 + const data = await api<T>(p);
41 + if (my !== seq.current) return;
42 + setState({ path: p, data, error: null, at: Date.now() });
43 + } catch (e) {
44 + if (my !== seq.current) return;
45 + const err = e instanceof ApiClientError ? e : new ApiClientError(0, "UNKNOWN", e instanceof Error ? e.message : "Unexpected error.");
46 + if (err.status === 401) useAdmin.getState().signedOut();
47 + setState((s) => ({ path: p, data: s.path === p ? s.data : null, error: err, at: Date.now() }));
48 + }
49 + }, []);
50 +
51 + useEffect(() => {
52 + if (!path) return;
53 + const first = setTimeout(() => void run(path), 0);
54 + const t = refreshMs
55 + ? setInterval(() => {
56 + if (document.visibilityState === "visible") void run(path);
57 + }, refreshMs)
58 + : null;
59 + return () => {
60 + clearTimeout(first);
61 + if (t) clearInterval(t);
62 + };
63 + }, [path, run, refreshMs]);
64 +
65 + const refresh = useCallback(async () => {
66 + if (!path) return;
67 + setRefreshing(true);
68 + await run(path);
69 + setRefreshing(false);
70 + }, [path, run]);
71 +
72 + const mutate = useCallback((fn: (prev: T) => T) => {
73 + setState((s) => (s.data ? { ...s, data: fn(s.data) } : s));
74 + }, []);
75 +
76 + const current = state.path === path;
77 + return {
78 + data: state.data,
79 + error: current ? state.error : null,
80 + loading: path !== null && !current,
81 + stale: path !== null && !current && state.data !== null,
82 + refreshing,
83 + refresh,
84 + mutate,
85 + updatedAt: state.at,
86 + };
87 +}
added apps/web/src/components/brand/logo.tsx +42 −0
@@ -0,0 +1,42 @@
1 +import { cn } from "@/lib/utils";
2 +
3 +/** Spinza mark: a ring split by a diagonal spin stroke — original, drawn inline. */
4 +export function SpinzaMark({ className, glow = true }: { className?: string; glow?: boolean }) {
5 + return (
6 + <svg viewBox="0 0 64 64" className={cn("h-8 w-8", className)} aria-hidden>
7 + <defs>
8 + <linearGradient id="spz-g" x1="0" y1="0" x2="1" y2="1">
9 + <stop offset="0" stopColor="#f3e2ad" />
10 + <stop offset="0.5" stopColor="#c9a961" />
11 + <stop offset="1" stopColor="#8a6d2e" />
12 + </linearGradient>
13 + {glow ? (
14 + <filter id="spz-glow" x="-30%" y="-30%" width="160%" height="160%">
15 + <feGaussianBlur stdDeviation="2.2" result="b" />
16 + <feMerge>
17 + <feMergeNode in="b" />
18 + <feMergeNode in="SourceGraphic" />
19 + </feMerge>
20 + </filter>
21 + ) : null}
22 + </defs>
23 + <g filter={glow ? "url(#spz-glow)" : undefined}>
24 + <circle cx="32" cy="32" r="24" fill="none" stroke="url(#spz-g)" strokeWidth="5" strokeDasharray="112 40" strokeLinecap="round" transform="rotate(-50 32 32)" />
25 + <path d="M20 40 L44 24" stroke="url(#spz-g)" strokeWidth="6" strokeLinecap="round" />
26 + <circle cx="44" cy="24" r="4.5" fill="#f3e2ad" />
27 + </g>
28 + </svg>
29 + );
30 +}
31 +
32 +export function SpinzaWordmark({ className, size = "md" }: { className?: string; size?: "sm" | "md" | "lg" }) {
33 + const s = { sm: "text-lg", md: "text-xl", lg: "text-3xl" }[size];
34 + return (
35 + <span className={cn("inline-flex items-center gap-2 font-semibold tracking-[-0.02em]", s, className)}>
36 + <SpinzaMark className={size === "lg" ? "h-10 w-10" : size === "sm" ? "h-6 w-6" : "h-7 w-7"} />
37 + <span>
38 + SPIN<span className="text-accent">ZA</span>
39 + </span>
40 + </span>
41 + );
42 +}
added apps/web/src/components/game/game-client.tsx +849 −0
@@ -0,0 +1,849 @@
1 +"use client";
2 +
3 +import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4 +import Link from "next/link";
5 +import { useRouter } from "next/navigation";
6 +import { AnimatePresence, motion } from "framer-motion";
7 +import { ArrowLeft, ChevronDown, ChevronUp, Heart, History, Info, Minus, Plus, Settings2, Volume2, VolumeX, Zap } from "lucide-react";
8 +import { AUTO_SPIN_OPTIONS, BET_LEVELS, classifyWin, formatMultiplier, formatSC, WIN_CLASSES, type GameInfo, type SpinResponse } from "@spinza/shared";
9 +import type { SpinStep } from "@spinza/game-core/client";
10 +import { api, ApiClientError } from "@/lib/api";
11 +import { toast, useSession } from "@/lib/store";
12 +import { cn } from "@/lib/utils";
13 +import { Button, Credits, Sheet, Tabs } from "@/components/ui";
14 +import { SpinzaMark } from "@/components/brand/logo";
15 +import type { ClientDefinition, ClientOutcome } from "./types";
16 +import { SlotRenderer } from "./renderer";
17 +import { getSound } from "./sound";
18 +
19 +interface Props {
20 + game: GameInfo;
21 + definition: ClientDefinition;
22 +}
23 +
24 +type Overlay =
25 + | { kind: "banner"; title: string; subtitle?: string }
26 + | { kind: "freespins"; title: string; spins: number }
27 + | { kind: "bonus"; name: string; picks: { cell: number; value: number; amount: number }[]; cells: number; total: number }
28 + | { kind: "jackpot"; tier: string; amount: number }
29 + | { kind: "win"; cls: string; amount: number; multiplier: number }
30 + | null;
31 +
32 +const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
33 +
34 +export function GameClient({ game, definition }: Props) {
35 + const router = useRouter();
36 + const { status, wallet, settings, setBalance, setUserXp, user } = useSession();
37 + const canvasRef = useRef<HTMLDivElement>(null);
38 + const rendererRef = useRef<SlotRenderer | null>(null);
39 + const soundRef = useRef(getSound());
40 + const [ready, setReady] = useState(false);
41 + const [progress, setProgress] = useState(0);
42 + const [bet, setBet] = useState<number>(() => Math.max(definition.minBet, Math.min(100, definition.maxBet)));
43 + const [spinning, setSpinning] = useState(false);
44 + const [lastWin, setLastWin] = useState(0);
45 + const [displayWin, setDisplayWin] = useState(0);
46 + const [overlay, setOverlay] = useState<Overlay>(null);
47 + const [featureLabel, setFeatureLabel] = useState<string | null>(null);
48 + const [fsInfo, setFsInfo] = useState<{ left: number; total: number } | null>(null);
49 + const [multiplier, setMultiplier] = useState<number>(1);
50 + const [state, setState] = useState<ClientOutcome["stateAfter"] | null>(null);
51 + const [auto, setAuto] = useState<number>(0);
52 + const autoRef = useRef(0);
53 + const [autoSheet, setAutoSheet] = useState(false);
54 + const [betSheet, setBetSheet] = useState(false);
55 + const [infoSheet, setInfoSheet] = useState(false);
56 + const [settingsSheet, setSettingsSheet] = useState(false);
57 + const [historySheet, setHistorySheet] = useState(false);
58 + const [favorite, setFavorite] = useState(!!game.favorite);
59 + const [quick, setQuick] = useState(false);
60 + const [error, setError] = useState<{ code: string; message: string } | null>(null);
61 + const stopRequested = useRef(false);
62 + const soundOn = settings?.soundEnabled ?? true;
63 +
64 + const bets = useMemo(() => (BET_LEVELS as readonly number[]).filter((b) => b >= definition.minBet && b <= definition.maxBet), [definition]);
65 + const balance = wallet?.balance ?? 0;
66 +
67 + /* ------------------------------------------------------------- mount */
68 + useEffect(() => {
69 + if (status === "guest") {
70 + router.replace(`/login?next=/games/${game.slug}`);
71 + return;
72 + }
73 + }, [status, router, game.slug]);
74 +
75 + useEffect(() => {
76 + if (!canvasRef.current || status !== "authenticated") return;
77 + const renderer = new SlotRenderer({
78 + def: definition,
79 + reduceMotion: settings?.reduceMotion ?? false,
80 + intensity: settings?.animationIntensity ?? "high",
81 + quick,
82 + onReelStop: (i) => soundRef.current.reelStop(i),
83 + onCascade: () => soundRef.current.cascade(),
84 + onCoin: () => soundRef.current.coin(),
85 + });
86 + rendererRef.current = renderer;
87 + let cancelled = false;
88 + (async () => {
89 + setProgress(15);
90 + await renderer.mount(canvasRef.current!);
91 + setProgress(60);
92 + try {
93 + const launch = await api<{ state: ClientOutcome["stateAfter"] | null }>(`/api/games/${game.slug}/launch`, { method: "POST" });
94 + if (launch.state) setState(launch.state);
95 + } catch {
96 + /* non-blocking */
97 + }
98 + setProgress(100);
99 + await wait(250);
100 + if (!cancelled) setReady(true);
101 + })();
102 + return () => {
103 + cancelled = true;
104 + renderer.destroy();
105 + rendererRef.current = null;
106 + };
107 + // eslint-disable-next-line react-hooks/exhaustive-deps
108 + }, [status, game.slug]);
109 +
110 + useEffect(() => {
111 + rendererRef.current?.setOptions({ reduceMotion: settings?.reduceMotion ?? false, intensity: settings?.animationIntensity ?? "high", quick });
112 + }, [settings?.reduceMotion, settings?.animationIntensity, quick]);
113 +
114 + useEffect(() => {
115 + const s = soundRef.current;
116 + s.setLevels({ enabled: soundOn, master: settings?.masterVolume ?? 0.8, music: settings?.musicVolume ?? 0.6, effects: settings?.effectsVolume ?? 0.8 });
117 + s.setAmbience(definition.presentation.ambience);
118 + }, [soundOn, settings?.masterVolume, settings?.musicVolume, settings?.effectsVolume, definition.presentation.ambience]);
119 +
120 + useEffect(() => () => soundRef.current.destroy(), []);
121 +
122 + // Win counter animation.
123 + useEffect(() => {
124 + if (displayWin === lastWin) return;
125 + const from = displayWin;
126 + const to = lastWin;
127 + const start = performance.now();
128 + const dur = to > from ? Math.min(1400, 300 + Math.log10(Math.max(1, to - from)) * 250) : 0;
129 + let raf = 0;
130 + const step = () => {
131 + const t = dur ? Math.min(1, (performance.now() - start) / dur) : 1;
132 + setDisplayWin(Math.round(from + (to - from) * (1 - Math.pow(1 - t, 3))));
133 + if (t < 1) raf = requestAnimationFrame(step);
134 + };
135 + raf = requestAnimationFrame(step);
136 + return () => cancelAnimationFrame(raf);
137 + // eslint-disable-next-line react-hooks/exhaustive-deps
138 + }, [lastWin]);
139 +
140 + /* -------------------------------------------------------------- spin */
141 + const playOutcome = useCallback(
142 + async (res: SpinResponse) => {
143 + const renderer = rendererRef.current;
144 + if (!renderer) return;
145 + const outcome = res.result as ClientOutcome;
146 + const sound = soundRef.current;
147 + let running = 0;
148 + let first = true;
149 + let inFreeSpins = false;
150 + for (let i = 0; i < outcome.steps.length; i++) {
151 + const step: SpinStep = outcome.steps[i];
152 + const meta = step.meta;
153 + if (step.type === "freespin" && meta.label) {
154 + inFreeSpins = true;
155 + sound.bonus();
156 + setOverlay({ kind: "freespins", title: meta.label, spins: meta.freeSpinsTotal ?? 0 });
157 + await wait(quick ? 900 : 1700);
158 + setOverlay(null);
159 + setFeatureLabel(meta.label);
160 + }
161 + if (step.type === "freespin") setFsInfo({ left: meta.freeSpinsLeft ?? 0, total: meta.freeSpinsTotal ?? 0 });
162 + if (step.type === "bonus" && meta.picks) {
163 + sound.bonus();
164 + const cfg = definition.pickBonuses.find((b) => b.name === meta.bonusName);
165 + setOverlay({ kind: "bonus", name: meta.bonusName ?? "Bonus", picks: meta.picks, cells: cfg?.cells ?? 12, total: step.win });
166 + await wait((quick ? 700 : 1100) * (meta.picks.length + 1));
167 + setOverlay(null);
168 + }
169 + if (step.type === "jackpot" && meta.jackpot) {
170 + sound.jackpot();
171 + setOverlay({ kind: "jackpot", tier: meta.jackpot.tier, amount: meta.jackpot.amount });
172 + renderer.celebrate(4);
173 + await wait(quick ? 1500 : 2600);
174 + setOverlay(null);
175 + }
176 + if (step.type === "feature" && meta.label) {
177 + setOverlay({ kind: "banner", title: meta.label, subtitle: `Multiplier ×${step.multiplier}` });
178 + await wait(quick ? 700 : 1300);
179 + setOverlay(null);
180 + }
181 + if (step.type === "respin" && meta.label === "Lock & Respin") {
182 + sound.bonus();
183 + setOverlay({ kind: "banner", title: "Lock & Respin", subtitle: "Collect every coin" });
184 + await wait(quick ? 700 : 1200);
185 + setOverlay(null);
186 + setFeatureLabel("Lock & Respin");
187 + }
188 + if (meta.randomFeature) {
189 + setOverlay({ kind: "banner", title: meta.randomFeature });
190 + await wait(quick ? 500 : 900);
191 + setOverlay(null);
192 + }
193 + if (meta.freeSpinsAwarded && step.type !== "base" && inFreeSpins && i > 0) {
194 + setOverlay({ kind: "banner", title: "Retrigger", subtitle: `+${meta.freeSpinsAwarded} free spins` });
195 + await wait(quick ? 500 : 900);
196 + setOverlay(null);
197 + }
198 + setMultiplier(step.multiplier);
199 + await renderer.playStep(step, { first, showWins: true });
200 + first = false;
201 + if (step.win > 0) {
202 + running += step.win;
203 + setLastWin(Math.min(running, outcome.totalWin));
204 + const cls = classifyWin(step.win / res.bet);
205 + sound.win(cls === "none" || cls === "regular" ? 0 : cls === "win" ? 1 : cls === "big" ? 2 : 3);
206 + }
207 + if (step.type === "respin" && i === outcome.steps.length - 1 && step.win > 0) {
208 + running = outcome.totalWin;
209 + setLastWin(outcome.totalWin);
210 + }
211 + if (meta.freeSpinsAwarded && step.type === "base") {
212 + sound.bonus();
213 + }
214 + }
215 + setFsInfo(null);
216 + setFeatureLabel(null);
217 + setMultiplier(1);
218 + setLastWin(outcome.totalWin);
219 + setState(outcome.stateAfter);
220 + // Win presentation.
221 + const cls = res.winClass;
222 + if (cls === "big" || cls === "mega" || cls === "epic" || cls === "legendary") {
223 + sound.bigWin();
224 + const intensity = { big: 1, mega: 2, epic: 3, legendary: 5 }[cls];
225 + renderer.celebrate(intensity);
226 + setOverlay({ kind: "win", cls, amount: outcome.totalWin, multiplier: res.multiplier });
227 + await wait(quick ? 1400 : 2200 + intensity * 400);
228 + setOverlay(null);
229 + }
230 + setBalance(res.balance);
231 + setUserXp(res.xp.total, res.xp.level);
232 + if (res.xp.leveledUp) toast({ title: `Level ${res.xp.level} reached`, description: `+${formatSC(res.xp.levelReward)} level reward`, tone: "credit" });
233 + for (const a of res.unlocked.achievements) toast({ title: "Achievement unlocked", description: a.replace(/-/g, " "), tone: "success" });
234 + for (const m of res.unlocked.missions) toast({ title: "Mission complete", description: m.replace(/-/g, " "), tone: "success" });
235 + return { bonus: outcome.freeSpinsTriggered || outcome.bonusTriggered };
236 + },
237 + [definition.pickBonuses, quick, setBalance, setUserXp],
238 + );
239 +
240 + const spin = useCallback(async (): Promise<{ ok: boolean; bonus?: boolean }> => {
241 + if (spinning || !ready) return { ok: false };
242 + const renderer = rendererRef.current;
243 + if (!renderer) return { ok: false };
244 + if (balance < bet) {
245 + setError({ code: "INSUFFICIENT_CREDITS", message: "Not enough Spinza Credits for this bet." });
246 + soundRef.current.error();
247 + return { ok: false };
248 + }
249 + setError(null);
250 + setSpinning(true);
251 + setLastWin(0);
252 + setDisplayWin(0);
253 + soundRef.current.unlock();
254 + soundRef.current.spinStart();
255 + setBalance(balance - bet); // optimistic; corrected by the server response
256 + renderer.startSpin();
257 + const clientRoundId = crypto.randomUUID();
258 + const started = performance.now();
259 + try {
260 + const res = await api<SpinResponse>(`/api/games/${game.slug}/spin`, { json: { bet, clientRoundId } });
261 + const elapsed = performance.now() - started;
262 + if (elapsed < 350) await wait(350 - elapsed);
263 + const r = await playOutcome(res);
264 + setSpinning(false);
265 + return { ok: true, bonus: r?.bonus };
266 + } catch (e) {
267 + setSpinning(false);
268 + renderer.clearFx();
269 + renderer.setGrid(renderer["currentGrid"] as string[][]);
270 + setBalance(balance);
271 + if (e instanceof ApiClientError) {
272 + if (e.status === 401) {
273 + router.replace(`/login?next=/games/${game.slug}`);
274 + return { ok: false };
275 + }
276 + setError({ code: e.code, message: e.message });
277 + if (e.code === "INSUFFICIENT_CREDITS" && e.details && typeof e.details === "object" && "balance" in e.details) setBalance((e.details as { balance: number }).balance);
278 + } else setError({ code: "UNKNOWN", message: "Something went wrong. Please try again." });
279 + soundRef.current.error();
280 + return { ok: false };
281 + }
282 + }, [spinning, ready, balance, bet, game.slug, playOutcome, setBalance, router]);
283 +
284 + // Auto-spin loop.
285 + const startAuto = useCallback(
286 + async (count: number) => {
287 + autoRef.current = count;
288 + setAuto(count);
289 + stopRequested.current = false;
290 + while (autoRef.current > 0 && !stopRequested.current) {
291 + const r = await spin();
292 + if (!r.ok) break;
293 + autoRef.current -= 1;
294 + setAuto(autoRef.current);
295 + if (r.bonus) break; // stop on bonus
296 + if ((useSession.getState().wallet?.balance ?? 0) < bet) break;
297 + await wait(quick ? 250 : 450);
298 + }
299 + autoRef.current = 0;
300 + setAuto(0);
301 + },
302 + [spin, bet, quick],
303 + );
304 +
305 + const stopAuto = () => {
306 + stopRequested.current = true;
307 + autoRef.current = 0;
308 + setAuto(0);
309 + };
310 +
311 + // Keyboard: space to spin.
312 + useEffect(() => {
313 + const onKey = (e: KeyboardEvent) => {
314 + if (e.code === "Space" && !e.repeat && !overlay && !betSheet && !infoSheet && !settingsSheet) {
315 + e.preventDefault();
316 + if (auto) stopAuto();
317 + else void spin();
318 + }
319 + };
320 + window.addEventListener("keydown", onKey);
321 + return () => window.removeEventListener("keydown", onKey);
322 + }, [spin, auto, overlay, betSheet, infoSheet, settingsSheet]);
323 +
324 + const toggleFavorite = async () => {
325 + setFavorite((f) => !f);
326 + try {
327 + const r = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" });
328 + setFavorite(r.favorite);
329 + } catch {
330 + setFavorite((f) => !f);
331 + }
332 + };
333 +
334 + const betIndex = bets.indexOf(bet);
335 + const palette = definition.presentation.palette;
336 + const meter = definition.meter && state ? { value: state.meters[definition.meter.id] ?? 0, max: definition.meter.max } : null;
337 + const heat = definition.heat && state ? { value: state.heat, max: definition.heat.max } : null;
338 + const meterLevel = definition.meter && state ? state.meterLevels[definition.meter.id] ?? 0 : 0;
339 +
340 + if (status === "guest") return null;
341 +
342 + return (
343 + <div className="fixed inset-0 flex flex-col bg-bg" style={{ background: `radial-gradient(120% 80% at 50% 0%, ${palette.surface} 0%, ${palette.bg} 60%, #050608 100%)` }}>
344 + {/* Top bar */}
345 + <div className="flex items-center justify-between gap-2 px-3 py-2" style={{ paddingTop: "calc(var(--safe-top) + 8px)" }}>
346 + <div className="flex items-center gap-2">
347 + <Link href="/" className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="Back to lobby">
348 + <ArrowLeft className="h-5 w-5" />
349 + </Link>
350 + <div className="leading-tight">
351 + <div className="text-[15px] font-semibold tracking-tight">{game.name}</div>
352 + <div className="text-[11px] text-fg-3">
353 + {game.grid.reels}×{game.grid.rows} · {game.features[0]}
354 + </div>
355 + </div>
356 + </div>
357 + <div className="flex items-center gap-1">
358 + <button onClick={toggleFavorite} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="Favourite">
359 + <Heart className={cn("h-5 w-5", favorite && "fill-danger text-danger")} />
360 + </button>
361 + <button onClick={() => setHistorySheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="History">
362 + <History className="h-5 w-5" />
363 + </button>
364 + <button onClick={() => setInfoSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="Game info">
365 + <Info className="h-5 w-5" />
366 + </button>
367 + <button onClick={() => useSession.getState().setSettings({ soundEnabled: !soundOn })} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="Sound">
368 + {soundOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
369 + </button>
370 + <button onClick={() => setSettingsSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-surface-2 focus-ring" aria-label="Settings">
371 + <Settings2 className="h-5 w-5" />
372 + </button>
373 + </div>
374 + </div>
375 +
376 + {/* Feature HUD */}
377 + <div className="flex min-h-7 items-center justify-center gap-2 px-3 text-[12px]">
378 + <AnimatePresence>
379 + {featureLabel ? (
380 + <motion.div initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="rounded-full px-3 py-1 font-semibold" style={{ background: `${palette.primary}22`, color: palette.primary }}>
381 + {featureLabel}
382 + {fsInfo ? ` · ${fsInfo.left} left of ${fsInfo.total}` : ""}
383 + </motion.div>
384 + ) : null}
385 + </AnimatePresence>
386 + {multiplier > 1 ? <span className="rounded-full bg-accent-soft px-2.5 py-1 font-bold text-accent-2">×{multiplier}</span> : null}
387 + {meter && definition.meter ? (
388 + <div className="flex items-center gap-2 rounded-full bg-surface-2 px-3 py-1">
389 + <span className="text-fg-3">{definition.meter.name}</span>
390 + <div className="h-1.5 w-20 overflow-hidden rounded-full bg-surface-3">
391 + <div className="h-full rounded-full transition-[width] duration-700" style={{ width: `${Math.min(100, (meter.value / meter.max) * 100)}%`, background: palette.primary }} />
392 + </div>
393 + <span className="tabular text-fg-2">
394 + {meter.value}/{meter.max}
395 + </span>
396 + {meterLevel > 0 ? <span className="font-bold text-accent-2">Lv {meterLevel}</span> : null}
397 + </div>
398 + ) : null}
399 + {heat && definition.heat ? (
400 + <div className="flex items-center gap-2 rounded-full bg-surface-2 px-3 py-1">
401 + <span className="text-fg-3">Heat</span>
402 + <div className="h-1.5 w-20 overflow-hidden rounded-full bg-surface-3">
403 + <div className="h-full rounded-full bg-[linear-gradient(90deg,#ffb347,#ff5c3d)] transition-[width] duration-700" style={{ width: `${Math.min(100, (heat.value / heat.max) * 100)}%` }} />
404 + </div>
405 + <span className="tabular text-fg-2">×{definition.heat.ladder[Math.min(Math.floor(heat.value / definition.heat.bandSize), definition.heat.ladder.length - 1)]}</span>
406 + </div>
407 + ) : null}
408 + </div>
409 +
410 + {/* Canvas */}
411 + <div className="relative flex-1 min-h-0">
412 + <div ref={canvasRef} className="spz-canvas absolute inset-0" />
413 + {/* Splash */}
414 + <AnimatePresence>
415 + {!ready ? (
416 + <motion.div key="splash" className="absolute inset-0 grid place-items-center" style={{ background: palette.bg }} exit={{ opacity: 0 }} transition={{ duration: 0.5 }}>
417 + <div className="flex flex-col items-center gap-5 px-8 text-center">
418 + <SpinzaMark className="h-12 w-12" />
419 + <div>
420 + <div className="eyebrow">Spinza presents</div>
421 + <div className="mt-1 text-3xl font-semibold tracking-tight" style={{ color: palette.primary }}>
422 + {game.name}
423 + </div>
424 + <div className="mt-1 text-sm text-fg-3">{game.tagline}</div>
425 + </div>
426 + <div className="w-56">
427 + <div className="h-1 w-full overflow-hidden rounded-full bg-surface-3">
428 + <div className="h-full rounded-full transition-[width] duration-300" style={{ width: `${progress}%`, background: palette.primary }} />
429 + </div>
430 + <div className="mt-2 text-[12px] tabular text-fg-3">Loading {progress}%</div>
431 + </div>
432 + </div>
433 + </motion.div>
434 + ) : null}
435 + </AnimatePresence>
436 + <Overlays overlay={overlay} palette={palette} />
437 + {/* Error */}
438 + <AnimatePresence>
439 + {error ? (
440 + <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute inset-x-4 bottom-3 mx-auto max-w-sm glass rounded-md p-3 text-center text-sm" style={{ zIndex: 5 }}>
441 + <div className="font-semibold">{errorTitle(error.code)}</div>
442 + <div className="mt-0.5 text-fg-2">{error.message}</div>
443 + {error.code === "INSUFFICIENT_CREDITS" ? (
444 + <div className="mt-2 flex justify-center gap-2">
445 + <Button size="sm" variant="secondary" onClick={() => setBetSheet(true)}>
446 + Lower bet
447 + </Button>
448 + <Button size="sm" variant="accent" href="/rewards">
449 + Get rewards
450 + </Button>
451 + </div>
452 + ) : error.code === "MAINTENANCE" ? (
453 + <Button size="sm" className="mt-2" href="/">
454 + Back to lobby
455 + </Button>
456 + ) : (
457 + <Button size="sm" variant="secondary" className="mt-2" onClick={() => setError(null)}>
458 + Dismiss
459 + </Button>
460 + )}
461 + </motion.div>
462 + ) : null}
463 + </AnimatePresence>
464 + </div>
465 +
466 + {/* Controls */}
467 + <div className="glass border-x-0 border-b-0 px-3 pt-3" style={{ paddingBottom: "calc(var(--safe-bottom) + 12px)" }}>
468 + <div className="mx-auto grid max-w-3xl grid-cols-[1fr_auto_1fr] items-center gap-3">
469 + {/* Balance + bet */}
470 + <div className="flex flex-col gap-2">
471 + <div>
472 + <div className="eyebrow">Balance</div>
473 + <Credits amount={balance} size="md" />
474 + </div>
475 + <div className="flex items-center gap-1">
476 + <button disabled={spinning || betIndex <= 0} onClick={() => setBet(bets[betIndex - 1])} className="tap grid h-10 w-10 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Decrease bet">
477 + <Minus className="h-4 w-4" />
478 + </button>
479 + <button disabled={spinning} onClick={() => setBetSheet(true)} className="tap flex h-10 min-w-[92px] flex-col items-center justify-center rounded-md surface-2 px-2 focus-ring">
480 + <span className="text-[10px] uppercase tracking-wider text-fg-3">Bet</span>
481 + <span className="text-sm font-bold tabular text-fg">{formatSC(bet)}</span>
482 + </button>
483 + <button disabled={spinning || betIndex >= bets.length - 1} onClick={() => setBet(bets[betIndex + 1])} className="tap grid h-10 w-10 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Increase bet">
484 + <Plus className="h-4 w-4" />
485 + </button>
486 + </div>
487 + </div>
488 +
489 + {/* Spin */}
490 + <div className="flex flex-col items-center gap-2">
491 + {auto > 0 ? (
492 + <button onClick={stopAuto} className="relative grid h-[84px] w-[84px] place-items-center rounded-full border-2 border-danger/60 bg-danger/15 text-danger shadow-[0_0_40px_-10px_rgba(255,92,122,0.8)] focus-ring">
493 + <span className="text-base font-extrabold tracking-wide">STOP</span>
494 + <span className="absolute -bottom-1 rounded-full bg-bg px-2 text-[11px] font-bold tabular text-fg-2">{auto}</span>
495 + </button>
496 + ) : (
497 + <button
498 + onClick={() => void spin()}
499 + disabled={!ready || spinning}
500 + className={cn("relative grid h-[84px] w-[84px] place-items-center rounded-full text-[#1a1406] transition-transform active:scale-95 focus-ring disabled:opacity-70", spinning && "animate-pulse-soft")}
501 + style={{ background: "linear-gradient(180deg,#f3e2ad,#c9a961 60%,#9a7b3a)", boxShadow: "0 0 0 4px rgba(201,169,97,0.18), 0 12px 40px -10px rgba(201,169,97,0.7)" }}
502 + aria-label="Spin"
503 + >
504 + <span className="text-base font-extrabold tracking-[0.12em]">SPIN</span>
505 + </button>
506 + )}
507 + <button disabled={spinning && auto === 0} onClick={() => (auto ? stopAuto() : setAutoSheet(true))} className="text-[11px] font-semibold uppercase tracking-wider text-fg-3 hover:text-fg-2 focus-ring rounded">
508 + {auto ? "Auto running" : "Auto spin"}
509 + </button>
510 + </div>
511 +
512 + {/* Win */}
513 + <div className="flex flex-col items-end gap-2 text-right">
514 + <div>
515 + <div className="eyebrow">Win</div>
516 + <div className={cn("text-base font-semibold tabular", displayWin > 0 ? "text-credit" : "text-fg-4")}>
517 + {displayWin > 0 ? formatSC(displayWin) : "—"}
518 + </div>
519 + </div>
520 + <button onClick={() => setQuick((q) => !q)} className={cn("tap flex h-10 items-center gap-1.5 rounded-md px-3 text-[12px] font-semibold focus-ring", quick ? "bg-accent-soft text-accent-2" : "surface-2 text-fg-3")} aria-pressed={quick}>
521 + <Zap className="h-3.5 w-3.5" /> Quick
522 + </button>
523 + </div>
524 + </div>
525 + </div>
526 +
527 + {/* Sheets */}
528 + <Sheet open={betSheet} onClose={() => setBetSheet(false)} title="Bet per spin">
529 + <div className="grid grid-cols-4 gap-2">
530 + {bets.map((b) => (
531 + <button key={b} onClick={() => { setBet(b); setBetSheet(false); }} className={cn("tap rounded-md border px-2 py-3 text-sm font-bold tabular focus-ring", b === bet ? "border-accent bg-accent-soft text-accent-2" : "border-line surface text-fg-2 hover:text-fg")}>
532 + {formatSC(b, { unit: false })}
533 + </button>
534 + ))}
535 + </div>
536 + <p className="mt-4 text-[12px] text-fg-3">
537 + Bets are in fictional Spinza Credits. Max win {formatMultiplier(game.maxMultiplier)} the bet.
538 + </p>
539 + </Sheet>
540 +
541 + <Sheet open={autoSheet} onClose={() => setAutoSheet(false)} title="Auto spin">
542 + <div className="grid grid-cols-4 gap-2">
543 + {AUTO_SPIN_OPTIONS.map((n) => (
544 + <button key={n} onClick={() => { setAutoSheet(false); void startAuto(n); }} className="tap rounded-md border border-line surface px-2 py-3 text-sm font-bold text-fg-2 hover:text-fg focus-ring">
545 + {n}
546 + </button>
547 + ))}
548 + </div>
549 + <p className="mt-4 text-[12px] text-fg-3">Auto spin stops automatically when a bonus triggers, when your balance is too low for the bet, or when you press STOP.</p>
550 + </Sheet>
551 +
552 + <GameInfoSheet open={infoSheet} onClose={() => setInfoSheet(false)} game={game} definition={definition} />
553 + <SettingsSheet open={settingsSheet} onClose={() => setSettingsSheet(false)} />
554 + <HistorySheet open={historySheet} onClose={() => setHistorySheet(false)} slug={game.slug} />
555 + <span className="sr-only">{user?.username}</span>
556 + </div>
557 + );
558 +}
559 +
560 +function errorTitle(code: string): string {
561 + return (
562 + {
563 + INSUFFICIENT_CREDITS: "Insufficient credits",
564 + NETWORK: "Connection lost",
565 + MAINTENANCE: "Spinza is getting an upgrade",
566 + GAME_UNAVAILABLE: "Game unavailable",
567 + RATE_LIMITED: "Slow down",
568 + UNAUTHORIZED: "Session expired",
569 + }[code] ?? "Something went wrong"
570 + );
571 +}
572 +
573 +/* ----------------------------------------------------------------- overlays */
574 +
575 +function Overlays({ overlay, palette }: { overlay: Overlay; palette: ClientDefinition["presentation"]["palette"] }) {
576 + return (
577 + <AnimatePresence>
578 + {overlay ? (
579 + <motion.div key={overlay.kind + ("title" in overlay ? overlay.title : "")} className="pointer-events-none absolute inset-0 grid place-items-center" style={{ zIndex: 4 }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
580 + <div className="absolute inset-0 bg-black/45" />
581 + {overlay.kind === "banner" ? (
582 + <motion.div initial={{ scale: 0.7, y: 20 }} animate={{ scale: 1, y: 0 }} transition={{ type: "spring", stiffness: 260, damping: 18 }} className="relative text-center">
583 + <div className="text-[clamp(28px,7vw,56px)] font-extrabold tracking-tight" style={{ color: palette.primary, textShadow: `0 0 40px ${palette.glow}` }}>
584 + {overlay.title}
585 + </div>
586 + {overlay.subtitle ? <div className="mt-1 text-lg text-fg-2">{overlay.subtitle}</div> : null}
587 + </motion.div>
588 + ) : null}
589 + {overlay.kind === "freespins" ? (
590 + <motion.div initial={{ scale: 0.6 }} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 220, damping: 16 }} className="relative text-center">
591 + <div className="eyebrow" style={{ color: palette.secondary }}>
592 + Feature unlocked
593 + </div>
594 + <div className="text-[clamp(32px,8vw,64px)] font-extrabold tracking-tight" style={{ color: palette.primary, textShadow: `0 0 50px ${palette.glow}` }}>
595 + {overlay.title}
596 + </div>
597 + <div className="mt-2 text-2xl font-semibold text-fg">{overlay.spins} spins</div>
598 + </motion.div>
599 + ) : null}
600 + {overlay.kind === "jackpot" ? (
601 + <motion.div initial={{ scale: 0.5, rotate: -4 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: "spring", stiffness: 200, damping: 14 }} className="relative text-center">
602 + <div className="text-[clamp(30px,7vw,60px)] font-extrabold uppercase tracking-tight shimmer-text">{overlay.tier} jackpot</div>
603 + <div className="mt-2 text-3xl font-bold tabular text-credit">{formatSC(overlay.amount)}</div>
604 + <div className="mt-1 text-xs text-fg-3">Fictional credits · no cash value</div>
605 + </motion.div>
606 + ) : null}
607 + {overlay.kind === "win" ? <WinPresentation cls={overlay.cls} amount={overlay.amount} multiplier={overlay.multiplier} /> : null}
608 + {overlay.kind === "bonus" ? <BonusReveal name={overlay.name} picks={overlay.picks} cells={overlay.cells} total={overlay.total} palette={palette} /> : null}
609 + </motion.div>
610 + ) : null}
611 + </AnimatePresence>
612 + );
613 +}
614 +
615 +function WinPresentation({ cls, amount, multiplier }: { cls: string; amount: number; multiplier: number }) {
616 + const label = WIN_CLASSES.find((c) => c.id === cls)?.label ?? "WIN";
617 + const [shown, setShown] = useState(0);
618 + useEffect(() => {
619 + const start = performance.now();
620 + const dur = 1400;
621 + let raf = 0;
622 + const step = () => {
623 + const t = Math.min(1, (performance.now() - start) / dur);
624 + setShown(Math.round(amount * (1 - Math.pow(1 - t, 3))));
625 + if (t < 1) raf = requestAnimationFrame(step);
626 + };
627 + raf = requestAnimationFrame(step);
628 + return () => cancelAnimationFrame(raf);
629 + }, [amount]);
630 + const size = { big: "text-[clamp(34px,9vw,72px)]", mega: "text-[clamp(38px,10vw,84px)]", epic: "text-[clamp(42px,11vw,96px)]", legendary: "text-[clamp(46px,12vw,110px)]" }[cls] ?? "text-5xl";
631 + return (
632 + <motion.div initial={{ scale: 0.4, opacity: 0 }} animate={{ scale: [0.4, 1.08, 1], opacity: 1 }} transition={{ duration: 0.7, times: [0, 0.7, 1] }} className="relative text-center">
633 + <div className={cn("font-extrabold uppercase tracking-tight shimmer-text", size)}>{label}</div>
634 + <div className="mt-3 text-4xl font-bold tabular text-credit sm:text-5xl">{formatSC(shown)}</div>
635 + <div className="mt-1 text-base font-semibold text-fg-2">{formatMultiplier(multiplier)} the bet</div>
636 + </motion.div>
637 + );
638 +}
639 +
640 +function BonusReveal({ name, picks, cells, total, palette }: { name: string; picks: { cell: number; value: number; amount: number }[]; cells: number; total: number; palette: ClientDefinition["presentation"]["palette"] }) {
641 + const [revealed, setRevealed] = useState(0);
642 + useEffect(() => {
643 + if (revealed >= picks.length) return;
644 + const t = setTimeout(() => setRevealed((r) => r + 1), 900);
645 + return () => clearTimeout(t);
646 + }, [revealed, picks.length]);
647 + const byCell = new Map(picks.slice(0, revealed).map((p) => [p.cell, p]));
648 + const cols = cells <= 6 ? 3 : cells <= 9 ? 3 : 4;
649 + return (
650 + <motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="relative w-[min(92vw,460px)] text-center">
651 + <div className="eyebrow" style={{ color: palette.secondary }}>
652 + Bonus game
653 + </div>
654 + <div className="text-3xl font-extrabold tracking-tight" style={{ color: palette.primary }}>
655 + {name}
656 + </div>
657 + <div className="mt-4 grid gap-2" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
658 + {Array.from({ length: cells }, (_, i) => {
659 + const p = byCell.get(i);
660 + return (
661 + <motion.div key={i} layout className={cn("grid aspect-[4/3] place-items-center rounded-md border text-sm font-bold tabular", p ? "border-accent/50 bg-accent-soft text-credit" : "border-line bg-surface-2 text-fg-4")} animate={p ? { scale: [0.8, 1.1, 1] } : {}}>
662 + {p ? formatSC(p.amount, { unit: false }) : "?"}
663 + </motion.div>
664 + );
665 + })}
666 + </div>
667 + <div className="mt-4 text-lg font-semibold">
668 + Total <span className="tabular text-credit">{formatSC(revealed >= picks.length ? total : picks.slice(0, revealed).reduce((a, p) => a + p.amount, 0))}</span>
669 + </div>
670 + </motion.div>
671 + );
672 +}
673 +
674 +/* ------------------------------------------------------------------ sheets */
675 +
676 +function GameInfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: ClientDefinition }) {
677 + const [tab, setTab] = useState<"rules" | "paytable" | "about">("rules");
678 + const symbols = new Map(definition.symbols.map((s) => [s.id, s]));
679 + return (
680 + <Sheet open={open} onClose={onClose} title={game.name} side="right">
681 + <Tabs value={tab} onChange={setTab} items={[{ value: "rules", label: "Rules" }, { value: "paytable", label: "Paytable" }, { value: "about", label: "About" }]} className="mb-4" />
682 + {tab === "rules" ? (
683 + <ul className="space-y-3 text-sm text-fg-2">
684 + {game.rules.map((r, i) => (
685 + <li key={i} className="flex gap-3">
686 + <span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-surface-2 text-[11px] font-bold text-fg-3">{i + 1}</span>
687 + <span>{r}</span>
688 + </li>
689 + ))}
690 + </ul>
691 + ) : null}
692 + {tab === "paytable" ? (
693 + <div className="space-y-2">
694 + <p className="text-[12px] text-fg-3">Pays shown as multiples of the total bet{definition.payModel.type === "ways" ? " per way" : " per line"}. Scatter pays apply anywhere.</p>
695 + {game.paytable.map((row) => {
696 + const s = symbols.get(row.symbol);
697 + return (
698 + <div key={row.symbol} className="surface flex items-center gap-3 rounded-md p-3">
699 + <span className="grid h-9 w-9 shrink-0 place-items-center rounded-md text-[11px] font-bold" style={{ background: `${s?.style.color ?? "#fff"}22`, color: s?.style.color ?? "#fff" }}>
700 + {s?.style.label ?? row.symbol}
701 + </span>
702 + <div className="min-w-0 flex-1">
703 + <div className="truncate text-sm font-semibold">{row.label}</div>
704 + <div className="text-[11px] uppercase tracking-wider text-fg-3">{s?.kind}</div>
705 + </div>
706 + <div className="flex gap-2 text-[12px] tabular">
707 + {Object.entries(row.pays)
708 + .sort((a, b) => Number(a[0]) - Number(b[0]))
709 + .map(([k, v]) => (
710 + <span key={k} className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-2">
711 + {k}× <span className="text-credit">{v}</span>
712 + </span>
713 + ))}
714 + </div>
715 + </div>
716 + );
717 + })}
718 + </div>
719 + ) : null}
720 + {tab === "about" ? (
721 + <div className="space-y-4 text-sm text-fg-2">
722 + <p>{game.description}</p>
723 + <dl className="grid grid-cols-2 gap-3">
724 + <Stat label="Volatility" value={game.volatility} />
725 + <Stat label="RTP (configured)" value={`${(game.rtp * 100).toFixed(2)}%`} />
726 + <Stat label="Hit frequency" value={game.hitFrequency ? `${(game.hitFrequency * 100).toFixed(1)}%` : "—"} />
727 + <Stat label="Max win" value={formatMultiplier(game.maxMultiplier)} />
728 + <Stat label="Grid" value={`${game.grid.reels} × ${game.grid.rows}`} />
729 + <Stat label="Version" value={game.version} />
730 + </dl>
731 + {game.certification ? (
732 + <div className="surface rounded-md p-3 text-[12px]">
733 + <div className="font-semibold text-fg">Internal certification · {game.certification.status}</div>
734 + <div className="mt-1 text-fg-3">
735 + {game.certification.spins.toLocaleString("en-US")} simulated spins · observed RTP {(game.certification.observedRtp * 100).toFixed(2)}%
736 + </div>
737 + </div>
738 + ) : null}
739 + <p className="text-[12px] text-fg-3">RTP is a gameplay-balancing statistic over millions of simulated spins. Spinza Credits are fictional and have no cash value.</p>
740 + </div>
741 + ) : null}
742 + </Sheet>
743 + );
744 +}
745 +
746 +function Stat({ label, value }: { label: string; value: string }) {
747 + return (
748 + <div className="surface rounded-md p-3">
749 + <dt className="eyebrow">{label}</dt>
750 + <dd className="mt-1 text-sm font-semibold capitalize text-fg">{value}</dd>
751 + </div>
752 + );
753 +}
754 +
755 +function SettingsSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
756 + const { settings, setSettings } = useSession();
757 + if (!settings) return null;
758 + return (
759 + <Sheet open={open} onClose={onClose} title="Game settings">
760 + <div className="space-y-4">
761 + <Slider label="Master volume" value={settings.masterVolume} onChange={(v) => setSettings({ masterVolume: v })} />
762 + <Slider label="Music" value={settings.musicVolume} onChange={(v) => setSettings({ musicVolume: v })} />
763 + <Slider label="Effects" value={settings.effectsVolume} onChange={(v) => setSettings({ effectsVolume: v })} />
764 + <div>
765 + <div className="mb-2 text-sm text-fg-2">Animation intensity</div>
766 + <Tabs value={settings.animationIntensity} onChange={(v) => setSettings({ animationIntensity: v })} items={[{ value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }]} />
767 + </div>
768 + <button onClick={() => setSettings({ reduceMotion: !settings.reduceMotion })} className="flex w-full items-center justify-between rounded-md surface px-4 py-3 text-sm focus-ring">
769 + <span>Reduce motion</span>
770 + <span className={cn("font-semibold", settings.reduceMotion ? "text-success" : "text-fg-3")}>{settings.reduceMotion ? "On" : "Off"}</span>
771 + </button>
772 + <Link href="/settings" className="block text-center text-[13px] text-fg-3 underline-offset-4 hover:underline">
773 + All account settings
774 + </Link>
775 + </div>
776 + </Sheet>
777 + );
778 +}
779 +
780 +function Slider({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
781 + return (
782 + <label className="block">
783 + <div className="mb-1 flex justify-between text-sm">
784 + <span className="text-fg-2">{label}</span>
785 + <span className="tabular text-fg-3">{Math.round(value * 100)}%</span>
786 + </div>
787 + <input type="range" min={0} max={1} step={0.05} value={value} onChange={(e) => onChange(Number(e.target.value))} className="w-full accent-[#c9a961]" />
788 + </label>
789 + );
790 +}
791 +
792 +function HistorySheet({ open, onClose, slug }: { open: boolean; onClose: () => void; slug: string }) {
793 + return (
794 + <Sheet open={open} onClose={onClose} title="Recent rounds" side="right">
795 + {open ? <HistoryList slug={slug} /> : null}
796 + </Sheet>
797 + );
798 +}
799 +
800 +type HistoryRow = { roundId: string; bet: number; win: number; multiplier: number; balanceAfter: number; createdAt: string; features: string[] };
801 +
802 +function HistoryList({ slug }: { slug: string }) {
803 + const [rows, setRows] = useState<HistoryRow[] | null>(null);
804 + const [expanded, setExpanded] = useState<string | null>(null);
805 + useEffect(() => {
806 + let alive = true;
807 + api<{ entries: HistoryRow[] }>(`/api/games/${slug}/history?limit=30`)
808 + .then((r) => alive && setRows(r.entries))
809 + .catch(() => alive && setRows([]));
810 + return () => {
811 + alive = false;
812 + };
813 + }, [slug]);
814 + return (
815 + <>
816 + {rows === null ? (
817 + <div className="text-sm text-fg-3">Loading…</div>
818 + ) : rows.length === 0 ? (
819 + <div className="text-sm text-fg-3">No rounds yet on this game.</div>
820 + ) : (
821 + <ul className="divide-y divide-line">
822 + {rows.map((r) => (
823 + <li key={r.roundId}>
824 + <button onClick={() => setExpanded(expanded === r.roundId ? null : r.roundId)} className="flex w-full items-center justify-between py-3 text-left">
825 + <div>
826 + <div className="text-sm font-medium">
827 + Bet {formatSC(r.bet, { unit: false })} · <span className={r.win > 0 ? "text-credit" : "text-fg-3"}>{r.win > 0 ? `+${formatSC(r.win, { unit: false })}` : "—"}</span>
828 + </div>
829 + <div className="text-[11px] text-fg-3">{new Date(r.createdAt).toLocaleString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}</div>
830 + </div>
831 + <div className="flex items-center gap-2 text-[12px] tabular text-fg-2">
832 + {formatMultiplier(r.multiplier)}
833 + {expanded === r.roundId ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
834 + </div>
835 + </button>
836 + {expanded === r.roundId ? (
837 + <div className="pb-3 text-[12px] text-fg-3">
838 + <div className="font-mono">{r.roundId}</div>
839 + <div>Balance after: {formatSC(r.balanceAfter)}</div>
840 + {r.features.length ? <div>Features: {r.features.join(", ")}</div> : null}
841 + </div>
842 + ) : null}
843 + </li>
844 + ))}
845 + </ul>
846 + )}
847 + </>
848 + );
849 +}
added apps/web/src/components/game/renderer.ts +889 −0
@@ -0,0 +1,889 @@
1 +"use client";
2 +
3 +import { Application, Container, Graphics, Sprite, Text, TextStyle, Texture, type Renderer } from "pixi.js";
4 +import type { SpinStep } from "@spinza/game-core/client";
5 +import type { ClientDefinition } from "./types";
6 +import { drawSymbol, hexColor, lighten } from "./symbol-art";
7 +import { formatSC } from "@spinza/shared";
8 +
9 +/* --------------------------------------------------------------- helpers */
10 +
11 +const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
12 +const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
13 +const easeOutBack = (t: number) => {
14 + const c1 = 1.70158;
15 + const c3 = c1 + 1;
16 + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
17 +};
18 +const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
19 +
20 +export interface RendererOptions {
21 + def: ClientDefinition;
22 + reduceMotion: boolean;
23 + intensity: "low" | "medium" | "high";
24 + quick: boolean;
25 + onReelStop?: (index: number) => void;
26 + onCascade?: () => void;
27 + onWinHighlight?: (amount: number) => void;
28 + onCoin?: () => void;
29 +}
30 +
31 +interface Cell {
32 + sprite: Container;
33 + id: string;
34 +}
35 +
36 +/**
37 + * Spinza slot renderer (PixiJS v8). One renderer serves all 20 games: it
38 + * reads the client definition for grid, symbols and presentation and plays
39 + * back server-resolved steps. It never decides outcomes.
40 + */
41 +export class SlotRenderer {
42 + private app: Application | null = null;
43 + private root = new Container();
44 + private backdrop = new Container();
45 + private ambient = new Container();
46 + private reelLayer = new Container();
47 + private fxLayer = new Container();
48 + private overlayLayer = new Container();
49 + private textures = new Map<string, Texture>();
50 + private cells: Cell[][] = []; // [reel][row]
51 + private reelContainers: Container[] = [];
52 + private masks: Graphics[] = [];
53 + private cols = 5;
54 + private rows = 3;
55 + private cellSize = 100;
56 + private gap = 8;
57 + private originX = 0;
58 + private originY = 0;
59 + private frame = new Graphics();
60 + private particles: { s: Sprite; vx: number; vy: number; life: number; max: number }[] = [];
61 + private ambientParticles: { s: Sprite; vx: number; vy: number; phase: number }[] = [];
62 + private destroyed = false;
63 + private currentGrid: string[][] = [];
64 + private multiplicity: number[][] | undefined;
65 + private badges = new Container();
66 + private width = 0;
67 + private height = 0;
68 + private opts: RendererOptions;
69 +
70 + constructor(opts: RendererOptions) {
71 + this.opts = opts;
72 + this.cols = opts.def.grid.reels;
73 + this.rows = opts.def.grid.rows;
74 + }
75 +
76 + get speed(): number {
77 + return this.opts.quick ? 0.55 : this.opts.reduceMotion ? 0.7 : 1;
78 + }
79 +
80 + setOptions(o: Partial<RendererOptions>) {
81 + this.opts = { ...this.opts, ...o };
82 + }
83 +
84 + async mount(el: HTMLElement): Promise<void> {
85 + const app = new Application();
86 + await app.init({
87 + resizeTo: el,
88 + backgroundAlpha: 0,
89 + antialias: true,
90 + autoDensity: true,
91 + resolution: Math.min(2, window.devicePixelRatio || 1),
92 + preference: "webgl",
93 + });
94 + if (this.destroyed) {
95 + app.destroy(true);
96 + return;
97 + }
98 + this.app = app;
99 + el.appendChild(app.canvas);
100 + this.root.addChild(this.backdrop, this.ambient, this.frame, this.reelLayer, this.fxLayer, this.badges, this.overlayLayer);
101 + app.stage.addChild(this.root);
102 + this.buildTextures(app.renderer);
103 + this.layout();
104 + this.drawBackdrop();
105 + this.initAmbient();
106 + this.buildReels(this.randomGrid());
107 + app.renderer.on("resize", () => this.onResize());
108 + app.ticker.add((t) => this.tick(t.deltaMS));
109 + }
110 +
111 + destroy() {
112 + this.destroyed = true;
113 + if (this.app) {
114 + this.app.destroy(true, { children: true, texture: true });
115 + this.app = null;
116 + }
117 + }
118 +
119 + /* ---------------------------------------------------------------- layout */
120 +
121 + private onResize() {
122 + this.layout();
123 + this.drawBackdrop();
124 + this.repositionCells();
125 + }
126 +
127 + private layout() {
128 + if (!this.app) return;
129 + this.width = this.app.screen.width;
130 + this.height = this.app.screen.height;
131 + const padX = Math.min(24, this.width * 0.04);
132 + const padY = Math.min(24, this.height * 0.05);
133 + const availW = this.width - padX * 2;
134 + const availH = this.height - padY * 2;
135 + this.gap = Math.max(4, Math.min(10, availW / this.cols / 14));
136 + this.cellSize = Math.floor(Math.min((availW - this.gap * (this.cols - 1)) / this.cols, (availH - this.gap * (this.rows - 1)) / this.rows));
137 + const totalW = this.cellSize * this.cols + this.gap * (this.cols - 1);
138 + const totalH = this.cellSize * this.rows + this.gap * (this.rows - 1);
139 + this.originX = (this.width - totalW) / 2;
140 + this.originY = (this.height - totalH) / 2;
141 + this.drawFrame(totalW, totalH);
142 + }
143 +
144 + private cellX(reel: number) {
145 + return this.originX + reel * (this.cellSize + this.gap) + this.cellSize / 2;
146 + }
147 + private cellY(row: number) {
148 + return this.originY + row * (this.cellSize + this.gap) + this.cellSize / 2;
149 + }
150 +
151 + private drawFrame(totalW: number, totalH: number) {
152 + const p = this.opts.def.presentation;
153 + const g = this.frame;
154 + g.clear();
155 + const pad = this.gap * 1.6;
156 + const x = this.originX - pad;
157 + const y = this.originY - pad;
158 + const w = totalW + pad * 2;
159 + const h = totalH + pad * 2;
160 + const primary = hexColor(p.palette.primary);
161 + const frameColors: Record<string, number> = { metal: 0x9aa3b5, glass: 0xffffff, gold: 0xe0c070, stone: 0x8a8578, ice: 0xbfe9ff, carbon: 0x5c6270, neon: primary, obsidian: 0x3a3a44 };
162 + const fc = frameColors[p.frame] ?? primary;
163 + g.roundRect(x, y, w, h, 18).fill({ color: 0x000000, alpha: 0.38 });
164 + g.roundRect(x, y, w, h, 18).stroke({ color: fc, alpha: p.frame === "neon" ? 0.85 : 0.35, width: 2 });
165 + g.roundRect(x + 3, y + 3, w - 6, h - 6, 15).stroke({ color: 0xffffff, alpha: 0.06, width: 1 });
166 + if (p.frame === "neon") g.roundRect(x - 3, y - 3, w + 6, h + 6, 21).stroke({ color: primary, alpha: 0.25, width: 6 });
167 + // Cell wells.
168 + for (let r = 0; r < this.cols; r++)
169 + for (let yy = 0; yy < this.rows; yy++) {
170 + g.roundRect(this.cellX(r) - this.cellSize / 2, this.cellY(yy) - this.cellSize / 2, this.cellSize, this.cellSize, this.cellSize * 0.14).fill({ color: 0xffffff, alpha: 0.025 });
171 + }
172 + }
173 +
174 + /* ------------------------------------------------------------- backdrop */
175 +
176 + private drawBackdrop() {
177 + const p = this.opts.def.presentation;
178 + const b = this.backdrop;
179 + b.removeChildren();
180 + const g = new Graphics();
181 + const W = this.width;
182 + const H = this.height;
183 + const primary = hexColor(p.palette.primary);
184 + const secondary = hexColor(p.palette.secondary);
185 + const bg = hexColor(p.palette.bg);
186 + g.rect(0, 0, W, H).fill(bg);
187 + // Vignette glow blobs.
188 + g.ellipse(W * 0.3, H * 0.2, W * 0.5, H * 0.45).fill({ color: primary, alpha: 0.08 });
189 + g.ellipse(W * 0.75, H * 0.85, W * 0.5, H * 0.4).fill({ color: secondary, alpha: 0.07 });
190 + const seed = hashString(this.opts.def.slug);
191 + const rnd = mulberry32(seed);
192 + switch (p.backdrop) {
193 + case "grid":
194 + case "circuit":
195 + case "vault": {
196 + const step = Math.max(28, W / 22);
197 + for (let x = 0; x <= W; x += step) g.moveTo(x, 0).lineTo(x, H).stroke({ color: primary, alpha: 0.06, width: 1 });
198 + for (let y = 0; y <= H; y += step) g.moveTo(0, y).lineTo(W, y).stroke({ color: primary, alpha: 0.06, width: 1 });
199 + if (p.backdrop === "vault") for (let i = 1; i <= 4; i++) g.circle(W / 2, H / 2, Math.min(W, H) * 0.2 * i).stroke({ color: primary, alpha: 0.05, width: 2 });
200 + if (p.backdrop === "circuit")
201 + for (let i = 0; i < 12; i++) {
202 + const x = rnd() * W;
203 + const y = rnd() * H;
204 + g.moveTo(x, y).lineTo(x + (rnd() - 0.5) * 200, y).lineTo(x + (rnd() - 0.5) * 200, y + (rnd() - 0.5) * 160).stroke({ color: secondary, alpha: 0.12, width: 2 });
205 + g.circle(x, y, 3).fill({ color: secondary, alpha: 0.35 });
206 + }
207 + break;
208 + }
209 + case "nebula":
210 + case "universe":
211 + case "gravity": {
212 + for (let i = 0; i < 6; i++) g.ellipse(rnd() * W, rnd() * H, 80 + rnd() * W * 0.3, 40 + rnd() * H * 0.25).fill({ color: i % 2 ? primary : secondary, alpha: 0.05 });
213 + for (let i = 0; i < 90; i++) g.circle(rnd() * W, rnd() * H, rnd() * 1.6 + 0.3).fill({ color: 0xffffff, alpha: 0.25 + rnd() * 0.5 });
214 + if (p.backdrop === "gravity" || p.backdrop === "universe") for (let i = 1; i <= 3; i++) g.ellipse(W / 2, H / 2, W * 0.28 * i, H * 0.18 * i).stroke({ color: primary, alpha: 0.08, width: 1.5 });
215 + break;
216 + }
217 + case "pillars":
218 + case "temple": {
219 + const n = 6;
220 + for (let i = 0; i < n; i++) {
221 + const x = (W / (n + 1)) * (i + 1);
222 + g.rect(x - 18, 0, 36, H).fill({ color: primary, alpha: 0.05 });
223 + g.rect(x - 26, H * 0.08, 52, 14).fill({ color: primary, alpha: 0.1 });
224 + }
225 + break;
226 + }
227 + case "aurora": {
228 + for (let k = 0; k < 4; k++) {
229 + g.moveTo(0, H * (0.2 + k * 0.12));
230 + for (let x = 0; x <= W; x += 40) g.lineTo(x, H * (0.2 + k * 0.12) + Math.sin(x / 90 + k) * 30);
231 + g.stroke({ color: k % 2 ? primary : secondary, alpha: 0.1, width: 26 });
232 + }
233 + break;
234 + }
235 + case "lava":
236 + case "core":
237 + case "reactor": {
238 + for (let i = 0; i < 14; i++) {
239 + const x = rnd() * W;
240 + const y = rnd() * H;
241 + g.moveTo(x, y).lineTo(x + (rnd() - 0.5) * 220, y + (rnd() - 0.5) * 220).stroke({ color: primary, alpha: 0.18, width: 2 });
242 + }
243 + g.circle(W / 2, H / 2, Math.min(W, H) * 0.42).stroke({ color: secondary, alpha: 0.12, width: 10 });
244 + break;
245 + }
246 + case "skyline": {
247 + for (let x = 0; x < W; x += 30 + rnd() * 40) {
248 + const h = 60 + rnd() * H * 0.45;
249 + g.rect(x, H - h, 24 + rnd() * 40, h).fill({ color: 0x000000, alpha: 0.45 });
250 + for (let i = 0; i < 5; i++) g.rect(x + 4 + rnd() * 30, H - h + 8 + rnd() * (h - 16), 4, 6).fill({ color: rnd() > 0.5 ? primary : secondary, alpha: 0.6 });
251 + }
252 + break;
253 + }
254 + case "pyramid": {
255 + g.poly([W * 0.5, H * 0.1, W * 0.95, H, W * 0.05, H]).fill({ color: primary, alpha: 0.06 });
256 + g.poly([W * 0.5, H * 0.1, W * 0.95, H, W * 0.5, H]).fill({ color: 0x000000, alpha: 0.15 });
257 + break;
258 + }
259 + case "arcade": {
260 + for (let i = 0; i < 60; i++) g.rect(Math.floor((rnd() * W) / 24) * 24, Math.floor((rnd() * H) / 24) * 24, 22, 22).fill({ color: rnd() > 0.5 ? primary : secondary, alpha: 0.08 });
261 + break;
262 + }
263 + case "asteroids": {
264 + for (let i = 0; i < 16; i++) polygonRandom(g, rnd() * W, rnd() * H, 10 + rnd() * 40, rnd).fill({ color: 0xffffff, alpha: 0.05 });
265 + for (let i = 0; i < 60; i++) g.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.5 });
266 + break;
267 + }
268 + case "track": {
269 + for (let k = 0; k < 5; k++) {
270 + g.moveTo(-50, H * 0.9 - k * 40);
271 + g.bezierCurveTo(W * 0.3, H * 0.1 - k * 30, W * 0.7, H * 1.1 - k * 30, W + 50, H * 0.2 - k * 40);
272 + g.stroke({ color: k % 2 ? primary : 0xffffff, alpha: 0.06, width: 12 });
273 + }
274 + break;
275 + }
276 + case "abyss": {
277 + for (let i = 0; i < 40; i++) g.circle(rnd() * W, rnd() * H, 2 + rnd() * 10).stroke({ color: primary, alpha: 0.12, width: 1 });
278 + g.rect(0, H * 0.6, W, H * 0.4).fill({ color: 0x000000, alpha: 0.35 });
279 + break;
280 + }
281 + case "moon": {
282 + g.circle(W * 0.8, H * 0.2, Math.min(W, H) * 0.18).fill({ color: lighten(p.palette.primary, 0.1), alpha: 0.12 });
283 + for (let i = 0; i < 8; i++) g.circle(W * 0.8 + (rnd() - 0.5) * 120, H * 0.2 + (rnd() - 0.5) * 120, 4 + rnd() * 14).fill({ color: 0x000000, alpha: 0.12 });
284 + for (let i = 0; i < 70; i++) g.circle(rnd() * W, rnd() * H, rnd() * 1.4 + 0.3).fill({ color: 0xffffff, alpha: 0.5 });
285 + break;
286 + }
287 + case "minimal": {
288 + g.moveTo(W * 0.1, H * 0.5).lineTo(W * 0.9, H * 0.5).stroke({ color: primary, alpha: 0.18, width: 1 });
289 + break;
290 + }
291 + }
292 + b.addChild(g);
293 + }
294 +
295 + private initAmbient() {
296 + if (!this.app || this.opts.reduceMotion || this.opts.intensity === "low") return;
297 + const p = this.opts.def.presentation;
298 + const count = this.opts.intensity === "high" ? 40 : 18;
299 + const tex = this.particleTexture(p.particles);
300 + this.ambient.removeChildren();
301 + this.ambientParticles = [];
302 + for (let i = 0; i < count; i++) {
303 + const s = new Sprite(tex);
304 + s.anchor.set(0.5);
305 + s.alpha = 0.15 + Math.random() * 0.35;
306 + s.scale.set(0.3 + Math.random() * 0.8);
307 + s.x = Math.random() * this.width;
308 + s.y = Math.random() * this.height;
309 + const dir = p.particles === "snow" || p.particles === "dust" || p.particles === "confetti" ? 1 : p.particles === "bubbles" || p.particles === "fire" || p.particles === "sparks" ? -1 : 0;
310 + this.ambientParticles.push({ s, vx: (Math.random() - 0.5) * 8, vy: dir * (6 + Math.random() * 18) || (Math.random() - 0.5) * 6, phase: Math.random() * Math.PI * 2 });
311 + this.ambient.addChild(s);
312 + }
313 + }
314 +
315 + private particleTexture(kind: string): Texture {
316 + const key = `p:${kind}`;
317 + const cached = this.textures.get(key);
318 + if (cached) return cached;
319 + const g = new Graphics();
320 + const p = this.opts.def.presentation.palette;
321 + const c = hexColor(kind === "coins" ? "#ffd66b" : kind === "diamonds" ? "#e8f7ff" : kind === "fire" ? "#ff8a3d" : kind === "snow" ? "#ffffff" : kind === "bubbles" ? p.secondary : p.glow);
322 + switch (kind) {
323 + case "coins":
324 + g.circle(8, 8, 7).fill(c).circle(8, 8, 4).stroke({ color: 0x8a6d2e, width: 1.5 });
325 + break;
326 + case "diamonds":
327 + g.poly([8, 0, 16, 8, 8, 16, 0, 8]).fill(c);
328 + break;
329 + case "sparks":
330 + case "energy":
331 + g.rect(6, 0, 4, 16).fill(c).rect(0, 6, 16, 4).fill(c);
332 + break;
333 + case "confetti":
334 + g.rect(2, 4, 12, 6).fill(c);
335 + break;
336 + case "stars":
337 + g.poly([8, 0, 10, 6, 16, 8, 10, 10, 8, 16, 6, 10, 0, 8, 6, 6]).fill(c);
338 + break;
339 + default:
340 + g.circle(8, 8, 6).fill(c);
341 + }
342 + const tex = this.app!.renderer.generateTexture({ target: g, resolution: 2 });
343 + this.textures.set(key, tex);
344 + return tex;
345 + }
346 +
347 + /* ------------------------------------------------------------- textures */
348 +
349 + private buildTextures(renderer: Renderer) {
350 + const size = 176;
351 + for (const s of this.opts.def.symbols) {
352 + const art = drawSymbol(s.style, { size, tier: s.tier, kind: s.kind });
353 + const tex = renderer.generateTexture({ target: art, resolution: 2, frame: { x: -size / 2, y: -size / 2, width: size, height: size } as never });
354 + this.textures.set(s.id, tex);
355 + art.destroy({ children: true });
356 + }
357 + // Blank cell (hold & respin).
358 + const blank = new Graphics().roundRect(-80, -80, 160, 160, 22).fill({ color: 0x000000, alpha: 0.2 });
359 + this.textures.set("__", renderer.generateTexture({ target: blank, resolution: 1 }));
360 + }
361 +
362 + private makeSprite(id: string): Container {
363 + const tex = this.textures.get(id) ?? this.textures.get("__")!;
364 + const sp = new Sprite(tex);
365 + sp.anchor.set(0.5);
366 + const c = new Container();
367 + c.addChild(sp);
368 + this.fitSprite(c);
369 + return c;
370 + }
371 +
372 + private fitSprite(c: Container) {
373 + const sp = c.children[0] as Sprite;
374 + const scale = this.cellSize / sp.texture.width;
375 + sp.scale.set(scale);
376 + }
377 +
378 + /* ---------------------------------------------------------------- reels */
379 +
380 + private randomGrid(): string[][] {
381 + // Visual placeholder only (never an outcome): deterministic pattern from symbol list.
382 + const regular = this.opts.def.symbols.filter((s) => s.kind === "regular");
383 + return Array.from({ length: this.cols }, (_, r) => Array.from({ length: this.rows }, (_, y) => regular[(r * 3 + y * 5) % regular.length].id));
384 + }
385 +
386 + private buildReels(grid: string[][]) {
387 + this.reelLayer.removeChildren();
388 + this.reelContainers = [];
389 + this.masks = [];
390 + this.cells = [];
391 + this.currentGrid = grid.map((c) => c.slice());
392 + for (let r = 0; r < this.cols; r++) {
393 + const rc = new Container();
394 + const m = new Graphics();
395 + rc.mask = m;
396 + this.reelLayer.addChild(m, rc);
397 + this.reelContainers.push(rc);
398 + this.masks.push(m);
399 + const col: Cell[] = [];
400 + for (let y = 0; y < this.rows; y++) {
401 + const id = grid[r]?.[y] ?? "__";
402 + const sp = this.makeSprite(id);
403 + rc.addChild(sp);
404 + col.push({ sprite: sp, id });
405 + }
406 + this.cells.push(col);
407 + }
408 + this.repositionCells();
409 + }
410 +
411 + private repositionCells() {
412 + for (let r = 0; r < this.cols; r++) {
413 + const m = this.masks[r];
414 + if (!m) continue;
415 + m.clear().roundRect(this.cellX(r) - this.cellSize / 2 - 2, this.originY - this.gap / 2, this.cellSize + 4, this.rows * (this.cellSize + this.gap), 12).fill(0xffffff);
416 + for (let y = 0; y < this.rows; y++) {
417 + const cell = this.cells[r]?.[y];
418 + if (!cell) continue;
419 + this.fitSprite(cell.sprite);
420 + cell.sprite.position.set(this.cellX(r), this.cellY(y));
421 + cell.sprite.alpha = 1;
422 + cell.sprite.scale.set(1);
423 + }
424 + }
425 + this.fxLayer.removeChildren();
426 + this.badges.removeChildren();
427 + }
428 +
429 + /** Change grid dimensions (Zero Gravity). */
430 + setGridSize(reels: number, rows: number) {
431 + if (reels === this.cols && rows === this.rows) return;
432 + this.cols = reels;
433 + this.rows = rows;
434 + this.layout();
435 + this.buildReels(this.randomGrid());
436 + }
437 +
438 + /** Instantly show a grid (used on load / restore). */
439 + setGrid(grid: string[][]) {
440 + if (grid.length !== this.cols || grid[0]?.length !== this.rows) this.setGridSize(grid.length, grid[0].length);
441 + this.buildReels(grid);
442 + }
443 +
444 + private spinning = false;
445 + private spinVel = 0;
446 +
447 + /** Start reels spinning (called when the request is sent). */
448 + startSpin() {
449 + this.clearFx();
450 + this.spinning = true;
451 + this.spinVel = 0;
452 + }
453 +
454 + /** Land the given grid reel by reel. Resolves when the last reel stops. */
455 + async landGrid(grid: string[][], opts: { minSpinMs?: number } = {}): Promise<void> {
456 + if (grid.length !== this.cols || grid[0].length !== this.rows) {
457 + this.spinning = false;
458 + this.setGridSize(grid.length, grid[0].length);
459 + await this.dropIn(grid);
460 + return;
461 + }
462 + const minSpin = (opts.minSpinMs ?? 500) * this.speed;
463 + if (!this.spinning) {
464 + this.startSpin();
465 + }
466 + await wait(minSpin);
467 + const stagger = (this.opts.quick ? 70 : 130) * this.speed;
468 + for (let r = 0; r < this.cols; r++) {
469 + await this.stopReel(r, grid[r]);
470 + this.opts.onReelStop?.(r);
471 + if (r < this.cols - 1) await wait(stagger);
472 + }
473 + this.spinning = false;
474 + this.currentGrid = grid.map((c) => c.slice());
475 + }
476 +
477 + private reelSpinning: boolean[] = [];
478 +
479 + private tick(deltaMS: number) {
480 + if (!this.app) return;
481 + const dt = deltaMS / 1000;
482 + // Ambient particles.
483 + for (const p of this.ambientParticles) {
484 + p.phase += dt;
485 + p.s.x += (p.vx + Math.sin(p.phase) * 6) * dt;
486 + p.s.y += p.vy * dt;
487 + if (p.s.y > this.height + 20) p.s.y = -20;
488 + if (p.s.y < -20) p.s.y = this.height + 20;
489 + if (p.s.x > this.width + 20) p.s.x = -20;
490 + if (p.s.x < -20) p.s.x = this.width + 20;
491 + p.s.rotation += dt * 0.5;
492 + }
493 + // Burst particles.
494 + for (let i = this.particles.length - 1; i >= 0; i--) {
495 + const p = this.particles[i];
496 + p.life += dt;
497 + p.vy += 600 * dt;
498 + p.s.x += p.vx * dt;
499 + p.s.y += p.vy * dt;
500 + p.s.rotation += dt * 3;
501 + p.s.alpha = Math.max(0, 1 - p.life / p.max);
502 + if (p.life >= p.max) {
503 + p.s.destroy();
504 + this.particles.splice(i, 1);
505 + }
506 + }
507 + // Reel spin: scroll symbols downward, wrapping with placeholder art.
508 + if (this.spinning) {
509 + this.spinVel = Math.min(2600, this.spinVel + 9000 * dt);
510 + const regular = this.opts.def.symbols.filter((s) => s.kind === "regular" || s.kind === "wild");
511 + for (let r = 0; r < this.cols; r++) {
512 + if (this.reelSpinning[r] === false) continue;
513 + this.reelSpinning[r] = true;
514 + const col = this.cells[r];
515 + const bottom = this.originY + this.rows * (this.cellSize + this.gap);
516 + for (const cell of col) {
517 + cell.sprite.y += this.spinVel * dt * (0.9 + r * 0.04);
518 + if (cell.sprite.y - this.cellSize / 2 > bottom) {
519 + cell.sprite.y -= this.rows * (this.cellSize + this.gap);
520 + const id = regular[Math.floor(Math.random() * regular.length)].id; // cosmetic blur only
521 + this.swapTexture(cell, id);
522 + }
523 + }
524 + // Motion blur feel.
525 + for (const cell of col) (cell.sprite.children[0] as Sprite).alpha = 0.85;
526 + }
527 + }
528 + }
529 +
530 + private swapTexture(cell: Cell, id: string) {
531 + const sp = cell.sprite.children[0] as Sprite;
532 + sp.texture = this.textures.get(id) ?? this.textures.get("__")!;
533 + cell.id = id;
534 + this.fitSprite(cell.sprite);
535 + }
536 +
537 + private async stopReel(r: number, ids: string[]) {
538 + this.reelSpinning[r] = false;
539 + const col = this.cells[r];
540 + // Snap: place final symbols slightly above target, then ease into place with a bounce.
541 + col.forEach((cell, y) => {
542 + this.swapTexture(cell, ids[y]);
543 + (cell.sprite.children[0] as Sprite).alpha = 1;
544 + cell.sprite.y = this.cellY(y) - this.cellSize * 0.35;
545 + });
546 + const dur = (this.opts.quick ? 120 : 220) * this.speed;
547 + await this.animate(dur, (t) => {
548 + const e = easeOutBack(t);
549 + col.forEach((cell, y) => {
550 + cell.sprite.y = this.cellY(y) - this.cellSize * 0.35 * (1 - e);
551 + });
552 + });
553 + col.forEach((cell, y) => (cell.sprite.y = this.cellY(y)));
554 + }
555 +
556 + private async dropIn(grid: string[][]) {
557 + for (let r = 0; r < this.cols; r++)
558 + for (let y = 0; y < this.rows; y++) {
559 + const cell = this.cells[r][y];
560 + this.swapTexture(cell, grid[r][y]);
561 + cell.sprite.y = this.cellY(y) - this.height;
562 + }
563 + await this.animate(500 * this.speed, (t) => {
564 + const e = easeOutCubic(t);
565 + for (let r = 0; r < this.cols; r++) for (let y = 0; y < this.rows; y++) this.cells[r][y].sprite.y = this.cellY(y) - this.height * (1 - e);
566 + });
567 + this.currentGrid = grid.map((c) => c.slice());
568 + }
569 +
570 + /* ---------------------------------------------------------------- steps */
571 +
572 + /** Play a resolved step. `first` = the initial landing of a spin sequence. */
573 + async playStep(step: SpinStep, ctx: { first: boolean; showWins: boolean }): Promise<void> {
574 + if (this.destroyed) return;
575 + const meta = step.meta;
576 + if (step.type === "respin") {
577 + await this.playRespin(step);
578 + return;
579 + }
580 + if (step.type === "bonus" || step.type === "jackpot" || step.type === "feature") {
581 + // Overlays are rendered by React; keep the grid visible.
582 + await wait(200 * this.speed);
583 + return;
584 + }
585 + if (step.type === "cascade") {
586 + await this.playCascadeLanding(step);
587 + } else if (ctx.first) {
588 + await this.landGrid(step.grid);
589 + } else {
590 + // Free spin: quick respin of the whole grid.
591 + this.startSpin();
592 + await this.landGrid(step.grid, { minSpinMs: 260 });
593 + }
594 + this.multiplicity = step.multiplicity;
595 + // Feature decorations.
596 + if (meta.stickyWilds?.length) this.markCells(meta.stickyWilds, 0xffffff, "STICKY");
597 + if (meta.movingWilds?.length) this.markCells(meta.movingWilds, 0xffffff, "");
598 + if (meta.addedWilds?.length) await this.popCells(meta.addedWilds);
599 + if (meta.expandedReels?.length) await this.flashReels(meta.expandedReels);
600 + if (meta.mysteryReveal) await this.pulseCells(meta.mysteryReveal.positions);
601 + if (meta.wildMultipliers?.length) for (const wm of meta.wildMultipliers) this.badge(wm.position, `×${wm.value}`);
602 + if (meta.quantumSplits?.length) for (const q of meta.quantumSplits) this.badge(q.position, `×${q.multiplicity}`, 0x67e8f9);
603 + if (meta.scatterPositions?.length && (meta.freeSpinsAwarded || meta.scatterWin)) await this.pulseCells(meta.scatterPositions, 0xffffff);
604 + if (meta.exploded?.length) await this.explode(meta.exploded);
605 + if (ctx.showWins && step.wins.length) {
606 + await this.highlightWins(step);
607 + }
608 + if (step.meta.removed?.length && step.wins.length === 0 && !meta.exploded?.length) {
609 + // nothing
610 + }
611 + }
612 +
613 + private async playCascadeLanding(step: SpinStep) {
614 + // Previous grid already has removed cells faded (see highlightWins/explode). Drop survivors + new symbols.
615 + const prev = this.currentGrid;
616 + const next = step.grid;
617 + const removedPrev = this.lastRemoved;
618 + this.lastRemoved = [];
619 + const removedSet = new Set(removedPrev.map(([r, y]) => `${r}:${y}`));
620 + const moves: { cell: Cell; fromY: number; toY: number }[] = [];
621 + for (let r = 0; r < this.cols; r++) {
622 + // Survivors from bottom up map to bottom of next.
623 + const survivors: number[] = [];
624 + for (let y = this.rows - 1; y >= 0; y--) if (!removedSet.has(`${r}:${y}`)) survivors.push(y);
625 + const newCells: Cell[] = [];
626 + let target = this.rows - 1;
627 + for (const y of survivors) {
628 + const cell = this.cells[r][y];
629 + moves.push({ cell, fromY: this.cellY(y), toY: this.cellY(target) });
630 + newCells[target] = cell;
631 + target--;
632 + }
633 + // Fresh symbols enter from above.
634 + for (let y = target; y >= 0; y--) {
635 + const cell = this.cells[r].find((c) => removedSet.has(`${r}:${this.cells[r].indexOf(c)}`) && !newCells.includes(c))!;
636 + this.swapTexture(cell, next[r][y]);
637 + cell.sprite.alpha = 1;
638 + cell.sprite.scale.set(1);
639 + moves.push({ cell, fromY: this.cellY(y) - (target + 1) * (this.cellSize + this.gap) - this.cellSize, toY: this.cellY(y) });
640 + newCells[y] = cell;
641 + }
642 + this.cells[r] = newCells;
643 + }
644 + void prev;
645 + this.opts.onCascade?.();
646 + await this.animate(380 * this.speed, (t) => {
647 + const e = easeOutCubic(t);
648 + for (const m of moves) m.cell.sprite.y = m.fromY + (m.toY - m.fromY) * e;
649 + });
650 + // Ensure textures match the server grid exactly.
651 + for (let r = 0; r < this.cols; r++) for (let y = 0; y < this.rows; y++) if (this.cells[r][y].id !== next[r][y]) this.swapTexture(this.cells[r][y], next[r][y]);
652 + this.currentGrid = next.map((c) => c.slice());
653 + }
654 +
655 + private lastRemoved: [number, number][] = [];
656 +
657 + private async highlightWins(step: SpinStep) {
658 + const fx = new Graphics();
659 + this.fxLayer.addChild(fx);
660 + const winCells = new Set<string>();
661 + for (const w of step.wins) for (const [r, y] of w.positions) winCells.add(`${r}:${y}`);
662 + const primary = hexColor(this.opts.def.presentation.palette.glow);
663 + // Dim non-winning.
664 + for (let r = 0; r < this.cols; r++) for (let y = 0; y < this.rows; y++) if (!winCells.has(`${r}:${y}`)) this.cells[r][y].sprite.alpha = 0.35;
665 + // Paylines.
666 + if (this.opts.def.payModel.type === "lines") {
667 + for (const w of step.wins) {
668 + const pts = w.positions.map(([r, y]) => [this.cellX(r), this.cellY(y)]);
669 + fx.moveTo(pts[0][0], pts[0][1]);
670 + for (const p of pts.slice(1)) fx.lineTo(p[0], p[1]);
671 + fx.stroke({ color: primary, alpha: 0.7, width: 3 });
672 + }
673 + }
674 + for (const k of winCells) {
675 + const [r, y] = k.split(":").map(Number);
676 + fx.roundRect(this.cellX(r) - this.cellSize / 2, this.cellY(y) - this.cellSize / 2, this.cellSize, this.cellSize, this.cellSize * 0.14).stroke({ color: primary, alpha: 0.9, width: 3 });
677 + }
678 + this.opts.onWinHighlight?.(step.win);
679 + const dur = (this.opts.quick ? 380 : 720) * this.speed;
680 + await this.animate(dur, (t) => {
681 + const pulse = 1 + Math.sin(t * Math.PI * 2) * 0.05;
682 + for (const k of winCells) {
683 + const [r, y] = k.split(":").map(Number);
684 + this.cells[r][y].sprite.scale.set(pulse);
685 + }
686 + fx.alpha = 0.6 + Math.sin(t * Math.PI * 4) * 0.4;
687 + });
688 + if (this.opts.intensity !== "low" && !this.opts.reduceMotion) {
689 + const cellsArr = [...winCells].slice(0, 12);
690 + for (const k of cellsArr) {
691 + const [r, y] = k.split(":").map(Number);
692 + this.burst(this.cellX(r), this.cellY(y), step.multiplier > 1 ? 6 : 3);
693 + }
694 + }
695 + // If a cascade follows, fade the winning symbols out.
696 + if (step.meta.removed?.length) {
697 + this.lastRemoved = step.meta.removed;
698 + const rem = step.meta.removed;
699 + await this.animate(200 * this.speed, (t) => {
700 + for (const [r, y] of rem) {
701 + const c = this.cells[r][y];
702 + c.sprite.alpha = 1 - t;
703 + c.sprite.scale.set(1 - t * 0.5);
704 + }
705 + });
706 + }
707 + fx.destroy();
708 + for (let r = 0; r < this.cols; r++) for (let y = 0; y < this.rows; y++) if (!this.lastRemoved.some(([rr, yy]) => rr === r && yy === y)) {
709 + this.cells[r][y].sprite.alpha = 1;
710 + this.cells[r][y].sprite.scale.set(1);
711 + }
712 + }
713 +
714 + private async explode(cells: [number, number][]) {
715 + for (const [r, y] of cells) this.burst(this.cellX(r), this.cellY(y), 8);
716 + await this.animate(240 * this.speed, (t) => {
717 + for (const [r, y] of cells) {
718 + const c = this.cells[r]?.[y];
719 + if (!c) continue;
720 + c.sprite.alpha = 1 - t;
721 + c.sprite.scale.set(1 + t * 0.4);
722 + }
723 + });
724 + this.lastRemoved = [...this.lastRemoved, ...cells];
725 + }
726 +
727 + private async playRespin(step: SpinStep) {
728 + const coins = step.meta.coins ?? [];
729 + const cs = this.opts.def.holdRespin?.symbolId ?? "__";
730 + // Reels spin for blanks only.
731 + const grid = step.grid;
732 + if (step.meta.label === "Lock & Respin") {
733 + await wait(150 * this.speed);
734 + } else {
735 + this.startSpin();
736 + for (let r = 0; r < this.cols; r++) this.reelSpinning[r] = true;
737 + await wait(320 * this.speed);
738 + }
739 + this.spinning = false;
740 + this.reelSpinning = [];
741 + for (let r = 0; r < this.cols; r++)
742 + for (let y = 0; y < this.rows; y++) {
743 + const c = this.cells[r][y];
744 + this.swapTexture(c, grid[r][y] === cs ? cs : "__");
745 + (c.sprite.children[0] as Sprite).alpha = 1;
746 + c.sprite.y = this.cellY(y);
747 + c.sprite.alpha = grid[r][y] === cs ? 1 : 0.6;
748 + }
749 + this.badges.removeChildren();
750 + for (const coin of coins) {
751 + const label = coin.value !== null ? formatSC(coin.value, { unit: false }) : (coin.jackpot ?? "").toUpperCase();
752 + this.badge([coin.reel, coin.row], label, coin.jackpot ? 0xffd66b : 0xffffff, true);
753 + if (coin.isNew) {
754 + this.burst(this.cellX(coin.reel), this.cellY(coin.row), 6);
755 + this.opts.onCoin?.();
756 + }
757 + }
758 + this.currentGrid = grid.map((c) => c.slice());
759 + await wait((step.meta.label === "Lock & Respin" ? 500 : 420) * this.speed);
760 + }
761 +
762 + /* --------------------------------------------------------------- effects */
763 +
764 + private badge(pos: [number, number], text: string, color = 0xffd66b, center = false) {
765 + const [r, y] = pos;
766 + const t = new Text({ text, style: new TextStyle({ fontFamily: "Geist, Inter, system-ui, sans-serif", fontSize: Math.max(11, this.cellSize * (center ? 0.2 : 0.17)), fontWeight: "800", fill: color, stroke: { color: 0x000000, width: 4 } }) });
767 + t.anchor.set(center ? 0.5 : 1, center ? 0.5 : 0);
768 + t.position.set(center ? this.cellX(r) : this.cellX(r) + this.cellSize / 2 - 6, center ? this.cellY(y) + this.cellSize * 0.22 : this.cellY(y) - this.cellSize / 2 + 4);
769 + this.badges.addChild(t);
770 + }
771 +
772 + private markCells(cells: [number, number][], color: number, text: string) {
773 + const g = new Graphics();
774 + for (const [r, y] of cells) g.roundRect(this.cellX(r) - this.cellSize / 2, this.cellY(y) - this.cellSize / 2, this.cellSize, this.cellSize, this.cellSize * 0.14).stroke({ color, alpha: 0.6, width: 2 });
775 + this.badges.addChild(g);
776 + if (text) for (const c of cells) this.badge(c, text, color);
777 + }
778 +
779 + private async popCells(cells: [number, number][]) {
780 + for (const [r, y] of cells) this.burst(this.cellX(r), this.cellY(y), 5);
781 + await this.animate(260 * this.speed, (t) => {
782 + const e = easeOutBack(t);
783 + for (const [r, y] of cells) this.cells[r]?.[y]?.sprite.scale.set(0.6 + 0.4 * e);
784 + });
785 + }
786 +
787 + private async flashReels(reels: number[]) {
788 + const g = new Graphics();
789 + for (const r of reels) g.roundRect(this.cellX(r) - this.cellSize / 2 - 2, this.originY - 2, this.cellSize + 4, this.rows * (this.cellSize + this.gap) - this.gap + 4, 12).fill({ color: 0xffffff, alpha: 0.5 });
790 + this.fxLayer.addChild(g);
791 + await this.animate(320 * this.speed, (t) => (g.alpha = 1 - t));
792 + g.destroy();
793 + }
794 +
795 + private async pulseCells(cells: [number, number][], color = 0xffffff) {
796 + const g = new Graphics();
797 + for (const [r, y] of cells) g.roundRect(this.cellX(r) - this.cellSize / 2, this.cellY(y) - this.cellSize / 2, this.cellSize, this.cellSize, this.cellSize * 0.14).stroke({ color, alpha: 0.9, width: 3 });
798 + this.fxLayer.addChild(g);
799 + await this.animate(360 * this.speed, (t) => {
800 + g.alpha = 1 - t;
801 + for (const [r, y] of cells) this.cells[r]?.[y]?.sprite.scale.set(1 + Math.sin(t * Math.PI) * 0.12);
802 + });
803 + g.destroy();
804 + for (const [r, y] of cells) this.cells[r]?.[y]?.sprite.scale.set(1);
805 + }
806 +
807 + burst(x: number, y: number, n: number) {
808 + if (!this.app || this.opts.reduceMotion) return;
809 + const tex = this.particleTexture(this.opts.def.presentation.particles);
810 + const count = this.opts.intensity === "high" ? n * 2 : n;
811 + for (let i = 0; i < count; i++) {
812 + const s = new Sprite(tex);
813 + s.anchor.set(0.5);
814 + s.position.set(x, y);
815 + s.scale.set(0.4 + Math.random() * 0.6);
816 + const a = Math.random() * Math.PI * 2;
817 + const v = 120 + Math.random() * 260;
818 + this.fxLayer.addChild(s);
819 + this.particles.push({ s, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 150, life: 0, max: 0.7 + Math.random() * 0.5 });
820 + }
821 + }
822 +
823 + /** Big celebration shower across the whole canvas. */
824 + celebrate(intensity: number) {
825 + if (!this.app || this.opts.reduceMotion) return;
826 + const n = Math.min(160, 30 * intensity) * (this.opts.intensity === "high" ? 1 : 0.5);
827 + for (let i = 0; i < n; i++) setTimeout(() => this.burst(Math.random() * this.width, this.height * 0.3 + Math.random() * this.height * 0.4, 3), i * 12);
828 + }
829 +
830 + clearFx() {
831 + this.fxLayer.removeChildren();
832 + this.badges.removeChildren();
833 + this.lastRemoved = [];
834 + for (const col of this.cells)
835 + for (const c of col) {
836 + c.sprite.alpha = 1;
837 + c.sprite.scale.set(1);
838 + (c.sprite.children[0] as Sprite).alpha = 1;
839 + }
840 + }
841 +
842 + private animate(durationMs: number, fn: (t: number) => void): Promise<void> {
843 + return new Promise((resolve) => {
844 + if (!this.app) return resolve();
845 + const start = performance.now();
846 + const step = () => {
847 + if (this.destroyed || !this.app) return resolve();
848 + const t = Math.min(1, (performance.now() - start) / Math.max(1, durationMs));
849 + fn(t);
850 + if (t >= 1) {
851 + this.app.ticker.remove(step);
852 + resolve();
853 + }
854 + };
855 + this.app.ticker.add(step);
856 + });
857 + }
858 +}
859 +
860 +function hashString(s: string): number {
861 + let h = 2166136261;
862 + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
863 + return h >>> 0;
864 +}
865 +
866 +/** Deterministic PRNG for backdrop art only (never for outcomes). */
867 +function mulberry32(seed: number) {
868 + let a = seed >>> 0;
869 + return () => {
870 + a = (a + 0x6d2b79f5) >>> 0;
871 + let t = a;
872 + t = Math.imul(t ^ (t >>> 15), t | 1);
873 + t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
874 + return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
875 + };
876 +}
877 +
878 +function polygonRandom(g: Graphics, cx: number, cy: number, r: number, rnd: () => number): Graphics {
879 + const pts: number[] = [];
880 + const n = 6 + Math.floor(rnd() * 4);
881 + for (let i = 0; i < n; i++) {
882 + const a = (i / n) * Math.PI * 2;
883 + const rr = r * (0.7 + rnd() * 0.5);
884 + pts.push(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr);
885 + }
886 + return g.poly(pts);
887 +}
888 +
889 +export { easeInOut };
added apps/web/src/components/game/sound.ts +244 −0
@@ -0,0 +1,244 @@
1 +"use client";
2 +
3 +/**
4 + * Spinza procedural sound engine. Every sound is synthesised with WebAudio —
5 + * no sample files — so each game gets an original sonic identity from its
6 + * `ambience` preset. Volumes come from the player's saved settings.
7 + */
8 +
9 +type Ambience = string;
10 +
11 +interface Levels {
12 + master: number;
13 + music: number;
14 + effects: number;
15 + enabled: boolean;
16 +}
17 +
18 +const PRESETS: Record<string, { root: number; wave: OscillatorType; detune: number; filter: number; lfo: number; shimmer: boolean }> = {
19 + neon: { root: 55, wave: "sawtooth", detune: 7, filter: 700, lfo: 0.11, shimmer: true },
20 + space: { root: 41.2, wave: "sine", detune: 3, filter: 500, lfo: 0.05, shimmer: true },
21 + royal: { root: 65.4, wave: "triangle", detune: 5, filter: 900, lfo: 0.08, shimmer: false },
22 + heist: { root: 49, wave: "square", detune: 4, filter: 450, lfo: 0.13, shimmer: false },
23 + ice: { root: 73.4, wave: "sine", detune: 6, filter: 1200, lfo: 0.07, shimmer: true },
24 + fire: { root: 43.65, wave: "sawtooth", detune: 9, filter: 600, lfo: 0.16, shimmer: false },
25 + quantum: { root: 58.3, wave: "square", detune: 12, filter: 800, lfo: 0.21, shimmer: true },
26 + city: { root: 51.9, wave: "sawtooth", detune: 6, filter: 750, lfo: 0.12, shimmer: true },
27 + desert: { root: 46.2, wave: "triangle", detune: 4, filter: 650, lfo: 0.06, shimmer: false },
28 + arcade: { root: 82.4, wave: "square", detune: 8, filter: 1400, lfo: 0.25, shimmer: false },
29 + mine: { root: 38.9, wave: "sawtooth", detune: 5, filter: 400, lfo: 0.09, shimmer: false },
30 + race: { root: 61.7, wave: "sawtooth", detune: 10, filter: 900, lfo: 0.18, shimmer: false },
31 + dragon: { root: 36.7, wave: "triangle", detune: 7, filter: 550, lfo: 0.1, shimmer: false },
32 + ocean: { root: 43.65, wave: "sine", detune: 4, filter: 480, lfo: 0.05, shimmer: true },
33 + lunar: { root: 55, wave: "triangle", detune: 3, filter: 700, lfo: 0.06, shimmer: true },
34 + jungle: { root: 49, wave: "triangle", detune: 6, filter: 850, lfo: 0.14, shimmer: false },
35 + void: { root: 32.7, wave: "sine", detune: 2, filter: 380, lfo: 0.04, shimmer: true },
36 + reactor: { root: 58.3, wave: "square", detune: 9, filter: 620, lfo: 0.19, shimmer: false },
37 + obsidian: { root: 41.2, wave: "sine", detune: 1, filter: 420, lfo: 0.03, shimmer: false },
38 + spinza: { root: 55, wave: "triangle", detune: 8, filter: 900, lfo: 0.1, shimmer: true },
39 +};
40 +
41 +export class SoundEngine {
42 + private ctx: AudioContext | null = null;
43 + private master!: GainNode;
44 + private musicBus!: GainNode;
45 + private fxBus!: GainNode;
46 + private ambienceNodes: AudioNode[] = [];
47 + private levels: Levels = { master: 0.8, music: 0.6, effects: 0.8, enabled: true };
48 + private ambience: Ambience = "spinza";
49 + private started = false;
50 +
51 + setLevels(l: Partial<Levels>) {
52 + this.levels = { ...this.levels, ...l };
53 + if (this.ctx) {
54 + this.master.gain.setTargetAtTime(this.levels.enabled ? this.levels.master : 0, this.ctx.currentTime, 0.05);
55 + this.musicBus.gain.setTargetAtTime(this.levels.music * 0.35, this.ctx.currentTime, 0.1);
56 + this.fxBus.gain.setTargetAtTime(this.levels.effects, this.ctx.currentTime, 0.05);
57 + }
58 + }
59 +
60 + setAmbience(a: Ambience) {
61 + this.ambience = a;
62 + if (this.started) this.startAmbience();
63 + }
64 +
65 + /** Must be called from a user gesture. */
66 + unlock() {
67 + if (this.ctx) {
68 + if (this.ctx.state === "suspended") void this.ctx.resume();
69 + return;
70 + }
71 + const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
72 + if (!AC) return;
73 + this.ctx = new AC();
74 + this.master = this.ctx.createGain();
75 + this.master.gain.value = this.levels.enabled ? this.levels.master : 0;
76 + const comp = this.ctx.createDynamicsCompressor();
77 + comp.threshold.value = -18;
78 + comp.ratio.value = 4;
79 + this.master.connect(comp).connect(this.ctx.destination);
80 + this.musicBus = this.ctx.createGain();
81 + this.musicBus.gain.value = this.levels.music * 0.35;
82 + this.musicBus.connect(this.master);
83 + this.fxBus = this.ctx.createGain();
84 + this.fxBus.gain.value = this.levels.effects;
85 + this.fxBus.connect(this.master);
86 + this.started = true;
87 + this.startAmbience();
88 + }
89 +
90 + destroy() {
91 + this.stopAmbience();
92 + void this.ctx?.close();
93 + this.ctx = null;
94 + this.started = false;
95 + }
96 +
97 + /* ------------------------------------------------------------ ambience */
98 +
99 + private stopAmbience() {
100 + for (const n of this.ambienceNodes) {
101 + try {
102 + (n as OscillatorNode).stop?.();
103 + } catch {
104 + /* noop */
105 + }
106 + n.disconnect();
107 + }
108 + this.ambienceNodes = [];
109 + }
110 +
111 + private startAmbience() {
112 + if (!this.ctx) return;
113 + this.stopAmbience();
114 + const p = PRESETS[this.ambience] ?? PRESETS.spinza;
115 + const ctx = this.ctx;
116 + const filter = ctx.createBiquadFilter();
117 + filter.type = "lowpass";
118 + filter.frequency.value = p.filter;
119 + filter.Q.value = 0.8;
120 + const lfo = ctx.createOscillator();
121 + lfo.frequency.value = p.lfo;
122 + const lfoGain = ctx.createGain();
123 + lfoGain.gain.value = p.filter * 0.45;
124 + lfo.connect(lfoGain).connect(filter.frequency);
125 + lfo.start();
126 + const pad = ctx.createGain();
127 + pad.gain.value = 0;
128 + pad.gain.setTargetAtTime(1, ctx.currentTime, 2.5);
129 + filter.connect(pad).connect(this.musicBus);
130 + const voices = [p.root, p.root * 1.5, p.root * 2, p.root * 2.9966];
131 + voices.forEach((f, i) => {
132 + const o = ctx.createOscillator();
133 + o.type = i === 3 ? "sine" : p.wave;
134 + o.frequency.value = f;
135 + o.detune.value = (i % 2 === 0 ? 1 : -1) * p.detune;
136 + const g = ctx.createGain();
137 + g.gain.value = i === 0 ? 0.35 : i === 3 ? 0.08 : 0.18;
138 + o.connect(g).connect(filter);
139 + o.start();
140 + this.ambienceNodes.push(o, g);
141 + });
142 + if (p.shimmer) {
143 + const s = ctx.createOscillator();
144 + s.type = "sine";
145 + s.frequency.value = p.root * 8;
146 + const sg = ctx.createGain();
147 + sg.gain.value = 0.02;
148 + const trem = ctx.createOscillator();
149 + trem.frequency.value = 0.3;
150 + const tg = ctx.createGain();
151 + tg.gain.value = 0.02;
152 + trem.connect(tg).connect(sg.gain);
153 + trem.start();
154 + s.connect(sg).connect(filter);
155 + s.start();
156 + this.ambienceNodes.push(s, sg, trem, tg);
157 + }
158 + this.ambienceNodes.push(filter, lfo, lfoGain, pad);
159 + }
160 +
161 + /* -------------------------------------------------------------- effects */
162 +
163 + private blip(freq: number, dur: number, type: OscillatorType = "sine", gain = 0.5, slide = 0) {
164 + if (!this.ctx) return;
165 + const ctx = this.ctx;
166 + const o = ctx.createOscillator();
167 + o.type = type;
168 + o.frequency.setValueAtTime(freq, ctx.currentTime);
169 + if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), ctx.currentTime + dur);
170 + const g = ctx.createGain();
171 + g.gain.setValueAtTime(0, ctx.currentTime);
172 + g.gain.linearRampToValueAtTime(gain, ctx.currentTime + 0.005);
173 + g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur);
174 + o.connect(g).connect(this.fxBus);
175 + o.start();
176 + o.stop(ctx.currentTime + dur + 0.02);
177 + }
178 +
179 + private noise(dur: number, gain = 0.3, filterHz = 1200) {
180 + if (!this.ctx) return;
181 + const ctx = this.ctx;
182 + const buf = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * dur), ctx.sampleRate);
183 + const d = buf.getChannelData(0);
184 + for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length); // noise texture only, never game logic
185 + const src = ctx.createBufferSource();
186 + src.buffer = buf;
187 + const f = ctx.createBiquadFilter();
188 + f.type = "bandpass";
189 + f.frequency.value = filterHz;
190 + const g = ctx.createGain();
191 + g.gain.value = gain;
192 + src.connect(f).connect(g).connect(this.fxBus);
193 + src.start();
194 + }
195 +
196 + click() {
197 + this.blip(1800, 0.05, "square", 0.12);
198 + }
199 + spinStart() {
200 + this.noise(0.35, 0.2, 900);
201 + this.blip(220, 0.25, "triangle", 0.2, 180);
202 + }
203 + reelStop(index: number) {
204 + this.blip(160 + index * 18, 0.09, "triangle", 0.35, -60);
205 + this.noise(0.06, 0.15, 2200);
206 + }
207 + tick() {
208 + this.blip(2400, 0.02, "square", 0.05);
209 + }
210 + win(level: number) {
211 + // level 0..4 — arpeggio grows with size.
212 + const base = [523.25, 659.25, 783.99, 1046.5, 1318.5];
213 + const notes = base.slice(0, 2 + Math.min(3, level));
214 + notes.forEach((f, i) => setTimeout(() => this.blip(f, 0.35, "triangle", 0.3), i * 70));
215 + }
216 + bigWin() {
217 + [392, 523.25, 659.25, 783.99, 1046.5, 1318.5, 1567.98].forEach((f, i) => setTimeout(() => this.blip(f, 0.6, "sawtooth", 0.22), i * 110));
218 + setTimeout(() => this.noise(0.8, 0.25, 3000), 300);
219 + }
220 + cascade() {
221 + this.blip(880, 0.12, "sine", 0.25, 440);
222 + this.noise(0.12, 0.12, 1600);
223 + }
224 + coin() {
225 + this.blip(1567.98, 0.16, "triangle", 0.3, 400);
226 + }
227 + bonus() {
228 + [261.63, 329.63, 392, 523.25].forEach((f, i) => setTimeout(() => this.blip(f, 0.5, "triangle", 0.35), i * 120));
229 + this.noise(0.6, 0.2, 800);
230 + }
231 + jackpot() {
232 + for (let i = 0; i < 12; i++) setTimeout(() => this.blip(523.25 * Math.pow(1.0595, (i * 5) % 24), 0.4, "square", 0.2), i * 90);
233 + setTimeout(() => this.noise(1.2, 0.3, 2500), 200);
234 + }
235 + error() {
236 + this.blip(180, 0.25, "square", 0.2, -60);
237 + }
238 +}
239 +
240 +let engine: SoundEngine | null = null;
241 +export function getSound(): SoundEngine {
242 + if (!engine) engine = new SoundEngine();
243 + return engine;
244 +}
added apps/web/src/components/game/symbol-art.ts +269 −0
@@ -0,0 +1,269 @@
1 +import { Container, Graphics, Text, TextStyle } from "pixi.js";
2 +import type { SymbolStyle } from "@spinza/game-core/client";
3 +
4 +/**
5 + * Procedural symbol artwork. Every Spinza symbol is drawn from primitives —
6 + * no external assets — so each game keeps an original look while sharing
7 + * one renderer. `size` is the tile edge in px; art is centred on (0,0).
8 + */
9 +
10 +function hex(c: string): number {
11 + return parseInt(c.replace("#", ""), 16);
12 +}
13 +
14 +function lighten(c: string, amt: number): number {
15 + const n = hex(c);
16 + const r = Math.min(255, ((n >> 16) & 255) + Math.round(255 * amt));
17 + const g = Math.min(255, ((n >> 8) & 255) + Math.round(255 * amt));
18 + const b = Math.min(255, (n & 255) + Math.round(255 * amt));
19 + return (r << 16) | (g << 8) | b;
20 +}
21 +
22 +function darken(c: string, amt: number): number {
23 + const n = hex(c);
24 + const r = Math.max(0, ((n >> 16) & 255) - Math.round(255 * amt));
25 + const g = Math.max(0, ((n >> 8) & 255) - Math.round(255 * amt));
26 + const b = Math.max(0, (n & 255) - Math.round(255 * amt));
27 + return (r << 16) | (g << 8) | b;
28 +}
29 +
30 +function polygon(g: Graphics, cx: number, cy: number, r: number, sides: number, rot = -Math.PI / 2): Graphics {
31 + const pts: number[] = [];
32 + for (let i = 0; i < sides; i++) {
33 + const a = rot + (i * Math.PI * 2) / sides;
34 + pts.push(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
35 + }
36 + return g.poly(pts);
37 +}
38 +
39 +function star(g: Graphics, cx: number, cy: number, outer: number, inner: number, points = 5): Graphics {
40 + const pts: number[] = [];
41 + for (let i = 0; i < points * 2; i++) {
42 + const r = i % 2 === 0 ? outer : inner;
43 + const a = -Math.PI / 2 + (i * Math.PI) / points;
44 + pts.push(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
45 + }
46 + return g.poly(pts);
47 +}
48 +
49 +export interface SymbolArtOptions {
50 + size: number;
51 + /** Tier drives frame richness. */
52 + tier: "low" | "mid" | "high" | "premium" | "special";
53 + kind: string;
54 +}
55 +
56 +export function drawSymbol(style: SymbolStyle, opts: SymbolArtOptions): Container {
57 + const { size } = opts;
58 + const c = new Container();
59 + const main = hex(style.color);
60 + const accent = style.accent ? hex(style.accent) : lighten(style.color, 0.35);
61 + const glow = style.glow ?? 0.3;
62 + const r = size * 0.36; // icon radius
63 + const g = new Graphics();
64 +
65 + // Tile plate.
66 + const plate = new Graphics();
67 + const pad = size * 0.06;
68 + plate.roundRect(-size / 2 + pad, -size / 2 + pad, size - pad * 2, size - pad * 2, size * 0.16).fill({ color: 0x0b0d14, alpha: 0.72 });
69 + plate.roundRect(-size / 2 + pad, -size / 2 + pad, size - pad * 2, size - pad * 2, size * 0.16).stroke({ color: opts.tier === "premium" || opts.tier === "special" ? accent : 0xffffff, alpha: opts.tier === "premium" || opts.tier === "special" ? 0.45 : 0.08, width: Math.max(1, size * 0.012) });
70 + c.addChild(plate);
71 +
72 + // Glow halo.
73 + if (glow > 0) {
74 + const halo = new Graphics();
75 + halo.circle(0, 0, r * 1.25).fill({ color: main, alpha: 0.18 * glow });
76 + halo.circle(0, 0, r * 1.0).fill({ color: main, alpha: 0.16 * glow });
77 + c.addChild(halo);
78 + }
79 +
80 + switch (style.shape) {
81 + case "gem": {
82 + g.poly([0, -r, r * 0.85, -r * 0.25, r * 0.55, r, -r * 0.55, r, -r * 0.85, -r * 0.25]).fill(main);
83 + g.poly([0, -r, r * 0.85, -r * 0.25, 0, -r * 0.1, -r * 0.85, -r * 0.25]).fill({ color: accent, alpha: 0.85 });
84 + g.poly([0, -r * 0.1, r * 0.85, -r * 0.25, r * 0.55, r]).fill({ color: darken(style.color, 0.15) });
85 + g.poly([-r * 0.2, -r * 0.7, r * 0.15, -r * 0.55, -r * 0.35, -r * 0.35]).fill({ color: 0xffffff, alpha: 0.65 });
86 + break;
87 + }
88 + case "hex": {
89 + polygon(g, 0, 0, r, 6).fill(main);
90 + polygon(g, 0, 0, r * 0.68, 6).fill({ color: darken(style.color, 0.25) });
91 + polygon(g, 0, 0, r * 0.42, 6).fill(accent);
92 + if (style.label) c.addChild(label(style.label, size * 0.26, 0x0b0d14));
93 + break;
94 + }
95 + case "star": {
96 + star(g, 0, 0, r, r * 0.45).fill(main);
97 + star(g, 0, 0, r * 0.55, r * 0.25).fill({ color: 0xffffff, alpha: 0.85 });
98 + break;
99 + }
100 + case "circle": {
101 + g.circle(0, 0, r).fill(main);
102 + g.circle(-r * 0.3, -r * 0.3, r * 0.35).fill({ color: 0xffffff, alpha: 0.35 });
103 + g.circle(0, 0, r).stroke({ color: accent, width: size * 0.02 });
104 + break;
105 + }
106 + case "diamond": {
107 + g.poly([0, -r, r, 0, 0, r, -r, 0]).fill(main);
108 + g.poly([0, -r, r, 0, 0, 0]).fill({ color: accent, alpha: 0.8 });
109 + g.poly([0, 0, 0, r, -r, 0]).fill({ color: darken(style.color, 0.2) });
110 + break;
111 + }
112 + case "shield": {
113 + g.poly([0, -r, r * 0.85, -r * 0.65, r * 0.75, r * 0.15, 0, r, -r * 0.75, r * 0.15, -r * 0.85, -r * 0.65]).fill(main);
114 + g.poly([0, -r * 0.7, r * 0.5, -r * 0.5, r * 0.45, r * 0.05, 0, r * 0.6, -r * 0.45, r * 0.05, -r * 0.5, -r * 0.5]).fill({ color: darken(style.color, 0.28) });
115 + g.rect(-r * 0.08, -r * 0.5, r * 0.16, r * 0.9).fill(accent);
116 + g.rect(-r * 0.38, -r * 0.25, r * 0.76, r * 0.16).fill(accent);
117 + break;
118 + }
119 + case "bolt": {
120 + g.poly([r * 0.15, -r, -r * 0.55, r * 0.1, -r * 0.05, r * 0.1, -r * 0.25, r, r * 0.6, -r * 0.15, r * 0.05, -r * 0.15]).fill(main);
121 + g.poly([r * 0.1, -r * 0.8, -r * 0.3, r * 0.0, r * 0.0, 0]).fill({ color: 0xffffff, alpha: 0.5 });
122 + break;
123 + }
124 + case "ring": {
125 + g.circle(0, 0, r).fill(main);
126 + g.circle(0, 0, r * 0.62).cut();
127 + g.circle(0, 0, r * 0.62).stroke({ color: accent, width: size * 0.02, alpha: 0.9 });
128 + g.arc(0, 0, r * 0.81, -Math.PI * 0.9, -Math.PI * 0.3).stroke({ color: 0xffffff, width: size * 0.04, alpha: 0.55 });
129 + break;
130 + }
131 + case "chip": {
132 + g.roundRect(-r, -r * 0.75, r * 2, r * 1.5, r * 0.25).fill(main);
133 + g.roundRect(-r * 0.75, -r * 0.5, r * 1.5, r, r * 0.15).fill({ color: darken(style.color, 0.35) });
134 + for (let i = -2; i <= 2; i++) {
135 + g.rect(-r - r * 0.18, i * r * 0.28 - r * 0.06, r * 0.18, r * 0.12).fill(accent);
136 + g.rect(r, i * r * 0.28 - r * 0.06, r * 0.18, r * 0.12).fill(accent);
137 + }
138 + if (style.label) c.addChild(label(style.label, size * 0.22, accent));
139 + break;
140 + }
141 + case "crystal": {
142 + g.poly([0, -r, r * 0.5, -r * 0.3, r * 0.35, r * 0.8, -r * 0.35, r * 0.8, -r * 0.5, -r * 0.3]).fill(main);
143 + g.poly([0, -r, r * 0.5, -r * 0.3, 0, r * 0.1]).fill({ color: accent, alpha: 0.75 });
144 + g.poly([0, r * 0.1, r * 0.35, r * 0.8, -r * 0.35, r * 0.8]).fill({ color: darken(style.color, 0.2) });
145 + break;
146 + }
147 + case "letter": {
148 + g.roundRect(-r * 0.95, -r * 0.95, r * 1.9, r * 1.9, r * 0.3).fill({ color: darken(style.color, 0.45), alpha: 0.9 });
149 + g.roundRect(-r * 0.95, -r * 0.95, r * 1.9, r * 1.9, r * 0.3).stroke({ color: main, width: size * 0.025 });
150 + c.addChild(label(style.label ?? "?", size * 0.4, main, true));
151 + break;
152 + }
153 + case "vault": {
154 + g.circle(0, 0, r).fill(darken(style.color, 0.4));
155 + g.circle(0, 0, r).stroke({ color: main, width: size * 0.03 });
156 + g.circle(0, 0, r * 0.7).stroke({ color: main, width: size * 0.02, alpha: 0.7 });
157 + for (let i = 0; i < 6; i++) {
158 + const a = (i * Math.PI) / 3;
159 + g.moveTo(Math.cos(a) * r * 0.25, Math.sin(a) * r * 0.25).lineTo(Math.cos(a) * r * 0.62, Math.sin(a) * r * 0.62).stroke({ color: accent, width: size * 0.035 });
160 + }
161 + g.circle(0, 0, r * 0.22).fill(accent);
162 + break;
163 + }
164 + case "orb": {
165 + g.circle(0, 0, r).fill(main);
166 + g.circle(0, 0, r * 0.75).fill({ color: accent, alpha: 0.35 });
167 + g.circle(-r * 0.25, -r * 0.3, r * 0.25).fill({ color: 0xffffff, alpha: 0.5 });
168 + g.circle(0, 0, r).stroke({ color: accent, width: size * 0.02, alpha: 0.9 });
169 + if (style.label) c.addChild(label(style.label, size * 0.36, accent, true));
170 + break;
171 + }
172 + case "coin": {
173 + g.circle(0, 0, r).fill(main);
174 + g.circle(0, 0, r * 0.8).stroke({ color: darken(style.color, 0.25), width: size * 0.03 });
175 + g.circle(0, 0, r).stroke({ color: accent, width: size * 0.02 });
176 + c.addChild(label(style.label ?? "SC", size * 0.24, darken(style.color, 0.5), true));
177 + break;
178 + }
179 + case "skull": {
180 + g.circle(0, -r * 0.15, r * 0.8).fill(main);
181 + g.roundRect(-r * 0.45, r * 0.3, r * 0.9, r * 0.55, r * 0.15).fill(main);
182 + g.circle(-r * 0.3, -r * 0.2, r * 0.22).fill(0x0b0d14);
183 + g.circle(r * 0.3, -r * 0.2, r * 0.22).fill(0x0b0d14);
184 + g.poly([0, r * 0.05, r * 0.12, r * 0.3, -r * 0.12, r * 0.3]).fill(0x0b0d14);
185 + break;
186 + }
187 + case "flame": {
188 + g.poly([0, -r, r * 0.55, -r * 0.2, r * 0.7, r * 0.45, r * 0.3, r, -r * 0.3, r, -r * 0.7, r * 0.45, -r * 0.55, -r * 0.2]).fill(main);
189 + g.poly([0, -r * 0.4, r * 0.3, r * 0.1, r * 0.25, r * 0.6, -r * 0.25, r * 0.6, -r * 0.3, r * 0.1]).fill(accent);
190 + break;
191 + }
192 + case "snow": {
193 + for (let i = 0; i < 3; i++) {
194 + const a = (i * Math.PI) / 3;
195 + g.moveTo(Math.cos(a) * r, Math.sin(a) * r).lineTo(-Math.cos(a) * r, -Math.sin(a) * r).stroke({ color: main, width: size * 0.035 });
196 + for (const s of [1, -1]) {
197 + const bx = Math.cos(a) * r * 0.6 * s;
198 + const by = Math.sin(a) * r * 0.6 * s;
199 + g.moveTo(bx, by).lineTo(bx + Math.cos(a + 0.6) * r * 0.3 * s, by + Math.sin(a + 0.6) * r * 0.3 * s).stroke({ color: accent, width: size * 0.025 });
200 + g.moveTo(bx, by).lineTo(bx + Math.cos(a - 0.6) * r * 0.3 * s, by + Math.sin(a - 0.6) * r * 0.3 * s).stroke({ color: accent, width: size * 0.025 });
201 + }
202 + }
203 + g.circle(0, 0, r * 0.14).fill(0xffffff);
204 + break;
205 + }
206 + case "eye": {
207 + g.ellipse(0, 0, r, r * 0.6).fill(main);
208 + g.circle(0, 0, r * 0.42).fill(darken(style.color, 0.5));
209 + g.circle(0, 0, r * 0.2).fill(accent);
210 + g.circle(r * 0.12, -r * 0.14, r * 0.07).fill(0xffffff);
211 + break;
212 + }
213 + case "moon": {
214 + g.circle(0, 0, r).fill(main);
215 + g.circle(r * 0.45, -r * 0.2, r * 0.78).fill({ color: 0x0b0d14, alpha: 0.85 });
216 + g.circle(-r * 0.4, r * 0.3, r * 0.12).fill({ color: darken(style.color, 0.2) });
217 + break;
218 + }
219 + case "leaf": {
220 + g.moveTo(0, r).bezierCurveTo(-r * 1.1, r * 0.2, -r * 0.6, -r * 0.9, 0, -r).bezierCurveTo(r * 0.6, -r * 0.9, r * 1.1, r * 0.2, 0, r).fill(main);
221 + g.moveTo(0, r * 0.9).lineTo(0, -r * 0.8).stroke({ color: accent, width: size * 0.02 });
222 + break;
223 + }
224 + case "atom": {
225 + g.circle(0, 0, r * 0.2).fill(accent);
226 + for (let i = 0; i < 3; i++) {
227 + const e = new Graphics();
228 + e.ellipse(0, 0, r, r * 0.38).stroke({ color: main, width: size * 0.025 });
229 + e.rotation = (i * Math.PI) / 3;
230 + c.addChild(e);
231 + }
232 + break;
233 + }
234 + case "crown": {
235 + g.poly([-r, r * 0.6, -r, -r * 0.4, -r * 0.5, r * 0.05, 0, -r, r * 0.5, r * 0.05, r, -r * 0.4, r, r * 0.6]).fill(main);
236 + g.rect(-r, r * 0.4, r * 2, r * 0.25).fill(darken(style.color, 0.3));
237 + g.circle(0, -r * 0.95, r * 0.12).fill(accent);
238 + g.circle(-r, -r * 0.4, r * 0.1).fill(accent);
239 + g.circle(r, -r * 0.4, r * 0.1).fill(accent);
240 + break;
241 + }
242 + case "wave": {
243 + for (let k = -1; k <= 1; k++) {
244 + const y = k * r * 0.5;
245 + g.moveTo(-r, y).bezierCurveTo(-r * 0.5, y - r * 0.4, -r * 0.2, y + r * 0.4, 0, y).bezierCurveTo(r * 0.2, y - r * 0.4, r * 0.5, y + r * 0.4, r, y).stroke({ color: k === 0 ? accent : main, width: size * 0.04, alpha: k === 0 ? 1 : 0.8 });
246 + }
247 + break;
248 + }
249 + case "cube": {
250 + g.poly([0, -r, r * 0.87, -r * 0.5, r * 0.87, r * 0.5, 0, r, -r * 0.87, r * 0.5, -r * 0.87, -r * 0.5]).fill(main);
251 + g.poly([0, -r, r * 0.87, -r * 0.5, 0, 0, -r * 0.87, -r * 0.5]).fill(accent);
252 + g.poly([0, 0, r * 0.87, -r * 0.5, r * 0.87, r * 0.5, 0, r]).fill(darken(style.color, 0.3));
253 + break;
254 + }
255 + }
256 + c.addChild(g);
257 + return c;
258 +}
259 +
260 +function label(text: string, fontSize: number, color: number, bold = true): Text {
261 + const t = new Text({
262 + text,
263 + style: new TextStyle({ fontFamily: "Geist, Inter, system-ui, sans-serif", fontSize, fontWeight: bold ? "800" : "600", fill: color, letterSpacing: 1 }),
264 + });
265 + t.anchor.set(0.5);
266 + return t;
267 +}
268 +
269 +export { hex as hexColor, lighten, darken };
added apps/web/src/components/game/types.ts +46 −0
@@ -0,0 +1,46 @@
1 +import type { GamePresentation, PayModel, SpinStep, SymbolKind, SymbolStyle, SymbolTier } from "@spinza/game-core/client";
2 +
3 +/** Shape returned by GET /api/games/:slug → `definition` (see apps/api clientDefinition). */
4 +export interface ClientDefinition {
5 + slug: string;
6 + name: string;
7 + version: string;
8 + grid: { reels: number; rows: number };
9 + payModel: PayModel;
10 + symbols: { id: string; name: string; kind: SymbolKind; tier: SymbolTier; style: SymbolStyle }[];
11 + wild: { id: string; expanding: boolean; sticky: boolean; multiplier: boolean; exploding: boolean; moving: boolean } | null;
12 + scatter: { id: string; triggers: Record<number, number> } | null;
13 + cascades: boolean;
14 + spinCollect: { symbolId: string; ladder: number[] } | null;
15 + holdRespin: { symbolId: string; respins: number; jackpots: Record<string, number> } | null;
16 + meter: { id: string; name: string; symbolId: string; max: number; label: string } | null;
17 + heat: { max: number; ladder: number[]; bandSize: number; label: string } | null;
18 + mystery: { symbolId: string } | null;
19 + quantum: boolean;
20 + dynamicGrid: { sequence: [number, number][] } | null;
21 + jackpot: { tiers: { id: string; multiplier: number }[] } | null;
22 + pickBonuses: { id: string; name: string; cells: number; picks: number }[];
23 + minBet: number;
24 + maxBet: number;
25 + maxMultiplier: number;
26 + volatility: string;
27 + presentation: GamePresentation;
28 + featureNames: string[];
29 +}
30 +
31 +export interface ClientOutcome {
32 + game: string;
33 + version: string;
34 + bet: number;
35 + totalWin: number;
36 + multiplier: number;
37 + capped: boolean;
38 + steps: SpinStep[];
39 + features: string[];
40 + freeSpinsTriggered: boolean;
41 + bonusTriggered: boolean;
42 + jackpot: { tier: string; amount: number } | null;
43 + stateAfter: { meters: Record<string, number>; meterLevels: Record<string, number>; heat: number; rounds: number };
44 +}
45 +
46 +export type { SpinStep };
added apps/web/src/components/lobby/game-art.tsx +760 −0
@@ -0,0 +1,760 @@
1 +import * as React from "react";
2 +import { cn } from "@/lib/utils";
3 +
4 +/* ------------------------------------------------------------------------
5 + Spinza key art — original procedural artwork, rendered as inline SVG.
6 + Deterministic per slug (seeded PRNG, no Math.random at render).
7 + ------------------------------------------------------------------------ */
8 +
9 +export type ArtVariant = "card" | "hero" | "banner" | "tile";
10 +
11 +export interface ArtPalette {
12 + primary: string;
13 + secondary: string;
14 + glow: string;
15 + bg: string;
16 +}
17 +
18 +export interface GameArtProps {
19 + slug: string;
20 + name: string;
21 + palette: ArtPalette;
22 + backdrop?: string;
23 + particles?: string;
24 + variant?: ArtVariant;
25 + className?: string;
26 + /** Render the game name inside the artwork (default true). */
27 + showName?: boolean;
28 + /** Decorative by default; pass a label to expose the art to assistive tech. */
29 + label?: string;
30 +}
31 +
32 +/** Presentation hints per game (the public /api/games payload does not carry them). */
33 +export const GAME_PRESENTATION: Record<string, { backdrop: string; particles: string }> = {
34 + "arctic-fortune": { backdrop: "aurora", particles: "snow" },
35 + "cosmic-collapse": { backdrop: "nebula", particles: "stars" },
36 + "deep-treasure": { backdrop: "abyss", particles: "bubbles" },
37 + "diamond-heist": { backdrop: "vault", particles: "diamonds" },
38 + "dragon-core": { backdrop: "core", particles: "fire" },
39 + "golden-emperor": { backdrop: "pillars", particles: "coins" },
40 + "inferno-reels": { backdrop: "lava", particles: "fire" },
41 + "lucky-circuit": { backdrop: "arcade", particles: "confetti" },
42 + "midnight-tokyo": { backdrop: "skyline", particles: "sparks" },
43 + "moonbase-77": { backdrop: "moon", particles: "stars" },
44 + "neon-vault": { backdrop: "vault", particles: "sparks" },
45 + obsidian: { backdrop: "minimal", particles: "dust" },
46 + "pharaoh-protocol": { backdrop: "pyramid", particles: "dust" },
47 + "quantum-jackpot": { backdrop: "circuit", particles: "energy" },
48 + "reel-reactor": { backdrop: "reactor", particles: "energy" },
49 + "royal-circuit": { backdrop: "track", particles: "sparks" },
50 + "spinza-original": { backdrop: "universe", particles: "energy" },
51 + "void-miner": { backdrop: "asteroids", particles: "dust" },
52 + "wild-temple": { backdrop: "temple", particles: "dust" },
53 + "zero-gravity": { backdrop: "gravity", particles: "stars" },
54 +};
55 +
56 +const BACKDROPS = ["vault", "nebula", "pillars", "aurora", "lava", "circuit", "skyline", "pyramid", "arcade", "asteroids", "track", "reactor", "abyss", "moon", "temple", "gravity", "core", "minimal", "universe", "grid"] as const;
57 +type Backdrop = (typeof BACKDROPS)[number];
58 +
59 +const DIMS: Record<ArtVariant, { w: number; h: number; pad: number; maxFont: number; oneLine: boolean }> = {
60 + card: { w: 360, h: 480, pad: 24, maxFont: 54, oneLine: false },
61 + tile: { w: 240, h: 240, pad: 16, maxFont: 30, oneLine: false },
62 + hero: { w: 1600, h: 900, pad: 72, maxFont: 132, oneLine: true },
63 + banner: { w: 1200, h: 400, pad: 48, maxFont: 84, oneLine: true },
64 +};
65 +
66 +/* ------------------------------------------------------------ seeded PRNG */
67 +
68 +function hashString(s: string): number {
69 + let h = 0x811c9dc5;
70 + for (let i = 0; i < s.length; i++) {
71 + h ^= s.charCodeAt(i);
72 + h = Math.imul(h, 0x01000193);
73 + }
74 + return h >>> 0;
75 +}
76 +
77 +function mulberry32(seed: number): () => number {
78 + let a = seed >>> 0;
79 + return () => {
80 + a = (a + 0x6d2b79f5) >>> 0;
81 + let t = a;
82 + t = Math.imul(t ^ (t >>> 15), t | 1);
83 + t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
84 + return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
85 + };
86 +}
87 +
88 +interface Ctx {
89 + W: number;
90 + H: number;
91 + rnd: () => number;
92 + p: ArtPalette;
93 + id: string;
94 +}
95 +
96 +const r2 = (n: number) => Math.round(n * 100) / 100;
97 +const between = (rnd: () => number, a: number, b: number) => a + (b - a) * rnd();
98 +
99 +/* ------------------------------------------------------------------ motifs */
100 +
101 +function Vault({ W, H, rnd, p, id }: Ctx) {
102 + const cx = W * 0.62;
103 + const cy = H * 0.42;
104 + const maxR = Math.max(W, H) * 0.55;
105 + const rings: React.ReactNode[] = [];
106 + for (let i = 1; i <= 7; i++) {
107 + const r = (maxR / 7) * i;
108 + rings.push(<circle key={`r${i}`} cx={cx} cy={cy} r={r2(r)} fill="none" stroke={i % 2 ? p.primary : p.secondary} strokeOpacity={r2(0.06 + (7 - i) * 0.035)} strokeWidth={i === 3 ? 6 : 1.5} />);
109 + if (i === 3 || i === 5) {
110 + for (let k = 0; k < 8; k++) {
111 + const a = (Math.PI * 2 * k) / 8 + rnd() * 0.2;
112 + rings.push(<circle key={`b${i}${k}`} cx={r2(cx + Math.cos(a) * r)} cy={r2(cy + Math.sin(a) * r)} r={i === 3 ? 4 : 2.5} fill={p.glow} fillOpacity={0.7} />);
113 + }
114 + }
115 + }
116 + const step = W / 8;
117 + const grid: React.ReactNode[] = [];
118 + for (let x = step / 2; x < W; x += step) grid.push(<line key={`gx${x}`} x1={r2(x)} y1={0} x2={r2(x)} y2={H} stroke={p.primary} strokeOpacity={0.05} />);
119 + for (let y = step / 2; y < H; y += step) grid.push(<line key={`gy${y}`} x1={0} y1={r2(y)} x2={W} y2={r2(y)} stroke={p.primary} strokeOpacity={0.05} />);
120 + return (
121 + <g>
122 + {grid}
123 + {rings}
124 + <circle cx={cx} cy={cy} r={r2(maxR / 14)} fill={`url(#${id}-glow)`} />
125 + <circle cx={cx} cy={cy} r={r2(maxR / 28)} fill={p.glow} fillOpacity={0.9} />
126 + </g>
127 + );
128 +}
129 +
130 +function Nebula({ W, H, rnd, p, id }: Ctx) {
131 + const blobs: React.ReactNode[] = [];
132 + for (let i = 0; i < 5; i++) {
133 + blobs.push(<ellipse key={i} cx={r2(between(rnd, W * 0.2, W * 0.8))} cy={r2(between(rnd, H * 0.15, H * 0.7))} rx={r2(between(rnd, W * 0.18, W * 0.42))} ry={r2(between(rnd, H * 0.1, H * 0.28))} fill={i % 2 ? p.primary : p.secondary} fillOpacity={r2(between(rnd, 0.18, 0.34))} transform={`rotate(${r2(between(rnd, -30, 30))} ${W / 2} ${H / 2})`} filter={`url(#${id}-blur)`} />);
134 + }
135 + const stars: React.ReactNode[] = [];
136 + for (let i = 0; i < 60; i++) {
137 + const big = rnd() > 0.85;
138 + stars.push(<circle key={i} cx={r2(rnd() * W)} cy={r2(rnd() * H)} r={big ? r2(between(rnd, 1.6, 2.6)) : r2(between(rnd, 0.5, 1.2))} fill={big ? p.glow : "#fff"} fillOpacity={r2(between(rnd, 0.35, 0.95))} />);
139 + }
140 + return (
141 + <g>
142 + {blobs}
143 + {stars}
144 + </g>
145 + );
146 +}
147 +
148 +function Pillars({ W, H, rnd, p, id }: Ctx) {
149 + const n = W > H ? 9 : 5;
150 + const gap = W / n;
151 + const cols: React.ReactNode[] = [];
152 + for (let i = 0; i < n; i++) {
153 + const cw = gap * 0.42;
154 + const x = gap * i + (gap - cw) / 2;
155 + const top = H * between(rnd, 0.12, 0.22);
156 + cols.push(
157 + <g key={i}>
158 + <rect x={r2(x)} y={r2(top)} width={r2(cw)} height={r2(H - top)} fill={`url(#${id}-col)`} />
159 + <rect x={r2(x - cw * 0.18)} y={r2(top - 10)} width={r2(cw * 1.36)} height={12} fill={p.primary} fillOpacity={0.5} />
160 + <rect x={r2(x + cw * 0.3)} y={r2(top)} width={2} height={r2(H - top)} fill={p.glow} fillOpacity={0.12} />
161 + </g>,
162 + );
163 + }
164 + return (
165 + <g>
166 + <circle cx={W * 0.5} cy={H * 0.3} r={Math.min(W, H) * 0.22} fill={`url(#${id}-glow)`} />
167 + {cols}
168 + <rect x={0} y={H * 0.88} width={W} height={H * 0.12} fill={p.secondary} fillOpacity={0.25} />
169 + </g>
170 + );
171 +}
172 +
173 +function Aurora({ W, H, rnd, p, id }: Ctx) {
174 + const bands: React.ReactNode[] = [];
175 + for (let i = 0; i < 4; i++) {
176 + const y = H * (0.2 + i * 0.14);
177 + const amp = H * between(rnd, 0.04, 0.1);
178 + const d = `M${-W * 0.1} ${r2(y)} C ${r2(W * 0.2)} ${r2(y - amp)}, ${r2(W * 0.35)} ${r2(y + amp)}, ${r2(W * 0.55)} ${r2(y)} S ${r2(W * 0.9)} ${r2(y - amp)}, ${r2(W * 1.1)} ${r2(y + amp * 0.5)}`;
179 + bands.push(<path key={i} d={d} fill="none" stroke={i % 2 ? p.primary : p.secondary} strokeOpacity={r2(0.55 - i * 0.08)} strokeWidth={r2(H * between(rnd, 0.05, 0.09))} strokeLinecap="round" filter={`url(#${id}-blur)`} />);
180 + }
181 + const stars: React.ReactNode[] = [];
182 + for (let i = 0; i < 30; i++) stars.push(<circle key={i} cx={r2(rnd() * W)} cy={r2(rnd() * H * 0.5)} r={r2(between(rnd, 0.6, 1.6))} fill="#fff" fillOpacity={r2(between(rnd, 0.3, 0.9))} />);
183 + const peaks: number[] = [];
184 + for (let x = 0; x <= W; x += W / 9) peaks.push(H * between(rnd, 0.72, 0.88));
185 + const ridge = `M0 ${H} ` + peaks.map((y, i) => `L${r2((i * W) / 9)} ${r2(y)}`).join(" ") + ` L${W} ${H} Z`;
186 + return (
187 + <g>
188 + {stars}
189 + {bands}
190 + <path d={ridge} fill={p.bg} fillOpacity={0.95} />
191 + <path d={ridge} fill={p.primary} fillOpacity={0.08} />
192 + </g>
193 + );
194 +}
195 +
196 +function Lava({ W, H, rnd, p, id }: Ctx) {
197 + const cracks: React.ReactNode[] = [];
198 + for (let i = 0; i < 6; i++) {
199 + let x = between(rnd, W * 0.05, W * 0.95);
200 + let y = H;
201 + const pts = [`M${r2(x)} ${r2(y)}`];
202 + while (y > H * 0.15) {
203 + x += between(rnd, -W * 0.08, W * 0.08);
204 + y -= between(rnd, H * 0.06, H * 0.14);
205 + pts.push(`L${r2(x)} ${r2(y)}`);
206 + }
207 + const d = pts.join(" ");
208 + cracks.push(<path key={`g${i}`} d={d} fill="none" stroke={p.glow} strokeOpacity={0.55} strokeWidth={8} strokeLinecap="round" strokeLinejoin="round" filter={`url(#${id}-blur)`} />);
209 + cracks.push(<path key={`c${i}`} d={d} fill="none" stroke={p.primary} strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" />);
210 + }
211 + return (
212 + <g>
213 + <rect x={0} y={H * 0.6} width={W} height={H * 0.4} fill={`url(#${id}-heat)`} />
214 + {cracks}
215 + </g>
216 + );
217 +}
218 +
219 +function Circuit({ W, H, rnd, p }: Ctx) {
220 + const traces: React.ReactNode[] = [];
221 + const step = Math.min(W, H) / 10;
222 + for (let i = 0; i < 16; i++) {
223 + let x = Math.round(between(rnd, 0, W / step)) * step;
224 + let y = Math.round(between(rnd, 0, H / step)) * step;
225 + const pts = [`M${r2(x)} ${r2(y)}`];
226 + const segs = 2 + Math.floor(rnd() * 3);
227 + for (let s = 0; s < segs; s++) {
228 + const horiz = rnd() > 0.5;
229 + const len = step * (1 + Math.floor(rnd() * 3));
230 + if (horiz) x += rnd() > 0.5 ? len : -len;
231 + else y += rnd() > 0.5 ? len : -len;
232 + pts.push(`L${r2(x)} ${r2(y)}`);
233 + }
234 + const color = i % 3 === 0 ? p.secondary : p.primary;
235 + traces.push(<path key={`t${i}`} d={pts.join(" ")} fill="none" stroke={color} strokeOpacity={0.35} strokeWidth={2} strokeLinejoin="round" />);
236 + traces.push(<circle key={`p${i}`} cx={r2(x)} cy={r2(y)} r={4} fill={p.bg} stroke={color} strokeWidth={2} />);
237 + }
238 + return <g>{traces}</g>;
239 +}
240 +
241 +function Skyline({ W, H, rnd, p, id }: Ctx) {
242 + const buildings: React.ReactNode[] = [];
243 + let x = -10;
244 + let i = 0;
245 + while (x < W + 10) {
246 + const bw = between(rnd, W * 0.05, W * 0.14);
247 + const bh = H * between(rnd, 0.25, 0.7);
248 + const y = H - bh;
249 + buildings.push(<rect key={`b${i}`} x={r2(x)} y={r2(y)} width={r2(bw)} height={r2(bh)} fill={i % 2 ? "#0a0a12" : "#0e0d18"} stroke={p.primary} strokeOpacity={0.35} strokeWidth={1} />);
250 + const cols = Math.max(1, Math.floor(bw / 14));
251 + const rows = Math.floor(bh / 18);
252 + for (let c = 0; c < cols; c++) {
253 + for (let r = 0; r < rows; r++) {
254 + if (rnd() > 0.45) continue;
255 + buildings.push(<rect key={`w${i}-${c}-${r}`} x={r2(x + 5 + c * 14)} y={r2(y + 8 + r * 18)} width={5} height={7} fill={rnd() > 0.7 ? p.secondary : p.primary} fillOpacity={r2(between(rnd, 0.4, 0.95))} />);
256 + }
257 + }
258 + if (rnd() > 0.7) buildings.push(<line key={`a${i}`} x1={r2(x + bw / 2)} y1={r2(y)} x2={r2(x + bw / 2)} y2={r2(y - H * 0.08)} stroke={p.primary} strokeOpacity={0.7} strokeWidth={1.5} />);
259 + x += bw + between(rnd, 4, 14);
260 + i++;
261 + }
262 + return (
263 + <g>
264 + <circle cx={W * 0.72} cy={H * 0.28} r={Math.min(W, H) * 0.14} fill={`url(#${id}-glow)`} />
265 + <circle cx={W * 0.72} cy={H * 0.28} r={Math.min(W, H) * 0.07} fill={p.primary} fillOpacity={0.55} />
266 + {buildings}
267 + <rect x={0} y={H * 0.995} width={W} height={H * 0.005} fill={p.secondary} />
268 + </g>
269 + );
270 +}
271 +
272 +function Pyramid({ W, H, rnd, p, id }: Ctx) {
273 + const tri = (cx: number, base: number, h: number, key: string, fill: string, op: number) => <polygon key={key} points={`${r2(cx - base / 2)},${r2(H * 0.86)} ${r2(cx)},${r2(H * 0.86 - h)} ${r2(cx + base / 2)},${r2(H * 0.86)}`} fill={fill} fillOpacity={op} stroke={p.primary} strokeOpacity={0.4} />;
274 + const rays: React.ReactNode[] = [];
275 + for (let i = 0; i < 14; i++) {
276 + const a = -Math.PI * 0.95 + (i / 13) * Math.PI * 0.9;
277 + rays.push(<line key={i} x1={W * 0.5} y1={H * 0.36} x2={r2(W * 0.5 + Math.cos(a) * W)} y2={r2(H * 0.36 + Math.sin(a) * W)} stroke={p.primary} strokeOpacity={0.06 + (rnd() > 0.5 ? 0.05 : 0)} strokeWidth={1.5} />);
278 + }
279 + return (
280 + <g>
281 + {rays}
282 + <circle cx={W * 0.5} cy={H * 0.36} r={Math.min(W, H) * 0.22} fill={`url(#${id}-glow)`} />
283 + <circle cx={W * 0.5} cy={H * 0.36} r={Math.min(W, H) * 0.1} fill={p.primary} fillOpacity={0.9} />
284 + {tri(W * 0.2, W * 0.5, H * 0.34, "l", "#120c05", 1)}
285 + {tri(W * 0.82, W * 0.55, H * 0.4, "r", "#0f0a04", 1)}
286 + {tri(W * 0.5, W * 0.72, H * 0.56, "c", "#1a1208", 1)}
287 + <polygon points={`${r2(W * 0.5)},${r2(H * 0.3)} ${r2(W * 0.86)},${r2(H * 0.86)} ${r2(W * 0.5)},${r2(H * 0.86)}`} fill={p.primary} fillOpacity={0.12} />
288 + <rect x={0} y={H * 0.86} width={W} height={H * 0.14} fill={p.secondary} fillOpacity={0.1} />
289 + </g>
290 + );
291 +}
292 +
293 +function Arcade({ W, H, rnd, p }: Ctx) {
294 + const size = Math.min(W, H) / 12;
295 + const cells: React.ReactNode[] = [];
296 + const colors = [p.primary, p.secondary, p.glow];
297 + for (let y = 0; y < H; y += size) {
298 + for (let x = 0; x < W; x += size) {
299 + const r = rnd();
300 + if (r > 0.22) continue;
301 + cells.push(<rect key={`${x}-${y}`} x={r2(x + 2)} y={r2(y + 2)} width={r2(size - 4)} height={r2(size - 4)} rx={3} fill={colors[Math.floor(rnd() * 3)]} fillOpacity={r2(between(rnd, 0.25, 0.85))} />);
302 + }
303 + }
304 + return (
305 + <g>
306 + {cells}
307 + <rect x={W * 0.3} y={H * 0.36} width={W * 0.4} height={size * 0.6} rx={size * 0.3} fill={p.primary} fillOpacity={0.9} />
308 + <rect x={W * 0.3 + size * 0.3} y={H * 0.36 + size * 0.15} width={W * 0.4 - size * 0.6} height={size * 0.3} rx={size * 0.15} fill="#fff" fillOpacity={0.35} />
309 + </g>
310 + );
311 +}
312 +
313 +function Asteroids({ W, H, rnd, p, id }: Ctx) {
314 + const rocks: React.ReactNode[] = [];
315 + for (let i = 0; i < 9; i++) {
316 + const cx = between(rnd, 0, W);
317 + const cy = between(rnd, 0, H * 0.8);
318 + const rad = between(rnd, Math.min(W, H) * 0.03, Math.min(W, H) * 0.14);
319 + const n = 7 + Math.floor(rnd() * 4);
320 + const pts: string[] = [];
321 + for (let k = 0; k < n; k++) {
322 + const a = (k / n) * Math.PI * 2;
323 + const rr = rad * between(rnd, 0.72, 1.1);
324 + pts.push(`${r2(cx + Math.cos(a) * rr)},${r2(cy + Math.sin(a) * rr)}`);
325 + }
326 + rocks.push(<polygon key={i} points={pts.join(" ")} fill={i % 3 === 0 ? "#171226" : "#100c1c"} stroke={p.primary} strokeOpacity={0.45} strokeWidth={1.5} strokeLinejoin="round" />);
327 + if (rnd() > 0.5) rocks.push(<circle key={`v${i}`} cx={r2(cx + rad * 0.2)} cy={r2(cy - rad * 0.1)} r={r2(rad * 0.18)} fill={p.secondary} fillOpacity={0.8} />);
328 + }
329 + const stars: React.ReactNode[] = [];
330 + for (let i = 0; i < 40; i++) stars.push(<circle key={i} cx={r2(rnd() * W)} cy={r2(rnd() * H)} r={r2(between(rnd, 0.5, 1.3))} fill="#fff" fillOpacity={r2(between(rnd, 0.3, 0.8))} />);
331 + return (
332 + <g>
333 + {stars}
334 + <circle cx={W * 0.5} cy={H * 0.45} r={Math.min(W, H) * 0.2} fill={`url(#${id}-glow)`} />
335 + {rocks}
336 + </g>
337 + );
338 +}
339 +
340 +function Track({ W, H, p }: Ctx) {
341 + const lanes: React.ReactNode[] = [];
342 + const cx = W * 1.05;
343 + const cy = H * 0.55;
344 + for (let i = 0; i < 6; i++) {
345 + const r = Math.max(W, H) * (0.28 + i * 0.13);
346 + lanes.push(<circle key={`l${i}`} cx={cx} cy={cy} r={r2(r)} fill="none" stroke={i % 5 === 0 ? p.primary : "#ffffff"} strokeOpacity={i % 5 === 0 ? 0.5 : 0.12} strokeWidth={i % 5 === 0 ? 5 : 2} />);
347 + if (i > 0 && i < 5) lanes.push(<circle key={`d${i}`} cx={cx} cy={cy} r={r2(r - Math.max(W, H) * 0.065)} fill="none" stroke="#fff" strokeOpacity={0.18} strokeWidth={2} strokeDasharray="18 22" />);
348 + }
349 + const kerb: React.ReactNode[] = [];
350 + const kr = Math.max(W, H) * 0.28;
351 + for (let k = 0; k < 40; k++) {
352 + const a = Math.PI * 0.5 + (k / 40) * Math.PI;
353 + kerb.push(<circle key={k} cx={r2(cx + Math.cos(a) * kr)} cy={r2(cy + Math.sin(a) * kr)} r={4} fill={k % 2 ? p.secondary : "#f5f5f5"} fillOpacity={0.9} />);
354 + }
355 + return (
356 + <g>
357 + {lanes}
358 + {kerb}
359 + <line x1={W * 0.05} y1={H * 0.2} x2={W * 0.55} y2={H * 0.2} stroke={p.glow} strokeOpacity={0.5} strokeWidth={2} strokeDasharray="4 10" />
360 + </g>
361 + );
362 +}
363 +
364 +function hexPoints(cx: number, cy: number, r: number, rot = 0): string {
365 + const pts: string[] = [];
366 + for (let k = 0; k < 6; k++) {
367 + const a = rot + (k / 6) * Math.PI * 2;
368 + pts.push(`${r2(cx + Math.cos(a) * r)},${r2(cy + Math.sin(a) * r)}`);
369 + }
370 + return pts.join(" ");
371 +}
372 +
373 +function Reactor({ W, H, rnd, p, id }: Ctx) {
374 + const cx = W * 0.5;
375 + const cy = H * 0.42;
376 + const base = Math.min(W, H) * 0.12;
377 + const rings: React.ReactNode[] = [];
378 + for (let i = 1; i <= 4; i++) rings.push(<polygon key={i} points={hexPoints(cx, cy, base * i, Math.PI / 6)} fill="none" stroke={i % 2 ? p.primary : p.secondary} strokeOpacity={r2(0.5 - i * 0.09)} strokeWidth={i === 1 ? 3 : 1.5} />);
379 + const spokes: React.ReactNode[] = [];
380 + for (let k = 0; k < 12; k++) {
381 + const a = (k / 12) * Math.PI * 2;
382 + const len = base * between(rnd, 3.2, 5);
383 + spokes.push(<line key={k} x1={r2(cx + Math.cos(a) * base * 1.2)} y1={r2(cy + Math.sin(a) * base * 1.2)} x2={r2(cx + Math.cos(a) * len)} y2={r2(cy + Math.sin(a) * len)} stroke={p.primary} strokeOpacity={0.18} strokeWidth={1.5} />);
384 + }
385 + const cells: React.ReactNode[] = [];
386 + for (let i = 0; i < 14; i++) {
387 + const a = rnd() * Math.PI * 2;
388 + const d = base * between(rnd, 3.5, 6);
389 + cells.push(<polygon key={i} points={hexPoints(cx + Math.cos(a) * d, cy + Math.sin(a) * d, base * between(rnd, 0.25, 0.5), Math.PI / 6)} fill={p.primary} fillOpacity={r2(between(rnd, 0.08, 0.25))} />);
390 + }
391 + return (
392 + <g>
393 + {cells}
394 + {spokes}
395 + <circle cx={cx} cy={cy} r={base * 1.6} fill={`url(#${id}-glow)`} />
396 + {rings}
397 + <polygon points={hexPoints(cx, cy, base * 0.55, Math.PI / 6)} fill={p.glow} fillOpacity={0.9} />
398 + </g>
399 + );
400 +}
401 +
402 +function Abyss({ W, H, rnd, p, id }: Ctx) {
403 + const bubbles: React.ReactNode[] = [];
404 + for (let i = 0; i < 26; i++) {
405 + const r = between(rnd, 2, Math.min(W, H) * 0.035);
406 + const cx = rnd() * W;
407 + const cy = between(rnd, H * 0.2, H);
408 + bubbles.push(<circle key={`b${i}`} cx={r2(cx)} cy={r2(cy)} r={r2(r)} fill="none" stroke={p.primary} strokeOpacity={r2(between(rnd, 0.25, 0.6))} strokeWidth={1.2} />);
409 + bubbles.push(<circle key={`h${i}`} cx={r2(cx - r * 0.35)} cy={r2(cy - r * 0.35)} r={r2(r * 0.22)} fill="#fff" fillOpacity={0.5} />);
410 + }
411 + const rays: React.ReactNode[] = [];
412 + for (let i = 0; i < 5; i++) {
413 + const x = W * (0.2 + i * 0.15);
414 + rays.push(<polygon key={i} points={`${r2(x)},0 ${r2(x + W * 0.05)},0 ${r2(x + W * 0.2)},${H} ${r2(x - W * 0.02)},${H}`} fill={`url(#${id}-ray)`} />);
415 + }
416 + return (
417 + <g>
418 + {rays}
419 + <ellipse cx={W * 0.5} cy={H * 0.95} rx={W * 0.5} ry={H * 0.12} fill={p.secondary} fillOpacity={0.18} filter={`url(#${id}-blur)`} />
420 + {bubbles}
421 + </g>
422 + );
423 +}
424 +
425 +function Moon({ W, H, rnd, p, id }: Ctx) {
426 + const R = Math.min(W, H) * 0.42;
427 + const cx = W * 0.62;
428 + const cy = H * 0.5;
429 + const craters: React.ReactNode[] = [];
430 + for (let i = 0; i < 12; i++) {
431 + const a = rnd() * Math.PI * 2;
432 + const d = rnd() * R * 0.8;
433 + const r = between(rnd, R * 0.04, R * 0.16);
434 + const x = cx + Math.cos(a) * d;
435 + const y = cy + Math.sin(a) * d;
436 + craters.push(<circle key={`c${i}`} cx={r2(x)} cy={r2(y)} r={r2(r)} fill="#000" fillOpacity={0.35} />);
437 + craters.push(<circle key={`r${i}`} cx={r2(x)} cy={r2(y)} r={r2(r)} fill="none" stroke={p.glow} strokeOpacity={0.35} strokeWidth={1.5} />);
438 + craters.push(<circle key={`s${i}`} cx={r2(x + r * 0.3)} cy={r2(y + r * 0.3)} r={r2(r * 0.55)} fill="#000" fillOpacity={0.2} />);
439 + }
440 + const stars: React.ReactNode[] = [];
441 + for (let i = 0; i < 40; i++) stars.push(<circle key={i} cx={r2(rnd() * W)} cy={r2(rnd() * H)} r={r2(between(rnd, 0.5, 1.4))} fill="#fff" fillOpacity={r2(between(rnd, 0.3, 0.9))} />);
442 + return (
443 + <g>
444 + {stars}
445 + <circle cx={cx} cy={cy} r={R * 1.15} fill={`url(#${id}-glow)`} />
446 + <circle cx={cx} cy={cy} r={R} fill={`url(#${id}-moon)`} />
447 + {craters}
448 + <path d={`M${r2(cx - R)} ${r2(cy)} A ${r2(R)} ${r2(R)} 0 0 0 ${r2(cx + R)} ${r2(cy)} Z`} fill="#000" fillOpacity={0.18} transform={`rotate(-28 ${cx} ${cy})`} />
449 + <line x1={0} y1={H * 0.82} x2={W} y2={H * 0.82} stroke={p.primary} strokeOpacity={0.5} strokeWidth={1.5} />
450 + <rect x={W * 0.1} y={H * 0.72} width={W * 0.16} height={H * 0.1} fill={p.bg} stroke={p.primary} strokeOpacity={0.6} />
451 + <rect x={W * 0.13} y={H * 0.75} width={W * 0.04} height={H * 0.03} fill={p.primary} fillOpacity={0.9} />
452 + </g>
453 + );
454 +}
455 +
456 +function Temple({ W, H, rnd, p, id }: Ctx) {
457 + const steps: React.ReactNode[] = [];
458 + const n = 6;
459 + for (let i = 0; i < n; i++) {
460 + const wdt = W * (0.95 - i * 0.14);
461 + const h = H * 0.11;
462 + const y = H - h * (i + 1);
463 + steps.push(<rect key={i} x={r2((W - wdt) / 2)} y={r2(y)} width={r2(wdt)} height={r2(h)} fill={i % 2 ? "#08170f" : "#0a1c12"} stroke={p.primary} strokeOpacity={0.35} />);
464 + const blocks = Math.floor(wdt / (W * 0.08));
465 + for (let b = 0; b < blocks; b++) if (rnd() > 0.5) steps.push(<line key={`${i}-${b}`} x1={r2((W - wdt) / 2 + (b + 1) * (wdt / (blocks + 1)))} y1={r2(y)} x2={r2((W - wdt) / 2 + (b + 1) * (wdt / (blocks + 1)))} y2={r2(y + h)} stroke={p.primary} strokeOpacity={0.15} />);
466 + }
467 + const vines: React.ReactNode[] = [];
468 + for (let i = 0; i < 8; i++) {
469 + const x = between(rnd, W * 0.05, W * 0.95);
470 + vines.push(<path key={i} d={`M${r2(x)} ${H} q ${r2(between(rnd, -30, 30))} ${r2(-H * 0.2)} ${r2(between(rnd, -20, 20))} ${r2(-H * between(rnd, 0.3, 0.6))}`} fill="none" stroke={p.primary} strokeOpacity={0.35} strokeWidth={2} strokeLinecap="round" />);
471 + }
472 + return (
473 + <g>
474 + <circle cx={W * 0.5} cy={H * 0.3} r={Math.min(W, H) * 0.24} fill={`url(#${id}-glow)`} />
475 + <circle cx={W * 0.5} cy={H * 0.3} r={Math.min(W, H) * 0.08} fill={p.secondary} fillOpacity={0.85} />
476 + {steps}
477 + {vines}
478 + </g>
479 + );
480 +}
481 +
482 +function Gravity({ W, H, rnd, p, id }: Ctx) {
483 + const cx = W * 0.5;
484 + const cy = H * 0.45;
485 + const orbits: React.ReactNode[] = [];
486 + for (let i = 0; i < 5; i++) {
487 + const rx = Math.min(W, H) * (0.22 + i * 0.1);
488 + const ry = rx * between(rnd, 0.3, 0.5);
489 + const rot = between(rnd, -60, 60);
490 + orbits.push(<ellipse key={`o${i}`} cx={cx} cy={cy} rx={r2(rx)} ry={r2(ry)} fill="none" stroke={i % 2 ? p.primary : p.secondary} strokeOpacity={r2(0.55 - i * 0.08)} strokeWidth={1.5} transform={`rotate(${r2(rot)} ${cx} ${cy})`} />);
491 + const a = rnd() * Math.PI * 2;
492 + const px = cx + Math.cos(a) * rx;
493 + const py = cy + Math.sin(a) * ry;
494 + orbits.push(<circle key={`p${i}`} cx={r2(px)} cy={r2(py)} r={r2(between(rnd, 3, 8))} fill={i % 2 ? p.secondary : p.primary} transform={`rotate(${r2(rot)} ${cx} ${cy})`} />);
495 + }
496 + const stars: React.ReactNode[] = [];
497 + for (let i = 0; i < 40; i++) stars.push(<circle key={i} cx={r2(rnd() * W)} cy={r2(rnd() * H)} r={r2(between(rnd, 0.5, 1.3))} fill="#fff" fillOpacity={r2(between(rnd, 0.3, 0.8))} />);
498 + return (
499 + <g>
500 + {stars}
501 + <circle cx={cx} cy={cy} r={Math.min(W, H) * 0.16} fill={`url(#${id}-glow)`} />
502 + <circle cx={cx} cy={cy} r={Math.min(W, H) * 0.045} fill="#fff" fillOpacity={0.95} />
503 + {orbits}
504 + </g>
505 + );
506 +}
507 +
508 +function Core({ W, H, rnd, p, id }: Ctx) {
509 + const cx = W * 0.5;
510 + const cy = H * 0.44;
511 + const R = Math.min(W, H) * 0.22;
512 + const arcs: React.ReactNode[] = [];
513 + for (let i = 0; i < 3; i++) {
514 + const r = R * (1.5 + i * 0.35);
515 + arcs.push(<circle key={i} cx={cx} cy={cy} r={r2(r)} fill="none" stroke={i ? p.secondary : p.primary} strokeOpacity={0.35 - i * 0.08} strokeWidth={i ? 1.5 : 3} strokeDasharray={`${r2(r * 1.2)} ${r2(r * 0.9)}`} transform={`rotate(${r2(rnd() * 360)} ${cx} ${cy})`} />);
516 + }
517 + const embers: React.ReactNode[] = [];
518 + for (let i = 0; i < 30; i++) {
519 + const a = rnd() * Math.PI * 2;
520 + const d = R * between(rnd, 1.1, 3.2);
521 + embers.push(<circle key={i} cx={r2(cx + Math.cos(a) * d)} cy={r2(cy + Math.sin(a) * d * 0.8)} r={r2(between(rnd, 1, 3))} fill={rnd() > 0.5 ? p.secondary : p.primary} fillOpacity={r2(between(rnd, 0.3, 0.9))} />);
522 + }
523 + return (
524 + <g>
525 + {embers}
526 + <circle cx={cx} cy={cy} r={R * 2.2} fill={`url(#${id}-glow)`} />
527 + {arcs}
528 + <circle cx={cx} cy={cy} r={R} fill={`url(#${id}-sphere)`} />
529 + <circle cx={r2(cx - R * 0.3)} cy={r2(cy - R * 0.35)} r={R * 0.28} fill="#fff" fillOpacity={0.35} filter={`url(#${id}-blur)`} />
530 + </g>
531 + );
532 +}
533 +
534 +function Minimal({ W, H, p }: Ctx) {
535 + const y = H * 0.5;
536 + return (
537 + <g>
538 + <line x1={W * 0.1} y1={y} x2={W * 0.9} y2={y} stroke={p.secondary} strokeWidth={1.5} strokeOpacity={0.85} />
539 + <line x1={W * 0.1} y1={y} x2={W * 0.9} y2={y} stroke={p.secondary} strokeWidth={6} strokeOpacity={0.15} />
540 + <circle cx={W * 0.9} cy={y} r={3} fill={p.primary} />
541 + <circle cx={W * 0.1} cy={y} r={1.5} fill={p.secondary} />
542 + <line x1={W * 0.5} y1={H * 0.3} x2={W * 0.5} y2={H * 0.34} stroke={p.secondary} strokeOpacity={0.5} />
543 + <line x1={W * 0.5} y1={H * 0.66} x2={W * 0.5} y2={H * 0.7} stroke={p.secondary} strokeOpacity={0.5} />
544 + </g>
545 + );
546 +}
547 +
548 +function Universe({ W, H, rnd, p, id }: Ctx) {
549 + const cx = W * 0.5;
550 + const cy = H * 0.44;
551 + const R = Math.min(W, H) * 0.3;
552 + const rays: React.ReactNode[] = [];
553 + for (let i = 0; i < 48; i++) {
554 + const a = (i / 48) * Math.PI * 2;
555 + const len = R * between(rnd, 1.2, 2.6);
556 + rays.push(<line key={i} x1={r2(cx + Math.cos(a) * R * 0.5)} y1={r2(cy + Math.sin(a) * R * 0.5)} x2={r2(cx + Math.cos(a) * len)} y2={r2(cy + Math.sin(a) * len)} stroke={i % 4 === 0 ? p.secondary : p.primary} strokeOpacity={r2(between(rnd, 0.1, 0.45))} strokeWidth={i % 6 === 0 ? 2 : 1} strokeLinecap="round" />);
557 + }
558 + const stars: React.ReactNode[] = [];
559 + for (let i = 0; i < 50; i++) stars.push(<circle key={i} cx={r2(rnd() * W)} cy={r2(rnd() * H)} r={r2(between(rnd, 0.5, 1.5))} fill="#fff" fillOpacity={r2(between(rnd, 0.3, 0.9))} />);
560 + return (
561 + <g>
562 + {stars}
563 + <circle cx={cx} cy={cy} r={R * 2.4} fill={`url(#${id}-glow)`} />
564 + {rays}
565 + <circle cx={cx} cy={cy} r={R} fill="none" stroke={`url(#${id}-ring)`} strokeWidth={Math.max(3, R * 0.06)} />
566 + <circle cx={cx} cy={cy} r={R * 0.82} fill="none" stroke={p.secondary} strokeOpacity={0.35} strokeWidth={1.5} strokeDasharray="6 10" />
567 + <circle cx={cx} cy={cy} r={R * 0.12} fill="#fff" />
568 + </g>
569 + );
570 +}
571 +
572 +function PerspectiveGrid({ W, H, p }: Ctx) {
573 + const horizon = H * 0.42;
574 + const lines: React.ReactNode[] = [];
575 + for (let i = -8; i <= 8; i++) {
576 + lines.push(<line key={`v${i}`} x1={W * 0.5} y1={horizon} x2={r2(W * 0.5 + i * W * 0.28)} y2={H} stroke={p.primary} strokeOpacity={0.3} strokeWidth={1.2} />);
577 + }
578 + for (let k = 1; k <= 10; k++) {
579 + const t = k / 10;
580 + const y = horizon + (H - horizon) * t * t;
581 + lines.push(<line key={`h${k}`} x1={0} y1={r2(y)} x2={W} y2={r2(y)} stroke={p.primary} strokeOpacity={r2(0.15 + t * 0.35)} strokeWidth={1.2} />);
582 + }
583 + return (
584 + <g>
585 + <circle cx={W * 0.5} cy={horizon} r={Math.min(W, H) * 0.2} fill={p.secondary} fillOpacity={0.25} />
586 + <rect x={0} y={horizon} width={W} height={H - horizon} fill="#000" fillOpacity={0.35} />
587 + {lines}
588 + <line x1={0} y1={horizon} x2={W} y2={horizon} stroke={p.secondary} strokeOpacity={0.8} strokeWidth={2} />
589 + </g>
590 + );
591 +}
592 +
593 +const MOTIFS: Record<Backdrop, (c: Ctx) => React.ReactNode> = {
594 + vault: Vault,
595 + nebula: Nebula,
596 + pillars: Pillars,
597 + aurora: Aurora,
598 + lava: Lava,
599 + circuit: Circuit,
600 + skyline: Skyline,
601 + pyramid: Pyramid,
602 + arcade: Arcade,
603 + asteroids: Asteroids,
604 + track: Track,
605 + reactor: Reactor,
606 + abyss: Abyss,
607 + moon: Moon,
608 + temple: Temple,
609 + gravity: Gravity,
610 + core: Core,
611 + minimal: Minimal,
612 + universe: Universe,
613 + grid: PerspectiveGrid,
614 +};
615 +
616 +/* --------------------------------------------------------------- particles */
617 +
618 +function Particles({ W, H, rnd, p, kind }: Ctx & { kind: string }) {
619 + const out: React.ReactNode[] = [];
620 + const n = 22;
621 + for (let i = 0; i < n; i++) {
622 + const x = r2(rnd() * W);
623 + const y = r2(rnd() * H);
624 + const op = r2(between(rnd, 0.25, 0.8));
625 + const s = between(rnd, 1.5, 4);
626 + switch (kind) {
627 + case "snow":
628 + out.push(<circle key={i} cx={x} cy={y} r={r2(s * 0.7)} fill="#fff" fillOpacity={op} />);
629 + break;
630 + case "coins":
631 + out.push(<ellipse key={i} cx={x} cy={y} rx={r2(s * 1.2)} ry={r2(s * 0.8)} fill={p.primary} fillOpacity={op} />);
632 + break;
633 + case "diamonds":
634 + out.push(<polygon key={i} points={`${x},${r2(y - s)} ${r2(x + s)},${y} ${x},${r2(y + s)} ${r2(x - s)},${y}`} fill={p.glow} fillOpacity={op} />);
635 + break;
636 + case "fire":
637 + case "sparks":
638 + out.push(<line key={i} x1={x} y1={y} x2={r2(x + between(rnd, -6, 6))} y2={r2(y - between(rnd, 4, 12))} stroke={rnd() > 0.5 ? p.primary : p.secondary} strokeOpacity={op} strokeWidth={1.5} strokeLinecap="round" />);
639 + break;
640 + case "confetti":
641 + out.push(<rect key={i} x={x} y={y} width={r2(s * 1.4)} height={r2(s * 0.7)} fill={[p.primary, p.secondary, p.glow][i % 3]} fillOpacity={op} transform={`rotate(${r2(rnd() * 90)} ${x} ${y})`} />);
642 + break;
643 + case "bubbles":
644 + out.push(<circle key={i} cx={x} cy={y} r={r2(s)} fill="none" stroke="#fff" strokeOpacity={op * 0.6} />);
645 + break;
646 + case "energy":
647 + out.push(<circle key={i} cx={x} cy={y} r={r2(s * 0.5)} fill={p.secondary} fillOpacity={op} />);
648 + break;
649 + case "dust":
650 + out.push(<circle key={i} cx={x} cy={y} r={r2(s * 0.35)} fill={p.primary} fillOpacity={op * 0.7} />);
651 + break;
652 + default:
653 + out.push(<circle key={i} cx={x} cy={y} r={r2(s * 0.4)} fill="#fff" fillOpacity={op} />);
654 + }
655 + }
656 + return <g>{out}</g>;
657 +}
658 +
659 +/* ------------------------------------------------------------------- title */
660 +
661 +function splitName(name: string, oneLine: boolean): string[] {
662 + if (oneLine) return [name];
663 + const words = name.trim().split(/\s+/);
664 + if (words.length <= 1) return words;
665 + if (words.length === 2) return words;
666 + const mid = Math.ceil(words.length / 2);
667 + return [words.slice(0, mid).join(" "), words.slice(mid).join(" ")];
668 +}
669 +
670 +function Title({ name, W, H, pad, maxFont, oneLine, p }: { name: string; W: number; H: number; pad: number; maxFont: number; oneLine: boolean; p: ArtPalette }) {
671 + const lines = splitName(name.toUpperCase(), oneLine);
672 + const longest = Math.max(...lines.map((l) => l.length), 1);
673 + const size = Math.min(maxFont, ((W - pad * 2) / longest) * 1.55);
674 + const lineH = size * 0.92;
675 + const baseY = H - pad - (lines.length - 1) * lineH;
676 + return (
677 + <g fontFamily="var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif" fontWeight={700} style={{ letterSpacing: "-0.045em" }}>
678 + {lines.map((l, i) => (
679 + <text key={`s${i}`} x={pad} y={r2(baseY + i * lineH)} fontSize={r2(size)} fill={p.glow} fillOpacity={0.45} style={{ filter: "blur(10px)" }}>
680 + {l}
681 + </text>
682 + ))}
683 + {lines.map((l, i) => (
684 + <text key={i} x={pad} y={r2(baseY + i * lineH)} fontSize={r2(size)} fill="#ffffff">
685 + {l}
686 + </text>
687 + ))}
688 + </g>
689 + );
690 +}
691 +
692 +/* ----------------------------------------------------------------- GameArt */
693 +
694 +export function GameArt({ slug, name, palette, backdrop, particles, variant = "card", className, showName = true, label }: GameArtProps) {
695 + const reactId = React.useId().replace(/[^a-zA-Z0-9]/g, "");
696 + const id = `ga${reactId}`;
697 + const preset = GAME_PRESENTATION[slug];
698 + const bd = (backdrop ?? preset?.backdrop ?? BACKDROPS[hashString(slug) % BACKDROPS.length]) as string;
699 + const motifKey: Backdrop = (BACKDROPS as readonly string[]).includes(bd) ? (bd as Backdrop) : "grid";
700 + const pt = particles ?? preset?.particles ?? "dust";
701 + const { w: W, h: H, pad, maxFont, oneLine } = DIMS[variant];
702 + const rnd = mulberry32(hashString(`${slug}:${variant}`));
703 + const ctx: Ctx = { W, H, rnd, p: palette, id };
704 + const Motif = MOTIFS[motifKey];
705 +
706 + return (
707 + <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid slice" className={cn("block h-full w-full", className)} role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true}>
708 + <defs>
709 + <linearGradient id={`${id}-bg`} x1="0" y1="0" x2="0.4" y2="1">
710 + <stop offset="0" stopColor={palette.bg} />
711 + <stop offset="1" stopColor="#05060a" />
712 + </linearGradient>
713 + <radialGradient id={`${id}-glow`} cx="0.5" cy="0.5" r="0.5">
714 + <stop offset="0" stopColor={palette.glow} stopOpacity="0.55" />
715 + <stop offset="0.5" stopColor={palette.primary} stopOpacity="0.18" />
716 + <stop offset="1" stopColor={palette.primary} stopOpacity="0" />
717 + </radialGradient>
718 + <linearGradient id={`${id}-vignette`} x1="0" y1="0" x2="0" y2="1">
719 + <stop offset="0.35" stopColor={palette.bg} stopOpacity="0" />
720 + <stop offset="1" stopColor="#03040a" stopOpacity="0.92" />
721 + </linearGradient>
722 + <linearGradient id={`${id}-col`} x1="0" y1="0" x2="1" y2="0">
723 + <stop offset="0" stopColor="#1c1410" />
724 + <stop offset="0.5" stopColor="#2a1d14" />
725 + <stop offset="1" stopColor="#120c08" />
726 + </linearGradient>
727 + <linearGradient id={`${id}-heat`} x1="0" y1="0" x2="0" y2="1">
728 + <stop offset="0" stopColor={palette.primary} stopOpacity="0" />
729 + <stop offset="1" stopColor={palette.primary} stopOpacity="0.45" />
730 + </linearGradient>
731 + <linearGradient id={`${id}-ray`} x1="0" y1="0" x2="0" y2="1">
732 + <stop offset="0" stopColor={palette.primary} stopOpacity="0.16" />
733 + <stop offset="1" stopColor={palette.primary} stopOpacity="0" />
734 + </linearGradient>
735 + <radialGradient id={`${id}-moon`} cx="0.35" cy="0.3" r="0.8">
736 + <stop offset="0" stopColor="#3a3d4a" />
737 + <stop offset="1" stopColor="#0d0e14" />
738 + </radialGradient>
739 + <radialGradient id={`${id}-sphere`} cx="0.4" cy="0.35" r="0.7">
740 + <stop offset="0" stopColor={palette.glow} />
741 + <stop offset="0.55" stopColor={palette.primary} />
742 + <stop offset="1" stopColor={palette.bg} />
743 + </radialGradient>
744 + <linearGradient id={`${id}-ring`} x1="0" y1="0" x2="1" y2="1">
745 + <stop offset="0" stopColor={palette.primary} />
746 + <stop offset="1" stopColor={palette.secondary} />
747 + </linearGradient>
748 + <filter id={`${id}-blur`} x="-30%" y="-30%" width="160%" height="160%">
749 + <feGaussianBlur stdDeviation={Math.max(W, H) / 60} />
750 + </filter>
751 + </defs>
752 + <rect width={W} height={H} fill={`url(#${id}-bg)`} />
753 + <Motif {...ctx} />
754 + <Particles {...ctx} kind={pt} />
755 + <rect width={W} height={H} fill={`url(#${id}-vignette)`} />
756 + <rect x="0.5" y="0.5" width={W - 1} height={H - 1} fill="none" stroke="#fff" strokeOpacity="0.06" />
757 + {showName ? <Title name={name} W={W} H={H} pad={pad} maxFont={maxFont} oneLine={oneLine} p={palette} /> : null}
758 + </svg>
759 + );
760 +}
added apps/web/src/components/lobby/game-card.tsx +194 −0
@@ -0,0 +1,194 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import Link from "next/link";
5 +import { useRouter } from "next/navigation";
6 +import { Heart, Play, ChevronRight } from "lucide-react";
7 +import type { GameCard as GameCardData } from "@spinza/shared";
8 +import { api } from "@/lib/api";
9 +import { toast, useSession } from "@/lib/store";
10 +import { cn, VOLATILITY_BARS, VOLATILITY_LABEL } from "@/lib/utils";
11 +import { Badge, Skeleton } from "@/components/ui";
12 +import { GameArt } from "./game-art";
13 +
14 +/* ------------------------------------------------------------- favourites */
15 +
16 +function useFavorite(game: GameCardData, onChange?: (slug: string, fav: boolean) => void) {
17 + const status = useSession((s) => s.status);
18 + const router = useRouter();
19 + const [fav, setFav] = React.useState<boolean>(!!game.favorite);
20 + const [busy, setBusy] = React.useState(false);
21 + // Keep in sync when the parent re-renders with fresh server data (adjust-state-from-props pattern).
22 + const [seen, setSeen] = React.useState(!!game.favorite);
23 + if (!!game.favorite !== seen) {
24 + setSeen(!!game.favorite);
25 + setFav(!!game.favorite);
26 + }
27 +
28 + const toggle = async (e: React.MouseEvent) => {
29 + e.preventDefault();
30 + e.stopPropagation();
31 + if (status !== "authenticated") {
32 + router.push(`/login?next=${encodeURIComponent(window.location.pathname)}`);
33 + return;
34 + }
35 + if (busy) return;
36 + const next = !fav;
37 + setFav(next);
38 + onChange?.(game.slug, next);
39 + setBusy(true);
40 + try {
41 + const res = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" });
42 + setFav(res.favorite);
43 + onChange?.(game.slug, res.favorite);
44 + } catch (err) {
45 + setFav(!next);
46 + onChange?.(game.slug, !next);
47 + toast({ title: "Could not update favourites", description: err instanceof Error ? err.message : undefined, tone: "danger" });
48 + } finally {
49 + setBusy(false);
50 + }
51 + };
52 + return { fav, toggle };
53 +}
54 +
55 +function HeartButton({ fav, onClick, className }: { fav: boolean; onClick: (e: React.MouseEvent) => void; className?: string }) {
56 + return (
57 + <button type="button" onClick={onClick} aria-pressed={fav} aria-label={fav ? "Remove from favourites" : "Add to favourites"} className={cn("tap grid place-items-center rounded-full glass text-fg-2 transition-all hover:text-fg active:scale-90 focus-ring", fav && "text-[#ff5c7a]", className)}>
58 + <Heart className={cn("h-[18px] w-[18px] transition-transform", fav && "fill-current scale-110")} />
59 + </button>
60 + );
61 +}
62 +
63 +/* -------------------------------------------------------------- volatility */
64 +
65 +export function VolatilityMeter({ volatility, className, showLabel = true }: { volatility: string; className?: string; showLabel?: boolean }) {
66 + const bars = VOLATILITY_BARS[volatility] ?? 2;
67 + return (
68 + <span className={cn("inline-flex items-center gap-1.5 text-[11px] font-medium text-fg-3", className)} title={`${VOLATILITY_LABEL[volatility] ?? volatility} volatility`}>
69 + <span className="flex items-end gap-[2px]" aria-hidden>
70 + {[1, 2, 3, 4].map((i) => (
71 + <span key={i} className={cn("w-[3px] rounded-[1px]", i <= bars ? "bg-accent" : "bg-fg-4/60")} style={{ height: 4 + i * 2 }} />
72 + ))}
73 + </span>
74 + {showLabel ? <span>{VOLATILITY_LABEL[volatility] ?? volatility}</span> : null}
75 + </span>
76 + );
77 +}
78 +
79 +/* ---------------------------------------------------------------- GameCard */
80 +
81 +export interface GameCardProps {
82 + game: GameCardData;
83 + className?: string;
84 + onFavoriteChange?: (slug: string, fav: boolean) => void;
85 + priority?: boolean;
86 +}
87 +
88 +export function GameCard({ game, className, onFavoriteChange }: GameCardProps) {
89 + const { fav, toggle } = useFavorite(game, onFavoriteChange);
90 + return (
91 + <article className={cn("group relative flex w-[164px] flex-col sm:w-[200px]", className)}>
92 + <Link href={`/games/${game.slug}`} className="relative block overflow-hidden rounded-lg border border-line bg-bg-1 shadow-card transition-transform duration-300 ease-out-expo focus-ring group-hover:-translate-y-1 group-hover:border-line-2" aria-label={`Play ${game.name}`}>
93 + <div className="aspect-[3/4]">
94 + <GameArt slug={game.slug} name={game.name} palette={game.palette} variant="card" />
95 + </div>
96 + <div className="absolute inset-x-2 top-2 flex items-start justify-between gap-2">
97 + <div className="flex flex-wrap gap-1">
98 + {game.isNew ? <Badge tone="new">New</Badge> : null}
99 + {game.isJackpot ? <Badge tone="accent">Jackpot</Badge> : null}
100 + </div>
101 + </div>
102 + <div className="pointer-events-none absolute inset-0 hidden items-center justify-center bg-black/35 opacity-0 transition-opacity duration-300 group-hover:opacity-100 sm:flex">
103 + <span className="grid h-14 w-14 place-items-center rounded-full bg-fg text-bg shadow-glow">
104 + <Play className="ml-0.5 h-6 w-6 fill-current" />
105 + </span>
106 + </div>
107 + </Link>
108 + <HeartButton fav={fav} onClick={toggle} className="absolute right-2 top-2 h-9 w-9 min-h-0 min-w-0" />
109 + <div className="mt-2.5 flex items-start justify-between gap-2 px-0.5">
110 + <div className="min-w-0">
111 + <Link href={`/games/${game.slug}`} className="block truncate text-[15px] font-semibold tracking-tight hover:text-accent-2">
112 + {game.name}
113 + </Link>
114 + <p className="truncate text-[12px] text-fg-3">{game.tagline}</p>
115 + </div>
116 + </div>
117 + <div className="mt-1.5 flex items-center justify-between px-0.5">
118 + <VolatilityMeter volatility={game.volatility} />
119 + <Link href={`/games/${game.slug}`} className="tap -mr-2 inline-flex h-9 min-h-0 items-center gap-1 rounded-sm px-2 text-[13px] font-semibold text-fg-2 hover:text-fg focus-ring">
120 + Play <Play className="h-3.5 w-3.5 fill-current" />
121 + </Link>
122 + </div>
123 + </article>
124 + );
125 +}
126 +
127 +export function GameCardSkeleton({ className }: { className?: string }) {
128 + return (
129 + <div className={cn("w-[164px] sm:w-[200px]", className)} aria-hidden>
130 + <Skeleton className="aspect-[3/4] w-full rounded-lg" />
131 + <Skeleton className="mt-3 h-4 w-3/4" />
132 + <Skeleton className="mt-2 h-3 w-1/2" />
133 + </div>
134 + );
135 +}
136 +
137 +/* ----------------------------------------------------------------- GameRow */
138 +
139 +export function GameRow({ title, eyebrow, games, href, className, id }: { title: string; eyebrow?: string; games: GameCardData[]; href?: string; className?: string; id?: string }) {
140 + if (!games.length) return null;
141 + return (
142 + <section className={cn("relative", className)} id={id} aria-labelledby={id ? `${id}-title` : undefined}>
143 + <div className="mb-3 flex items-end justify-between gap-4">
144 + <div>
145 + {eyebrow ? <div className="eyebrow mb-1">{eyebrow}</div> : null}
146 + <h2 id={id ? `${id}-title` : undefined} className="text-xl font-semibold tracking-tight sm:text-2xl">
147 + {title}
148 + </h2>
149 + </div>
150 + {href ? (
151 + <Link href={href} className="tap inline-flex h-9 min-h-0 items-center gap-0.5 text-sm font-medium text-fg-3 hover:text-fg">
152 + See all <ChevronRight className="h-4 w-4" />
153 + </Link>
154 + ) : null}
155 + </div>
156 + <div className="snap-row -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
157 + {games.map((g) => (
158 + <GameCard key={g.slug} game={g} />
159 + ))}
160 + </div>
161 + </section>
162 + );
163 +}
164 +
165 +/* ---------------------------------------------------------------- GameTile */
166 +
167 +/** Compact row tile for dense lists (continue playing, search results). */
168 +export function GameTile({ game, meta, className, onFavoriteChange }: { game: GameCardData; meta?: React.ReactNode; className?: string; onFavoriteChange?: (slug: string, fav: boolean) => void }) {
169 + const { fav, toggle } = useFavorite(game, onFavoriteChange);
170 + return (
171 + <div className={cn("surface relative flex items-center gap-3 rounded-md p-2 pr-3 transition-colors hover:bg-surface-2", className)}>
172 + <Link href={`/games/${game.slug}`} className="flex min-w-0 flex-1 items-center gap-3 focus-ring rounded-sm" aria-label={`Play ${game.name}`}>
173 + <div className="h-14 w-14 shrink-0 overflow-hidden rounded-sm border border-line">
174 + <GameArt slug={game.slug} name={game.name} palette={game.palette} variant="tile" showName={false} />
175 + </div>
176 + <div className="min-w-0 flex-1">
177 + <div className="flex items-center gap-2">
178 + <span className="truncate text-[15px] font-semibold tracking-tight">{game.name}</span>
179 + {game.isNew ? <Badge tone="new">New</Badge> : null}
180 + {game.isJackpot ? <Badge tone="accent">Jackpot</Badge> : null}
181 + </div>
182 + <div className="mt-0.5 flex items-center gap-2 text-[12px] text-fg-3">
183 + <VolatilityMeter volatility={game.volatility} />
184 + {meta ? <span className="truncate">· {meta}</span> : null}
185 + </div>
186 + </div>
187 + </Link>
188 + <HeartButton fav={fav} onClick={toggle} className="h-10 w-10 min-h-0 min-w-0" />
189 + <Link href={`/games/${game.slug}`} className="tap grid h-10 w-10 min-h-0 min-w-0 place-items-center rounded-full bg-fg text-bg focus-ring" aria-label={`Play ${game.name}`}>
190 + <Play className="ml-0.5 h-4 w-4 fill-current" />
191 + </Link>
192 + </div>
193 + );
194 +}
added apps/web/src/components/lobby/games-browser.tsx +129 −0
@@ -0,0 +1,129 @@
1 +"use client";
2 +
3 +import { useMemo, useState, useEffect } from "react";
4 +import { useRouter, useSearchParams } from "next/navigation";
5 +import { Search, X, Heart } from "lucide-react";
6 +import type { GameCard as GameCardData } from "@spinza/shared";
7 +import { useSession } from "@/lib/store";
8 +import { cn } from "@/lib/utils";
9 +import { Button, Empty } from "@/components/ui";
10 +import { GameCard } from "./game-card";
11 +
12 +const FILTERS = [
13 + { key: "all", label: "All" },
14 + { key: "featured", label: "Featured" },
15 + { key: "new", label: "New" },
16 + { key: "high", label: "High volatility" },
17 + { key: "relaxed", label: "Relaxed" },
18 + { key: "jackpot", label: "Jackpot" },
19 + { key: "favourites", label: "Favourites" },
20 +] as const;
21 +type FilterKey = (typeof FILTERS)[number]["key"];
22 +
23 +const isFilter = (v: string | null): v is FilterKey => !!v && FILTERS.some((f) => f.key === v);
24 +
25 +export function GamesBrowser({ games }: { games: GameCardData[] }) {
26 + const params = useSearchParams();
27 + const router = useRouter();
28 + const status = useSession((s) => s.status);
29 + const [filter, setFilter] = useState<FilterKey>(isFilter(params.get("filter")) ? (params.get("filter") as FilterKey) : "all");
30 + const [query, setQuery] = useState(params.get("q") ?? "");
31 + const [favs, setFavs] = useState<Record<string, boolean>>(() => Object.fromEntries(games.map((g) => [g.slug, !!g.favorite])));
32 +
33 + useEffect(() => {
34 + const p = new URLSearchParams();
35 + if (filter !== "all") p.set("filter", filter);
36 + if (query.trim()) p.set("q", query.trim());
37 + const qs = p.toString();
38 + router.replace(qs ? `/games?${qs}` : "/games", { scroll: false });
39 + }, [filter, query, router]);
40 +
41 + const list = useMemo(() => {
42 + const q = query.trim().toLowerCase();
43 + return games
44 + .filter((g) => {
45 + switch (filter) {
46 + case "featured":
47 + return g.isFeatured;
48 + case "new":
49 + return g.isNew;
50 + case "high":
51 + return g.volatility === "high" || g.volatility === "extreme";
52 + case "relaxed":
53 + return g.volatility === "low" || g.volatility === "medium";
54 + case "jackpot":
55 + return g.isJackpot;
56 + case "favourites":
57 + return favs[g.slug];
58 + default:
59 + return true;
60 + }
61 + })
62 + .filter((g) => !q || g.name.toLowerCase().includes(q) || g.tagline.toLowerCase().includes(q) || g.theme.toLowerCase().includes(q) || g.tags.some((t) => t.includes(q)) || g.features.some((f) => f.toLowerCase().includes(q)));
63 + }, [games, filter, query, favs]);
64 +
65 + const counts = useMemo<Record<FilterKey, number>>(
66 + () => ({
67 + all: games.length,
68 + featured: games.filter((g) => g.isFeatured).length,
69 + new: games.filter((g) => g.isNew).length,
70 + high: games.filter((g) => g.volatility === "high" || g.volatility === "extreme").length,
71 + relaxed: games.filter((g) => g.volatility === "low" || g.volatility === "medium").length,
72 + jackpot: games.filter((g) => g.isJackpot).length,
73 + favourites: Object.values(favs).filter(Boolean).length,
74 + }),
75 + [games, favs],
76 + );
77 +
78 + return (
79 + <div className="space-y-6">
80 + <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
81 + <div className="snap-row -mx-4 px-4 sm:mx-0 sm:px-0 sm:flex-wrap" role="tablist" aria-label="Filter games">
82 + {FILTERS.map((f) => (
83 + <button key={f.key} role="tab" aria-selected={filter === f.key} onClick={() => setFilter(f.key)} className={cn("tap inline-flex h-10 min-w-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-4 text-[13px] font-semibold transition-colors focus-ring", filter === f.key ? "border-accent/50 bg-accent-soft text-accent-2" : "border-line text-fg-3 hover:border-line-2 hover:text-fg")}>
84 + {f.key === "favourites" ? <Heart className={cn("h-3.5 w-3.5", filter === f.key && "fill-current")} /> : null}
85 + {f.label}
86 + <span className="tabular text-[11px] opacity-70">{counts[f.key]}</span>
87 + </button>
88 + ))}
89 + </div>
90 + <label className="relative block lg:w-72">
91 + <Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-fg-3" />
92 + <input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search games, features…" aria-label="Search games" className="h-11 w-full rounded-full border border-line-2 bg-bg-1 pl-10 pr-10 text-[15px] placeholder:text-fg-4 focus-ring focus:border-accent/60" />
93 + {query ? (
94 + <button type="button" onClick={() => setQuery("")} className="absolute right-2 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center rounded-full text-fg-3 hover:bg-surface-2 hover:text-fg" aria-label="Clear search">
95 + <X className="h-4 w-4" />
96 + </button>
97 + ) : null}
98 + </label>
99 + </div>
100 +
101 + {filter === "favourites" && status !== "authenticated" ? (
102 + <Empty title="Sign in to keep favourites" description="Tap the heart on any game to pin it here. Favourites sync across your devices." icon={<Heart className="h-5 w-5" />} action={<Button href="/login?next=%2Fgames%3Ffilter%3Dfavourites">Sign in</Button>} />
103 + ) : list.length ? (
104 + <div className="grid grid-cols-2 gap-x-3 gap-y-6 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
105 + {list.map((g) => (
106 + <GameCard key={g.slug} game={{ ...g, favorite: favs[g.slug] }} className="w-full sm:w-full" onFavoriteChange={(slug, fav) => setFavs((f) => ({ ...f, [slug]: fav }))} />
107 + ))}
108 + </div>
109 + ) : (
110 + <Empty
111 + title={filter === "favourites" ? "No favourites yet" : query ? `No games match “${query}”` : "Nothing here yet"}
112 + description={filter === "favourites" ? "Tap the heart on any game to pin it here." : query ? "Try a feature name like “cascades”, “jackpot” or “free spins”." : "New titles are being certified and will appear here soon."}
113 + icon={filter === "favourites" ? <Heart className="h-5 w-5" /> : <Search className="h-5 w-5" />}
114 + action={
115 + <Button
116 + variant="secondary"
117 + onClick={() => {
118 + setFilter("all");
119 + setQuery("");
120 + }}
121 + >
122 + Show all games
123 + </Button>
124 + }
125 + />
126 + )}
127 + </div>
128 + );
129 +}
added apps/web/src/components/lobby/landing.tsx +163 −0
@@ -0,0 +1,163 @@
1 +import Link from "next/link";
2 +import { ArrowRight, ShieldCheck, Sparkles, UserRound, Coins, Gamepad2, Clock, Ban } from "lucide-react";
3 +import type { GameCard as GameCardData } from "@spinza/shared";
4 +import { STARTING_BALANCE, formatSC } from "@spinza/shared";
5 +import { Button } from "@/components/ui";
6 +import { GameArt } from "./game-art";
7 +import { GameRow } from "./game-card";
8 +import { SiteFooter } from "./site-footer";
9 +
10 +const FLAGSHIP = { slug: "spinza-original", name: "Spinza Original", palette: { primary: "#8b5cf6", secondary: "#22d3ee", glow: "#a78bfa", bg: "#06040f" } };
11 +
12 +const STEPS = [
13 + { icon: UserRound, title: "Pick a username", body: "That is the whole sign-up. No email address, no phone number, no card. A recovery code protects your account." },
14 + { icon: Coins, title: `Receive ${formatSC(STARTING_BALANCE)}`, body: "Every new player starts with 10,000 Spinza Credits. They are fictional, they refill daily and they can never be bought." },
15 + { icon: Gamepad2, title: "Play 20 original games", body: "Cascades, expanding wilds, hold-and-respin jackpots, meters and mystery symbols — all designed and built in-house." },
16 +];
17 +
18 +export function Landing({ games, apiDown }: { games: GameCardData[]; apiDown: boolean }) {
19 + const flagship = games.find((g) => g.slug === FLAGSHIP.slug);
20 + const featured = games.filter((g) => g.isFeatured);
21 + const featuredRow = (featured.length ? featured : games).slice(0, 10);
22 +
23 + return (
24 + <div className="page py-6 lg:py-10">
25 + {/* ----------------------------------------------------------- hero */}
26 + <section className="relative overflow-hidden rounded-xl border border-line bg-bg-1" aria-labelledby="hero-title">
27 + <div className="absolute inset-0">
28 + <GameArt slug={FLAGSHIP.slug} name={FLAGSHIP.name} palette={flagship?.palette ?? FLAGSHIP.palette} variant="hero" showName={false} className="opacity-90" />
29 + <div className="absolute inset-0 bg-[linear-gradient(90deg,rgba(7,8,12,0.94)_0%,rgba(7,8,12,0.78)_45%,rgba(7,8,12,0.25)_100%)]" />
30 + <div className="absolute inset-x-0 bottom-0 h-1/2 bg-[linear-gradient(180deg,transparent,rgba(7,8,12,0.9))]" />
31 + </div>
32 + <div className="relative flex min-h-[520px] flex-col justify-end p-6 sm:min-h-[580px] sm:p-10 lg:min-h-[640px] lg:p-14">
33 + <div className="eyebrow mb-4 flex items-center gap-2 text-accent">
34 + <Sparkles className="h-3.5 w-3.5" /> Spinza Original · Fictional credits
35 + </div>
36 + <h1 id="hero-title" className="max-w-3xl text-[44px] font-semibold leading-[0.95] tracking-[-0.04em] sm:text-6xl lg:text-7xl">
37 + Play. <span className="shimmer-text">Spin.</span> Unlock.
38 + </h1>
39 + <p className="mt-5 max-w-xl text-base leading-relaxed text-fg-2 sm:text-lg">Twenty original casino-style games, a premium lobby and a living progression system — played entirely with fictional credits. Nothing to buy, nothing to lose.</p>
40 + <div className="mt-8 flex flex-col gap-3 sm:flex-row">
41 + <Button variant="accent" size="xl" href="/register" className="sm:min-w-[200px]">
42 + PLAY FREE
43 + </Button>
44 + <Button variant="outline" size="xl" href="/games" className="glass sm:min-w-[200px]">
45 + EXPLORE GAMES <ArrowRight className="h-4 w-4" />
46 + </Button>
47 + </div>
48 + <p className="mt-6 max-w-xl text-[13px] leading-relaxed text-fg-3">
49 + <span className="font-semibold text-fg-2">Virtual credits only. No deposits. No withdrawals. No cash value.</span> Spinza is for adults 18 and over. Already have an account?{" "}
50 + <Link href="/login" className="text-accent-2 underline-offset-4 hover:underline">
51 + Sign in
52 + </Link>
53 + .
54 + </p>
55 + </div>
56 + </section>
57 +
58 + {/* -------------------------------------------------- how it works */}
59 + <section className="mt-16 lg:mt-24" aria-labelledby="how-title">
60 + <div className="mb-8 max-w-2xl">
61 + <div className="eyebrow mb-2">How it works</div>
62 + <h2 id="how-title" className="text-3xl font-semibold tracking-tight sm:text-4xl">
63 + Three steps. Zero friction.
64 + </h2>
65 + </div>
66 + <ol className="grid gap-4 md:grid-cols-3">
67 + {STEPS.map((s, i) => (
68 + <li key={s.title} className="surface relative rounded-lg p-6">
69 + <div className="flex items-center justify-between">
70 + <span className="grid h-11 w-11 place-items-center rounded-md metal text-accent-2">
71 + <s.icon className="h-5 w-5" />
72 + </span>
73 + <span className="font-mono text-sm text-fg-4">0{i + 1}</span>
74 + </div>
75 + <h3 className="mt-5 text-lg font-semibold tracking-tight">{s.title}</h3>
76 + <p className="mt-2 text-sm leading-relaxed text-fg-3">{s.body}</p>
77 + </li>
78 + ))}
79 + </ol>
80 + <div className="mt-6">
81 + <Link href="/how-it-works" className="tap inline-flex h-10 min-h-0 items-center gap-1 text-sm font-medium text-fg-2 hover:text-fg">
82 + Read the full guide <ArrowRight className="h-4 w-4" />
83 + </Link>
84 + </div>
85 + </section>
86 +
87 + {/* ----------------------------------------------------- featured */}
88 + <section className="mt-16 lg:mt-24" aria-labelledby="featured-title">
89 + {featuredRow.length ? (
90 + <GameRow id="featured" title="Featured games" eyebrow="Tonight in the lobby" games={featuredRow} href="/games" />
91 + ) : (
92 + <div className="surface rounded-lg p-8 text-center">
93 + <div className="eyebrow mb-2">Featured games</div>
94 + <h2 id="featured-title" className="text-xl font-semibold tracking-tight">
95 + {apiDown ? "The lobby is warming up." : "New titles are being certified."}
96 + </h2>
97 + <p className="mx-auto mt-2 max-w-md text-sm text-fg-3">{apiDown ? "We could not reach the game service right now. Refresh in a moment — your credits and progress are safe." : "Every Spinza game is verified against its published math before it opens. Check back soon."}</p>
98 + </div>
99 + )}
100 + </section>
101 +
102 + {/* ------------------------------------------------- originals */}
103 + <section className="mt-16 grid gap-6 lg:mt-24 lg:grid-cols-[1.1fr_0.9fr]" aria-labelledby="originals-title">
104 + <div className="surface relative overflow-hidden rounded-xl p-8 sm:p-10">
105 + <div className="eyebrow mb-3">Original games</div>
106 + <h2 id="originals-title" className="text-3xl font-semibold tracking-tight sm:text-4xl">
107 + Built from scratch. Every reel, every rule.
108 + </h2>
109 + <p className="mt-4 max-w-lg text-[15px] leading-relaxed text-fg-2">Spinza games are not clones. Each title is designed in-house with its own symbol set, math model, feature logic and artwork — from the tumbling multipliers of Cosmic Collapse to the hold-and-respin vault of Diamond Heist. Every game is simulated across millions of rounds and certified against its published math before it reaches the lobby.</p>
110 + <ul className="mt-6 grid gap-2 text-sm text-fg-2 sm:grid-cols-2">
111 + {["Provably consistent math", "Original artwork and symbols", "Transparent Game Info on every title", "Fair, server-side outcomes"].map((t) => (
112 + <li key={t} className="flex items-center gap-2">
113 + <ShieldCheck className="h-4 w-4 text-accent" /> {t}
114 + </li>
115 + ))}
116 + </ul>
117 + </div>
118 + <div className="grid gap-4">
119 + <div className="surface rounded-xl p-8">
120 + <div className="flex items-center gap-2 text-accent">
121 + <Coins className="h-5 w-5" />
122 + <h3 className="text-lg font-semibold tracking-tight text-fg">What are Spinza Credits?</h3>
123 + </div>
124 + <p className="mt-3 text-sm leading-relaxed text-fg-2">Spinza Credits (SC) are a fictional currency that exists only inside Spinza. They have no monetary value and cannot be bought, sold, transferred, withdrawn or exchanged for anything. Run low and they refill through daily rewards, missions, level-ups and rescue credits — always for free.</p>
125 + <div className="mt-4 flex flex-wrap gap-2 text-[12px] font-semibold text-fg-3">
126 + {["No purchases", "No withdrawals", "No prizes", "No cash value"].map((t) => (
127 + <span key={t} className="inline-flex items-center gap-1 rounded-full border border-line px-2.5 py-1">
128 + <Ban className="h-3 w-3" /> {t}
129 + </span>
130 + ))}
131 + </div>
132 + </div>
133 + <div className="surface rounded-xl p-8">
134 + <div className="flex items-center gap-2 text-accent">
135 + <Clock className="h-5 w-5" />
136 + <h3 className="text-lg font-semibold tracking-tight text-fg">Play responsibly</h3>
137 + </div>
138 + <p className="mt-3 text-sm leading-relaxed text-fg-2">Even with fictional credits, time matters. Spinza includes session reminders, break prompts, animation controls and an opt-out for leaderboards. Spinza is for adults 18 and over.</p>
139 + <Link href="/responsible-play" className="tap mt-3 inline-flex h-10 min-h-0 items-center gap-1 text-sm font-medium text-accent-2 hover:underline">
140 + Responsible play tools <ArrowRight className="h-4 w-4" />
141 + </Link>
142 + </div>
143 + </div>
144 + </section>
145 +
146 + {/* -------------------------------------------------------- CTA */}
147 + <section className="mt-16 rounded-xl border border-accent/25 bg-[radial-gradient(ellipse_at_top,rgba(201,169,97,0.16),transparent_60%)] p-8 text-center sm:p-12 lg:mt-24">
148 + <h2 className="text-3xl font-semibold tracking-tight sm:text-4xl">Your first {formatSC(STARTING_BALANCE)} are waiting.</h2>
149 + <p className="mx-auto mt-3 max-w-md text-sm text-fg-2">Create a username, save your recovery code, and you are in the lobby in under a minute.</p>
150 + <div className="mt-6 flex flex-col justify-center gap-3 sm:flex-row">
151 + <Button variant="accent" size="lg" href="/register">
152 + PLAY FREE
153 + </Button>
154 + <Button variant="ghost" size="lg" href="/login">
155 + Sign in
156 + </Button>
157 + </div>
158 + </section>
159 +
160 + <SiteFooter className="mt-16 lg:mt-24" />
161 + </div>
162 + );
163 +}
added apps/web/src/components/lobby/lobby.tsx +137 −0
@@ -0,0 +1,137 @@
1 +import { Suspense } from "react";
2 +import Link from "next/link";
3 +import { Play, Info, Sparkles } from "lucide-react";
4 +import type { GameCard as GameCardData, PublicUser } from "@spinza/shared";
5 +import { Button } from "@/components/ui";
6 +import { ServerUnavailable } from "@/components/shell/api-error";
7 +import { GameArt } from "./game-art";
8 +import { GameCard, GameRow, GameTile, VolatilityMeter } from "./game-card";
9 +import { RewardsStrip } from "./rewards-strip";
10 +import { WelcomeOverlay } from "./welcome-overlay";
11 +import { timeAgo } from "@/lib/utils";
12 +
13 +export interface LobbyProps {
14 + user: PublicUser;
15 + games: GameCardData[] | null;
16 + livePlayers: number;
17 + recommendations: string[];
18 +}
19 +
20 +const FLAGSHIP_SLUG = "spinza-original";
21 +
22 +export function Lobby({ user, games, livePlayers, recommendations }: LobbyProps) {
23 + const list = games ?? [];
24 + const flagship = list.find((g) => g.slug === FLAGSHIP_SLUG) ?? list.find((g) => g.isFeatured) ?? list[0] ?? null;
25 + const continuePlaying = list.filter((g) => g.lastPlayedAt).sort((a, b) => new Date(b.lastPlayedAt!).getTime() - new Date(a.lastPlayedAt!).getTime());
26 + const featured = list.filter((g) => g.isFeatured);
27 + const fresh = list.filter((g) => g.isNew);
28 + const popular = [...list].sort((a, b) => b.popularity - a.popularity).slice(0, 10);
29 + const highVol = list.filter((g) => g.volatility === "high" || g.volatility === "extreme");
30 + const relaxed = list.filter((g) => g.volatility === "low" || g.volatility === "medium");
31 + const jackpots = list.filter((g) => g.isJackpot);
32 + const picks = recommendations.map((s) => list.find((g) => g.slug === s)).filter((g): g is GameCardData => !!g);
33 + const welcomePicks = picks.length ? picks : list.slice(0, 3);
34 +
35 + return (
36 + <div className="space-y-10 lg:space-y-12">
37 + <Suspense fallback={null}>
38 + <WelcomeOverlay picks={welcomePicks} />
39 + </Suspense>
40 +
41 + {/* ---------------------------------------------------------- hero */}
42 + {flagship ? (
43 + <section className="relative overflow-hidden rounded-xl border border-line bg-bg-1" aria-labelledby="lobby-hero-title">
44 + <div className="absolute inset-0">
45 + <GameArt slug={flagship.slug} name={flagship.name} palette={flagship.palette} variant="hero" showName={false} />
46 + <div className="absolute inset-0 bg-[linear-gradient(90deg,rgba(7,8,12,0.92)_0%,rgba(7,8,12,0.7)_45%,rgba(7,8,12,0.15)_100%)]" />
47 + <div className="absolute inset-x-0 bottom-0 h-2/3 bg-[linear-gradient(180deg,transparent,rgba(7,8,12,0.85))]" />
48 + </div>
49 + <div className="relative flex min-h-[340px] flex-col justify-end p-6 sm:min-h-[420px] sm:p-10">
50 + <div className="eyebrow mb-3 flex items-center gap-2 text-accent">
51 + <Sparkles className="h-3.5 w-3.5" /> {flagship.slug === FLAGSHIP_SLUG ? "Flagship title" : "Featured"}
52 + </div>
53 + <h1 id="lobby-hero-title" className="text-4xl font-semibold leading-[0.95] tracking-[-0.04em] sm:text-6xl">
54 + {flagship.name}
55 + </h1>
56 + <p className="mt-3 max-w-md text-[15px] text-fg-2 sm:text-base">{flagship.tagline}</p>
57 + <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px] text-fg-3">
58 + <VolatilityMeter volatility={flagship.volatility} />
59 + <span>{flagship.grid.reels}×{flagship.grid.rows} · {flagship.features.slice(0, 2).join(" · ")}</span>
60 + </div>
61 + <div className="mt-6 flex flex-wrap gap-3">
62 + <Button size="lg" href={`/games/${flagship.slug}`} className="min-w-[160px]">
63 + <Play className="h-4 w-4 fill-current" /> Play now
64 + </Button>
65 + <Button variant="outline" size="lg" href={`/games/${flagship.slug}#info`} className="glass">
66 + <Info className="h-4 w-4" /> Game info
67 + </Button>
68 + </div>
69 + </div>
70 + </section>
71 + ) : games === null ? (
72 + <ServerUnavailable />
73 + ) : (
74 + <section className="surface rounded-xl p-8 text-center sm:p-12">
75 + <div className="eyebrow mb-2">Lobby</div>
76 + <h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Welcome back, {user.username}.</h1>
77 + <p className="mx-auto mt-2 max-w-md text-sm text-fg-3">New games are being certified and will appear here as soon as they pass. Your credits and progress are safe.</p>
78 + <Button className="mt-6" variant="secondary" href="/rewards">
79 + Visit rewards
80 + </Button>
81 + </section>
82 + )}
83 +
84 + {/* --------------------------------------------------------- strip */}
85 + <RewardsStrip livePlayers={livePlayers} />
86 +
87 + {games === null && flagship ? <ServerUnavailable compact /> : null}
88 +
89 + {/* ------------------------------------------------------ sections */}
90 + {continuePlaying.length ? (
91 + <section aria-labelledby="continue-title">
92 + <div className="mb-3">
93 + <div className="eyebrow mb-1">Pick up where you left off</div>
94 + <h2 id="continue-title" className="text-xl font-semibold tracking-tight sm:text-2xl">
95 + Continue playing
96 + </h2>
97 + </div>
98 + <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
99 + {continuePlaying.slice(0, 6).map((g) => (
100 + <GameTile key={g.slug} game={g} meta={`Played ${timeAgo(g.lastPlayedAt!)}`} />
101 + ))}
102 + </div>
103 + </section>
104 + ) : null}
105 +
106 + <GameRow id="featured" title="Featured games" eyebrow="Curated tonight" games={featured} href="/games?filter=featured" />
107 + <GameRow id="new" title="New releases" eyebrow="Fresh from the studio" games={fresh} href="/games?filter=new" />
108 + <GameRow id="popular" title="Popular" eyebrow="Most played this week" games={popular} href="/games" />
109 + <GameRow id="high" title="High volatility" eyebrow="Big swings, big moments" games={highVol} href="/games?filter=high" />
110 + <GameRow id="relaxed" title="Relaxed games" eyebrow="Steady, easy sessions" games={relaxed} href="/games?filter=relaxed" />
111 + <GameRow id="jackpot" title="Jackpot games" eyebrow="Hold, respin, fill the grid" games={jackpots} href="/games?filter=jackpot" />
112 +
113 + {list.length ? (
114 + <section aria-labelledby="all-title">
115 + <div className="mb-4 flex items-end justify-between">
116 + <div>
117 + <div className="eyebrow mb-1">{list.length} titles</div>
118 + <h2 id="all-title" className="text-xl font-semibold tracking-tight sm:text-2xl">
119 + All games
120 + </h2>
121 + </div>
122 + <Link href="/games" className="tap inline-flex h-9 min-h-0 items-center text-sm font-medium text-fg-3 hover:text-fg">
123 + Filter &amp; search
124 + </Link>
125 + </div>
126 + <div className="grid grid-cols-2 gap-x-3 gap-y-6 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
127 + {list.map((g) => (
128 + <GameCard key={g.slug} game={g} className="w-full sm:w-full" />
129 + ))}
130 + </div>
131 + </section>
132 + ) : null}
133 +
134 + <p className="pt-4 text-center text-[12px] text-fg-4">Virtual credits only. No deposits. No withdrawals. No cash value. 18+.</p>
135 + </div>
136 + );
137 +}
added apps/web/src/components/lobby/prose.tsx +64 −0
@@ -0,0 +1,64 @@
1 +import Link from "next/link";
2 +import { AppShell } from "@/components/shell/app-shell";
3 +import { SiteFooter } from "./site-footer";
4 +import { cn } from "@/lib/utils";
5 +
6 +/** Long-form page shell: eyebrow, title, lead, sectioned prose, footer. */
7 +export function ProsePage({ eyebrow, title, lead, updated, children, toc }: { eyebrow: string; title: string; lead: string; updated?: string; children: React.ReactNode; toc?: { id: string; label: string }[] }) {
8 + return (
9 + <AppShell>
10 + <article className="mx-auto max-w-3xl">
11 + <header className="mb-10">
12 + <div className="eyebrow mb-2">{eyebrow}</div>
13 + <h1 className="text-4xl font-semibold tracking-[-0.03em] sm:text-5xl">{title}</h1>
14 + <p className="mt-4 text-lg leading-relaxed text-fg-2">{lead}</p>
15 + {updated ? <p className="mt-3 text-[12px] text-fg-4">Last updated {updated}</p> : null}
16 + </header>
17 + {toc?.length ? (
18 + <nav aria-label="On this page" className="mb-10 rounded-lg border border-line p-4">
19 + <div className="eyebrow mb-2">On this page</div>
20 + <ol className="grid gap-1.5 text-sm sm:grid-cols-2">
21 + {toc.map((t, i) => (
22 + <li key={t.id}>
23 + <Link href={`#${t.id}`} className="inline-flex items-baseline gap-2 text-fg-2 hover:text-fg">
24 + <span className="font-mono text-[11px] text-fg-4">{String(i + 1).padStart(2, "0")}</span> {t.label}
25 + </Link>
26 + </li>
27 + ))}
28 + </ol>
29 + </nav>
30 + ) : null}
31 + <div className="prose-spinza space-y-10">{children}</div>
32 + <SiteFooter className="mt-20" />
33 + </article>
34 + </AppShell>
35 + );
36 +}
37 +
38 +export function Section({ id, title, children, className }: { id: string; title: string; children: React.ReactNode; className?: string }) {
39 + return (
40 + <section id={id} aria-labelledby={`${id}-h`} className={cn("scroll-mt-24", className)}>
41 + <h2 id={`${id}-h`} className="text-2xl font-semibold tracking-tight">
42 + {title}
43 + </h2>
44 + <div className="mt-3 space-y-3 text-[15px] leading-relaxed text-fg-2">{children}</div>
45 + </section>
46 + );
47 +}
48 +
49 +export function Callout({ children, tone = "accent" }: { children: React.ReactNode; tone?: "accent" | "neutral" }) {
50 + return <div className={cn("rounded-md border p-4 text-[15px] leading-relaxed", tone === "accent" ? "border-accent/30 bg-accent-soft text-fg" : "border-line bg-surface text-fg-2")}>{children}</div>;
51 +}
52 +
53 +export function Bullets({ items }: { items: React.ReactNode[] }) {
54 + return (
55 + <ul className="space-y-2">
56 + {items.map((it, i) => (
57 + <li key={i} className="flex gap-3">
58 + <span className="mt-[9px] h-1.5 w-1.5 shrink-0 rounded-full bg-accent" aria-hidden />
59 + <span>{it}</span>
60 + </li>
61 + ))}
62 + </ul>
63 + );
64 +}
added apps/web/src/components/lobby/rewards-strip.tsx +115 −0
@@ -0,0 +1,115 @@
1 +"use client";
2 +
3 +import { useEffect, useState } from "react";
4 +import Link from "next/link";
5 +import { Gift, Users, ChevronRight, Flame } from "lucide-react";
6 +import type { DailyRewardStatus } from "@spinza/shared";
7 +import { api } from "@/lib/api";
8 +import { toast, useSession } from "@/lib/store";
9 +import { useApi } from "@/lib/use-api";
10 +import { countdown, cn } from "@/lib/utils";
11 +import { Button, Credits, Skeleton } from "@/components/ui";
12 +
13 +interface ClaimResponse {
14 + amount: number;
15 + streakDay: number;
16 + balance: number;
17 + nextAvailableAt: string;
18 + xp: { xp: number; level: number; leveledUp?: boolean } | null;
19 +}
20 +
21 +/** Lobby strip: daily reward availability + live players. */
22 +export function RewardsStrip({ livePlayers }: { livePlayers: number }) {
23 + const { data, loading, error, setData, reload } = useApi<DailyRewardStatus>("/api/rewards/daily");
24 + const setBalance = useSession((s) => s.setBalance);
25 + const setUserXp = useSession((s) => s.setUserXp);
26 + const [claiming, setClaiming] = useState(false);
27 + const [, tick] = useState(0);
28 +
29 + useEffect(() => {
30 + if (!data || data.available) return;
31 + const t = setInterval(() => tick((n) => n + 1), 1000);
32 + return () => clearInterval(t);
33 + }, [data]);
34 +
35 + const claim = async () => {
36 + if (claiming) return;
37 + setClaiming(true);
38 + try {
39 + const res = await api<ClaimResponse>("/api/rewards/daily/claim", { method: "POST" });
40 + setBalance(res.balance);
41 + if (res.xp) setUserXp(res.xp.xp, res.xp.level);
42 + toast({ title: `+${res.amount.toLocaleString("en-US")} SC`, description: `Daily reward · day ${res.streakDay} of ${data?.schedule.length ?? 7}`, tone: "credit" });
43 + setData((d) => (d ? { ...d, available: false, claimedToday: true, streakDay: res.streakDay, nextAvailableAt: res.nextAvailableAt } : d));
44 + } catch (e) {
45 + toast({ title: "Could not claim", description: e instanceof Error ? e.message : undefined, tone: "danger" });
46 + void reload();
47 + } finally {
48 + setClaiming(false);
49 + }
50 + };
51 +
52 + return (
53 + <div className="grid gap-3 sm:grid-cols-[1fr_auto]">
54 + <div className={cn("surface flex items-center gap-4 rounded-lg p-3 pl-4", data?.available && "border-accent/40 shadow-glow")}>
55 + <span className={cn("grid h-11 w-11 shrink-0 place-items-center rounded-md", data?.available ? "bg-accent text-bg" : "metal text-accent-2")}>
56 + <Gift className="h-5 w-5" />
57 + </span>
58 + <div className="min-w-0 flex-1">
59 + {loading && !data ? (
60 + <>
61 + <Skeleton className="h-4 w-40" />
62 + <Skeleton className="mt-2 h-3 w-24" />
63 + </>
64 + ) : error && !data ? (
65 + <>
66 + <div className="text-[15px] font-semibold">Daily reward</div>
67 + <div className="text-[13px] text-fg-3">Unavailable right now.</div>
68 + </>
69 + ) : data ? (
70 + <>
71 + <div className="flex items-center gap-2 text-[15px] font-semibold tracking-tight">
72 + {data.available ? "Your daily reward is ready" : "Daily reward claimed"}
73 + {data.streakDay > 1 ? (
74 + <span className="inline-flex items-center gap-0.5 rounded-full bg-surface-2 px-2 py-0.5 text-[11px] font-bold text-fg-2">
75 + <Flame className="h-3 w-3 text-accent" /> {data.streakDay}-day streak
76 + </span>
77 + ) : null}
78 + </div>
79 + <div className="text-[13px] text-fg-3">
80 + {data.available ? (
81 + <>
82 + Claim <Credits amount={data.nextAmount} size="sm" /> · day {Math.min(data.streakDay + 1, data.schedule.length)} of {data.schedule.length}
83 + </>
84 + ) : (
85 + <>
86 + Next reward <Credits amount={data.nextAmount} size="sm" /> in <span className="tabular text-fg-2">{countdown(data.nextAvailableAt)}</span>
87 + </>
88 + )}
89 + </div>
90 + </>
91 + ) : null}
92 + </div>
93 + {data?.available ? (
94 + <Button variant="accent" size="md" onClick={claim} loading={claiming} className="shrink-0">
95 + Claim
96 + </Button>
97 + ) : (
98 + <Link href="/rewards" className="tap grid h-11 w-11 place-items-center rounded-md text-fg-3 hover:bg-surface-2 hover:text-fg focus-ring" aria-label="Open rewards">
99 + <ChevronRight className="h-5 w-5" />
100 + </Link>
101 + )}
102 + </div>
103 + <div className="surface flex items-center gap-3 rounded-lg px-4 py-3">
104 + <span className="relative grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-success">
105 + <Users className="h-4 w-4" />
106 + <span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-success shadow-[0_0_10px_rgba(61,220,151,0.9)] animate-pulse-soft" />
107 + </span>
108 + <div>
109 + <div className="text-[15px] font-semibold tabular">{livePlayers.toLocaleString("en-US")}</div>
110 + <div className="text-[12px] text-fg-3">{livePlayers === 1 ? "player" : "players"} live now</div>
111 + </div>
112 + </div>
113 + </div>
114 + );
115 +}
added apps/web/src/components/lobby/site-footer.tsx +40 −0
@@ -0,0 +1,40 @@
1 +import Link from "next/link";
2 +import { SpinzaWordmark } from "@/components/brand/logo";
3 +
4 +const LINKS = [
5 + { href: "/how-it-works", label: "How it works" },
6 + { href: "/responsible-play", label: "Responsible play" },
7 + { href: "/legal/terms", label: "Terms" },
8 + { href: "/legal/privacy", label: "Privacy" },
9 + { href: "/login", label: "Sign in" },
10 + { href: "/register", label: "Create account" },
11 +];
12 +
13 +export function SiteFooter({ className }: { className?: string }) {
14 + return (
15 + <footer className={className}>
16 + <div className="border-t border-line pt-10">
17 + <div className="flex flex-col gap-8 md:flex-row md:items-start md:justify-between">
18 + <div className="max-w-sm">
19 + <SpinzaWordmark size="sm" />
20 + <p className="mt-3 text-[13px] leading-relaxed text-fg-3">Spinza is a fictional social casino. Spinza Credits (SC) are virtual, have no cash value, cannot be purchased and cannot be withdrawn or exchanged.</p>
21 + </div>
22 + <nav aria-label="Footer" className="grid grid-cols-2 gap-x-10 gap-y-2 sm:grid-cols-3">
23 + {LINKS.map((l) => (
24 + <Link key={l.href} href={l.href} className="tap inline-flex h-9 min-h-0 items-center text-sm text-fg-2 hover:text-fg">
25 + {l.label}
26 + </Link>
27 + ))}
28 + </nav>
29 + </div>
30 + <div className="mt-8 flex flex-col gap-2 border-t border-line py-6 text-[12px] text-fg-4 sm:flex-row sm:items-center sm:justify-between">
31 + <p>Virtual credits only. No deposits. No withdrawals. No cash value.</p>
32 + <p>
33 + <span className="mr-2 inline-flex h-6 items-center rounded-full border border-line-2 px-2 font-bold text-fg-3">18+</span>
34 + For adults only. Spinza is not gambling: nothing of value is ever at stake.
35 + </p>
36 + </div>
37 + </div>
38 + </footer>
39 + );
40 +}
added apps/web/src/components/lobby/welcome-overlay.tsx +103 −0
@@ -0,0 +1,103 @@
1 +"use client";
2 +
3 +import { useEffect, useState } from "react";
4 +import Link from "next/link";
5 +import { useRouter, useSearchParams } from "next/navigation";
6 +import { AnimatePresence, animate, motion } from "framer-motion";
7 +import { X, Play } from "lucide-react";
8 +import type { GameCard as GameCardData } from "@spinza/shared";
9 +import { STARTING_BALANCE } from "@spinza/shared";
10 +import { useSession } from "@/lib/store";
11 +import { Button } from "@/components/ui";
12 +import { GameArt } from "./game-art";
13 +import { VolatilityMeter } from "./game-card";
14 +
15 +/** First-time experience shown after registration (`/?welcome=1`). */
16 +export function WelcomeOverlay({ picks }: { picks: GameCardData[] }) {
17 + const params = useSearchParams();
18 + const router = useRouter();
19 + const user = useSession((s) => s.user);
20 + const reduceMotion = useSession((s) => s.settings?.reduceMotion) ?? false;
21 + const [open, setOpen] = useState(params.get("welcome") === "1");
22 + const [phase, setPhase] = useState<"credits" | "choose">("credits");
23 + const [count, setCount] = useState(0);
24 +
25 + useEffect(() => {
26 + if (!open) return;
27 + document.body.style.overflow = "hidden";
28 + const ctrl = animate(0, STARTING_BALANCE, { duration: reduceMotion ? 0.2 : 1.8, ease: [0.16, 1, 0.3, 1], onUpdate: (v) => setCount(Math.round(v)) });
29 + const t = setTimeout(() => setPhase("choose"), reduceMotion ? 600 : 2600);
30 + return () => {
31 + ctrl.stop();
32 + clearTimeout(t);
33 + document.body.style.overflow = "";
34 + };
35 + }, [open, reduceMotion]);
36 +
37 + const dismiss = () => {
38 + setOpen(false);
39 + router.replace("/", { scroll: false });
40 + };
41 +
42 + return (
43 + <AnimatePresence>
44 + {open ? (
45 + <motion.div className="fixed inset-0 flex flex-col overflow-y-auto bg-bg/95 backdrop-blur-xl" style={{ zIndex: "var(--z-modal)" }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} role="dialog" aria-modal aria-labelledby="welcome-title">
46 + <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_50%_30%,rgba(139,92,246,0.22),transparent_55%)]" />
47 + <button onClick={dismiss} className="tap absolute right-4 top-4 grid place-items-center rounded-full glass text-fg-2 hover:text-fg focus-ring" style={{ marginTop: "var(--safe-top)" }} aria-label="Skip">
48 + <X className="h-5 w-5" />
49 + </button>
50 + <div className="page relative flex flex-1 flex-col items-center justify-center py-16 text-center">
51 + <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}>
52 + <div className="eyebrow mb-3 text-accent">{user ? `@${user.username}` : "New player"}</div>
53 + <h1 id="welcome-title" className="text-4xl font-semibold tracking-[-0.04em] sm:text-6xl">
54 + Welcome to Spinza.
55 + </h1>
56 + </motion.div>
57 + <motion.div className="mt-8" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.3, duration: 0.7, ease: [0.16, 1, 0.3, 1] }}>
58 + <div className="text-[64px] font-semibold leading-none tracking-[-0.05em] text-credit tabular sm:text-[96px]" aria-live="polite">
59 + +{count.toLocaleString("en-US")}
60 + <span className="ml-2 text-[0.35em] font-bold tracking-wider text-credit/70">SC</span>
61 + </div>
62 + <p className="mt-3 text-sm text-fg-3">Your starting Spinza Credits. Fictional, free, and refilled every day you come back.</p>
63 + </motion.div>
64 +
65 + <AnimatePresence>
66 + {phase === "choose" ? (
67 + <motion.div className="mt-12 w-full max-w-3xl" initial={{ opacity: 0, y: 24 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}>
68 + <h2 className="text-xl font-semibold tracking-tight sm:text-2xl">Choose your first game</h2>
69 + <p className="mt-1 text-sm text-fg-3">Three picks to start with. You can always come back to the lobby.</p>
70 + <div className="mt-6 grid grid-cols-3 gap-2 sm:gap-3">
71 + {picks.map((g, i) => (
72 + <motion.div key={g.slug} initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 + i * 0.1, duration: 0.5, ease: [0.16, 1, 0.3, 1] }}>
73 + <Link href={`/games/${g.slug}`} onClick={() => setOpen(false)} className="group block overflow-hidden rounded-lg border border-line bg-bg-1 text-left shadow-card transition-transform hover:-translate-y-1 focus-ring" aria-label={`Play ${g.name}`}>
74 + <div className="aspect-[3/4]">
75 + <GameArt slug={g.slug} name={g.name} palette={g.palette} variant="card" />
76 + </div>
77 + <div className="flex items-center justify-between gap-2 p-2 sm:p-3">
78 + <div className="min-w-0">
79 + <div className="hidden truncate text-[12px] text-fg-3 sm:block">{g.tagline}</div>
80 + <VolatilityMeter volatility={g.volatility} className="sm:mt-1" />
81 + </div>
82 + <span className="hidden h-9 w-9 shrink-0 place-items-center rounded-full bg-fg text-bg sm:grid">
83 + <Play className="ml-0.5 h-4 w-4 fill-current" />
84 + </span>
85 + </div>
86 + </Link>
87 + </motion.div>
88 + ))}
89 + </div>
90 + <div className="mt-8">
91 + <Button variant="ghost" onClick={dismiss}>
92 + Browse the whole lobby instead
93 + </Button>
94 + </div>
95 + </motion.div>
96 + ) : null}
97 + </AnimatePresence>
98 + </div>
99 + </motion.div>
100 + ) : null}
101 + </AnimatePresence>
102 + );
103 +}
added apps/web/src/components/shell/api-error.tsx +34 −0
@@ -0,0 +1,34 @@
1 +"use client";
2 +
3 +import { useRouter } from "next/navigation";
4 +import { WifiOff, ServerCrash, Wrench, AlertCircle, RefreshCw } from "lucide-react";
5 +import { Button } from "@/components/ui";
6 +import { describeError } from "@/lib/use-api";
7 +import { ApiClientError } from "@/lib/api";
8 +import { cn } from "@/lib/utils";
9 +
10 +/** Polished error state for connection loss, server outage and maintenance. */
11 +export function ApiErrorState({ error, retry, className, compact }: { error: unknown; retry?: () => void; className?: string; compact?: boolean }) {
12 + const d = describeError(error);
13 + const Icon = d.kind === "network" ? WifiOff : d.kind === "maintenance" ? Wrench : d.kind === "server" ? ServerCrash : AlertCircle;
14 + return (
15 + <div className={cn("surface rounded-lg text-center", compact ? "px-4 py-6" : "px-6 py-12", className)} role="alert">
16 + <div className={cn("mx-auto grid place-items-center rounded-full bg-surface-2 text-fg-2", compact ? "mb-3 h-10 w-10" : "mb-4 h-14 w-14", d.kind === "maintenance" && "text-accent")}>
17 + <Icon className={compact ? "h-5 w-5" : "h-6 w-6"} />
18 + </div>
19 + <h3 className={cn("font-semibold tracking-tight", compact ? "text-base" : "text-lg")}>{d.title}</h3>
20 + <p className="mx-auto mt-1.5 max-w-sm text-sm text-fg-3">{d.description}</p>
21 + {retry ? (
22 + <Button variant="secondary" size={compact ? "sm" : "md"} className="mt-5" onClick={retry}>
23 + <RefreshCw className="h-4 w-4" /> Try again
24 + </Button>
25 + ) : null}
26 + </div>
27 + );
28 +}
29 +
30 +/** Server components can't retry in place — this reloads the route from the client. */
31 +export function ServerUnavailable({ className, compact }: { className?: string; compact?: boolean }) {
32 + const router = useRouter();
33 + return <ApiErrorState error={new ApiClientError(503, "UNAVAILABLE", "Server unavailable. Please try again shortly.")} retry={() => router.refresh()} className={className} compact={compact} />;
34 +}
added apps/web/src/components/shell/app-shell.tsx +152 −0
@@ -0,0 +1,152 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { usePathname, useRouter } from "next/navigation";
5 +import { Home, LayoutGrid, Gift, Trophy, User, LogOut, Settings, History, Shield } from "lucide-react";
6 +import { SpinzaWordmark, SpinzaMark } from "@/components/brand/logo";
7 +import { useSession } from "@/lib/store";
8 +import { Button, Credits } from "@/components/ui";
9 +import { cn } from "@/lib/utils";
10 +
11 +const NAV = [
12 + { href: "/", label: "Home", icon: Home },
13 + { href: "/games", label: "Games", icon: LayoutGrid },
14 + { href: "/rewards", label: "Rewards", icon: Gift },
15 + { href: "/leaderboard", label: "Leaderboard", icon: Trophy },
16 + { href: "/profile", label: "Profile", icon: User },
17 +];
18 +
19 +const SIDE_EXTRA = [
20 + { href: "/profile/history", label: "History", icon: History },
21 + { href: "/settings", label: "Settings", icon: Settings },
22 + { href: "/responsible-play", label: "Responsible play", icon: Shield },
23 +];
24 +
25 +function isActive(path: string, href: string) {
26 + return href === "/" ? path === "/" : path.startsWith(href);
27 +}
28 +
29 +/** Desktop: sidebar + top bar. Mobile: top logo + balance, bottom navigation. */
30 +export function AppShell({ children, wide }: { children: React.ReactNode; wide?: boolean }) {
31 + const path = usePathname();
32 + const router = useRouter();
33 + const { status, user, wallet, signOut } = useSession();
34 + const authed = status === "authenticated";
35 +
36 + return (
37 + <div className="min-h-dvh lg:grid lg:grid-cols-[248px_1fr]">
38 + {/* Sidebar (desktop) */}
39 + <aside className="sticky top-0 hidden h-dvh flex-col border-r border-line bg-bg-1/60 px-4 py-6 lg:flex">
40 + <Link href="/" className="px-2">
41 + <SpinzaWordmark />
42 + </Link>
43 + <nav className="mt-8 flex flex-col gap-1">
44 + {NAV.map((n) => (
45 + <Link key={n.href} href={n.href} className={cn("flex items-center gap-3 rounded-md px-3 py-2.5 text-[15px] font-medium transition-colors", isActive(path, n.href) ? "bg-surface-2 text-fg" : "text-fg-3 hover:bg-surface hover:text-fg-2")}>
46 + <n.icon className="h-[18px] w-[18px]" />
47 + {n.label}
48 + </Link>
49 + ))}
50 + <div className="my-3 h-px bg-line" />
51 + {SIDE_EXTRA.map((n) => (
52 + <Link key={n.href} href={n.href} className={cn("flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors", isActive(path, n.href) ? "bg-surface-2 text-fg" : "text-fg-3 hover:bg-surface hover:text-fg-2")}>
53 + <n.icon className="h-4 w-4" />
54 + {n.label}
55 + </Link>
56 + ))}
57 + </nav>
58 + <div className="mt-auto">
59 + {authed && user ? (
60 + <div className="surface rounded-lg p-3">
61 + <div className="flex items-center justify-between">
62 + <div className="truncate text-sm font-semibold">{user.username}</div>
63 + <span className="rounded-full bg-accent-soft px-2 py-0.5 text-[11px] font-bold text-accent-2">LVL {user.level}</span>
64 + </div>
65 + <div className="mt-1">
66 + <Credits amount={wallet?.balance ?? 0} size="md" />
67 + </div>
68 + <button
69 + onClick={() =>
70 + void signOut().then(() => {
71 + router.push("/");
72 + router.refresh();
73 + })
74 + }
75 + className="mt-3 flex items-center gap-2 text-[13px] text-fg-3 hover:text-fg"
76 + >
77 + <LogOut className="h-3.5 w-3.5" /> Sign out
78 + </button>
79 + </div>
80 + ) : (
81 + <div className="flex flex-col gap-2">
82 + <Button variant="accent" href="/register">
83 + Play free
84 + </Button>
85 + <Button variant="ghost" href="/login">
86 + Sign in
87 + </Button>
88 + </div>
89 + )}
90 + <p className="mt-4 px-1 text-[11px] leading-relaxed text-fg-4">Virtual credits only. No deposits. No withdrawals. No cash value. 18+.</p>
91 + </div>
92 + </aside>
93 +
94 + <div className="flex min-h-dvh flex-col">
95 + {/* Top bar */}
96 + <header className="sticky top-0 glass border-x-0 border-t-0" style={{ zIndex: "var(--z-nav)", paddingTop: "var(--safe-top)" }}>
97 + <div className={cn("flex h-14 items-center justify-between gap-3 px-4 lg:px-8", wide ? "" : "mx-auto max-w-[1320px]")}>
98 + <Link href="/" className="lg:hidden">
99 + <SpinzaMark className="h-8 w-8" />
100 + </Link>
101 + <div className="hidden text-sm text-fg-3 lg:block">{pageTitle(path)}</div>
102 + <div className="flex items-center gap-2">
103 + {authed ? (
104 + <>
105 + <Link href="/rewards" className="surface flex h-10 items-center gap-2 rounded-full px-3.5">
106 + <Credits amount={wallet?.balance ?? 0} size="sm" />
107 + </Link>
108 + <Link href="/profile" className="metal hidden h-10 items-center gap-2 rounded-full px-3 text-sm font-semibold sm:flex">
109 + <span className="grid h-6 w-6 place-items-center rounded-full bg-accent text-[11px] font-bold text-bg">{user?.username.slice(0, 1).toUpperCase()}</span>
110 + <span className="max-w-[120px] truncate">{user?.username}</span>
111 + </Link>
112 + </>
113 + ) : status === "guest" ? (
114 + <>
115 + <Button variant="ghost" size="sm" href="/login">
116 + Sign in
117 + </Button>
118 + <Button variant="accent" size="sm" href="/register">
119 + Play free
120 + </Button>
121 + </>
122 + ) : null}
123 + </div>
124 + </div>
125 + </header>
126 +
127 + <main className={cn("flex-1 pb-[calc(76px+var(--safe-bottom))] lg:pb-10", wide ? "" : "page py-6 lg:py-8")}>{children}</main>
128 +
129 + {/* Bottom nav (mobile) */}
130 + <nav className="fixed inset-x-0 bottom-0 glass border-x-0 border-b-0 lg:hidden" style={{ zIndex: "var(--z-nav)", paddingBottom: "var(--safe-bottom)" }}>
131 + <div className="grid h-[64px] grid-cols-5">
132 + {NAV.map((n) => {
133 + const active = isActive(path, n.href);
134 + return (
135 + <Link key={n.href} href={n.href} className={cn("flex flex-col items-center justify-center gap-1 text-[11px] font-medium transition-colors", active ? "text-accent-2" : "text-fg-3")}>
136 + <n.icon className={cn("h-[22px] w-[22px]", active && "drop-shadow-[0_0_8px_rgba(201,169,97,0.6)]")} />
137 + {n.label}
138 + </Link>
139 + );
140 + })}
141 + </div>
142 + </nav>
143 + </div>
144 + </div>
145 + );
146 +}
147 +
148 +function pageTitle(path: string): string {
149 + if (path === "/") return "Lobby";
150 + const seg = path.split("/")[1];
151 + return seg ? seg.charAt(0).toUpperCase() + seg.slice(1) : "";
152 +}
added apps/web/src/components/shell/providers.tsx +57 −0
@@ -0,0 +1,57 @@
1 +"use client";
2 +
3 +import { useEffect, useState } from "react";
4 +import { useSession, useToasts } from "@/lib/store";
5 +import type { PublicUser, UserSettings, WalletView } from "@spinza/shared";
6 +import { AnimatePresence, motion } from "framer-motion";
7 +import { cn } from "@/lib/utils";
8 +import { SessionReminder } from "./session-reminder";
9 +
10 +export function Providers({ children, session }: { children: React.ReactNode; session: { user: PublicUser; wallet: WalletView; settings: UserSettings } | null }) {
11 + const hydrate = useSession((s) => s.hydrate);
12 + // Hydrate synchronously on first render so the first paint already knows the session.
13 + useState(() => {
14 + hydrate(session);
15 + return true;
16 + });
17 + useEffect(() => {
18 + hydrate(session);
19 + }, [session, hydrate]);
20 + return (
21 + <>
22 + {children}
23 + <Toaster />
24 + <SessionReminder />
25 + </>
26 + );
27 +}
28 +
29 +function Toaster() {
30 + const toasts = useToasts((s) => s.toasts);
31 + const dismiss = useToasts((s) => s.dismiss);
32 + return (
33 + <div className="pointer-events-none fixed inset-x-0 top-0 flex flex-col items-center gap-2 px-4" style={{ zIndex: "var(--z-toast)", paddingTop: "calc(var(--safe-top) + 12px)" }}>
34 + <AnimatePresence>
35 + {toasts.map((t) => (
36 + <motion.button
37 + key={t.id}
38 + layout
39 + initial={{ opacity: 0, y: -16, scale: 0.96 }}
40 + animate={{ opacity: 1, y: 0, scale: 1 }}
41 + exit={{ opacity: 0, y: -10, scale: 0.96 }}
42 + onClick={() => dismiss(t.id)}
43 + className={cn(
44 + "pointer-events-auto glass w-full max-w-sm rounded-md px-4 py-3 text-left shadow-2xl",
45 + t.tone === "success" && "border-success/40",
46 + t.tone === "danger" && "border-danger/40",
47 + t.tone === "credit" && "border-accent/40",
48 + )}
49 + >
50 + <div className={cn("text-sm font-semibold", t.tone === "credit" && "text-credit", t.tone === "danger" && "text-danger", t.tone === "success" && "text-success")}>{t.title}</div>
51 + {t.description ? <div className="mt-0.5 text-[13px] text-fg-2">{t.description}</div> : null}
52 + </motion.button>
53 + ))}
54 + </AnimatePresence>
55 + </div>
56 + );
57 +}
added apps/web/src/components/shell/recovery-code.tsx +66 −0
@@ -0,0 +1,66 @@
1 +"use client";
2 +
3 +import { useState } from "react";
4 +import { Copy, Check, KeyRound, TriangleAlert } from "lucide-react";
5 +import { Button } from "@/components/ui";
6 +import { cn } from "@/lib/utils";
7 +
8 +const NOTICE = "Save this recovery code. Spinza does not collect your email address. If you lose your password and recovery code, your account cannot be recovered.";
9 +
10 +/** Prominent recovery-code display with copy button and a save-confirmation gate. */
11 +export function RecoveryCodePanel({ code, title = "Your recovery code", notice = NOTICE, continueLabel = "Continue", onContinue, loading, className, hideHeader }: { code: string; title?: string; notice?: string; continueLabel?: string; onContinue: () => void; loading?: boolean; className?: string; hideHeader?: boolean }) {
12 + const [copied, setCopied] = useState(false);
13 + const [saved, setSaved] = useState(false);
14 +
15 + const copy = async () => {
16 + try {
17 + await navigator.clipboard.writeText(code);
18 + setCopied(true);
19 + setTimeout(() => setCopied(false), 1800);
20 + } catch {
21 + // Clipboard unavailable (insecure context): user can still select the code.
22 + }
23 + };
24 +
25 + return (
26 + <div className={cn("space-y-5", className)}>
27 + {hideHeader ? null : (
28 + <div className="flex items-center gap-3">
29 + <span className="grid h-11 w-11 shrink-0 place-items-center rounded-md bg-accent text-bg">
30 + <KeyRound className="h-5 w-5" />
31 + </span>
32 + <div>
33 + <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
34 + <p className="text-[13px] text-fg-3">This code is the only way to reset your password.</p>
35 + </div>
36 + </div>
37 + )}
38 +
39 + <div className="metal rounded-lg p-4">
40 + <div className="eyebrow mb-2">Recovery code</div>
41 + <div className="flex items-center justify-between gap-3">
42 + <code className="min-w-0 select-all whitespace-nowrap font-mono text-[17px] font-semibold tracking-[0.03em] text-accent-2 sm:text-2xl sm:tracking-[0.06em]" aria-label={`Recovery code ${code}`}>
43 + {code}
44 + </code>
45 + <Button type="button" variant="secondary" size="icon" onClick={copy} aria-label="Copy recovery code" className="shrink-0">
46 + {copied ? <Check className="h-4 w-4 text-success" /> : <Copy className="h-4 w-4" />}
47 + </Button>
48 + </div>
49 + </div>
50 +
51 + <div className="flex gap-3 rounded-md border border-accent/30 bg-accent-soft p-4 text-[13px] leading-relaxed text-fg-2" role="note">
52 + <TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-accent" />
53 + <p>{notice}</p>
54 + </div>
55 +
56 + <label className="flex cursor-pointer items-start gap-3 rounded-md border border-line p-3.5 tap hover:bg-surface">
57 + <input type="checkbox" checked={saved} onChange={(e) => setSaved(e.target.checked)} className="mt-0.5 h-5 w-5 shrink-0 accent-[#c9a961]" />
58 + <span className="text-[15px] font-medium">I saved my recovery code</span>
59 + </label>
60 +
61 + <Button size="lg" variant="accent" className="w-full" disabled={!saved} loading={loading} onClick={onContinue}>
62 + {continueLabel}
63 + </Button>
64 + </div>
65 + );
66 +}
added apps/web/src/components/shell/require-auth.tsx +35 −0
@@ -0,0 +1,35 @@
1 +"use client";
2 +
3 +import { useEffect } from "react";
4 +import { usePathname, useRouter } from "next/navigation";
5 +import { useSession } from "@/lib/store";
6 +import { Skeleton } from "@/components/ui";
7 +
8 +/**
9 + * Gate for player-only screens. Guests are sent to /login?next=<current path>;
10 + * while the session is still hydrating, a skeleton keeps the layout stable.
11 + */
12 +export function RequireAuth({ children, fallback }: { children: React.ReactNode; fallback?: React.ReactNode }) {
13 + const status = useSession((s) => s.status);
14 + const router = useRouter();
15 + const pathname = usePathname();
16 +
17 + useEffect(() => {
18 + if (status === "guest") router.replace(`/login?next=${encodeURIComponent(pathname)}`);
19 + }, [status, router, pathname]);
20 +
21 + if (status !== "authenticated") {
22 + return (
23 + <>
24 + {fallback ?? (
25 + <div className="space-y-4" aria-busy>
26 + <Skeleton className="h-8 w-48" />
27 + <Skeleton className="h-40 w-full rounded-lg" />
28 + <Skeleton className="h-24 w-full rounded-lg" />
29 + </div>
30 + )}
31 + </>
32 + );
33 + }
34 + return <>{children}</>;
35 +}
added apps/web/src/components/shell/session-reminder.tsx +51 −0
@@ -0,0 +1,51 @@
1 +"use client";
2 +
3 +import { useEffect, useState } from "react";
4 +import { useSession } from "@/lib/store";
5 +import { Sheet, Button } from "@/components/ui";
6 +import { Clock } from "lucide-react";
7 +
8 +/** Responsible-play reminder: "You've been playing for N minutes. Take a break anytime." */
9 +export function SessionReminder() {
10 + const settings = useSession((s) => s.settings);
11 + const status = useSession((s) => s.status);
12 + const startedAt = useSession((s) => s.sessionStartedAt);
13 + const [shownFor, setShownFor] = useState<number | null>(null);
14 + const [open, setOpen] = useState(false);
15 + const interval = settings?.sessionReminderMinutes ?? 0;
16 +
17 + useEffect(() => {
18 + if (status !== "authenticated" || !interval) return;
19 + const t = setInterval(() => {
20 + const minutes = Math.floor((Date.now() - startedAt) / 60_000);
21 + const step = Math.floor(minutes / interval) * interval;
22 + if (step > 0 && step !== shownFor) {
23 + setShownFor(step);
24 + setOpen(true);
25 + }
26 + }, 15_000);
27 + return () => clearInterval(t);
28 + }, [status, interval, startedAt, shownFor]);
29 +
30 + return (
31 + <Sheet open={open} onClose={() => setOpen(false)} side="center" title="Time check">
32 + <div className="flex items-start gap-4">
33 + <div className="grid h-12 w-12 shrink-0 place-items-center rounded-full bg-accent-soft text-accent">
34 + <Clock className="h-6 w-6" />
35 + </div>
36 + <div>
37 + <p className="text-lg font-semibold tracking-tight">You&apos;ve been playing for {shownFor} minutes.</p>
38 + <p className="mt-1 text-sm text-fg-2">Take a break anytime. Spinza credits are fictional and your progress is always saved.</p>
39 + </div>
40 + </div>
41 + <div className="mt-6 flex gap-3">
42 + <Button variant="secondary" className="flex-1" onClick={() => setOpen(false)}>
43 + Keep playing
44 + </Button>
45 + <Button className="flex-1" href="/" onClick={() => setOpen(false)}>
46 + Back to lobby
47 + </Button>
48 + </div>
49 + </Sheet>
50 + );
51 +}
added apps/web/src/components/ui/index.tsx +247 −0
@@ -0,0 +1,247 @@
1 +"use client";
2 +
3 +import * as React from "react";
4 +import Link from "next/link";
5 +import { AnimatePresence, motion } from "framer-motion";
6 +import { X } from "lucide-react";
7 +import { cn } from "@/lib/utils";
8 +import { formatSC } from "@spinza/shared";
9 +
10 +/* ------------------------------------------------------------------ Button */
11 +
12 +type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent" | "outline";
13 +type ButtonSize = "sm" | "md" | "lg" | "xl" | "icon";
14 +
15 +export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
16 + variant?: ButtonVariant;
17 + size?: ButtonSize;
18 + loading?: boolean;
19 + href?: string;
20 +}
21 +
22 +const variantClass: Record<ButtonVariant, string> = {
23 + primary: "bg-fg text-bg hover:bg-white active:scale-[0.98] shadow-[0_10px_30px_-12px_rgba(255,255,255,0.35)]",
24 + accent: "bg-[linear-gradient(180deg,#e8cf8f,#c9a961)] text-[#1a1406] hover:brightness-110 active:scale-[0.98] shadow-glow",
25 + secondary: "surface-2 text-fg hover:bg-surface-3",
26 + outline: "border border-line-2 text-fg hover:bg-surface-2",
27 + ghost: "text-fg-2 hover:text-fg hover:bg-surface-2",
28 + danger: "bg-danger/15 text-danger border border-danger/30 hover:bg-danger/25",
29 +};
30 +
31 +const sizeClass: Record<ButtonSize, string> = {
32 + sm: "h-9 px-3 text-[13px] rounded-sm gap-1.5",
33 + md: "h-11 px-4 text-sm rounded-md gap-2",
34 + lg: "h-12 px-6 text-[15px] rounded-md gap-2",
35 + xl: "h-14 px-8 text-base rounded-lg gap-2.5",
36 + icon: "h-11 w-11 rounded-md",
37 +};
38 +
39 +export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button({ className, variant = "primary", size = "md", loading, href, children, disabled, ...props }, ref) {
40 + const cls = cn("inline-flex items-center justify-center font-semibold tracking-tight transition-all duration-200 select-none focus-ring disabled:opacity-50 disabled:pointer-events-none whitespace-nowrap", variantClass[variant], sizeClass[size], className);
41 + if (href) {
42 + return (
43 + <Link href={href} className={cls} aria-disabled={disabled}>
44 + {children}
45 + </Link>
46 + );
47 + }
48 + return (
49 + <button ref={ref} className={cls} disabled={disabled || loading} {...props}>
50 + {loading ? <Spinner className="h-4 w-4" /> : null}
51 + {children}
52 + </button>
53 + );
54 +});
55 +
56 +export function Spinner({ className }: { className?: string }) {
57 + return (
58 + <svg className={cn("animate-spin", className)} viewBox="0 0 24 24" fill="none" aria-hidden>
59 + <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
60 + <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
61 + </svg>
62 + );
63 +}
64 +
65 +/* -------------------------------------------------------------------- Card */
66 +
67 +export function Card({ className, children, as: Tag = "div", ...props }: React.HTMLAttributes<HTMLDivElement> & { as?: React.ElementType }) {
68 + return (
69 + <Tag className={cn("surface rounded-lg", className)} {...props}>
70 + {children}
71 + </Tag>
72 + );
73 +}
74 +
75 +/* ------------------------------------------------------------------- Badge */
76 +
77 +export function Badge({ className, tone = "neutral", children }: { className?: string; tone?: "neutral" | "accent" | "success" | "danger" | "info" | "new"; children: React.ReactNode }) {
78 + const tones = {
79 + neutral: "bg-surface-2 text-fg-2 border-line",
80 + accent: "bg-accent-soft text-accent-2 border-accent/30",
81 + success: "bg-success/10 text-success border-success/30",
82 + danger: "bg-danger/10 text-danger border-danger/30",
83 + info: "bg-info/10 text-info border-info/30",
84 + new: "bg-[#ff5c7a]/15 text-[#ff8aa0] border-[#ff5c7a]/30",
85 + };
86 + return <span className={cn("inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider", tones[tone], className)}>{children}</span>;
87 +}
88 +
89 +/* ------------------------------------------------------------------- Input */
90 +
91 +export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement> & { label?: string; hint?: string; error?: string | null }>(function Input({ className, label, hint, error, id, ...props }, ref) {
92 + const generatedId = React.useId();
93 + const inputId = id ?? generatedId;
94 + return (
95 + <label className="block" htmlFor={inputId}>
96 + {label ? <span className="mb-1.5 block text-[13px] font-medium text-fg-2">{label}</span> : null}
97 + <input
98 + ref={ref}
99 + id={inputId}
100 + className={cn(
101 + "h-12 w-full rounded-md border bg-bg-1 px-4 text-[15px] text-fg placeholder:text-fg-4 transition-colors focus-ring",
102 + error ? "border-danger/60" : "border-line-2 focus:border-accent/60",
103 + className,
104 + )}
105 + {...props}
106 + />
107 + {error ? <span className="mt-1.5 block text-[13px] text-danger">{error}</span> : hint ? <span className="mt-1.5 block text-[13px] text-fg-3">{hint}</span> : null}
108 + </label>
109 + );
110 +});
111 +
112 +/* ------------------------------------------------------------------ Switch */
113 +
114 +export function Switch({ checked, onChange, label, description }: { checked: boolean; onChange: (v: boolean) => void; label: string; description?: string }) {
115 + return (
116 + <button type="button" role="switch" aria-checked={checked} onClick={() => onChange(!checked)} className="flex w-full items-center justify-between gap-4 rounded-md px-1 py-3 text-left tap focus-ring">
117 + <span>
118 + <span className="block text-[15px] font-medium text-fg">{label}</span>
119 + {description ? <span className="block text-[13px] text-fg-3">{description}</span> : null}
120 + </span>
121 + <span className={cn("relative h-7 w-12 shrink-0 rounded-full border transition-colors", checked ? "bg-accent border-accent" : "bg-surface-3 border-line-2")}>
122 + <span className={cn("absolute top-0.5 h-[22px] w-[22px] rounded-full bg-white shadow transition-transform", checked ? "translate-x-[22px]" : "translate-x-0.5")} />
123 + </span>
124 + </button>
125 + );
126 +}
127 +
128 +/* ---------------------------------------------------------------- Progress */
129 +
130 +export function Progress({ value, max = 1, className, tone = "accent" }: { value: number; max?: number; className?: string; tone?: "accent" | "success" | "info" | "credit" }) {
131 + const pct = Math.max(0, Math.min(100, (value / (max || 1)) * 100));
132 + const tones = { accent: "bg-[linear-gradient(90deg,#c9a961,#e8cf8f)]", success: "bg-success", info: "bg-info", credit: "bg-credit" };
133 + return (
134 + <div className={cn("h-1.5 w-full overflow-hidden rounded-full bg-surface-3", className)} role="progressbar" aria-valuenow={Math.round(pct)} aria-valuemin={0} aria-valuemax={100}>
135 + <div className={cn("h-full rounded-full transition-[width] duration-700 ease-out", tones[tone])} style={{ width: `${pct}%` }} />
136 + </div>
137 + );
138 +}
139 +
140 +/* -------------------------------------------------------------- Credits */
141 +
142 +export function Credits({ amount, className, size = "md", sign }: { amount: number; className?: string; size?: "sm" | "md" | "lg" | "xl"; sign?: boolean }) {
143 + const s = { sm: "text-sm", md: "text-base", lg: "text-2xl", xl: "text-4xl sm:text-5xl" }[size];
144 + return (
145 + <span className={cn("font-semibold tabular text-credit", s, className)}>
146 + {sign && amount > 0 ? "+" : ""}
147 + {formatSC(amount, { unit: false })}
148 + <span className="ml-1 text-[0.6em] font-bold tracking-wider text-credit/70">SC</span>
149 + </span>
150 + );
151 +}
152 +
153 +/* ------------------------------------------------------------------ Sheet */
154 +
155 +export function Sheet({ open, onClose, title, children, side = "bottom", className }: { open: boolean; onClose: () => void; title?: string; children: React.ReactNode; side?: "bottom" | "right" | "center"; className?: string }) {
156 + React.useEffect(() => {
157 + if (!open) return;
158 + const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
159 + window.addEventListener("keydown", onKey);
160 + document.body.style.overflow = "hidden";
161 + return () => {
162 + window.removeEventListener("keydown", onKey);
163 + document.body.style.overflow = "";
164 + };
165 + }, [open, onClose]);
166 + const variants = {
167 + bottom: { initial: { y: "100%" }, animate: { y: 0 }, exit: { y: "100%" }, cls: "inset-x-0 bottom-0 rounded-t-xl max-h-[88dvh] sm:inset-x-auto sm:left-1/2 sm:-translate-x-1/2 sm:bottom-6 sm:w-[560px] sm:rounded-xl" },
168 + right: { initial: { x: "100%" }, animate: { x: 0 }, exit: { x: "100%" }, cls: "inset-y-0 right-0 w-full max-w-md" },
169 + center: { initial: { scale: 0.96, opacity: 0 }, animate: { scale: 1, opacity: 1 }, exit: { scale: 0.96, opacity: 0 }, cls: "left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[calc(100%-2rem)] max-w-lg rounded-xl" },
170 + }[side];
171 + return (
172 + <AnimatePresence>
173 + {open ? (
174 + <>
175 + <motion.div className="fixed inset-0 bg-black/70 backdrop-blur-sm" style={{ zIndex: "var(--z-sheet)" }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} />
176 + <motion.div
177 + role="dialog"
178 + aria-modal
179 + className={cn("fixed glass overflow-y-auto shadow-2xl", variants.cls, className)}
180 + style={{ zIndex: "calc(var(--z-sheet) + 1)", paddingBottom: side === "bottom" ? "var(--safe-bottom)" : undefined }}
181 + initial={variants.initial}
182 + animate={variants.animate}
183 + exit={variants.exit}
184 + transition={{ type: "spring", stiffness: 380, damping: 36 }}
185 + >
186 + <div className="flex items-center justify-between px-5 pt-4 pb-2">
187 + <h2 className="text-lg font-semibold tracking-tight">{title}</h2>
188 + <button onClick={onClose} className="tap -mr-2 grid place-items-center rounded-md text-fg-3 hover:text-fg focus-ring" aria-label="Close">
189 + <X className="h-5 w-5" />
190 + </button>
191 + </div>
192 + <div className="px-5 pb-5">{children}</div>
193 + </motion.div>
194 + </>
195 + ) : null}
196 + </AnimatePresence>
197 + );
198 +}
199 +
200 +/* ------------------------------------------------------------------- Tabs */
201 +
202 +export function Tabs<T extends string>({ value, onChange, items, className }: { value: T; onChange: (v: T) => void; items: { value: T; label: string }[]; className?: string }) {
203 + return (
204 + <div className={cn("inline-flex rounded-md bg-surface p-1 border border-line", className)} role="tablist">
205 + {items.map((it) => (
206 + <button key={it.value} role="tab" aria-selected={value === it.value} onClick={() => onChange(it.value)} className={cn("h-9 rounded-sm px-3.5 text-[13px] font-semibold transition-colors focus-ring", value === it.value ? "bg-surface-3 text-fg shadow" : "text-fg-3 hover:text-fg-2")}>
207 + {it.label}
208 + </button>
209 + ))}
210 + </div>
211 + );
212 +}
213 +
214 +/* ------------------------------------------------------------- Empty state */
215 +
216 +export function Empty({ title, description, action, icon }: { title: string; description?: string; action?: React.ReactNode; icon?: React.ReactNode }) {
217 + return (
218 + <div className="surface rounded-lg px-6 py-12 text-center">
219 + {icon ? <div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-surface-2 text-fg-3">{icon}</div> : null}
220 + <h3 className="text-lg font-semibold tracking-tight">{title}</h3>
221 + {description ? <p className="mx-auto mt-1.5 max-w-sm text-sm text-fg-3">{description}</p> : null}
222 + {action ? <div className="mt-5">{action}</div> : null}
223 + </div>
224 + );
225 +}
226 +
227 +/* --------------------------------------------------------------- Skeleton */
228 +
229 +export function Skeleton({ className }: { className?: string }) {
230 + return <div className={cn("animate-pulse-soft rounded-md bg-surface-2", className)} />;
231 +}
232 +
233 +/* ------------------------------------------------------------ SectionHead */
234 +
235 +export function SectionHead({ title, eyebrow, action, className }: { title: string; eyebrow?: string; action?: React.ReactNode; className?: string }) {
236 + return (
237 + <div className={cn("mb-4 flex items-end justify-between gap-4", className)}>
238 + <div>
239 + {eyebrow ? <div className="eyebrow mb-1">{eyebrow}</div> : null}
240 + <h2 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h2>
241 + </div>
242 + {action}
243 + </div>
244 + );
245 +}
246 +
247 +export { motion, AnimatePresence };
added apps/web/src/lib/api-server.ts +26 −0
@@ -0,0 +1,26 @@
1 +import "server-only";
2 +import { cookies, headers } from "next/headers";
3 +
4 +const apiUrl = (process.env.API_URL ?? "http://127.0.0.1:8231").replace(/\/$/, "");
5 +
6 +/** Server-side fetch to the API, forwarding the player's session cookie. Returns null on 401/404. */
7 +export async function apiServer<T>(path: string, init: RequestInit = {}): Promise<T | null> {
8 + const c = await cookies();
9 + const h = await headers();
10 + const cookie = c
11 + .getAll()
12 + .map((x) => `${x.name}=${x.value}`)
13 + .join("; ");
14 + try {
15 + const res = await fetch(`${apiUrl}${path}`, {
16 + ...init,
17 + cache: "no-store",
18 + headers: { ...(init.headers ?? {}), cookie, "x-forwarded-for": h.get("x-forwarded-for") ?? "", "user-agent": h.get("user-agent") ?? "" },
19 + });
20 + if (res.status === 401 || res.status === 404) return null;
21 + if (!res.ok) return null;
22 + return (await res.json()) as T;
23 + } catch {
24 + return null;
25 + }
26 +}
added apps/web/src/lib/api.ts +42 −0
@@ -0,0 +1,42 @@
1 +"use client";
2 +
3 +import type { ApiError } from "@spinza/shared";
4 +
5 +export class ApiClientError extends Error {
6 + constructor(
7 + public status: number,
8 + public code: string,
9 + message: string,
10 + public details?: unknown,
11 + ) {
12 + super(message);
13 + }
14 +}
15 +
16 +/** Browser fetch wrapper: same-origin `/api/*`, JSON in/out, typed errors. */
17 +export async function api<T>(path: string, init: RequestInit & { json?: unknown } = {}): Promise<T> {
18 + const { json, headers, ...rest } = init;
19 + const res = await fetch(path, {
20 + ...rest,
21 + method: rest.method ?? (json !== undefined ? "POST" : "GET"),
22 + credentials: "same-origin",
23 + headers: { ...(json !== undefined ? { "Content-Type": "application/json" } : {}), ...(headers ?? {}) },
24 + body: json !== undefined ? JSON.stringify(json) : rest.body,
25 + }).catch(() => {
26 + throw new ApiClientError(0, "NETWORK", "Connection lost. Check your network and try again.");
27 + });
28 + const text = await res.text();
29 + let data: unknown = null;
30 + try {
31 + data = text ? JSON.parse(text) : null;
32 + } catch {
33 + data = null;
34 + }
35 + if (!res.ok) {
36 + const e = (data ?? {}) as Partial<ApiError>;
37 + throw new ApiClientError(res.status, e.error ?? "HTTP_" + res.status, e.message ?? (res.status >= 500 ? "Server unavailable. Please try again shortly." : "Request failed."), e.details);
38 + }
39 + return data as T;
40 +}
41 +
42 +export const isUnauthorized = (e: unknown) => e instanceof ApiClientError && e.status === 401;
added apps/web/src/lib/store.ts +86 −0
@@ -0,0 +1,86 @@
1 +"use client";
2 +
3 +import { create } from "zustand";
4 +import type { PublicUser, UserSettings, WalletView } from "@spinza/shared";
5 +import { api } from "./api";
6 +
7 +export interface SessionState {
8 + status: "loading" | "guest" | "authenticated";
9 + user: PublicUser | null;
10 + wallet: WalletView | null;
11 + settings: UserSettings | null;
12 + /** Session start (for break reminders). */
13 + sessionStartedAt: number;
14 + refresh: () => Promise<void>;
15 + setBalance: (balance: number) => void;
16 + setUserXp: (xp: number, level: number) => void;
17 + setSettings: (s: Partial<UserSettings>) => void;
18 + signOut: () => Promise<void>;
19 + hydrate: (data: { user: PublicUser; wallet: WalletView; settings: UserSettings } | null) => void;
20 +}
21 +
22 +export const useSession = create<SessionState>((set, get) => ({
23 + status: "loading",
24 + user: null,
25 + wallet: null,
26 + settings: null,
27 + sessionStartedAt: Date.now(),
28 + hydrate: (data) => {
29 + if (data) set({ status: "authenticated", user: data.user, wallet: data.wallet, settings: data.settings });
30 + else set({ status: "guest", user: null, wallet: null, settings: null });
31 + },
32 + refresh: async () => {
33 + try {
34 + const data = await api<{ user: PublicUser; wallet: WalletView; settings: UserSettings }>("/api/user");
35 + set({ status: "authenticated", user: data.user, wallet: data.wallet, settings: data.settings });
36 + } catch {
37 + set({ status: "guest", user: null, wallet: null, settings: null });
38 + }
39 + },
40 + setBalance: (balance) => {
41 + const w = get().wallet;
42 + if (w) set({ wallet: { ...w, balance } });
43 + },
44 + setUserXp: (xp, level) => {
45 + const u = get().user;
46 + if (u) set({ user: { ...u, xp, level } });
47 + },
48 + setSettings: (s) => {
49 + const cur = get().settings;
50 + if (cur) set({ settings: { ...cur, ...s } });
51 + api("/api/user/settings", { method: "PATCH", json: s }).catch(() => {});
52 + },
53 + signOut: async () => {
54 + await api("/api/auth/logout", { method: "POST" }).catch(() => {});
55 + set({ status: "guest", user: null, wallet: null, settings: null });
56 + },
57 +}));
58 +
59 +/* ------------------------------------------------------------- toasts */
60 +
61 +export interface Toast {
62 + id: number;
63 + title: string;
64 + description?: string;
65 + tone?: "default" | "success" | "danger" | "credit";
66 + ttl?: number;
67 +}
68 +
69 +interface ToastState {
70 + toasts: Toast[];
71 + push: (t: Omit<Toast, "id">) => void;
72 + dismiss: (id: number) => void;
73 +}
74 +
75 +let toastSeq = 1;
76 +export const useToasts = create<ToastState>((set) => ({
77 + toasts: [],
78 + push: (t) => {
79 + const id = toastSeq++;
80 + set((s) => ({ toasts: [...s.toasts, { id, ttl: 4200, ...t }] }));
81 + setTimeout(() => set((s) => ({ toasts: s.toasts.filter((x) => x.id !== id) })), t.ttl ?? 4200);
82 + },
83 + dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((x) => x.id !== id) })),
84 +}));
85 +
86 +export const toast = (t: Omit<Toast, "id">) => useToasts.getState().push(t);
added apps/web/src/lib/use-api.ts +78 −0
@@ -0,0 +1,78 @@
1 +"use client";
2 +
3 +import { useCallback, useEffect, useRef, useState } from "react";
4 +import { usePathname, useRouter } from "next/navigation";
5 +import { api, ApiClientError } from "./api";
6 +import { useSession } from "./store";
7 +
8 +export interface ApiState<T> {
9 + data: T | null;
10 + error: ApiClientError | null;
11 + loading: boolean;
12 + reload: () => Promise<void>;
13 + setData: React.Dispatch<React.SetStateAction<T | null>>;
14 +}
15 +
16 +/**
17 + * Client-side data hook. Handles the shared failure modes:
18 + * 401 → session expired → redirect to /login?next=…
19 + * 503 MAINTENANCE / network → surfaced as `error` for <ApiErrorState/>.
20 + */
21 +export function useApi<T>(path: string | null): ApiState<T> {
22 + const [data, setData] = useState<T | null>(null);
23 + const [error, setError] = useState<ApiClientError | null>(null);
24 + const [loading, setLoading] = useState<boolean>(path !== null);
25 + const [tick, setTick] = useState(0);
26 + const router = useRouter();
27 + const pathname = usePathname();
28 + const hydrate = useSession((s) => s.hydrate);
29 + const seq = useRef(0);
30 +
31 + // When the path changes, flip back to loading during render (adjust-state-from-props).
32 + const [prevPath, setPrevPath] = useState(path);
33 + if (path !== prevPath) {
34 + setPrevPath(path);
35 + setLoading(path !== null);
36 + }
37 +
38 + useEffect(() => {
39 + if (!path) return;
40 + const my = ++seq.current;
41 + api<T>(path)
42 + .then((res) => {
43 + if (my !== seq.current) return;
44 + setData(res);
45 + setError(null);
46 + setLoading(false);
47 + })
48 + .catch((e: unknown) => {
49 + if (my !== seq.current) return;
50 + const err = e instanceof ApiClientError ? e : new ApiClientError(0, "UNKNOWN", "Something went wrong.");
51 + if (err.status === 401) {
52 + hydrate(null);
53 + router.replace(`/login?next=${encodeURIComponent(pathname)}&reason=expired`);
54 + return;
55 + }
56 + setError(err);
57 + setLoading(false);
58 + });
59 + }, [path, tick, router, pathname, hydrate]);
60 +
61 + const reload = useCallback(async () => {
62 + setLoading(true);
63 + setTick((t) => t + 1);
64 + }, []);
65 +
66 + return { data, error, loading, reload, setData };
67 +}
68 +
69 +/** Human copy for an API failure. */
70 +export function describeError(e: unknown): { title: string; description: string; kind: "network" | "maintenance" | "server" | "other" } {
71 + if (e instanceof ApiClientError) {
72 + if (e.status === 0) return { kind: "network", title: "Connection lost", description: "Check your network and try again. Your credits and progress are safe." };
73 + if (e.status === 503 && e.code === "MAINTENANCE") return { kind: "maintenance", title: "Spinza is getting an upgrade.", description: "Your credits and progress are safe. Check back in a few minutes." };
74 + if (e.status >= 500) return { kind: "server", title: "Server unavailable", description: "Spinza is temporarily unreachable. Please try again shortly." };
75 + return { kind: "other", title: "Something went wrong", description: e.message };
76 + }
77 + return { kind: "other", title: "Something went wrong", description: "Please try again." };
78 +}
added apps/web/src/lib/utils.ts +29 −0
@@ -0,0 +1,29 @@
1 +import { clsx, type ClassValue } from "clsx";
2 +import { twMerge } from "tailwind-merge";
3 +
4 +export function cn(...inputs: ClassValue[]): string {
5 + return twMerge(clsx(inputs));
6 +}
7 +
8 +export function timeAgo(iso: string | Date): string {
9 + const d = typeof iso === "string" ? new Date(iso) : iso;
10 + const s = Math.max(0, (Date.now() - d.getTime()) / 1000);
11 + if (s < 60) return "just now";
12 + if (s < 3600) return `${Math.floor(s / 60)}m ago`;
13 + if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
14 + if (s < 7 * 86400) return `${Math.floor(s / 86400)}d ago`;
15 + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
16 +}
17 +
18 +export function countdown(iso: string | null): string {
19 + if (!iso) return "";
20 + const ms = new Date(iso).getTime() - Date.now();
21 + if (ms <= 0) return "now";
22 + const h = Math.floor(ms / 3600_000);
23 + const m = Math.floor((ms % 3600_000) / 60_000);
24 + const s = Math.floor((ms % 60_000) / 1000);
25 + return h > 0 ? `${h}h ${String(m).padStart(2, "0")}m` : `${m}m ${String(s).padStart(2, "0")}s`;
26 +}
27 +
28 +export const VOLATILITY_LABEL: Record<string, string> = { low: "Relaxed", medium: "Balanced", high: "High", extreme: "Extreme" };
29 +export const VOLATILITY_BARS: Record<string, number> = { low: 1, medium: 2, high: 3, extreme: 4 };
added apps/web/tsconfig.json +21 −0
@@ -0,0 +1,21 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2022",
4 + "lib": ["dom", "dom.iterable", "esnext"],
5 + "allowJs": true,
6 + "skipLibCheck": true,
7 + "strict": true,
8 + "noEmit": true,
9 + "esModuleInterop": true,
10 + "module": "esnext",
11 + "moduleResolution": "bundler",
12 + "resolveJsonModule": true,
13 + "isolatedModules": true,
14 + "jsx": "react-jsx",
15 + "incremental": true,
16 + "plugins": [{ "name": "next" }],
17 + "paths": { "@/*": ["./src/*"] }
18 + },
19 + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
20 + "exclude": ["node_modules"]
21 +}
added deploy/backup.sh +27 −0
@@ -0,0 +1,27 @@
1 +#!/bin/bash
2 +# Daily PostgreSQL backup for Spinza (wallet ledger is critical).
3 +# Installed on the node by deploy/install-backup.sh as a launchd agent (03:40 daily).
4 +set -euo pipefail
5 +export PATH="/opt/homebrew/bin:/opt/homebrew/opt/postgresql@17/bin:$PATH"
6 +DB="${SPINZA_DB:-spinza}"
7 +DEST="${SPINZA_BACKUP_DIR:-$HOME/backups/spinza}"
8 +KEEP_DAYS="${SPINZA_BACKUP_KEEP_DAYS:-21}"
9 +mkdir -p "$DEST"
10 +STAMP=$(date +%Y%m%d-%H%M%S)
11 +FILE="$DEST/spinza-$STAMP.dump"
12 +pg_dump --format=custom --compress=6 --file="$FILE" "$DB"
13 +# Verify the archive lists cleanly (restore test light).
14 +pg_restore --list "$FILE" >/dev/null
15 +SIZE=$(du -h "$FILE" | cut -f1)
16 +echo "$(date -Iseconds) backup ok $FILE ($SIZE)" >>"$DEST/backup.log"
17 +# Rotate.
18 +find "$DEST" -name 'spinza-*.dump' -mtime +"$KEEP_DAYS" -delete
19 +# Monthly full restore test into a scratch database (first day of month).
20 +if [ "$(date +%d)" = "01" ]; then
21 + dropdb --if-exists spinza_restore_test
22 + createdb spinza_restore_test
23 + pg_restore --dbname=spinza_restore_test --no-owner "$FILE"
24 + ROWS=$(psql -tAqc "select count(*) from credit_transactions" spinza_restore_test)
25 + echo "$(date -Iseconds) restore test ok ($ROWS ledger rows)" >>"$DEST/backup.log"
26 + dropdb spinza_restore_test
27 +fi
added deploy/install-backup.sh +22 −0
@@ -0,0 +1,22 @@
1 +#!/bin/bash
2 +# Run ON the node: installs the daily backup launchd agent.
3 +set -euo pipefail
4 +APP_DIR="${1:-$HOME/apps/spinza}"
5 +PLIST="$HOME/Library/LaunchAgents/dev.spinza.backup.plist"
6 +mkdir -p "$HOME/Library/LaunchAgents" "$HOME/backups/spinza"
7 +chmod +x "$APP_DIR/deploy/backup.sh"
8 +cat >"$PLIST" <<EOF
9 +<?xml version="1.0" encoding="UTF-8"?>
10 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
11 +<plist version="1.0"><dict>
12 + <key>Label</key><string>dev.spinza.backup</string>
13 + <key>ProgramArguments</key><array><string>/bin/bash</string><string>$APP_DIR/deploy/backup.sh</string></array>
14 + <key>StartCalendarInterval</key><dict><key>Hour</key><integer>3</integer><key>Minute</key><integer>40</integer></dict>
15 + <key>StandardOutPath</key><string>$HOME/backups/spinza/launchd.log</string>
16 + <key>StandardErrorPath</key><string>$HOME/backups/spinza/launchd.err</string>
17 + <key>EnvironmentVariables</key><dict><key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin</string></dict>
18 +</dict></plist>
19 +EOF
20 +launchctl bootout "gui/$(id -u)/dev.spinza.backup" 2>/dev/null || true
21 +launchctl bootstrap "gui/$(id -u)" "$PLIST"
22 +echo "installed dev.spinza.backup (daily 03:40) → $HOME/backups/spinza"
added docs/SPEC-original.md +60 −0
@@ -0,0 +1,60 @@
1 +# Spinza — original product brief (2026-09-07)
2 +
3 +Condensed transcription of the founding CLAUDE.md brief. `../CLAUDE.md` is the working guide; this document is the product contract.
4 +
5 +## 1–3. Identity & principles
6 +- **Spinza**, https://www.spinza.dev — premium fictional social casino. Tagline: *Play. Spin. Unlock.* Disclaimer: *Virtual credits only. No deposits. No withdrawals. No cash value.*
7 +- No real money, deposits, withdrawals, crypto, purchasable/exchangeable credits, monetary prizes, cash-out, marketplace. Closer to a premium gaming platform than gambling.
8 +- Every account receives **10,000 Spinza Credits (SC)**; ~**20 original games**, all built from scratch (no cloned slots, assets, math or branding from any studio).
9 +- Feel: premium, cinematic, fast, modern, mobile-first, immersive, polished. Avoid generic casino templates, cheap UI, identical card grids, excessive gradients, Bootstrap look, crypto aesthetics, fake money marketing.
10 +
11 +## 4–8. Accounts
12 +- Username + password only. No email/phone/social login/verification/email reset.
13 +- Username unique, 3–24 chars, lowercase, letters/numbers/_/-, profanity filter, reserved list (admin, administrator, root, system, support, spenza, spinza, staff, moderator, api, www, security…).
14 +- Passwords: Argon2id (`argon2`), min 8 chars, store `password_hash` only, never log, never expose.
15 +- Recovery code `SPZ-XXXX-XXXX-XXXX` generated at signup, shown once with the warning that lost password + code = unrecoverable account; only a hash is stored. Recovery flow: username + code + new password.
16 +- Initial balance 10,000 SC; never displayed with $ € £ CAD USD BTC ETH.
17 +
18 +## 9–13. Economy & responsible design
19 +- No store, packages, microtransactions, payment provider, Stripe/PayPal/crypto, ads for credits. Credits only from gameplay systems: start grant, daily rewards, achievements, level rewards, missions, tournaments, bonus drops, streaks.
20 +- Daily reward: 1,000 / 1,250 / 1,500 / 1,750 / 2,000 / 2,500 / 5,000 SC (days 1–7), soft reset on missed days.
21 +- Rescue Credits at 0 SC: 2,500 SC, 12 h cooldown, admin-configurable.
22 +- Settings: session reminder (30/60/90/120/off), break reminder, animation intensity, sound, reduce motion. Periodic "You've been playing for 60 minutes. Take a break anytime."
23 +- 18+ confirmation at signup (no DOB stored).
24 +
25 +## 14–21. Game platform
26 +- Shared Spinza Game Engine: definition → math engine → RNG → result generator → game state → animation → renderer.
27 +- **Server-authoritative**: `POST /api/games/:slug/spin` validates balance, generates RNG result, calculates wins, writes atomically, returns balance; frontend only renders.
28 +- RNG: `crypto.randomInt`/`randomBytes`, never `Math.random`.
29 +- Each game defines RTP (94–98 %, default 96 %), volatility, hit frequency, max multiplier, reels, symbols, weights, wilds, scatters, bonuses, free spins, multipliers, jackpot probabilities. JSON/TS `GameDefinition`.
30 +- Engine modules: rng, reels, symbols, ways, paylines, wilds, scatters, multipliers, free-spins, cascades, jackpots, bonus-games, simulation, validation.
31 +- Simulator: 1K → 10M spins without rendering; metrics: observed RTP, hit rate, bonus frequency, avg/median/max win, std dev, distribution, wagered/returned, free-spin & feature frequency.
32 +- Internal certification report per game (configured vs observed RTP, deviation, PASS/FAIL); no `published` without passing.
33 +
34 +## 22. Game library (20)
35 +01 Neon Vault (cyberpunk heist, 5×4, 1024 ways, expanding wilds, vault multipliers, Hack the Vault) · 02 Cosmic Collapse (cascades, gravity multipliers ×1→×13, black-hole wilds) · 03 Golden Emperor (5×3, sticky golden wilds, dragon multiplier) · 04 Diamond Heist (lock-and-respin, Mini/Minor/Major/Grand fictional jackpots) · 05 Arctic Fortune (ice wilds, avalanche cascades, frozen multipliers) · 06 Inferno Reels (high vol, exploding wilds, lava multiplier, chain reactions) · 07 Quantum Jackpot (Quantum Split probabilistic symbols) · 08 Midnight Tokyo (neon respins, stacked wilds, night multiplier) · 09 Pharaoh Protocol (artifact collection, scanning wilds, pyramid bonus) · 10 Lucky Circuit (low vol, frequent small wins) · 11 Void Miner (iron/plasma/crystals/dark matter, dark matter multipliers) · 12 Royal Circuit (laps, boost multiplier, pit-stop bonus) · 13 Dragon Core (dragon charge → Dragon Mode) · 14 Deep Treasure (descending levels, persistent multipliers, chests) · 15 Moonbase 77 (moon gravity, floating wilds, orbital bonus) · 16 Wild Temple (moving wilds, temple chambers, artifact bonuses) · 17 Zero Gravity (grid 5×3 → 6×4 → 7×5 in one bonus) · 18 Reel Reactor (heat meter raises multipliers/volatility/bonus chance) · 19 Obsidian (minimal black luxury, extreme volatility, up to 50,000×) · 20 Spinza Original (flagship: cascades, persistent multipliers, mystery symbols, feature drops, free spins, mega feature).
36 +
37 +## 23–36. Product surfaces
38 +- Game card: artwork, name, volatility, favourite, play. RTP only in Game Info.
39 +- Home: Hero (flagship), Continue Playing, Featured, New Releases, Popular, High Volatility, Relaxed, Jackpot, All Games. Desktop: sidebar + top nav + hero + horizontal collections. Mobile: logo, balance, categories, grid, bottom nav (Home, Games, Rewards, Leaderboard, Profile).
40 +- Game screen: canvas, balance, bet, win, SPIN (always obvious), auto spin (10/25/50/100 with visible STOP; stops on low balance, bonus, stop, boundaries), info, sound, settings. Bets 10/20/50/100/200/500/1,000 SC.
41 +- Win classes WIN 5× · BIG 20× · MEGA 50× · EPIC 100× · LEGENDARY 500×.
42 +- Audio per game (ambient, reels, buttons, wins, bonus, big win) with master/music/effects/mute saved in DB.
43 +- Achievements (First Spin, First Big Win, 100/1,000/10,000 Spins, First Bonus, Jackpot Hunter, Explorer, Play 10 Games, Play Every Game, 100× Club, 1,000× Club), levels 1–100 with XP, missions (daily/weekly), leaderboards (biggest win today/week, biggest multiplier, most spins, highest level; opt-out; no internal IDs), profile, game history with round IDs `spz_rnd_…`.
44 +
45 +## 37–60. Data, security, API
46 +- Immutable ledger `credit_transactions` (INITIAL_GRANT, BET, WIN, DAILY_REWARD, ACHIEVEMENT, MISSION, RESCUE_CREDITS, ADMIN_ADJUSTMENT). Atomic balance: lock wallet → verify → subtract → generate → add → write round → ledger → commit. BIGINT, never floats.
47 +- PostgreSQL + Drizzle. Tables: users, sessions, wallets, credit_transactions, games, game_versions, game_rounds, game_statistics, achievements, user_achievements, missions, user_missions, daily_rewards, player_levels, leaderboards, favorites, user_settings, recovery_codes, security_events. Game versioning `slug@x.y.z` stored on every round.
48 +- Admin `/admin`: dashboard (users, DAU/WAU/MAU, sessions, spins, wagered/won, effective RTP, avg session, top games, highest wins, latency, errors), users, games, simulator (10K–10M with charts), economy, analytics, missions, achievements, daily rewards, system health, security. Admin auth = username + password + TOTP, optional IP restriction.
49 +- Redis for session cache, rate limits, leaderboard cache, live counts, stats; never the wallet source of truth.
50 +- Opaque sessions: HttpOnly Secure SameSite=Lax cookie `spinza_session`, 256-bit token, hash stored. CSRF via SameSite/Origin. Rate limits on login/register/recovery/spin/admin. Login error always "Invalid username or password." Security events logged.
51 +- API: auth (register/login/logout/recover), user, wallet, games, spin, history, rewards/daily(+claim), achievements, missions, leaderboards. Spin request `{bet, clientRoundId}``{roundId, game, bet, win, multiplier, balance, result}`; idempotent on `(user_id, client_round_id)`.
52 +
53 +## 61–70. Frontend
54 +- Own design system: dark luxury, deep charcoal, subtle metallic surfaces, high contrast, cinematic art, restrained accent, Geist font. Mobile-first, 44×44 targets. LCP < 2 s, API p95 < 150 ms, spin p95 < 250 ms, 60 FPS. Lazy per-game bundles. Original assets (SVG/procedural). PixiJS reels, GSAP/Framer UI. Particle engine (coins, sparks, fire, snow, stars, energy, diamonds, confetti). Splash "SPINZA / artwork / Loading %"; audio loads progressively.
55 +
56 +## 71–94. Infrastructure & quality
57 +- Private Apple Silicon cluster, ngrok ingress for www.spinza.dev (+ apex redirect), gateway node, internal proxy, web/api/admin/sim services, Postgres/Redis internal only; ports web 3000 / api 3100 / admin 3200 / sim 3300 (cluster uses 8230/8231). PM2 or Docker. Health `/api/health[/database|/redis|/game-engine]`. Structured Pino logs without secrets. Daily Postgres backups with restore tests. Analytics per game. Minimal privacy footprint, no trackers/ads/data sale. SEO for landing only ("Spinza — The Virtual Casino Playground"). Landing → register → welcome +10,000 SC → choose first game (Neon Vault, Cosmic Collapse, Spinza Original). Polished empty/error states, maintenance mode, feature flags, dev/staging/prod, game lifecycle draft→simulation→approved→staging→published→disabled. Tests: Vitest + Playwright (auth, wallet atomicity, RNG, math, duplicate spin, sessions, daily rewards, recovery, leaderboards, admin). Wallet invariants. k6 load tests (100–5,000).
58 +
59 +## 95–102. Future & quality bar
60 +Live events, tournaments (no real prizes), AI Game Factory (still simulated/approved/versioned), `@spinza/game-sdk defineGame(...)`. Spinza is a fictional gaming universe around probability, animation, progression and statistics. Never: copy games/assets/branding, misuse real-money terms, add payments/withdrawals/crypto, plaintext passwords, Math.random outcomes, client-side wins, balance updates without ledger, expose Postgres/Redis, ship an unsimulated game. No fake buttons, dead navigation, placeholder functionality, demo balances, client-side outcomes or static mockups.
added games/certifications/arctic-fortune.json +317 −0
@@ -0,0 +1,317 @@
1 +{
2 + "game": "arctic-fortune",
3 + "name": "Arctic Fortune",
4 + "version": "1.0.0",
5 + "spins": 10000000,
6 + "configuredRtp": 0.96,
7 + "observedRtp": 0.969892353,
8 + "deviation": 0.00989235300000002,
9 + "hitRate": 0.410068,
10 + "bonusRate": 0,
11 + "freeSpinRate": 0.0075707,
12 + "maxWinMultiplier": 4000,
13 + "stdDev": 26.300029657978204,
14 + "status": "PASS",
15 + "checks": [
16 + {
17 + "name": "definition",
18 + "pass": true,
19 + "detail": "ok"
20 + },
21 + {
22 + "name": "spins",
23 + "pass": true,
24 + "detail": "10,000,000 spins (min 1,000,000)"
25 + },
26 + {
27 + "name": "rtp-deviation",
28 + "pass": true,
29 + "detail": "0.989% (tolerance ±1.50% = max(0.40%, 3σ/√n=2.50%))"
30 + },
31 + {
32 + "name": "rtp-band",
33 + "pass": true,
34 + "detail": "96.99%"
35 + },
36 + {
37 + "name": "hit-rate",
38 + "pass": true,
39 + "detail": "41.01%"
40 + },
41 + {
42 + "name": "max-win",
43 + "pass": true,
44 + "detail": "4000.0× (cap 4000×)"
45 + },
46 + {
47 + "name": "cap-share",
48 + "pass": true,
49 + "detail": "129 capped rounds"
50 + }
51 + ],
52 + "certifiedAt": "2026-09-08T01:55:34.870Z",
53 + "rules": {
54 + "maxDeviation": 0.004,
55 + "minSpins": 1000000,
56 + "rtpBand": [
57 + 0.93,
58 + 0.99
59 + ],
60 + "hitRateBand": [
61 + 0.08,
62 + 0.6
63 + ],
64 + "maxCappedShare": 0.0005
65 + },
66 + "distribution": [
67 + {
68 + "label": "0×",
69 + "min": 0,
70 + "max": 0,
71 + "count": 5899320,
72 + "share": 0.589932
73 + },
74 + {
75 + "label": "0–1×",
76 + "min": 0.000001,
77 + "max": 1,
78 + "count": 3217418,
79 + "share": 0.3217418
80 + },
81 + {
82 + "label": "1–2×",
83 + "min": 1,
84 + "max": 2,
85 + "count": 461372,
86 + "share": 0.0461372
87 + },
88 + {
89 + "label": "2–5×",
90 + "min": 2,
91 + "max": 5,
92 + "count": 285554,
93 + "share": 0.0285554
94 + },
95 + {
96 + "label": "5–10×",
97 + "min": 5,
98 + "max": 10,
99 + "count": 71493,
100 + "share": 0.0071493
101 + },
102 + {
103 + "label": "10–20×",
104 + "min": 10,
105 + "max": 20,
106 + "count": 26255,
107 + "share": 0.0026255
108 + },
109 + {
110 + "label": "20–50×",
111 + "min": 20,
112 + "max": 50,
113 + "count": 18121,
114 + "share": 0.0018121
115 + },
116 + {
117 + "label": "50–100×",
118 + "min": 50,
119 + "max": 100,
120 + "count": 8662,
121 + "share": 0.0008662
122 + },
123 + {
124 + "label": "100–500×",
125 + "min": 100,
126 + "max": 500,
127 + "count": 9380,
128 + "share": 0.000938
129 + },
130 + {
131 + "label": "500–1000×",
132 + "min": 500,
133 + "max": 1000,
134 + "count": 1320,
135 + "share": 0.000132
136 + },
137 + {
138 + "label": "1000×+",
139 + "min": 1000,
140 + "max": null,
141 + "count": 1105,
142 + "share": 0.0001105
143 + }
144 + ],
145 + "convergence": [
146 + {
147 + "spins": 249990,
148 + "rtp": 1.0266561462458497
149 + },
150 + {
151 + "spins": 499979,
152 + "rtp": 0.9623192327693106
153 + },
154 + {
155 + "spins": 749969,
156 + "rtp": 0.9481959678387137
157 + },
158 + {
159 + "spins": 999958,
160 + "rtp": 0.9431782571302852
161 + },
162 + {
163 + "spins": 1249948,
164 + "rtp": 0.9545677187087483
165 + },
166 + {
167 + "spins": 1499938,
168 + "rtp": 0.9588834686720801
169 + },
170 + {
171 + "spins": 1749927,
172 + "rtp": 0.9657598475367586
173 + },
174 + {
175 + "spins": 1999917,
176 + "rtp": 0.9791313052522101
177 + },
178 + {
179 + "spins": 2249906,
180 + "rtp": 0.9810984306038907
181 + },
182 + {
183 + "spins": 2499896,
184 + "rtp": 0.9738952318092724
185 + },
186 + {
187 + "spins": 2749886,
188 + "rtp": 0.9754977035445054
189 + },
190 + {
191 + "spins": 2999875,
192 + "rtp": 0.9753515140605624
193 + },
194 + {
195 + "spins": 3249865,
196 + "rtp": 0.9825930360291335
197 + },
198 + {
199 + "spins": 3499854,
200 + "rtp": 0.9757457326864505
201 + },
202 + {
203 + "spins": 3749844,
204 + "rtp": 0.9701778284464713
205 + },
206 + {
207 + "spins": 3999834,
208 + "rtp": 0.966394340773631
209 + },
210 + {
211 + "spins": 4249823,
212 + "rtp": 0.9681425351131694
213 + },
214 + {
215 + "spins": 4499813,
216 + "rtp": 0.9672260534865837
217 + },
218 + {
219 + "spins": 4749802,
220 + "rtp": 0.9709666997206203
221 + },
222 + {
223 + "spins": 4999792,
224 + "rtp": 0.9687886535461417
225 + },
226 + {
227 + "spins": 5249782,
228 + "rtp": 0.9724669691549567
229 + },
230 + {
231 + "spins": 5499771,
232 + "rtp": 0.9745866598300295
233 + },
234 + {
235 + "spins": 5749761,
236 + "rtp": 0.9757801616412483
237 + },
238 + {
239 + "spins": 5999750,
240 + "rtp": 0.9737093117058014
241 + },
242 + {
243 + "spins": 6249740,
244 + "rtp": 0.9720717916716667
245 + },
246 + {
247 + "spins": 6499730,
248 + "rtp": 0.9705155913928862
249 + },
250 + {
251 + "spins": 6749719,
252 + "rtp": 0.9690098981737048
253 + },
254 + {
255 + "spins": 6999709,
256 + "rtp": 0.9721583991931106
257 + },
258 + {
259 + "spins": 7249698,
260 + "rtp": 0.9733902252641831
261 + },
262 + {
263 + "spins": 7499688,
264 + "rtp": 0.9738360907769644
265 + },
266 + {
267 + "spins": 7749678,
268 + "rtp": 0.973673746949878
269 + },
270 + {
271 + "spins": 7999667,
272 + "rtp": 0.9738707310792432
273 + },
274 + {
275 + "spins": 8249657,
276 + "rtp": 0.9735854391751426
277 + },
278 + {
279 + "spins": 8499646,
280 + "rtp": 0.9756515025306898
281 + },
282 + {
283 + "spins": 8749636,
284 + "rtp": 0.9738019120764831
285 + },
286 + {
287 + "spins": 8999626,
288 + "rtp": 0.9731798960847324
289 + },
290 + {
291 + "spins": 9249615,
292 + "rtp": 0.9721950056380634
293 + },
294 + {
295 + "spins": 9499605,
296 + "rtp": 0.9705341845252757
297 + },
298 + {
299 + "spins": 9749594,
300 + "rtp": 0.9703696937621095
301 + },
302 + {
303 + "spins": 9999584,
304 + "rtp": 0.9699178297131883
305 + },
306 + {
307 + "spins": 10000000,
308 + "rtp": 0.9698923880903696
309 + }
310 + ],
311 + "featureCounts": {
312 + "Cascade": 4100680,
313 + "Free Spins": 75707,
314 + "Retrigger": 5394
315 + },
316 + "durationMs": 8435
317 +}
added games/certifications/cosmic-collapse.json +318 −0
@@ -0,0 +1,318 @@
1 +{
2 + "game": "cosmic-collapse",
3 + "name": "Cosmic Collapse",
4 + "version": "1.0.0",
5 + "spins": 10000000,
6 + "configuredRtp": 0.961,
7 + "observedRtp": 0.960123825,
8 + "deviation": -0.0008761749999999235,
9 + "hitRate": 0.576897,
10 + "bonusRate": 0,
11 + "freeSpinRate": 0.0148319,
12 + "maxWinMultiplier": 2926.27,
13 + "stdDev": 9.410654854140823,
14 + "status": "PASS",
15 + "checks": [
16 + {
17 + "name": "definition",
18 + "pass": true,
19 + "detail": "ok"
20 + },
21 + {
22 + "name": "spins",
23 + "pass": true,
24 + "detail": "10,000,000 spins (min 1,000,000)"
25 + },
26 + {
27 + "name": "rtp-deviation",
28 + "pass": true,
29 + "detail": "-0.088% (tolerance ±0.89% = max(0.40%, 3σ/√n=0.89%))"
30 + },
31 + {
32 + "name": "rtp-band",
33 + "pass": true,
34 + "detail": "96.01%"
35 + },
36 + {
37 + "name": "hit-rate",
38 + "pass": true,
39 + "detail": "57.69%"
40 + },
41 + {
42 + "name": "max-win",
43 + "pass": true,
44 + "detail": "2926.3× (cap 10000×)"
45 + },
46 + {
47 + "name": "cap-share",
48 + "pass": true,
49 + "detail": "0 capped rounds"
50 + }
51 + ],
52 + "certifiedAt": "2026-09-08T01:55:09.647Z",
53 + "rules": {
54 + "maxDeviation": 0.004,
55 + "minSpins": 1000000,
56 + "rtpBand": [
57 + 0.93,
58 + 0.99
59 + ],
60 + "hitRateBand": [
61 + 0.08,
62 + 0.6
63 + ],
64 + "maxCappedShare": 0.0005
65 + },
66 + "distribution": [
67 + {
68 + "label": "0×",
69 + "min": 0,
70 + "max": 0,
71 + "count": 4231030,
72 + "share": 0.423103
73 + },
74 + {
75 + "label": "0–1×",
76 + "min": 0.000001,
77 + "max": 1,
78 + "count": 4784496,
79 + "share": 0.4784496
80 + },
81 + {
82 + "label": "1–2×",
83 + "min": 1,
84 + "max": 2,
85 + "count": 442907,
86 + "share": 0.0442907
87 + },
88 + {
89 + "label": "2–5×",
90 + "min": 2,
91 + "max": 5,
92 + "count": 286882,
93 + "share": 0.0286882
94 + },
95 + {
96 + "label": "5–10×",
97 + "min": 5,
98 + "max": 10,
99 + "count": 101081,
100 + "share": 0.0101081
101 + },
102 + {
103 + "label": "10–20×",
104 + "min": 10,
105 + "max": 20,
106 + "count": 64858,
107 + "share": 0.0064858
108 + },
109 + {
110 + "label": "20–50×",
111 + "min": 20,
112 + "max": 50,
113 + "count": 58197,
114 + "share": 0.0058197
115 + },
116 + {
117 + "label": "50–100×",
118 + "min": 50,
119 + "max": 100,
120 + "count": 18046,
121 + "share": 0.0018046
122 + },
123 + {
124 + "label": "100–500×",
125 + "min": 100,
126 + "max": 500,
127 + "count": 12183,
128 + "share": 0.0012183
129 + },
130 + {
131 + "label": "500–1000×",
132 + "min": 500,
133 + "max": 1000,
134 + "count": 269,
135 + "share": 0.0000269
136 + },
137 + {
138 + "label": "1000×+",
139 + "min": 1000,
140 + "max": null,
141 + "count": 51,
142 + "share": 0.0000051
143 + }
144 + ],
145 + "convergence": [
146 + {
147 + "spins": 249990,
148 + "rtp": 0.9620085203408136
149 + },
150 + {
151 + "spins": 499979,
152 + "rtp": 0.9561548261930477
153 + },
154 + {
155 + "spins": 749969,
156 + "rtp": 0.9625190740962974
157 + },
158 + {
159 + "spins": 999958,
160 + "rtp": 0.9583367434697387
161 + },
162 + {
163 + "spins": 1249948,
164 + "rtp": 0.960228217128685
165 + },
166 + {
167 + "spins": 1499938,
168 + "rtp": 0.9591529661186449
169 + },
170 + {
171 + "spins": 1749927,
172 + "rtp": 0.9616081443257731
173 + },
174 + {
175 + "spins": 1999917,
176 + "rtp": 0.9673292581703267
177 + },
178 + {
179 + "spins": 2249906,
180 + "rtp": 0.9688148459271706
181 + },
182 + {
183 + "spins": 2499896,
184 + "rtp": 0.9698452658106326
185 + },
186 + {
187 + "spins": 2749886,
188 + "rtp": 0.9663233838444446
189 + },
190 + {
191 + "spins": 2999875,
192 + "rtp": 0.9669122498233265
193 + },
194 + {
195 + "spins": 3249865,
196 + "rtp": 0.9653830583992595
197 + },
198 + {
199 + "spins": 3499854,
200 + "rtp": 0.9638233386478317
201 + },
202 + {
203 + "spins": 3749844,
204 + "rtp": 0.9650603837486835
205 + },
206 + {
207 + "spins": 3999834,
208 + "rtp": 0.9627829788191526
209 + },
210 + {
211 + "spins": 4249823,
212 + "rtp": 0.962358567283868
213 + },
214 + {
215 + "spins": 4499813,
216 + "rtp": 0.9622775066558217
217 + },
218 + {
219 + "spins": 4749802,
220 + "rtp": 0.963779991199648
221 + },
222 + {
223 + "spins": 4999792,
224 + "rtp": 0.9649657966318654
225 + },
226 + {
227 + "spins": 5249782,
228 + "rtp": 0.9655722000308582
229 + },
230 + {
231 + "spins": 5499771,
232 + "rtp": 0.9652647451352602
233 + },
234 + {
235 + "spins": 5749761,
236 + "rtp": 0.9647184878699495
237 + },
238 + {
239 + "spins": 5999750,
240 + "rtp": 0.9645517637372163
241 + },
242 + {
243 + "spins": 6249740,
244 + "rtp": 0.964243614544582
245 + },
246 + {
247 + "spins": 6499730,
248 + "rtp": 0.9634737235643271
249 + },
250 + {
251 + "spins": 6749719,
252 + "rtp": 0.962049701247309
253 + },
254 + {
255 + "spins": 6999709,
256 + "rtp": 0.9614473464652872
257 + },
258 + {
259 + "spins": 7249698,
260 + "rtp": 0.9606563614268709
261 + },
262 + {
263 + "spins": 7499688,
264 + "rtp": 0.9611934210701764
265 + },
266 + {
267 + "spins": 7749678,
268 + "rtp": 0.9615483780641547
269 + },
270 + {
271 + "spins": 7999667,
272 + "rtp": 0.960985516920677
273 + },
274 + {
275 + "spins": 8249657,
276 + "rtp": 0.9615840718477224
277 + },
278 + {
279 + "spins": 8499646,
280 + "rtp": 0.9612746392208626
281 + },
282 + {
283 + "spins": 8749636,
284 + "rtp": 0.9615425519877938
285 + },
286 + {
287 + "spins": 8999626,
288 + "rtp": 0.9607345149361529
289 + },
290 + {
291 + "spins": 9249615,
292 + "rtp": 0.9603543374167399
293 + },
294 + {
295 + "spins": 9499605,
296 + "rtp": 0.959989160619056
297 + },
298 + {
299 + "spins": 9749594,
300 + "rtp": 0.9598341636229549
301 + },
302 + {
303 + "spins": 9999584,
304 + "rtp": 0.9600861644465778
305 + },
306 + {
307 + "spins": 10000000,
308 + "rtp": 0.9601238126046662
309 + }
310 + ],
311 + "featureCounts": {
312 + "Exploding Wild": 2669656,
313 + "Cascade": 6234362,
314 + "Free Spins": 148319,
315 + "Retrigger": 21042
316 + },
317 + "durationMs": 8778
318 +}
added games/certifications/deep-treasure.json +318 −0
@@ -0,0 +1,318 @@
1 +{
2 + "game": "deep-treasure",
3 + "name": "Deep Treasure",
4 + "version": "1.0.0",
5 + "spins": 10000000,
6 + "configuredRtp": 0.96,
7 + "observedRtp": 0.961073966,
8 + "deviation": 0.0010739659999999818,
9 + "hitRate": 0.4011484,
10 + "bonusRate": 0.0120643,
11 + "freeSpinRate": 0.0120643,
12 + "maxWinMultiplier": 1019.93,
13 + "stdDev": 6.082229405567507,
14 + "status": "PASS",
15 + "checks": [
16 + {
17 + "name": "definition",
18 + "pass": true,
19 + "detail": "ok"
20 + },
21 + {
22 + "name": "spins",
23 + "pass": true,
24 + "detail": "10,000,000 spins (min 1,000,000)"
25 + },
26 + {
27 + "name": "rtp-deviation",
28 + "pass": true,
29 + "detail": "0.107% (tolerance ±0.58% = max(0.40%, 3σ/√n=0.58%))"
30 + },
31 + {
32 + "name": "rtp-band",
33 + "pass": true,
34 + "detail": "96.11%"
35 + },
36 + {
37 + "name": "hit-rate",
38 + "pass": true,
39 + "detail": "40.11%"
40 + },
41 + {
42 + "name": "max-win",
43 + "pass": true,
44 + "detail": "1019.9× (cap 8000×)"
45 + },
46 + {
47 + "name": "cap-share",
48 + "pass": true,
49 + "detail": "0 capped rounds"
50 + }
51 + ],
52 + "certifiedAt": "2026-09-08T01:56:50.988Z",
53 + "rules": {
54 + "maxDeviation": 0.004,
55 + "minSpins": 1000000,
56 + "rtpBand": [
57 + 0.93,
58 + 0.99
59 + ],
60 + "hitRateBand": [
61 + 0.08,
62 + 0.6
63 + ],
64 + "maxCappedShare": 0.0005
65 + },
66 + "distribution": [
67 + {
68 + "label": "0×",
69 + "min": 0,
70 + "max": 0,
71 + "count": 5988516,
72 + "share": 0.5988516
73 + },
74 + {
75 + "label": "0–1×",
76 + "min": 0.000001,
77 + "max": 1,
78 + "count": 2925487,
79 + "share": 0.2925487
80 + },
81 + {
82 + "label": "1–2×",
83 + "min": 1,
84 + "max": 2,
85 + "count": 459758,
86 + "share": 0.0459758
87 + },
88 + {
89 + "label": "2–5×",
90 + "min": 2,
91 + "max": 5,
92 + "count": 344462,
93 + "share": 0.0344462
94 + },
95 + {
96 + "label": "5–10×",
97 + "min": 5,
98 + "max": 10,
99 + "count": 107664,
100 + "share": 0.0107664
101 + },
102 + {
103 + "label": "10–20×",
104 + "min": 10,
105 + "max": 20,
106 + "count": 56677,
107 + "share": 0.0056677
108 + },
109 + {
110 + "label": "20–50×",
111 + "min": 20,
112 + "max": 50,
113 + "count": 83079,
114 + "share": 0.0083079
115 + },
116 + {
117 + "label": "50–100×",
118 + "min": 50,
119 + "max": 100,
120 + "count": 27842,
121 + "share": 0.0027842
122 + },
123 + {
124 + "label": "100–500×",
125 + "min": 100,
126 + "max": 500,
127 + "count": 6498,
128 + "share": 0.0006498
129 + },
130 + {
131 + "label": "500–1000×",
132 + "min": 500,
133 + "max": 1000,
134 + "count": 16,
135 + "share": 0.0000016
136 + },
137 + {
138 + "label": "1000×+",
139 + "min": 1000,
140 + "max": null,
141 + "count": 1,
142 + "share": 1e-7
143 + }
144 + ],
145 + "convergence": [
146 + {
147 + "spins": 249990,
148 + "rtp": 0.9657924316972678
149 + },
150 + {
151 + "spins": 499979,
152 + "rtp": 0.9663946757870314
153 + },
154 + {
155 + "spins": 749969,
156 + "rtp": 0.9656618131391923
157 + },
158 + {
159 + "spins": 999958,
160 + "rtp": 0.9675742529701186
161 + },
162 + {
163 + "spins": 1249948,
164 + "rtp": 0.9647753190127606
165 + },
166 + {
167 + "spins": 1499938,
168 + "rtp": 0.9616336920143473
169 + },
170 + {
171 + "spins": 1749927,
172 + "rtp": 0.958647037310064
173 + },
174 + {
175 + "spins": 1999917,
176 + "rtp": 0.9603987659506381
177 + },
178 + {
179 + "spins": 2249906,
180 + "rtp": 0.9602353294131766
181 + },
182 + {
183 + "spins": 2499896,
184 + "rtp": 0.9609235089403576
185 + },
186 + {
187 + "spins": 2749886,
188 + "rtp": 0.9607068173636034
189 + },
190 + {
191 + "spins": 2999875,
192 + "rtp": 0.9599221568862752
193 + },
194 + {
195 + "spins": 3249865,
196 + "rtp": 0.9584108410490265
197 + },
198 + {
199 + "spins": 3499854,
200 + "rtp": 0.9600159720674539
201 + },
202 + {
203 + "spins": 3749844,
204 + "rtp": 0.9589942531034575
205 + },
206 + {
207 + "spins": 3999834,
208 + "rtp": 0.9597835388415538
209 + },
210 + {
211 + "spins": 4249823,
212 + "rtp": 0.9595958967770476
213 + },
214 + {
215 + "spins": 4499813,
216 + "rtp": 0.9603823486272783
217 + },
218 + {
219 + "spins": 4749802,
220 + "rtp": 0.96056086032915
221 + },
222 + {
223 + "spins": 4999792,
224 + "rtp": 0.9614966698667947
225 + },
226 + {
227 + "spins": 5249782,
228 + "rtp": 0.9612588941652904
229 + },
230 + {
231 + "spins": 5499771,
232 + "rtp": 0.96103472320711
233 + },
234 + {
235 + "spins": 5749761,
236 + "rtp": 0.9612555545700091
237 + },
238 + {
239 + "spins": 5999750,
240 + "rtp": 0.9608634295371815
241 + },
242 + {
243 + "spins": 6249740,
244 + "rtp": 0.9602628137125486
245 + },
246 + {
247 + "spins": 6499730,
248 + "rtp": 0.9602572241351192
249 + },
250 + {
251 + "spins": 6749719,
252 + "rtp": 0.9599480794046578
253 + },
254 + {
255 + "spins": 6999709,
256 + "rtp": 0.9596207133999644
257 + },
258 + {
259 + "spins": 7249698,
260 + "rtp": 0.9601989707174495
261 + },
262 + {
263 + "spins": 7499688,
264 + "rtp": 0.9603758937024147
265 + },
266 + {
267 + "spins": 7749678,
268 + "rtp": 0.9606316007479011
269 + },
270 + {
271 + "spins": 7999667,
272 + "rtp": 0.9603780226209047
273 + },
274 + {
275 + "spins": 8249657,
276 + "rtp": 0.9610420828954369
277 + },
278 + {
279 + "spins": 8499646,
280 + "rtp": 0.9614078927862997
281 + },
282 + {
283 + "spins": 8749636,
284 + "rtp": 0.9609158834924826
285 + },
286 + {
287 + "spins": 8999626,
288 + "rtp": 0.9611102366316875
289 + },
290 + {
291 + "spins": 9249615,
292 + "rtp": 0.9607823177791978
293 + },
294 + {
295 + "spins": 9499605,
296 + "rtp": 0.9611805145890043
297 + },
298 + {
299 + "spins": 9749594,
300 + "rtp": 0.960905987008711
301 + },
302 + {
303 + "spins": 9999584,
304 + "rtp": 0.961088270530821
305 + },
306 + {
307 + "spins": 10000000,
308 + "rtp": 0.9610739659861162
309 + }
310 + ],
311 + "featureCounts": {
312 + "Cascade": 4010266,
313 + "Free Spins": 120643,
314 + "Treasure Chests": 120643,
315 + "Retrigger": 11417
316 + },
317 + "durationMs": 8414
318 +}
added games/certifications/diamond-heist.json +320 −0
@@ -0,0 +1,320 @@
1 +{
2 + "game": "diamond-heist",
3 + "name": "Diamond Heist",
4 + "version": "1.0.0",
5 + "spins": 10000000,
6 + "configuredRtp": 0.96,
7 + "observedRtp": 0.957285856,
8 + "deviation": -0.0027141439999999184,
9 + "hitRate": 0.2119682,
10 + "bonusRate": 0.0055322,
11 + "freeSpinRate": 0.0028942,
12 + "maxWinMultiplier": 1313.35,
13 + "stdDev": 7.5256235413717185,
14 + "status": "PASS",
15 + "checks": [
16 + {
17 + "name": "definition",
18 + "pass": true,
19 + "detail": "ok"
20 + },
21 + {
22 + "name": "spins",
23 + "pass": true,
24 + "detail": "10,000,000 spins (min 1,000,000)"
25 + },
26 + {
27 + "name": "rtp-deviation",
28 + "pass": true,
29 + "detail": "-0.271% (tolerance ±0.71% = max(0.40%, 3σ/√n=0.71%))"
30 + },
31 + {
32 + "name": "rtp-band",
33 + "pass": true,
34 + "detail": "95.73%"
35 + },
36 + {
37 + "name": "hit-rate",
38 + "pass": true,
39 + "detail": "21.20%"
40 + },
41 + {
42 + "name": "max-win",
43 + "pass": true,
44 + "detail": "1313.3× (cap 5000×)"
45 + },
46 + {
47 + "name": "cap-share",
48 + "pass": true,
49 + "detail": "0 capped rounds"
50 + }
51 + ],
52 + "certifiedAt": "2026-09-08T01:55:26.329Z",
53 + "rules": {
54 + "maxDeviation": 0.004,
55 + "minSpins": 1000000,
56 + "rtpBand": [
57 + 0.93,
58 + 0.99
59 + ],
60 + "hitRateBand": [
61 + 0.08,
62 + 0.6
63 + ],
64 + "maxCappedShare": 0.0005
65 + },
66 + "distribution": [
67 + {
68 + "label": "0×",
69 + "min": 0,
70 + "max": 0,
71 + "count": 7880318,
72 + "share": 0.7880318
73 + },
74 + {
75 + "label": "0–1×",
76 + "min": 0.000001,
77 + "max": 1,
78 + "count": 440621,
79 + "share": 0.0440621
80 + },
81 + {
82 + "label": "1–2×",
83 + "min": 1,
84 + "max": 2,
85 + "count": 626981,
86 + "share": 0.0626981
87 + },
88 + {
89 + "label": "2–5×",
90 + "min": 2,
91 + "max": 5,
92 + "count": 703335,
93 + "share": 0.0703335
94 + },
95 + {
96 + "label": "5–10×",
97 + "min": 5,
98 + "max": 10,
99 + "count": 194195,
100 + "share": 0.0194195
101 + },
102 + {
103 + "label": "10–20×",
104 + "min": 10,
105 + "max": 20,
106 + "count": 90694,
107 + "share": 0.0090694
108 + },
109 + {
110 + "label": "20–50×",
111 + "min": 20,
112 + "max": 50,
113 + "count": 44905,
114 + "share": 0.0044905
115 + },
116 + {
117 + "label": "50–100×",
118 + "min": 50,
119 + "max": 100,
120 + "count": 16441,
121 + "share": 0.0016441
122 + },
123 + {
124 + "label": "100–500×",
125 + "min": 100,
126 + "max": 500,
127 + "count": 2196,
128 + "share": 0.0002196
129 + },
130 + {
131 + "label": "500–1000×",
132 + "min": 500,
133 + "max": 1000,
134 + "count": 0,
135 + "share": 0
136 + },
137 + {
138 + "label": "1000×+",
139 + "min": 1000,
140 + "max": null,
141 + "count": 314,
142 + "share": 0.0000314
143 + }
144 + ],
145 + "convergence": [
146 + {
147 + "spins": 249990,
148 + "rtp": 0.9556297451898076
149 + },
150 + {
151 + "spins": 499979,
152 + "rtp": 0.9398523540941638
153 + },
154 + {
155 + "spins": 749969,
156 + "rtp": 0.9442020214141896
157 + },
158 + {
159 + "spins": 999958,
160 + "rtp": 0.9531561962478498
161 + },
162 + {
163 + "spins": 1249948,
164 + "rtp": 0.9559248049921997
165 + },
166 + {
167 + "spins": 1499938,
168 + "rtp": 0.9508016720668826
169 + },
170 + {
171 + "spins": 1749927,
172 + "rtp": 0.9538723777522529
173 + },
174 + {
175 + "spins": 1999917,
176 + "rtp": 0.9541414206568262
177 + },
178 + {
179 + "spins": 2249906,
180 + "rtp": 0.9537783155770676
181 + },
182 + {
183 + "spins": 2499896,
184 + "rtp": 0.9535932037281489
185 + },
186 + {
187 + "spins": 2749886,
188 + "rtp": 0.9544458578343135
189 + },
190 + {
191 + "spins": 2999875,
192 + "rtp": 0.9522292925050337
193 + },
194 + {
195 + "spins": 3249865,
196 + "rtp": 0.9537267644551933
197 + },
198 + {
199 + "spins": 3499854,
200 + "rtp": 0.9553251015754918
201 + },
202 + {
203 + "spins": 3749844,
204 + "rtp": 0.9565862314492578
205 + },
206 + {
207 + "spins": 3999834,
208 + "rtp": 0.9564389425577023
209 + },
210 + {
211 + "spins": 4249823,
212 + "rtp": 0.9572651447234363
213 + },
214 + {
215 + "spins": 4499813,
216 + "rtp": 0.9588255285766984
217 + },
218 + {
219 + "spins": 4749802,
220 + "rtp": 0.9594369227400672
221 + },
222 + {
223 + "spins": 4999792,
224 + "rtp": 0.9601899435977437
225 + },
226 + {
227 + "spins": 5249782,
228 + "rtp": 0.9609084858632441
229 + },
230 + {
231 + "spins": 5499771,
232 + "rtp": 0.9599872849459435
233 + },
234 + {
235 + "spins": 5749761,
236 + "rtp": 0.9597527396748045
237 + },
238 + {
239 + "spins": 5999750,
240 + "rtp": 0.959803058789018
241 + },
242 + {
243 + "spins": 6249740,
244 + "rtp": 0.958641256050242
245 + },
246 + {
247 + "spins": 6499730,
248 + "rtp": 0.9586467489468811
249 + },
250 + {
251 + "spins": 6749719,
252 + "rtp": 0.957959011693801
253 + },
254 + {
255 + "spins": 6999709,
256 + "rtp": 0.95827137514072
257 + },
258 + {
259 + "spins": 7249698,
260 + "rtp": 0.9586510108680208
261 + },
262 + {
263 + "spins": 7499688,
264 + "rtp": 0.9578079296505193
265 + },
266 + {
267 + "spins": 7749678,
268 + "rtp": 0.9582512810189827
269 + },
270 + {
271 + "spins": 7999667,
272 + "rtp": 0.9583589106064241
273 + },
274 + {
275 + "spins": 8249657,
276 + "rtp": 0.9583587064694707
277 + },
278 + {
279 + "spins": 8499646,
280 + "rtp": 0.9573606756034945
281 + },
282 + {
283 + "spins": 8749636,
284 + "rtp": 0.9571939666158074
285 + },
286 + {
287 + "spins": 8999626,
288 + "rtp": 0.9571318797196332
289 + },
290 + {
291 + "spins": 9249615,
292 + "rtp": 0.9577899548414366
293 + },
294 + {
295 + "spins": 9499605,
296 + "rtp": 0.9577757605041042
297 + },
298 + {
299 + "spins": 9749594,
300 + "rtp": 0.9575062817897333
301 + },
302 + {
303 + "spins": 9999584,
304 + "rtp": 0.9572991619664786
305 + },
306 + {
307 + "spins": 10000000,
308 + "rtp": 0.9572858456171142
309 + }
310 + ],
311 + "featureCounts": {
312 + "Hold & Respin": 55322,
313 + "Jackpot mini": 15227,
314 + "Free Spins": 28942,
315 + "Jackpot grand": 314,
316 + "Jackpot minor": 4652,
317 + "Jackpot major": 439
318 + },
319 + "durationMs": 8204
320 +}
added games/certifications/dragon-core.json +317 −0
@@ -0,0 +1,317 @@
1 +{
2 + "game": "dragon-core",
3 + "name": "Dragon Core",
4 + "version": "1.0.0",
5 + "spins": 10000000,
6 + "configuredRtp": 0.96,
7 + "observedRtp": 0.957941387,
8 + "deviation": -0.002058612999999987,
9 + "hitRate": 0.4108252,
10 + "bonusRate": 0,
11 + "freeSpinRate": 0.0256125,
12 + "maxWinMultiplier": 6000,
13 + "stdDev": 18.08759249183618,
14 + "status": "PASS",
15 + "checks": [
16 + {
17 + "name": "definition",
18 + "pass": true,
19 + "detail": "ok"
20 + },
21 + {
22 + "name": "spins",
23 + "pass": true,
24 + "detail": "10,000,000 spins (min 1,000,000)"
25 + },
26 + {
27 + "name": "rtp-deviation",
28 + "pass": true,
29 + "detail": "-0.206% (tolerance ±1.50% = max(0.40%, 3σ/√n=1.72%))"
30 + },
31 + {
32 + "name": "rtp-band",
33 + "pass": true,
34 + "detail": "95.79%"
35 + },
36 + {
37 + "name": "hit-rate",
38 + "pass": true,
39 + "detail": "41.08%"
40 + },
41 + {
42 + "name": "max-win",
43 + "pass": true,
44 + "detail": "6000.0× (cap 6000×)"
45 + },
46 + {
47 + "name": "cap-share",
48 + "pass": true,
49 + "detail": "5 capped rounds"
50 + }
51 + ],
52 + "certifiedAt": "2026-09-08T01:56:42.458Z",
53 + "rules": {
54 + "maxDeviation": 0.004,
55 + "minSpins": 1000000,
56 + "rtpBand": [
57 + 0.93,
58 + 0.99
59 + ],
60 + "hitRateBand": [
61 + 0.08,
62 + 0.6
63 + ],
64 + "maxCappedShare": 0.0005
65 + },
66 + "distribution": [
67 + {
68 + "label": "0×",
69 + "min": 0,
70 + "max": 0,
71 + "count": 5891748,
72 + "share": 0.5891748
73 + },
74 + {
75 + "label": "0–1×",
76 + "min": 0.000001,
77 + "max": 1,
78 + "count": 3672514,
79 + "share": 0.3672514
80 + },
81 + {
82 + "label": "1–2×",
83 + "min": 1,
84 + "max": 2,
85 + "count": 188844,
86 + "share": 0.0188844
87 + },
88 + {
89 + "label": "2–5×",
90 + "min": 2,
91 + "max": 5,
92 + "count": 90052,
93 + "share": 0.0090052
94 + },
95 + {
96 + "label": "5–10×",
97 + "min": 5,
98 + "max": 10,
99 + "count": 33728,
100 + "share": 0.0033728
101 + },
102 + {
103 + "label": "10–20×",
104 + "min": 10,
105 + "max": 20,
106 + "count": 37236,
107 + "share": 0.0037236
108 + },
109 + {
110 + "label": "20–50×",
111 + "min": 20,
112 + "max": 50,
113 + "count": 49086,
114 + "share": 0.0049086
115 + },
116 + {
117 + "label": "50–100×",
118 + "min": 50,
119 + "max": 100,
120 + "count": 18849,
121 + "share": 0.0018849
122 + },
123 + {
124 + "label": "100–500×",
125 + "min": 100,
126 + "max": 500,
127 + "count": 16817,
128 + "share": 0.0016817
129 + },
130 + {
131 + "label": "500–1000×",
132 + "min": 500,
133 + "max": 1000,
134 + "count": 688,
135 + "share": 0.0000688
136 + },
137 + {
138 + "label": "1000×+",
139 + "min": 1000,
140 + "max": null,
141 + "count": 438,
142 + "share": 0.0000438
143 + }
144 + ],
145 + "convergence": [
146 + {
147 + "spins": 249990,
148 + "rtp": 0.909830633225329
149 + },
150 + {
151 + "spins": 499979,
152 + "rtp": 0.9436241649665985
153 + },
154 + {
155 + "spins": 749969,
156 + "rtp": 0.9443939224235633
157 + },
158 + {
159 + "spins": 999958,
160 + "rtp": 0.9383748349934
161 + },
162 + {
163 + "spins": 1249948,
164 + "rtp": 0.9490960118404735
165 + },
166 + {
167 + "spins": 1499938,
168 + "rtp": 0.9505358614344573
169 + },
170 + {
171 + "spins": 1749927,
172 + "rtp": 0.9551697953632432
173 + },
174 + {
175 + "spins": 1999917,
176 + "rtp": 0.9528150026001039
177 + },
178 + {
179 + "spins": 2249906,
180 + "rtp": 0.9508894933575122
181 + },
182 + {
183 + "spins": 2499896,
184 + "rtp": 0.9530906356254252
185 + },
186 + {
187 + "spins": 2749886,
188 + "rtp": 0.957969471506133
189 + },
190 + {
191 + "spins": 2999875,
192 + "rtp": 0.9555518420736828
193 + },
194 + {
195 + "spins": 3249865,
196 + "rtp": 0.9592855991162723
197 + },
198 + {
199 + "spins": 3499854,
200 + "rtp": 0.9599314515437763
201 + },
202 + {
203 + "spins": 3749844,
204 + "rtp": 0.9586007706974949
205 + },
206 + {
207 + "spins": 3999834,
208 + "rtp": 0.959358116824673
209 + },
210 + {
211 + "spins": 4249823,
212 + "rtp": 0.9560507808547638
213 + },
214 + {
215 + "spins": 4499813,
216 + "rtp": 0.9550884635385416
217 + },
218 + {
219 + "spins": 4749802,
220 + "rtp": 0.9546970110383364
221 + },
222 + {
223 + "spins": 4999792,
224 + "rtp": 0.9566570642825714
225 + },
226 + {
227 + "spins": 5249782,
228 + "rtp": 0.9542499433310668
229 + },
230 + {
231 + "spins": 5499771,
232 + "rtp": 0.9573543469011488
233 + },
234 + {
235 + "spins": 5749761,
236 + "rtp": 0.955873034921397
237 + },
238 + {
239 + "spins": 5999750,
240 + "rtp": 0.9568673396935876
241 + },
242 + {
243 + "spins": 6249740,
244 + "rtp": 0.955548157926317
245 + },
246 + {
247 + "spins": 6499730,
248 + "rtp": 0.9550065494927491
249 + },
250 + {
251 + "spins": 6749719,
252 + "rtp": 0.9550385096885357
253 + },
254 + {
255 + "spins": 6999709,
256 + "rtp": 0.9565353099838279
257 + },
258 + {
259 + "spins": 7249698,
260 + "rtp": 0.9566008998980648
261 + },
262 + {
263 + "spins": 7499688,
264 + "rtp": 0.9567843740416287
265 + },
266 + {
267 + "spins": 7749678,
268 + "rtp": 0.9562796189266927
269 + },
270 + {
271 + "spins": 7999667,
272 + "rtp": 0.9574944197767912
273 + },
274 + {
275 + "spins": 8249657,
276 + "rtp": 0.9589924166663638
277 + },
278 + {
279 + "spins": 8499646,
280 + "rtp": 0.9589947233183445
281 + },
282 + {
283 + "spins": 8749636,
284 + "rtp": 0.9582822284319943
285 + },
286 + {
287 + "spins": 8999626,
288 + "rtp": 0.9574092241467435
289 + },
290 + {
291 + "spins": 9249615,
292 + "rtp": 0.9583883139109349
293 + },
294 + {
295 + "spins": 9499605,
296 + "rtp": 0.9579769980272896
297 + },
298 + {
299 + "spins": 9749594,
300 + "rtp": 0.9587267767633783
301 + },
302 + {
303 + "spins": 9999584,
304 + "rtp": 0.9579556182247289
305 + },
306 + {
307 + "spins": 10000000,
308 + "rtp": 0.9579413735621954
309 + }
310 + ],
311 + "featureCounts": {
312 + "Dragon Mode": 197168,
313 + "Free Spins": 71703,
314 + "Retrigger": 12646
315 + },
316 + "durationMs": 8464
317 +}
added games/certifications/golden-emperor.json +316 −0
@@ -0,0 +1,316 @@
1 +{
2 + "game": "golden-emperor",
3 + "name": "Golden Emperor",
4 + "version": "1.0.0",
5 + "spins": 10000000,
6 + "configuredRtp": 0.96,
7 + "observedRtp": 0.956664544,
8 + "deviation": -0.003335455999999959,
9 + "hitRate": 0.2822178,
10 + "bonusRate": 0,
11 + "freeSpinRate": 0.0041274,
12 + "maxWinMultiplier": 3000,
13 + "stdDev": 12.419776502603776,
14 + "status": "PASS",
15 + "checks": [
16 + {
17 + "name": "definition",
18 + "pass": true,
19 + "detail": "ok"
20 + },
21 + {
22 + "name": "spins",
23 + "pass": true,
24 + "detail": "10,000,000 spins (min 1,000,000)"
25 + },
26 + {
27 + "name": "rtp-deviation",
28 + "pass": true,
29 + "detail": "-0.334% (tolerance ±1.18% = max(0.40%, 3σ/√n=1.18%))"
30 + },
31 + {
32 + "name": "rtp-band",
33 + "pass": true,
34 + "detail": "95.67%"
35 + },
36 + {
37 + "name": "hit-rate",
38 + "pass": true,
39 + "detail": "28.22%"
40 + },
41 + {
42 + "name": "max-win",
43 + "pass": true,
44 + "detail": "3000.0× (cap 3000×)"
45 + },
46 + {
47 + "name": "cap-share",
48 + "pass": true,
49 + "detail": "39 capped rounds"
50 + }
51 + ],
52 + "certifiedAt": "2026-09-08T01:55:18.025Z",
53 + "rules": {
54 + "maxDeviation": 0.004,
55 + "minSpins": 1000000,
56 + "rtpBand": [
57 + 0.93,
58 + 0.99
59 + ],
60 + "hitRateBand": [
61 + 0.08,
62 + 0.6
63 + ],
64 + "maxCappedShare": 0.0005
65 + },
66 + "distribution": [
67 + {
68 + "label": "0×",
69 + "min": 0,
70 + "max": 0,
71 + "count": 7177822,
72 + "share": 0.7177822
73 + },
74 + {
75 + "label": "0–1×",
76 + "min": 0.000001,
77 + "max": 1,
78 + "count": 971696,
79 + "share": 0.0971696
80 + },
81 + {
82 + "label": "1–2×",
83 + "min": 1,
84 + "max": 2,
85 + "count": 652996,
86 + "share": 0.0652996
87 + },
88 + {
89 + "label": "2–5×",
90 + "min": 2,
91 + "max": 5,
92 + "count": 822626,
93 + "share": 0.0822626
94 + },
95 + {
96 + "label": "5–10×",
97 + "min": 5,
98 + "max": 10,
99 + "count": 269274,
100 + "share": 0.0269274
101 + },
102 + {
103 + "label": "10–20×",
104 + "min": 10,
105 + "max": 20,
106 + "count": 77831,
107 + "share": 0.0077831
108 + },
109 + {
110 + "label": "20–50×",
111 + "min": 20,
112 + "max": 50,
113 + "count": 19867,
114 + "share": 0.0019867
115 + },
116 + {
117 + "label": "50–100×",
118 + "min": 50,
119 + "max": 100,
120 + "count": 3559,
121 + "share": 0.0003559
122 + },
123 + {
124 + "label": "100–500×",
125 + "min": 100,
126 + "max": 500,
127 + "count": 3608,
128 + "share": 0.0003608
129 + },
130 + {
131 + "label": "500–1000×",
132 + "min": 500,
133 + "max": 1000,
134 + "count": 411,
135 + "share": 0.0000411
136 + },
137 + {
138 + "label": "1000×+",
139 + "min": 1000,
140 + "max": null,
141 + "count": 310,
142 + "share": 0.000031
143 + }
144 + ],
145 + "convergence": [
146 + {
147 + "spins": 249990,
148 + "rtp": 0.9683610944437777
149 + },
150 + {
151 + "spins": 499979,
152 + "rtp": 0.9405536421456857
153 + },
154 + {
155 + "spins": 749969,
156 + "rtp": 0.9493723215595291
157 + },
158 + {
159 + "spins": 999958,
160 + "rtp": 0.9477046181847275
161 + },
162 + {
163 + "spins": 1249948,
164 + "rtp": 0.9490065042601704
165 + },
166 + {
167 + "spins": 1499938,
168 + "rtp": 0.9500300545355146
169 + },
170 + {
171 + "spins": 1749927,
172 + "rtp": 0.9456613978844868
173 + },
174 + {
175 + "spins": 1999917,
176 + "rtp": 0.9446045691827673
177 + },
178 + {
179 + "spins": 2249906,
180 + "rtp": 0.9449516958456117
181 + },
182 + {
183 + "spins": 2499896,
184 + "rtp": 0.9530451698067922
185 + },
186 + {
187 + "spins": 2749886,
188 + "rtp": 0.9524345773830956
189 + },
190 + {
191 + "spins": 2999875,
192 + "rtp": 0.9490950304678852
193 + },
194 + {
195 + "spins": 3249865,
196 + "rtp": 0.9496400871419473
197 + },
198 + {
199 + "spins": 3499854,
200 + "rtp": 0.9537988462395639
201 + },
202 + {
203 + "spins": 3749844,
204 + "rtp": 0.9540989586250114
205 + },
206 + {
207 + "spins": 3999834,
208 + "rtp": 0.956634412876515
209 + },
210 + {
211 + "spins": 4249823,
212 + "rtp": 0.9554296430680759
213 + },
214 + {
215 + "spins": 4499813,
216 + "rtp": 0.9583330044312881
217 + },
218 + {
219 + "spins": 4749802,
220 + "rtp": 0.9578873112819252
221 + },
222 + {
223 + "spins": 4999792,
224 + "rtp": 0.9573994259770389
225 + },
226 + {
227 + "spins": 5249782,
228 + "rtp": 0.9543207061615799
229 + },
230 + {
231 + "spins": 5499771,
232 + "rtp": 0.9556341871856691
233 + },
234 + {
235 + "spins": 5749761,
236 + "rtp": 0.9579621550079396
237 + },
238 + {
239 + "spins": 5999750,
240 + "rtp": 0.9587499883328667
241 + },
242 + {
243 + "spins": 6249740,
244 + "rtp": 0.9575895803832152
245 + },
246 + {
247 + "spins": 6499730,
248 + "rtp": 0.9567167825174544
249 + },
250 + {
251 + "spins": 6749719,
252 + "rtp": 0.9570605327916822
253 + },
254 + {
255 + "spins": 6999709,
256 + "rtp": 0.9576202719537352
257 + },
258 + {
259 + "spins": 7249698,
260 + "rtp": 0.9591094140317338
261 + },
262 + {
263 + "spins": 7499688,
264 + "rtp": 0.9589501620064803
265 + },
266 + {
267 + "spins": 7749678,
268 + "rtp": 0.9583883316622988
269 + },
270 + {
271 + "spins": 7999667,
272 + "rtp": 0.9581040716628666
273 + },
274 + {
275 + "spins": 8249657,
276 + "rtp": 0.957334583080293
277 + },
278 + {
279 + "spins": 8499646,
280 + "rtp": 0.9576460858434337
281 + },
282 + {
283 + "spins": 8749636,
284 + "rtp": 0.9575469315915495
285 + },
286 + {
287 + "spins": 8999626,
288 + "rtp": 0.9573909367485811
289 + },
290 + {
291 + "spins": 9249615,
292 + "rtp": 0.9568612420172484
293 + },
294 + {
295 + "spins": 9499605,
296 + "rtp": 0.9577735751535326
297 + },
298 + {
299 + "spins": 9749594,
300 + "rtp": 0.9573443963399563
301 + },
302 + {
303 + "spins": 9999584,
304 + "rtp": 0.9566783991359653
305 + },
306 + {
307 + "spins": 10000000,
308 + "rtp": 0.9566645446894898
309 + }
310 + ],
311 + "featureCounts": {
312 + "Free Spins": 41274,
313 + "Retrigger": 1254
314 + },
315 + "durationMs": 8264
316 +}
added games/certifications/inferno-reels.json +318 −0
@@ -0,0 +1,318 @@
1 +{
2 + "game": "inferno-reels",
3 + "name": "Inferno Reels",
4 + "version": "1.0.0",
5 + "spins": 10000000,
6 + "configuredRtp": 0.96,
7 + "observedRtp": 0.954560768,
8 + "deviation": -0.0054392319999999605,
9 + "hitRate": 0.376029,
10 + "bonusRate": 0,
11 + "freeSpinRate": 0.0041428,
12 + "maxWinMultiplier": 3963.23,
13 + "stdDev": 9.77660863627645,
14 + "status": "PASS",
15 + "checks": [
16 + {
17 + "name": "definition",
18 + "pass": true,
19 + "detail": "ok"
20 + },
21 + {
22 + "name": "spins",
23 + "pass": true,
24 + "detail": "10,000,000 spins (min 1,000,000)"
25 + },
26 + {
27 + "name": "rtp-deviation",
28 + "pass": true,
29 + "detail": "-0.544% (tolerance ±0.93% = max(0.40%, 3σ/√n=0.93%))"
30 + },
31 + {
32 + "name": "rtp-band",
33 + "pass": true,
34 + "detail": "95.46%"
35 + },
36 + {
37 + "name": "hit-rate",
38 + "pass": true,
39 + "detail": "37.60%"
40 + },
41 + {
42 + "name": "max-win",
43 + "pass": true,
44 + "detail": "3963.2× (cap 8000×)"
45 + },
46 + {
47 + "name": "cap-share",
48 + "pass": true,
49 + "detail": "0 capped rounds"
50 + }
51 + ],
52 + "certifiedAt": "2026-09-08T01:55:43.268Z",
53 + "rules": {
54 + "maxDeviation": 0.004,
55 + "minSpins": 1000000,
56 + "rtpBand": [
57 + 0.93,
58 + 0.99
59 + ],
60 + "hitRateBand": [
61 + 0.08,
62 + 0.6
63 + ],
64 + "maxCappedShare": 0.0005
65 + },
66 + "distribution": [
67 + {
68 + "label": "0×",
69 + "min": 0,
70 + "max": 0,
71 + "count": 6239710,
72 + "share": 0.623971
73 + },
74 + {
75 + "label": "0–1×",
76 + "min": 0.000001,
77 + "max": 1,
78 + "count": 2553679,
79 + "share": 0.2553679
80 + },
81 + {
82 + "label": "1–2×",
83 + "min": 1,
84 + "max": 2,
85 + "count": 483868,
86 + "share": 0.0483868
87 + },
88 + {
89 + "label": "2–5×",
90 + "min": 2,
91 + "max": 5,
92 + "count": 406126,
93 + "share": 0.0406126
94 + },
95 + {
96 + "label": "5–10×",
97 + "min": 5,
98 + "max": 10,
99 + "count": 164625,
100 + "share": 0.0164625
101 + },
102 + {
103 + "label": "10–20×",
104 + "min": 10,
105 + "max": 20,
106 + "count": 83441,
107 + "share": 0.0083441
108 + },
109 + {
110 + "label": "20–50×",
111 + "min": 20,
112 + "max": 50,
113 + "count": 46094,
114 + "share": 0.0046094
115 + },
116 + {
117 + "label": "50–100×",
118 + "min": 50,
119 + "max": 100,
120 + "count": 13497,
121 + "share": 0.0013497
122 + },
123 + {
124 + "label": "100–500×",
125 + "min": 100,
126 + "max": 500,
127 + "count": 8380,
128 + "share": 0.000838
129 + },
130 + {
131 + "label": "500–1000×",
132 + "min": 500,
133 + "max": 1000,
134 + "count": 471,
135 + "share": 0.0000471
136 + },
137 + {
138 + "label": "1000×+",
139 + "min": 1000,
140 + "max": null,
141 + "count": 109,
142 + "share": 0.0000109
143 + }
144 + ],
145 + "convergence": [
146 + {
147 + "spins": 249990,
148 + "rtp": 0.9824024160966438
149 + },
150 + {
151 + "spins": 499979,
152 + "rtp": 0.9514544581783272
153 + },
154 + {
155 + "spins": 749969,
156 + "rtp": 0.9640772564235905
157 + },
158 + {
159 + "spins": 999958,
160 + "rtp": 0.960722568902756
161 + },
162 + {
163 + "spins": 1249948,
164 + "rtp": 0.9607069162766512
165 + },
166 + {
167 + "spins": 1499938,
168 + "rtp": 0.9589750723362266
169 + },
170 + {
171 + "spins": 1749927,
172 + "rtp": 0.9600180178635719
173 + },
174 + {
175 + "spins": 1999917,
176 + "rtp": 0.9591658666346655
177 + },
178 + {
179 + "spins": 2249906,
180 + "rtp": 0.9577917116684667
181 + },
182 + {
183 + "spins": 2499896,
184 + "rtp": 0.957070042801712
185 + },
186 + {
187 + "spins": 2749886,
188 + "rtp": 0.9564882195287812
189 + },
190 + {
191 + "spins": 2999875,
192 + "rtp": 0.9567821146179178
193 + },
194 + {
195 + "spins": 3249865,
196 + "rtp": 0.9579770513897479
197 + },
198 + {
199 + "spins": 3499854,
200 + "rtp": 0.9568411307880889
201 + },
202 + {
203 + "spins": 3749844,
204 + "rtp": 0.9548942277691107
205 + },
206 + {
207 + "spins": 3999834,
208 + "rtp": 0.9524486179447176
209 + },
210 + {
211 + "spins": 4249823,
212 + "rtp": 0.9504445142511583
213 + },
214 + {
215 + "spins": 4499813,
216 + "rtp": 0.9493630678560476
217 + },
218 + {
219 + "spins": 4749802,
220 + "rtp": 0.9526266166436129
221 + },
222 + {
223 + "spins": 4999792,
224 + "rtp": 0.9531725969038765
225 + },
226 + {
227 + "spins": 5249782,
228 + "rtp": 0.9517479956341113
229 + },
230 + {
231 + "spins": 5499771,
232 + "rtp": 0.9515804559455106
233 + },
234 + {
235 + "spins": 5749761,
236 + "rtp": 0.9529948884911918
237 + },
238 + {
239 + "spins": 5999750,
240 + "rtp": 0.9538769950798032
241 + },
242 + {
243 + "spins": 6249740,
244 + "rtp": 0.9535096683867353
245 + },
246 + {
247 + "spins": 6499730,
248 + "rtp": 0.9537855806539952
249 + },
250 + {
251 + "spins": 6749719,
252 + "rtp": 0.9552141270836019
253 + },
254 + {
255 + "spins": 6999709,
256 + "rtp": 0.955499638556971
257 + },
258 + {
259 + "spins": 7249698,
260 + "rtp": 0.9558115262541537
261 + },
262 + {
263 + "spins": 7499688,
264 + "rtp": 0.955014424576983
265 + },
266 + {
267 + "spins": 7749678,
268 + "rtp": 0.9556644807727793
269 + },
270 + {
271 + "spins": 7999667,
272 + "rtp": 0.9547604029161165
273 + },
274 + {
275 + "spins": 8249657,
276 + "rtp": 0.955123801315689
277 + },
278 + {
279 + "spins": 8499646,
280 + "rtp": 0.9548766115350494
281 + },
282 + {
283 + "spins": 8749636,
284 + "rtp": 0.9554272022309462
285 + },
286 + {
287 + "spins": 8999626,
288 + "rtp": 0.9549874083852244
289 + },
290 + {
291 + "spins": 9249615,
292 + "rtp": 0.9556566943758832
293 + },
294 + {
295 + "spins": 9499605,
296 + "rtp": 0.9550477755952346
297 + },
298 + {
299 + "spins": 9749594,
300 + "rtp": 0.954136071083869
301 + },
302 + {
303 + "spins": 9999584,
304 + "rtp": 0.9545721138845553
305 + },
306 + {
307 + "spins": 10000000,
308 + "rtp": 0.9545608013263269
309 + }
310 + ],
311 + "featureCounts": {
312 + "Cascade": 4130785,
313 + "Exploding Wild": 1855342,
314 + "Free Spins": 41428,
315 + "Retrigger": 1374
316 + },
317 + "durationMs": 8298
318 +}
added games/certifications/lucky-circuit.json +316 −0
@@ -0,0 +1,316 @@
1 +{
2 + "game": "lucky-circuit",
3 + "name": "Lucky Circuit",
4 + "version": "1.0.0",
5 + "spins": 10000000,
6 + "configuredRtp": 0.97,
7 + "observedRtp": 0.970507677,
8 + "deviation": 0.0005076769999999842,
9 + "hitRate": 0.4663264,
10 + "bonusRate": 0,
11 + "freeSpinRate": 0.0029839,
12 + "maxWinMultiplier": 193.4,
13 + "stdDev": 2.080577670661291,
14 + "status": "PASS",
15 + "checks": [
16 + {
17 + "name": "definition",
18 + "pass": true,
19 + "detail": "ok"
20 + },
21 + {
22 + "name": "spins",
23 + "pass": true,
24 + "detail": "10,000,000 spins (min 1,000,000)"
25 + },
26 + {
27 + "name": "rtp-deviation",
28 + "pass": true,
29 + "detail": "0.051% (tolerance ±0.40% = max(0.40%, 3σ/√n=0.20%))"
30 + },
31 + {
32 + "name": "rtp-band",
33 + "pass": true,
34 + "detail": "97.05%"
35 + },
36 + {
37 + "name": "hit-rate",
38 + "pass": true,
39 + "detail": "46.63%"
40 + },
41 + {
42 + "name": "max-win",
43 + "pass": true,
44 + "detail": "193.4× (cap 1000×)"
45 + },
46 + {
47 + "name": "cap-share",
48 + "pass": true,
49 + "detail": "0 capped rounds"
50 + }
51 + ],
52 + "certifiedAt": "2026-09-08T01:56:16.945Z",
53 + "rules": {
54 + "maxDeviation": 0.004,
55 + "minSpins": 1000000,
56 + "rtpBand": [
57 + 0.93,
58 + 0.99
59 + ],
60 + "hitRateBand": [
61 + 0.08,
62 + 0.6
63 + ],
64 + "maxCappedShare": 0.0005
65 + },
66 + "distribution": [
67 + {
68 + "label": "0×",
69 + "min": 0,
70 + "max": 0,
71 + "count": 5336736,
72 + "share": 0.5336736
73 + },
74 + {
75 + "label": "0–1×",
76 + "min": 0.000001,
77 + "max": 1,
78 + "count": 2034251,
79 + "share": 0.2034251
80 + },
81 + {
82 + "label": "1–2×",
83 + "min": 1,
84 + "max": 2,
85 + "count": 1086360,
86 + "share": 0.108636
87 + },
88 + {
89 + "label": "2–5×",
90 + "min": 2,
91 + "max": 5,
92 + "count": 1119236,
93 + "share": 0.1119236
94 + },
95 + {
96 + "label": "5–10×",
97 + "min": 5,
98 + "max": 10,
99 + "count": 333539,
100 + "share": 0.0333539
101 + },
102 + {
103 + "label": "10–20×",
104 + "min": 10,
105 + "max": 20,
106 + "count": 79732,
107 + "share": 0.0079732
108 + },
109 + {
110 + "label": "20–50×",
111 + "min": 20,
112 + "max": 50,
113 + "count": 9807,
114 + "share": 0.0009807
115 + },
116 + {
117 + "label": "50–100×",
118 + "min": 50,
119 + "max": 100,
120 + "count": 327,
121 + "share": 0.0000327
122 + },
123 + {
124 + "label": "100–500×",
125 + "min": 100,
126 + "max": 500,
127 + "count": 12,
128 + "share": 0.0000012
129 + },
130 + {
131 + "label": "500–1000×",
132 + "min": 500,
133 + "max": 1000,
134 + "count": 0,
135 + "share": 0
136 + },
137 + {
138 + "label": "1000×+",
139 + "min": 1000,
140 + "max": null,
141 + "count": 0,
142 + "share": 0
143 + }
144 + ],
145 + "convergence": [
146 + {
147 + "spins": 249990,
148 + "rtp": 0.9661060842433696
149 + },
150 + {
151 + "spins": 499979,
152 + "rtp": 0.9694011560462417
153 + },
154 + {
155 + "spins": 749969,
156 + "rtp": 0.9706579063162525
157 + },
158 + {
159 + "spins": 999958,
160 + "rtp": 0.9700107104284172
161 + },
162 + {
163 + "spins": 1249948,
164 + "rtp": 0.9708569142765712
165 + },
166 + {
167 + "spins": 1499938,
168 + "rtp": 0.9709946531194581
169 + },
170 + {
171 + "spins": 1749927,
172 + "rtp": 0.9697535730000629
173 + },
174 + {
175 + "spins": 1999917,
176 + "rtp": 0.9707688657546303
177 + },
178 + {
179 + "spins": 2249906,
180 + "rtp": 0.9708480161428679
181 + },
182 + {
183 + "spins": 2499896,
184 + "rtp": 0.970694967798712
185 + },
186 + {
187 + "spins": 2749886,
188 + "rtp": 0.9710328122215799
189 + },
190 + {
191 + "spins": 2999875,
192 + "rtp": 0.9713900789364907
193 + },
194 + {
195 + "spins": 3249865,
196 + "rtp": 0.9715417601319439
197 + },
198 + {
199 + "spins": 3499854,
200 + "rtp": 0.9714034532809885
201 + },
202 + {
203 + "spins": 3749844,
204 + "rtp": 0.9712865314612583
205 + },
206 + {
207 + "spins": 3999834,
208 + "rtp": 0.9707546876875074
209 + },
210 + {
211 + "spins": 4249823,
212 + "rtp": 0.9708825223597177
213 + },
214 + {
215 + "spins": 4499813,
216 + "rtp": 0.9711972434452932
217 + },
218 + {
219 + "spins": 4749802,
220 + "rtp": 0.9716981331884853
221 + },
222 + {
223 + "spins": 4999792,
224 + "rtp": 0.9715589803592144
225 + },
226 + {
227 + "spins": 5249782,
228 + "rtp": 0.9714551648732616
229 + },
230 + {
231 + "spins": 5499771,
232 + "rtp": 0.9715703973613492
233 + },
234 + {
235 + "spins": 5749761,
236 + "rtp": 0.9713594804661752
237 + },
238 + {
239 + "spins": 5999750,
240 + "rtp": 0.9712577853114125
241 + },
242 + {
243 + "spins": 6249740,
244 + "rtp": 0.9710423568942758
245 + },
246 + {
247 + "spins": 6499730,
248 + "rtp": 0.9707900700643411
249 + },
250 + {
251 + "spins": 6749719,
252 + "rtp": 0.9709941479140644
253 + },
254 + {
255 + "spins": 6999709,
256 + "rtp": 0.9710342885143978
257 + },
258 + {
259 + "spins": 7249698,
260 + "rtp": 0.9711687929586148
261 + },
262 + {
263 + "spins": 7499688,
264 + "rtp": 0.9712285251410057
265 + },
266 + {
267 + "spins": 7749678,
268 + "rtp": 0.97114409479605
269 + },
270 + {
271 + "spins": 7999667,
272 + "rtp": 0.9708141700668027
273 + },
274 + {
275 + "spins": 8249657,
276 + "rtp": 0.9705534209247156
277 + },
278 + {
279 + "spins": 8499646,
280 + "rtp": 0.9707142603351193
281 + },
282 + {
283 + "spins": 8749636,
284 + "rtp": 0.9705655174778423
285 + },
286 + {
287 + "spins": 8999626,
288 + "rtp": 0.9706906442924385
289 + },
290 + {
291 + "spins": 9249615,
292 + "rtp": 0.9706958321576108
293 + },
294 + {
295 + "spins": 9499605,
296 + "rtp": 0.9706726100622972
297 + },
298 + {
299 + "spins": 9749594,
300 + "rtp": 0.9704166956421847
301 + },
302 + {
303 + "spins": 9999584,
304 + "rtp": 0.9705073642945717
305 + },
306 + {
307 + "spins": 10000000,
308 + "rtp": 0.9705076833894541
309 + }
310 + ],
311 + "featureCounts": {
312 + "Free Spins": 29839,
313 + "Retrigger": 513
314 + },
315 + "durationMs": 8205
316 +}
added games/certifications/midnight-tokyo.json +318 −0
@@ -0,0 +1,318 @@
1 +{
2 + "game": "midnight-tokyo",
3 + "name": "Midnight Tokyo",
4 + "version": "1.0.0",
5 + "spins": 10000000,
6 + "configuredRtp": 0.96,
7 + "observedRtp": 0.959194856,
8 + "deviation": -0.0008051439999999799,
9 + "hitRate": 0.3279698,
10 + "bonusRate": 0,
11 + "freeSpinRate": 0.0032782,
12 + "maxWinMultiplier": 736.46,
13 + "stdDev": 4.17912395460683,
14 + "status": "PASS",
15 + "checks": [
16 + {
17 + "name": "definition",
18 + "pass": true,
19 + "detail": "ok"
20 + },
21 + {
22 + "name": "spins",
23 + "pass": true,
24 + "detail": "10,000,000 spins (min 1,000,000)"
25 + },
26 + {
27 + "name": "rtp-deviation",
28 + "pass": true,
29 + "detail": "-0.081% (tolerance ±0.40% = max(0.40%, 3σ/√n=0.40%))"
30 + },
31 + {
32 + "name": "rtp-band",
33 + "pass": true,
34 + "detail": "95.92%"
35 + },
36 + {
37 + "name": "hit-rate",
38 + "pass": true,
39 + "detail": "32.80%"
40 + },
41 + {
42 + "name": "max-win",
43 + "pass": true,
44 + "detail": "736.5× (cap 3000×)"
45 + },
46 + {
47 + "name": "cap-share",
48 + "pass": true,
49 + "detail": "0 capped rounds"
50 + }
51 + ],
52 + "certifiedAt": "2026-09-08T01:56:00.253Z",
53 + "rules": {
54 + "maxDeviation": 0.004,
55 + "minSpins": 1000000,
56 + "rtpBand": [
57 + 0.93,
58 + 0.99
59 + ],
60 + "hitRateBand": [
61 + 0.08,
62 + 0.6
63 + ],
64 + "maxCappedShare": 0.0005
65 + },
66 + "distribution": [
67 + {
68 + "label": "0×",
69 + "min": 0,
70 + "max": 0,
71 + "count": 6720302,
72 + "share": 0.6720302
73 + },
74 + {
75 + "label": "0–1×",
76 + "min": 0.000001,
77 + "max": 1,
78 + "count": 1273381,
79 + "share": 0.1273381
80 + },
81 + {
82 + "label": "1–2×",
83 + "min": 1,
84 + "max": 2,
85 + "count": 910591,
86 + "share": 0.0910591
87 + },
88 + {
89 + "label": "2–5×",
90 + "min": 2,
91 + "max": 5,
92 + "count": 661994,
93 + "share": 0.0661994
94 + },
95 + {
96 + "label": "5–10×",
97 + "min": 5,
98 + "max": 10,
99 + "count": 272886,
100 + "share": 0.0272886
101 + },
102 + {
103 + "label": "10–20×",
104 + "min": 10,
105 + "max": 20,
106 + "count": 107887,
107 + "share": 0.0107887
108 + },
109 + {
110 + "label": "20–50×",
111 + "min": 20,
112 + "max": 50,
113 + "count": 42650,
114 + "share": 0.004265
115 + },
116 + {
117 + "label": "50–100×",
118 + "min": 50,
119 + "max": 100,
120 + "count": 8269,
121 + "share": 0.0008269
122 + },
123 + {
124 + "label": "100–500×",
125 + "min": 100,
126 + "max": 500,
127 + "count": 2015,
128 + "share": 0.0002015
129 + },
130 + {
131 + "label": "500–1000×",
132 + "min": 500,
133 + "max": 1000,
134 + "count": 25,
135 + "share": 0.0000025
136 + },
137 + {
138 + "label": "1000×+",
139 + "min": 1000,
140 + "max": null,
141 + "count": 0,
142 + "share": 0
143 + }
144 + ],
145 + "convergence": [
146 + {
147 + "spins": 249990,
148 + "rtp": 0.9523501340053601
149 + },
150 + {
151 + "spins": 499979,
152 + "rtp": 0.954116824672987
153 + },
154 + {
155 + "spins": 749969,
156 + "rtp": 0.955794391775671
157 + },
158 + {
159 + "spins": 999958,
160 + "rtp": 0.9543490139605585
161 + },
162 + {
163 + "spins": 1249948,
164 + "rtp": 0.9558479619184769
165 + },
166 + {
167 + "spins": 1499938,
168 + "rtp": 0.9565892502366762
169 + },
170 + {
171 + "spins": 1749927,
172 + "rtp": 0.9555953952443813
173 + },
174 + {
175 + "spins": 1999917,
176 + "rtp": 0.9555961688467539
177 + },
178 + {
179 + "spins": 2249906,
180 + "rtp": 0.955911796471859
181 + },
182 + {
183 + "spins": 2499896,
184 + "rtp": 0.9556885635425417
185 + },
186 + {
187 + "spins": 2749886,
188 + "rtp": 0.9555387306401346
189 + },
190 + {
191 + "spins": 2999875,
192 + "rtp": 0.95674175633692
193 + },
194 + {
195 + "spins": 3249865,
196 + "rtp": 0.9576766516814517
197 + },
198 + {
199 + "spins": 3499854,
200 + "rtp": 0.9570676141331369
201 + },
202 + {
203 + "spins": 3749844,
204 + "rtp": 0.9575805592223688
205 + },
206 + {
207 + "spins": 3999834,
208 + "rtp": 0.9575448542941717
209 + },
210 + {
211 + "spins": 4249823,
212 + "rtp": 0.9570833868648864
213 + },
214 + {
215 + "spins": 4499813,
216 + "rtp": 0.9578483561564686
217 + },
218 + {
219 + "spins": 4749802,
220 + "rtp": 0.9575025611550778
221 + },
222 + {
223 + "spins": 4999792,
224 + "rtp": 0.9573105004200168
225 + },
226 + {
227 + "spins": 5249782,
228 + "rtp": 0.9572021280851233
229 + },
230 + {
231 + "spins": 5499771,
232 + "rtp": 0.9578805788595182
233 + },
234 + {
235 + "spins": 5749761,
236 + "rtp": 0.9572035020531254
237 + },
238 + {
239 + "spins": 5999750,
240 + "rtp": 0.9569882695307812
241 + },
242 + {
243 + "spins": 6249740,
244 + "rtp": 0.9567076987079483
245 + },
246 + {
247 + "spins": 6499730,
248 + "rtp": 0.9571434657386297
249 + },
250 + {
251 + "spins": 6749719,
252 + "rtp": 0.9573264486135
253 + },
254 + {
255 + "spins": 6999709,
256 + "rtp": 0.9574241683953073
257 + },
258 + {
259 + "spins": 7249698,
260 + "rtp": 0.9577673548321245
261 + },
262 + {
263 + "spins": 7499688,
264 + "rtp": 0.9573892089016893
265 + },
266 + {
267 + "spins": 7749678,
268 + "rtp": 0.9577579283816514
269 + },
270 + {
271 + "spins": 7999667,
272 + "rtp": 0.957436327453098
273 + },
274 + {
275 + "spins": 8249657,
276 + "rtp": 0.9572205033655892
277 + },
278 + {
279 + "spins": 8499646,
280 + "rtp": 0.957234641150352
281 + },
282 + {
283 + "spins": 8749636,
284 + "rtp": 0.9574501905790516
285 + },
286 + {
287 + "spins": 8999626,
288 + "rtp": 0.9579570493930868
289 + },
290 + {
291 + "spins": 9249615,
292 + "rtp": 0.9582354050918794
293 + },
294 + {
295 + "spins": 9499605,
296 + "rtp": 0.9584325362488182
297 + },
298 + {
299 + "spins": 9749594,
300 + "rtp": 0.9591003076020477
301 + },
302 + {
303 + "spins": 9999584,
304 + "rtp": 0.9591814602584106
305 + },
306 + {
307 + "spins": 10000000,
308 + "rtp": 0.9591948629445479
309 + }
310 + ],
311 + "featureCounts": {
312 + "Neon Respin": 247085,
313 + "Free Spins": 32782,
314 + "Night Multiplier": 370391,
315 + "Retrigger": 1049
316 + },
317 + "durationMs": 8287
318 +}
added games/certifications/moonbase-77.json +316 −0
@@ -0,0 +1,316 @@
1 +{
2 + "game": "moonbase-77",
3 + "name": "Moonbase 77",
4 + "version": "1.0.0",
5 + "spins": 10000000,
6 + "configuredRtp": 0.96,
7 + "observedRtp": 0.961517646,
8 + "deviation": 0.0015176460000000391,
9 + "hitRate": 0.3081475,
10 + "bonusRate": 0,
11 + "freeSpinRate": 0.003385,
12 + "maxWinMultiplier": 847.16,
13 + "stdDev": 5.514035491414552,
14 + "status": "PASS",
15 + "checks": [
16 + {
17 + "name": "definition",
18 + "pass": true,
19 + "detail": "ok"
20 + },
21 + {
22 + "name": "spins",
23 + "pass": true,
24 + "detail": "10,000,000 spins (min 1,000,000)"
25 + },
26 + {
27 + "name": "rtp-deviation",
28 + "pass": true,
29 + "detail": "0.152% (tolerance ±0.52% = max(0.40%, 3σ/√n=0.52%))"
30 + },
31 + {
32 + "name": "rtp-band",
33 + "pass": true,
34 + "detail": "96.15%"
35 + },
36 + {
37 + "name": "hit-rate",
38 + "pass": true,
39 + "detail": "30.81%"
40 + },
41 + {
42 + "name": "max-win",
43 + "pass": true,
44 + "detail": "847.2× (cap 2500×)"
45 + },
46 + {
47 + "name": "cap-share",
48 + "pass": true,
49 + "detail": "0 capped rounds"
50 + }
51 + ],
52 + "certifiedAt": "2026-09-08T01:56:59.351Z",
53 + "rules": {
54 + "maxDeviation": 0.004,
55 + "minSpins": 1000000,
56 + "rtpBand": [
57 + 0.93,
58 + 0.99
59 + ],
60 + "hitRateBand": [
61 + 0.08,
62 + 0.6
63 + ],
64 + "maxCappedShare": 0.0005
65 + },
66 + "distribution": [
67 + {
68 + "label": "0×",
69 + "min": 0,
70 + "max": 0,
71 + "count": 6918525,
72 + "share": 0.6918525
73 + },
74 + {
75 + "label": "0–1×",
76 + "min": 0.000001,
77 + "max": 1,
78 + "count": 1042564,
79 + "share": 0.1042564
80 + },
81 + {
82 + "label": "1–2×",
83 + "min": 1,
84 + "max": 2,
85 + "count": 986071,
86 + "share": 0.0986071
87 + },
88 + {
89 + "label": "2–5×",
90 + "min": 2,
91 + "max": 5,
92 + "count": 665477,
93 + "share": 0.0665477
94 + },
95 + {
96 + "label": "5–10×",
97 + "min": 5,
98 + "max": 10,
99 + "count": 250968,
100 + "share": 0.0250968
101 + },
102 + {
103 + "label": "10–20×",
104 + "min": 10,
105 + "max": 20,
106 + "count": 88629,
107 + "share": 0.0088629
108 + },
109 + {
110 + "label": "20–50×",
111 + "min": 20,
112 + "max": 50,
113 + "count": 32524,
114 + "share": 0.0032524
115 + },
116 + {
117 + "label": "50–100×",
118 + "min": 50,
119 + "max": 100,
120 + "count": 9802,
121 + "share": 0.0009802
122 + },
123 + {
124 + "label": "100–500×",
125 + "min": 100,
126 + "max": 500,
127 + "count": 5391,
128 + "share": 0.0005391
129 + },
130 + {
131 + "label": "500–1000×",
132 + "min": 500,
133 + "max": 1000,
134 + "count": 49,
135 + "share": 0.0000049
136 + },
137 + {
138 + "label": "1000×+",
139 + "min": 1000,
140 + "max": null,
141 + "count": 0,
142 + "share": 0
143 + }
144 + ],
145 + "convergence": [
146 + {
147 + "spins": 249990,
148 + "rtp": 0.9694246169846795
149 + },
150 + {
151 + "spins": 499979,
152 + "rtp": 0.9598665546621867
153 + },
154 + {
155 + "spins": 749969,
156 + "rtp": 0.9604264037228154
157 + },
158 + {
159 + "spins": 999958,
160 + "rtp": 0.9620266510660426
161 + },
162 + {
163 + "spins": 1249948,
164 + "rtp": 0.9594447457898319
165 + },
166 + {
167 + "spins": 1499938,
168 + "rtp": 0.9609965998639945
169 + },
170 + {
171 + "spins": 1749927,
172 + "rtp": 0.9609497179887196
173 + },
174 + {
175 + "spins": 1999917,
176 + "rtp": 0.959346928877155
177 + },
178 + {
179 + "spins": 2249906,
180 + "rtp": 0.9606710712872959
181 + },
182 + {
183 + "spins": 2499896,
184 + "rtp": 0.9604722788911558
185 + },
186 + {
187 + "spins": 2749886,
188 + "rtp": 0.9619537472407985
189 + },
190 + {
191 + "spins": 2999875,
192 + "rtp": 0.9616741703001455
193 + },
194 + {
195 + "spins": 3249865,
196 + "rtp": 0.9627818189650662
197 + },
198 + {
199 + "spins": 3499854,
200 + "rtp": 0.9624815821204277
201 + },
202 + {
203 + "spins": 3749844,
204 + "rtp": 0.9623807058949023
205 + },
206 + {
207 + "spins": 3999834,
208 + "rtp": 0.9626618314732587
209 + },
210 + {
211 + "spins": 4249823,
212 + "rtp": 0.9632449415623683
213 + },
214 + {
215 + "spins": 4499813,
216 + "rtp": 0.9634293282842423
217 + },
218 + {
219 + "spins": 4749802,
220 + "rtp": 0.963056021188216
221 + },
222 + {
223 + "spins": 4999792,
224 + "rtp": 0.9636913256530261
225 + },
226 + {
227 + "spins": 5249782,
228 + "rtp": 0.963891277555864
229 + },
230 + {
231 + "spins": 5499771,
232 + "rtp": 0.9633605635134497
233 + },
234 + {
235 + "spins": 5749761,
236 + "rtp": 0.96236246841178
237 + },
238 + {
239 + "spins": 5999750,
240 + "rtp": 0.9622862631171916
241 + },
242 + {
243 + "spins": 6249740,
244 + "rtp": 0.9617066874674988
245 + },
246 + {
247 + "spins": 6499730,
248 + "rtp": 0.9622792080914007
249 + },
250 + {
251 + "spins": 6749719,
252 + "rtp": 0.9619098778765964
253 + },
254 + {
255 + "spins": 6999709,
256 + "rtp": 0.9613535684284512
257 + },
258 + {
259 + "spins": 7249698,
260 + "rtp": 0.9616708337299009
261 + },
262 + {
263 + "spins": 7499688,
264 + "rtp": 0.9612023920956839
265 + },
266 + {
267 + "spins": 7749678,
268 + "rtp": 0.9613024624210778
269 + },
270 + {
271 + "spins": 7999667,
272 + "rtp": 0.9612110721928875
273 + },
274 + {
275 + "spins": 8249657,
276 + "rtp": 0.9611053787606051
277 + },
278 + {
279 + "spins": 8499646,
280 + "rtp": 0.961097873326698
281 + },
282 + {
283 + "spins": 8749636,
284 + "rtp": 0.9611010806146533
285 + },
286 + {
287 + "spins": 8999626,
288 + "rtp": 0.9606812816957125
289 + },
290 + {
291 + "spins": 9249615,
292 + "rtp": 0.9608760696373799
293 + },
294 + {
295 + "spins": 9499605,
296 + "rtp": 0.960894010497262
297 + },
298 + {
299 + "spins": 9749594,
300 + "rtp": 0.961108105862696
301 + },
302 + {
303 + "spins": 9999584,
304 + "rtp": 0.9615159476379055
305 + },
306 + {
307 + "spins": 10000000,
308 + "rtp": 0.9615176493647096
309 + }
310 + ],
311 + "featureCounts": {
312 + "Free Spins": 33850,
313 + "Retrigger": 947
314 + },
315 + "durationMs": 8249
316 +}
added games/certifications/neon-vault.json +318 −0
@@ -0,0 +1,318 @@
1 +{
2 + "game": "neon-vault",
3 + "name": "Neon Vault",
4 + "version": "1.0.0",
5 + "spins": 10000000,
6 + "configuredRtp": 0.962,
7 + "observedRtp": 0.963495025,
8 + "deviation": 0.0014950249999999832,
9 + "hitRate": 0.5870231,
10 + "bonusRate": 0.0100604,
11 + "freeSpinRate": 0.0100604,
12 + "maxWinMultiplier": 5000,
13 + "stdDev": 11.62381937595908,
14 + "status": "PASS",
15 + "checks": [
16 + {
17 + "name": "definition",
18 + "pass": true,
19 + "detail": "ok"
20 + },
21 + {
22 + "name": "spins",
23 + "pass": true,
24 + "detail": "10,000,000 spins (min 1,000,000)"
25 + },
26 + {
27 + "name": "rtp-deviation",
28 + "pass": true,
29 + "detail": "0.150% (tolerance ±1.10% = max(0.40%, 3σ/√n=1.10%))"
30 + },
31 + {
32 + "name": "rtp-band",
33 + "pass": true,
34 + "detail": "96.35%"
35 + },
36 + {
37 + "name": "hit-rate",
38 + "pass": true,
39 + "detail": "58.70%"
40 + },
41 + {
42 + "name": "max-win",
43 + "pass": true,
44 + "detail": "5000.0× (cap 5000×)"
45 + },
46 + {
47 + "name": "cap-share",
48 + "pass": true,
49 + "detail": "3 capped rounds"
50 + }
51 + ],
52 + "certifiedAt": "2026-09-08T01:55:00.738Z",
53 + "rules": {
54 + "maxDeviation": 0.004,
55 + "minSpins": 1000000,
56 + "rtpBand": [
57 + 0.93,
58 + 0.99
59 + ],
60 + "hitRateBand": [
61 + 0.08,
62 + 0.6
63 + ],
64 + "maxCappedShare": 0.0005
65 + },
66 + "distribution": [
67 + {
68 + "label": "0×",
69 + "min": 0,
70 + "max": 0,
71 + "count": 4129769,
72 + "share": 0.4129769
73 + },
74 + {
75 + "label": "0–1×",
76 + "min": 0.000001,
77 + "max": 1,
78 + "count": 4764205,
79 + "share": 0.4764205
80 + },
81 + {
82 + "label": "1–2×",
83 + "min": 1,
84 + "max": 2,
85 + "count": 428150,
86 + "share": 0.042815
87 + },
88 + {
89 + "label": "2–5×",
90 + "min": 2,
91 + "max": 5,
92 + "count": 372295,
93 + "share": 0.0372295
94 + },
95 + {
96 + "label": "5–10×",
97 + "min": 5,
98 + "max": 10,
99 + "count": 143550,
100 + "share": 0.014355
101 + },
102 + {
103 + "label": "10–20×",
104 + "min": 10,
105 + "max": 20,
106 + "count": 80181,
107 + "share": 0.0080181
108 + },
109 + {
110 + "label": "20–50×",
111 + "min": 20,
112 + "max": 50,
113 + "count": 60393,
114 + "share": 0.0060393
115 + },
116 + {
117 + "label": "50–100×",
118 + "min": 50,
119 + "max": 100,
120 + "count": 13977,
121 + "share": 0.0013977
122 + },
123 + {
124 + "label": "100–500×",
125 + "min": 100,
126 + "max": 500,
127 + "count": 6835,
128 + "share": 0.0006835
129 + },
130 + {
131 + "label": "500–1000×",
132 + "min": 500,
133 + "max": 1000,
134 + "count": 465,
135 + "share": 0.0000465
136 + },
137 + {
138 + "label": "1000×+",
139 + "min": 1000,
140 + "max": null,
141 + "count": 180,
142 + "share": 0.000018
143 + }
144 + ],
145 + "convergence": [
146 + {
147 + "spins": 249990,
148 + "rtp": 0.9379747589903593
149 + },
150 + {
151 + "spins": 499979,
152 + "rtp": 0.9340145405816229
153 + },
154 + {
155 + "spins": 749969,
156 + "rtp": 0.9460340146939212
157 + },
158 + {
159 + "spins": 999958,
160 + "rtp": 0.9536836273450936
161 + },
162 + {
163 + "spins": 1249948,
164 + "rtp": 0.9578176967078684
165 + },
166 + {
167 + "spins": 1499938,
168 + "rtp": 0.9608258463671882
169 + },
170 + {
171 + "spins": 1749927,
172 + "rtp": 0.9589653014692014
173 + },
174 + {
175 + "spins": 1999917,
176 + "rtp": 0.9578334683387334
177 + },
178 + {
179 + "spins": 2249906,
180 + "rtp": 0.9593066255983573
181 + },
182 + {
183 + "spins": 2499896,
184 + "rtp": 0.9628695427817114
185 + },
186 + {
187 + "spins": 2749886,
188 + "rtp": 0.9614290426162503
189 + },
190 + {
191 + "spins": 2999875,
192 + "rtp": 0.9640075503020124
193 + },
194 + {
195 + "spins": 3249865,
196 + "rtp": 0.9626175077772342
197 + },
198 + {
199 + "spins": 3499854,
200 + "rtp": 0.9612812083911929
201 + },
202 + {
203 + "spins": 3749844,
204 + "rtp": 0.9617846820539488
205 + },
206 + {
207 + "spins": 3999834,
208 + "rtp": 0.9638493389735593
209 + },
210 + {
211 + "spins": 4249823,
212 + "rtp": 0.9639508427395919
213 + },
214 + {
215 + "spins": 4499813,
216 + "rtp": 0.9652970363258974
217 + },
218 + {
219 + "spins": 4749802,
220 + "rtp": 0.9651804493232361
221 + },
222 + {
223 + "spins": 4999792,
224 + "rtp": 0.9651203468138726
225 + },
226 + {
227 + "spins": 5249782,
228 + "rtp": 0.9649826431152484
229 + },
230 + {
231 + "spins": 5499771,
232 + "rtp": 0.9624501652793384
233 + },
234 + {
235 + "spins": 5749761,
236 + "rtp": 0.9623364099781384
237 + },
238 + {
239 + "spins": 5999750,
240 + "rtp": 0.964276821072843
241 + },
242 + {
243 + "spins": 6249740,
244 + "rtp": 0.9646535877435097
245 + },
246 + {
247 + "spins": 6499730,
248 + "rtp": 0.965135725429017
249 + },
250 + {
251 + "spins": 6749719,
252 + "rtp": 0.9642619467741673
253 + },
254 + {
255 + "spins": 6999709,
256 + "rtp": 0.9637823584371946
257 + },
258 + {
259 + "spins": 7249698,
260 + "rtp": 0.9640068030307419
261 + },
262 + {
263 + "spins": 7499688,
264 + "rtp": 0.9631106844273772
265 + },
266 + {
267 + "spins": 7749678,
268 + "rtp": 0.9621009885556712
269 + },
270 + {
271 + "spins": 7999667,
272 + "rtp": 0.9629390838133524
273 + },
274 + {
275 + "spins": 8249657,
276 + "rtp": 0.9622992107563089
277 + },
278 + {
279 + "spins": 8499646,
280 + "rtp": 0.9626872804323935
281 + },
282 + {
283 + "spins": 8749636,
284 + "rtp": 0.963327699965141
285 + },
286 + {
287 + "spins": 8999626,
288 + "rtp": 0.9642192087683508
289 + },
290 + {
291 + "spins": 9249615,
292 + "rtp": 0.96401765367912
293 + },
294 + {
295 + "spins": 9499605,
296 + "rtp": 0.963145765830633
297 + },
298 + {
299 + "spins": 9749594,
300 + "rtp": 0.9637718370273275
301 + },
302 + {
303 + "spins": 9999584,
304 + "rtp": 0.9634973078923158
305 + },
306 + {
307 + "spins": 10000000,
308 + "rtp": 0.9634950146302336
309 + }
310 + ],
311 + "featureCounts": {
312 + "Collect": 2317596,
313 + "Free Spins": 100604,
314 + "Hack the Vault": 100604,
315 + "Retrigger": 10206
316 + },
317 + "durationMs": 8576
318 +}
added games/certifications/obsidian.json +316 −0
@@ -0,0 +1,316 @@
1 +{
2 + "game": "obsidian",
3 + "name": "Obsidian",
4 + "version": "1.0.0",
5 + "spins": 10000000,
6 + "configuredRtp": 0.96,
7 + "observedRtp": 0.95343292,
8 + "deviation": -0.006567079999999947,
9 + "hitRate": 0.1523012,
10 + "bonusRate": 0,
11 + "freeSpinRate": 0.0046826,
12 + "maxWinMultiplier": 50000,
13 + "stdDev": 47.83170875533792,
14 + "status": "PASS",
15 + "checks": [
16 + {
17 + "name": "definition",
18 + "pass": true,
19 + "detail": "ok"
20 + },
21 + {
22 + "name": "spins",
23 + "pass": true,
24 + "detail": "10,000,000 spins (min 1,000,000)"
25 + },
26 + {
27 + "name": "rtp-deviation",
28 + "pass": true,
29 + "detail": "-0.657% (tolerance ±1.50% = max(0.40%, 3σ/√n=4.54%))"
30 + },
31 + {
32 + "name": "rtp-band",
33 + "pass": true,
34 + "detail": "95.34%"
35 + },
36 + {
37 + "name": "hit-rate",
38 + "pass": true,
39 + "detail": "15.23%"
40 + },
41 + {
42 + "name": "max-win",
43 + "pass": true,
44 + "detail": "50000.0× (cap 50000×)"
45 + },
46 + {
47 + "name": "cap-share",
48 + "pass": true,
49 + "detail": "2 capped rounds"
50 + }
51 + ],
52 + "certifiedAt": "2026-09-08T01:57:33.396Z",
53 + "rules": {
54 + "maxDeviation": 0.004,
55 + "minSpins": 1000000,
56 + "rtpBand": [
57 + 0.93,
58 + 0.99
59 + ],
60 + "hitRateBand": [
61 + 0.08,
62 + 0.6
63 + ],
64 + "maxCappedShare": 0.0005
65 + },
66 + "distribution": [
67 + {
68 + "label": "0×",
69 + "min": 0,
70 + "max": 0,
71 + "count": 8476988,
72 + "share": 0.8476988
73 + },
74 + {
75 + "label": "0–1×",
76 + "min": 0.000001,
77 + "max": 1,
78 + "count": 724307,
79 + "share": 0.0724307
80 + },
81 + {
82 + "label": "1–2×",
83 + "min": 1,
84 + "max": 2,
85 + "count": 323501,
86 + "share": 0.0323501
87 + },
88 + {
89 + "label": "2–5×",
90 + "min": 2,
91 + "max": 5,
92 + "count": 267976,
93 + "share": 0.0267976
94 + },
95 + {
96 + "label": "5–10×",
97 + "min": 5,
98 + "max": 10,
99 + "count": 92600,
100 + "share": 0.00926
101 + },
102 + {
103 + "label": "10–20×",
104 + "min": 10,
105 + "max": 20,
106 + "count": 56923,
107 + "share": 0.0056923
108 + },
109 + {
110 + "label": "20–50×",
111 + "min": 20,
112 + "max": 50,
113 + "count": 35007,
114 + "share": 0.0035007
115 + },
116 + {
117 + "label": "50–100×",
118 + "min": 50,
119 + "max": 100,
120 + "count": 11935,
121 + "share": 0.0011935
122 + },
123 + {
124 + "label": "100–500×",
125 + "min": 100,
126 + "max": 500,
127 + "count": 8980,
128 + "share": 0.000898
129 + },
130 + {
131 + "label": "500–1000×",
132 + "min": 500,
133 + "max": 1000,
134 + "count": 1031,
135 + "share": 0.0001031
136 + },
137 + {
138 + "label": "1000×+",
139 + "min": 1000,
140 + "max": null,
141 + "count": 752,
142 + "share": 0.0000752
143 + }
144 + ],
145 + "convergence": [
146 + {
147 + "spins": 249990,
148 + "rtp": 1.0063774150966038
149 + },
150 + {
151 + "spins": 499979,
152 + "rtp": 1.0289496379855194
153 + },
154 + {
155 + "spins": 749969,
156 + "rtp": 0.9729359041028309
157 + },
158 + {
159 + "spins": 999958,
160 + "rtp": 0.9609941397655908
161 + },
162 + {
163 + "spins": 1249948,
164 + "rtp": 0.965134509380375
165 + },
166 + {
167 + "spins": 1499938,
168 + "rtp": 0.9549757123618281
169 + },
170 + {
171 + "spins": 1749927,
172 + "rtp": 0.9445753315846921
173 + },
174 + {
175 + "spins": 1999917,
176 + "rtp": 0.9351854274170968
177 + },
178 + {
179 + "spins": 2249906,
180 + "rtp": 0.931592659261926
181 + },
182 + {
183 + "spins": 2499896,
184 + "rtp": 0.9522221488859552
185 + },
186 + {
187 + "spins": 2749886,
188 + "rtp": 0.9519745917109412
189 + },
190 + {
191 + "spins": 2999875,
192 + "rtp": 0.9549690387615506
193 + },
194 + {
195 + "spins": 3249865,
196 + "rtp": 0.9634920689135259
197 + },
198 + {
199 + "spins": 3499854,
200 + "rtp": 0.9708459309800964
201 + },
202 + {
203 + "spins": 3749844,
204 + "rtp": 0.969581041908343
205 + },
206 + {
207 + "spins": 3999834,
208 + "rtp": 0.9644262745509822
209 + },
210 + {
211 + "spins": 4249823,
212 + "rtp": 0.9634510062755451
213 + },
214 + {
215 + "spins": 4499813,
216 + "rtp": 0.9546426479281395
217 + },
218 + {
219 + "spins": 4749802,
220 + "rtp": 0.9531522039828961
221 + },
222 + {
223 + "spins": 4999792,
224 + "rtp": 0.95256262850514
225 + },
226 + {
227 + "spins": 5249782,
228 + "rtp": 0.9490316603140314
229 + },
230 + {
231 + "spins": 5499771,
232 + "rtp": 0.9487990410525511
233 + },
234 + {
235 + "spins": 5749761,
236 + "rtp": 0.9548217859149148
237 + },
238 + {
239 + "spins": 5999750,
240 + "rtp": 0.9541732202621438
241 + },
242 + {
243 + "spins": 6249740,
244 + "rtp": 0.955711990079603
245 + },
246 + {
247 + "spins": 6499730,
248 + "rtp": 0.9570117712400804
249 + },
250 + {
251 + "spins": 6749719,
252 + "rtp": 0.959735992402659
253 + },
254 + {
255 + "spins": 6999709,
256 + "rtp": 0.9682361123016349
257 + },
258 + {
259 + "spins": 7249698,
260 + "rtp": 0.9650534793805549
261 + },
262 + {
263 + "spins": 7499688,
264 + "rtp": 0.9621419936797473
265 + },
266 + {
267 + "spins": 7749678,
268 + "rtp": 0.9579120984194206
269 + },
270 + {
271 + "spins": 7999667,
272 + "rtp": 0.9590560734929396
273 + },
274 + {
275 + "spins": 8249657,
276 + "rtp": 0.9612607098223322
277 + },
278 + {
279 + "spins": 8499646,
280 + "rtp": 0.9628459597207418
281 + },
282 + {
283 + "spins": 8749636,
284 + "rtp": 0.9588522260890435
285 + },
286 + {
287 + "spins": 8999626,
288 + "rtp": 0.9594213624100518
289 + },
290 + {
291 + "spins": 9249615,
292 + "rtp": 0.9565081889762077
293 + },
294 + {
295 + "spins": 9499605,
296 + "rtp": 0.9554979809718704
297 + },
298 + {
299 + "spins": 9749594,
300 + "rtp": 0.9527787306364051
301 + },
302 + {
303 + "spins": 9999584,
304 + "rtp": 0.9534334983399335
305 + },
306 + {
307 + "spins": 10000000,
308 + "rtp": 0.9534327689768365
309 + }
310 + ],
311 + "featureCounts": {
312 + "Free Spins": 46826,
313 + "Retrigger": 1158
314 + },
315 + "durationMs": 8350
316 +}
added games/certifications/pharaoh-protocol.json +317 −0
@@ -0,0 +1,317 @@
1 +{
2 + "game": "pharaoh-protocol",
3 + "name": "Pharaoh Protocol",
4 + "version": "1.0.0",
5 + "spins": 10000000,
6 + "configuredRtp": 0.96,
7 + "observedRtp": 0.958482408,
8 + "deviation": -0.001517592000000012,
9 + "hitRate": 0.3112288,
10 + "bonusRate": 0.0221319,
11 + "freeSpinRate": 0.0031919,
12 + "maxWinMultiplier": 239.36,
13 + "stdDev": 3.4145746925313003,
14 + "status": "PASS",
15 + "checks": [
16 + {
17 + "name": "definition",
18 + "pass": true,
19 + "detail": "ok"
20 + },
21 + {
22 + "name": "spins",
23 + "pass": true,
24 + "detail": "10,000,000 spins (min 1,000,000)"
25 + },
26 + {
27 + "name": "rtp-deviation",
28 + "pass": true,
29 + "detail": "-0.152% (tolerance ±0.40% = max(0.40%, 3σ/√n=0.32%))"
30 + },
31 + {
32 + "name": "rtp-band",
33 + "pass": true,
34 + "detail": "95.85%"
35 + },
36 + {
37 + "name": "hit-rate",
38 + "pass": true,
39 + "detail": "31.12%"
40 + },
41 + {
42 + "name": "max-win",
43 + "pass": true,
44 + "detail": "239.4× (cap 3000×)"
45 + },
46 + {
47 + "name": "cap-share",
48 + "pass": true,
49 + "detail": "0 capped rounds"
50 + }
51 + ],
52 + "certifiedAt": "2026-09-08T01:56:08.638Z",
53 + "rules": {
54 + "maxDeviation": 0.004,
55 + "minSpins": 1000000,
56 + "rtpBand": [
57 + 0.93,
58 + 0.99
59 + ],
60 + "hitRateBand": [
61 + 0.08,
62 + 0.6
63 + ],
64 + "maxCappedShare": 0.0005
65 + },
66 + "distribution": [
67 + {
68 + "label": "0×",
69 + "min": 0,
70 + "max": 0,
71 + "count": 6887712,
72 + "share": 0.6887712
73 + },
74 + {
75 + "label": "0–1×",
76 + "min": 0.000001,
77 + "max": 1,
78 + "count": 1272249,
79 + "share": 0.1272249
80 + },
81 + {
82 + "label": "1–2×",
83 + "min": 1,
84 + "max": 2,
85 + "count": 780283,
86 + "share": 0.0780283
87 + },
88 + {
89 + "label": "2–5×",
90 + "min": 2,
91 + "max": 5,
92 + "count": 571547,
93 + "share": 0.0571547
94 + },
95 + {
96 + "label": "5–10×",
97 + "min": 5,
98 + "max": 10,
99 + "count": 272380,
100 + "share": 0.027238
101 + },
102 + {
103 + "label": "10–20×",
104 + "min": 10,
105 + "max": 20,
106 + "count": 148055,
107 + "share": 0.0148055
108 + },
109 + {
110 + "label": "20–50×",
111 + "min": 20,
112 + "max": 50,
113 + "count": 62863,
114 + "share": 0.0062863
115 + },
116 + {
117 + "label": "50–100×",
118 + "min": 50,
119 + "max": 100,
120 + "count": 4833,
121 + "share": 0.0004833
122 + },
123 + {
124 + "label": "100–500×",
125 + "min": 100,
126 + "max": 500,
127 + "count": 78,
128 + "share": 0.0000078
129 + },
130 + {
131 + "label": "500–1000×",
132 + "min": 500,
133 + "max": 1000,
134 + "count": 0,
135 + "share": 0
136 + },
137 + {
138 + "label": "1000×+",
139 + "min": 1000,
140 + "max": null,
141 + "count": 0,
142 + "share": 0
143 + }
144 + ],
145 + "convergence": [
146 + {
147 + "spins": 249990,
148 + "rtp": 0.9632215288611543
149 + },
150 + {
151 + "spins": 499979,
152 + "rtp": 0.9617622304892195
153 + },
154 + {
155 + "spins": 749969,
156 + "rtp": 0.9610919236769473
157 + },
158 + {
159 + "spins": 999958,
160 + "rtp": 0.9596581163246528
161 + },
162 + {
163 + "spins": 1249948,
164 + "rtp": 0.9595932077283091
165 + },
166 + {
167 + "spins": 1499938,
168 + "rtp": 0.9602130751896744
169 + },
170 + {
171 + "spins": 1749927,
172 + "rtp": 0.9593601801214906
173 + },
174 + {
175 + "spins": 1999917,
176 + "rtp": 0.9594352674106964
177 + },
178 + {
179 + "spins": 2249906,
180 + "rtp": 0.959046495193141
181 + },
182 + {
183 + "spins": 2499896,
184 + "rtp": 0.9601021480859233
185 + },
186 + {
187 + "spins": 2749886,
188 + "rtp": 0.9597038426991625
189 + },
190 + {
191 + "spins": 2999875,
192 + "rtp": 0.9604210201741401
193 + },
194 + {
195 + "spins": 3249865,
196 + "rtp": 0.9602718047183427
197 + },
198 + {
199 + "spins": 3499854,
200 + "rtp": 0.9602314092563702
201 + },
202 + {
203 + "spins": 3749844,
204 + "rtp": 0.9601179833860021
205 + },
206 + {
207 + "spins": 3999834,
208 + "rtp": 0.9599091988679546
209 + },
210 + {
211 + "spins": 4249823,
212 + "rtp": 0.9601753058357628
213 + },
214 + {
215 + "spins": 4499813,
216 + "rtp": 0.9601557151174936
217 + },
218 + {
219 + "spins": 4749802,
220 + "rtp": 0.9598338880923658
221 + },
222 + {
223 + "spins": 4999792,
224 + "rtp": 0.9599212328493139
225 + },
226 + {
227 + "spins": 5249782,
228 + "rtp": 0.9595758954167691
229 + },
230 + {
231 + "spins": 5499771,
232 + "rtp": 0.9597501281869457
233 + },
234 + {
235 + "spins": 5749761,
236 + "rtp": 0.9596806428778888
237 + },
238 + {
239 + "spins": 5999750,
240 + "rtp": 0.9596884425377017
241 + },
242 + {
243 + "spins": 6249740,
244 + "rtp": 0.9597116092643705
245 + },
246 + {
247 + "spins": 6499730,
248 + "rtp": 0.9595221593479124
249 + },
250 + {
251 + "spins": 6749719,
252 + "rtp": 0.9595729577331242
253 + },
254 + {
255 + "spins": 6999709,
256 + "rtp": 0.9597740281039815
257 + },
258 + {
259 + "spins": 7249698,
260 + "rtp": 0.959670308191638
261 + },
262 + {
263 + "spins": 7499688,
264 + "rtp": 0.9591579049828661
265 + },
266 + {
267 + "spins": 7749678,
268 + "rtp": 0.9591299110028915
269 + },
270 + {
271 + "spins": 7999667,
272 + "rtp": 0.9592940867634707
273 + },
274 + {
275 + "spins": 8249657,
276 + "rtp": 0.9592583945782075
277 + },
278 + {
279 + "spins": 8499646,
280 + "rtp": 0.9592206394138116
281 + },
282 + {
283 + "spins": 8749636,
284 + "rtp": 0.9591442183401624
285 + },
286 + {
287 + "spins": 8999626,
288 + "rtp": 0.9590398060366858
289 + },
290 + {
291 + "spins": 9249615,
292 + "rtp": 0.9588217128685148
293 + },
294 + {
295 + "spins": 9499605,
296 + "rtp": 0.9586770555032728
297 + },
298 + {
299 + "spins": 9749594,
300 + "rtp": 0.9585771625736825
301 + },
302 + {
303 + "spins": 9999584,
304 + "rtp": 0.9584788741549661
305 + },
306 + {
307 + "spins": 10000000,
308 + "rtp": 0.9584824039717001
309 + }
310 + ],
311 + "featureCounts": {
312 + "Pyramid Bonus": 221319,
313 + "Free Spins": 31919,
314 + "Retrigger": 1059
315 + },
316 + "durationMs": 8277
317 +}
added games/certifications/quantum-jackpot.json +319 −0
@@ -0,0 +1,321 @@
1 +{
2 + "game": "quantum-jackpot",
3 + "name": "Quantum Jackpot",
4 + "version": "1.0.0",
5 + "spins": 10000000,
6 + "configuredRtp": 0.96,
7 + "observedRtp": 0.962350267,
8 + "deviation": 0.0023502670000000725,
9 + "hitRate": 0.4288146,
10 + "bonusRate": 0,
11 + "freeSpinRate": 0.0075109,
12 + "maxWinMultiplier": 1290.77,
13 + "stdDev": 3.5992497091584186,
14 + "status": "PASS",
15 + "checks": [
16 + {
17 + "name": "definition",
18 + "pass": true,
19 + "detail": "ok"
20 + },
21 + {
22 + "name": "spins",
23 + "pass": true,
24 + "detail": "10,000,000 spins (min 1,000,000)"
25 + },
26 + {
27 + "name": "rtp-deviation",
28 + "pass": true,
29 + "detail": "0.235% (tolerance ±0.40% = max(0.40%, 3σ/√n=0.34%))"
30 + },
31 + {
32 + "name": "rtp-band",
33 + "pass": true,
34 + "detail": "96.24%"
35 + },
36 + {
37 + "name": "hit-rate",
38 + "pass": true,
39 + "detail": "42.88%"
40 + },
41 + {
42 + "name": "max-win",
43 + "pass": true,
44 + "detail": "1290.8× (cap 5000×)"
45 + },
46 + {
47 + "name": "cap-share",
48 + "pass": true,
49 + "detail": "0 capped rounds"
50 + }
51 + ],
52 + "certifiedAt": "2026-09-08T01:55:51.869Z",
53 + "rules": {
54 + "maxDeviation": 0.004,
55 + "minSpins": 1000000,
56 + "rtpBand": [
57 + 0.93,
58 + 0.99
59 + ],
60 + "hitRateBand": [
61 + 0.08,
62 + 0.6
63 + ],
64 + "maxCappedShare": 0.0005
65 + },
66 + "distribution": [
67 + {
68 + "label": "0×",
69 + "min": 0,
70 + "max": 0,
71 + "count": 5711854,
72 + "share": 0.5711854
73 + },
74 + {
75 + "label": "0–1×",
76 + "min": 0.000001,
77 + "max": 1,
78 + "count": 2264901,
79 + "share": 0.2264901
80 + },
81 + {
82 + "label": "1–2×",
83 + "min": 1,
84 + "max": 2,
85 + "count": 824924,
86 + "share": 0.0824924
87 + },
88 + {
89 + "label": "2–5×",
90 + "min": 2,
91 + "max": 5,
92 + "count": 771597,
93 + "share": 0.0771597
94 + },
95 + {
96 + "label": "5–10×",
97 + "min": 5,
98 + "max": 10,
99 + "count": 277161,
100 + "share": 0.0277161
101 + },
102 + {
103 + "label": "10–20×",
104 + "min": 10,
105 + "max": 20,
106 + "count": 109079,
107 + "share": 0.0109079
108 + },
109 + {
110 + "label": "20–50×",
111 + "min": 20,
112 + "max": 50,
113 + "count": 34700,
114 + "share": 0.00347
115 + },
116 + {
117 + "label": "50–100×",
118 + "min": 50,
119 + "max": 100,
120 + "count": 4773,
121 + "share": 0.0004773
122 + },
123 + {
124 + "label": "100–500×",
125 + "min": 100,
126 + "max": 500,
127 + "count": 992,
128 + "share": 0.0000992
129 + },
130 + {
131 + "label": "500–1000×",
132 + "min": 500,
133 + "max": 1000,
134 + "count": 8,
135 + "share": 8e-7
136 + },
137 + {
138 + "label": "1000×+",
139 + "min": 1000,
140 + "max": null,
141 + "count": 11,
142 + "share": 0.0000011
143 + }
144 + ],
145 + "convergence": [
146 + {
147 + "spins": 249990,
148 + "rtp": 0.9574660986439458
149 + },
150 + {
151 + "spins": 499979,
152 + "rtp": 0.953416236649466
153 + },
154 + {
155 + "spins": 749969,
156 + "rtp": 0.9582380361881142
157 + },
158 + {
159 + "spins": 999958,
160 + "rtp": 0.9626183147325894
161 + },
162 + {
163 + "spins": 1249948,
164 + "rtp": 0.9635973678947161
165 + },
166 + {
167 + "spins": 1499938,
168 + "rtp": 0.9637575369681453
169 + },
170 + {
171 + "spins": 1749927,
172 + "rtp": 0.9666133216757239
173 + },
174 + {
175 + "spins": 1999917,
176 + "rtp": 0.9657833513340532
177 + },
178 + {
179 + "spins": 2249906,
180 + "rtp": 0.9649037650394903
181 + },
182 + {
183 + "spins": 2499896,
184 + "rtp": 0.9643273850954037
185 + },
186 + {
187 + "spins": 2749886,
188 + "rtp": 0.9648149053234858
189 + },
190 + {
191 + "spins": 2999875,
192 + "rtp": 0.9640881201914746
193 + },
194 + {
195 + "spins": 3249865,
196 + "rtp": 0.9646208002166243
197 + },
198 + {
199 + "spins": 3499854,
200 + "rtp": 0.9635114804592184
201 + },
202 + {
203 + "spins": 3749844,
204 + "rtp": 0.9641471205514888
205 + },
206 + {
207 + "spins": 3999834,
208 + "rtp": 0.9643327933117324
209 + },
210 + {
211 + "spins": 4249823,
212 + "rtp": 0.9642275926331171
213 + },
214 + {
215 + "spins": 4499813,
216 + "rtp": 0.9644461200670248
217 + },
218 + {
219 + "spins": 4749802,
220 + "rtp": 0.964563470959891
221 + },
222 + {
223 + "spins": 4999792,
224 + "rtp": 0.9639017540701628
225 + },
226 + {
227 + "spins": 5249782,
228 + "rtp": 0.9631897542568366
229 + },
230 + {
231 + "spins": 5499771,
232 + "rtp": 0.9632326620337542
233 + },
234 + {
235 + "spins": 5749761,
236 + "rtp": 0.9627223054139556
237 + },
238 + {
239 + "spins": 5999750,
240 + "rtp": 0.9633792518367399
241 + },
242 + {
243 + "spins": 6249740,
244 + "rtp": 0.9633997215888637
245 + },
246 + {
247 + "spins": 6499730,
248 + "rtp": 0.9626690806093783
249 + },
250 + {
251 + "spins": 6749719,
252 + "rtp": 0.9628914534359153
253 + },
254 + {
255 + "spins": 6999709,
256 + "rtp": 0.9628863525969611
257 + },
258 + {
259 + "spins": 7249698,
260 + "rtp": 0.9633044232114111
261 + },
262 + {
263 + "spins": 7499688,
264 + "rtp": 0.9629498459938398
265 + },
266 + {
267 + "spins": 7749678,
268 + "rtp": 0.9625680523995153
269 + },
270 + {
271 + "spins": 7999667,
272 + "rtp": 0.9626974228969158
273 + },
274 + {
275 + "spins": 8249657,
276 + "rtp": 0.9626907427812265
277 + },
278 + {
279 + "spins": 8499646,
280 + "rtp": 0.962249509980399
281 + },
282 + {
283 + "spins": 8749636,
284 + "rtp": 0.9620690736200875
285 + },
286 + {
287 + "spins": 8999626,
288 + "rtp": 0.9620485230520329
289 + },
290 + {
291 + "spins": 9249615,
292 + "rtp": 0.9624086552651294
293 + },
294 + {
295 + "spins": 9499605,
296 + "rtp": 0.962407968950337
297 + },
298 + {
299 + "spins": 9749594,
300 + "rtp": 0.9625158390951024
301 + },
302 + {
303 + "spins": 9999584,
304 + "rtp": 0.9623507600304012
305 + },
306 + {
307 + "spins": 10000000,
308 + "rtp": 0.9623502792208323
309 + }
310 + ],
311 + "featureCounts": {
312 + "Quantum Split": 1849408,
313 + "Free Spins": 75109,
314 + "Retrigger": 4552,
315 + "Jackpot mini": 605,
316 + "Jackpot minor": 181,
317 + "Jackpot major": 67,
318 + "Jackpot grand": 9
319 + },

Diff truncated — file too large.