Spinza v0.1.0 — fictional social casino: engine, 20 certified games, API, web, admin
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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'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.<slug>.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's design targets, and the game only opens to players if it passes. The certification summary is shown in each game'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'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'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'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 & 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'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.