Beyond Slots: arcade engine (instant + ladder), 5 originals with APIs, 5 game UIs, 15 new posters, admin/docs updates
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
43 changed files +11,415 −41
modified
CLAUDE.md
+15 −3
@@ -7,13 +7,25 @@ Spinza (www.spinza.dev) is a premium **fictional** social casino. Virtual credit | ||
| 7 | 7 | | Path | Package | Role | |
| 8 | 8 | |---|---|---| |
| 9 | 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). | | |
| 10 | +| `packages/game-core` | `@spinza/game-core` | **Also**: `src/crash/` (crash engine: `P(crash ≥ x) = rtp/x`, SHA-256 commit/reveal, curves exp/power/steps, pacing events, `multiplierAt` shared with the browser via `crash/curve.ts`) and `src/arcade/` (instant resolvers + ladder engine `m_k = rtp·Π(1/p_i)` — strategy-independent RTP). | | |
| 11 | +| | | **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). | | |
| 12 | +| `games/` | `@spinza/games` | 20 slot definitions (`src/<slug>/index.ts`, `registry.ts`), 10 **Risk Games** (`src/crash/index.ts`, cash-out/crash engine), 5 **Spinza Originals — Beyond Slots** (`src/arcade/index.ts`: Dropzone, Grid//Break, Orbit = instant; The Vault, Escape 99 = ladder), `calibration.json` (payScale per slug+version), `certifications/<slug>.json` (PASS/FAIL reports for all 35 games). | | |
| 12 | 13 | | `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 | 14 | | `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 | 15 | | `apps/simulator` | `@spinza/simulator` | CLI `pnpm sim <validate|run|calibrate|certify>` with worker threads; also used by the admin simulator. | |
| 15 | 16 | | `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 | |
| 18 | +## Game families & API | |
| 19 | + | |
| 20 | +| Family | Kind | Play API | Client | | |
| 21 | +|---|---|---|---| | |
| 22 | +| Slots (20) | `slot` | `POST /api/games/:slug/spin` | `components/game/*` (PixiJS renderer) | | |
| 23 | +| Risk Games (10) | `crash` | `POST /api/crash/:slug/start` → poll `GET /api/crash/:slug/rounds/:id` → `POST /api/crash/:slug/cashout` (auto cash-out settles server-side; `crash_rounds` table; lazy settlement) | `components/crash/*` (2D canvas scenes) | | |
| 24 | +| Beyond Slots instant (3) | `arcade` | `POST /api/arcade/:slug/play {bet, clientRoundId, input}` | `components/arcade/{dropzone,gridbreak,orbit}.tsx` | | |
| 25 | +| Beyond Slots ladder (2) | `arcade` | `POST /api/arcade/:slug/start` → `POST /api/arcade/:slug/act {roundId, action}` (`arcade_sessions`) | `components/arcade/{vault,escape}.tsx` | | |
| 26 | + | |
| 27 | +Every settled round of any family goes through `apps/api/src/services/settle.ts` (`settleRound`: game_rounds row, stats, XP, achievements, missions, leaderboards). Lobby categories come from `summary.kind`/`category` written by `sync-games.ts`. | |
| 28 | + | |
| 17 | 29 | ## Non-negotiable rules |
| 18 | 30 | |
| 19 | 31 | - Outcomes are decided **only** in `apps/api` via `runSpin`. The browser animates `result.steps`; it never computes wins. |
@@ -29,7 +41,7 @@ Spinza (www.spinza.dev) is a premium **fictional** social casino. Virtual credit | ||
| 29 | 41 | pnpm install |
| 30 | 42 | createdb spinza && pnpm db:migrate && pnpm db:seed # local Postgres 17 + Redis required |
| 31 | 43 | 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 | |
| 44 | +pnpm sim validate | pnpm sim run <slug> --spins 1000000 | pnpm sim calibrate all|arcade --spins 10000000 --threads 26 | pnpm sim certify all|crash|arcade --spins 10000000 | |
| 33 | 45 | pnpm --filter @spinza/game-core test # engine unit tests |
| 34 | 46 | pnpm --filter @spinza/api test # integration tests (needs local DB/Redis) |
| 35 | 47 | pnpm admin:create <username> [password] # prints TOTP secret once |
modified
apps/api/src/app.ts
+2 −0
@@ -11,6 +11,7 @@ import { rewardRoutes } from "./routes/rewards"; | ||
| 11 | 11 | import { healthRoutes } from "./routes/health"; |
| 12 | 12 | import { adminRoutes } from "./routes/admin"; |
| 13 | 13 | import { crashRoutes } from "./routes/crash"; |
| 14 | +import { arcadeRoutes } from "./routes/arcade"; | |
| 14 | 15 | import { refreshSettings } from "./lib/settings"; |
| 15 | 16 | |
| 16 | 17 | const REDACT = ["req.headers.cookie", "req.headers.authorization", "*.password", "*.newPassword", "*.currentPassword", "*.recoveryCode", "*.totp", "*.passwordHash", "*.tokenHash"]; |
@@ -71,6 +72,7 @@ export async function buildApp(): Promise<FastifyInstance> { | ||
| 71 | 72 | await app.register(gameRoutes); |
| 72 | 73 | await app.register(rewardRoutes); |
| 73 | 74 | await app.register(crashRoutes); |
| 75 | + await app.register(arcadeRoutes); | |
| 74 | 76 | await app.register(adminRoutes); |
| 75 | 77 | |
| 76 | 78 | return app; |
modified
apps/api/src/routes/admin.ts
+6 −5
@@ -1,7 +1,7 @@ | ||
| 1 | 1 | import type { FastifyInstance } from "fastify"; |
| 2 | 2 | import os from "node:os"; |
| 3 | 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"; | |
| 4 | +import { GAMES, getGame, CRASH_BY_SLUG, ARCADE_BY_SLUG } from "@spinza/games"; | |
| 5 | 5 | import { certify, DEFAULT_CERTIFICATION_RULES, validateDefinition } from "@spinza/game-core"; |
| 6 | 6 | import { simulateParallel } from "@spinza/simulator"; |
| 7 | 7 | import { GAME_LIFECYCLE } from "@spinza/shared"; |
@@ -153,13 +153,14 @@ export async function adminRoutes(app: FastifyInstance) { | ||
| 153 | 153 | return { |
| 154 | 154 | games: rows.map(({ game, stats }) => { |
| 155 | 155 | const def = getGame(game.slug); |
| 156 | + const other = CRASH_BY_SLUG.get(game.slug) ?? ARCADE_BY_SLUG.get(game.slug); | |
| 156 | 157 | return { |
| 157 | 158 | ...game, |
| 158 | 159 | enabled: flags[`game.${game.slug}.enabled`] ?? true, |
| 159 | − rtp: def?.rtp ?? null, | |
| 160 | − payScale: def?.payScale ?? null, | |
| 160 | + rtp: def?.rtp ?? other?.rtp ?? null, | |
| 161 | + payScale: def?.payScale ?? (other && "payScale" in other ? other.payScale : null), | |
| 161 | 162 | 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 | + validation: def ? validateDefinition(def) : other ? [] : [{ level: "error", message: "definition missing from library" }], | |
| 163 | 164 | }; |
| 164 | 165 | }), |
| 165 | 166 | }; |
@@ -175,7 +176,7 @@ export async function adminRoutes(app: FastifyInstance) { | ||
| 175 | 176 | 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 | 177 | from game_rounds where game_id = ${g.id} and created_at >= now() - interval '30 days' group by 1 order by 1`); |
| 177 | 178 | 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 | + return { game: g, definition: getGame(slug) ?? CRASH_BY_SLUG.get(slug) ?? ARCADE_BY_SLUG.get(slug) ?? null, versions, stats, daily: daily.rows, simulationRuns: runs }; | |
| 179 | 180 | }); |
| 180 | 181 | |
| 181 | 182 | app.post("/api/admin/games/:slug/lifecycle", async (req) => { |
added
apps/api/src/routes/arcade.ts
+218 −0
@@ -0,0 +1,218 @@ | ||
| 1 | +import type { FastifyInstance } from "fastify"; | |
| 2 | +import { and, arcadeSessions, db, desc, eq, games, sql } from "@spinza/database"; | |
| 3 | +import { CryptoRng, ladderAdvance, ladderStart, ladderView, resolveInstant, type ArcadeGameDefinition, type LadderAction, type LadderState } from "@spinza/game-core"; | |
| 4 | +import { ARCADE_BY_SLUG } from "@spinza/games"; | |
| 5 | +import { BET_LEVELS, classifyWin } from "@spinza/shared"; | |
| 6 | +import { z } from "zod"; | |
| 7 | +import { errors } from "../lib/errors"; | |
| 8 | +import { newRoundId } from "../lib/crypto"; | |
| 9 | +import { flag, maintenance } from "../lib/settings"; | |
| 10 | +import { rateLimit, redis } from "../lib/redis"; | |
| 11 | +import { requireUser } from "../plugins/auth"; | |
| 12 | +import { applyCredit, lockWallet, saveWallet } from "../services/wallet"; | |
| 13 | +import { settleRound } from "../services/settle"; | |
| 14 | +import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg"; | |
| 15 | + | |
| 16 | +type SessionRow = typeof arcadeSessions.$inferSelect; | |
| 17 | + | |
| 18 | +function guard(slug: string): { def: ArcadeGameDefinition } { | |
| 19 | + const def = ARCADE_BY_SLUG.get(slug); | |
| 20 | + if (!def) throw errors.notFound("Unknown game"); | |
| 21 | + const m = maintenance(); | |
| 22 | + if (m.enabled) throw errors.maintenance(m.message); | |
| 23 | + if (!flag(`game.${slug}.enabled`)) throw errors.unavailable("This game is temporarily unavailable."); | |
| 24 | + return { def }; | |
| 25 | +} | |
| 26 | + | |
| 27 | +function checkBet(def: ArcadeGameDefinition, bet: number) { | |
| 28 | + if (!(BET_LEVELS as readonly number[]).includes(bet) || bet < def.minBet || bet > def.maxBet) throw errors.badRequest("Invalid bet for this game."); | |
| 29 | +} | |
| 30 | + | |
| 31 | +export async function arcadeRoutes(app: FastifyInstance) { | |
| 32 | + /* ------------------------------------------------------------ instant */ | |
| 33 | + app.post("/api/arcade/:slug/play", async (req) => { | |
| 34 | + const user = requireUser(req); | |
| 35 | + const { slug } = req.params as { slug: string }; | |
| 36 | + const { def } = guard(slug); | |
| 37 | + if (def.mode !== "instant") throw errors.badRequest("This game is played step by step — use /start."); | |
| 38 | + const retry = await rateLimit(`arcade:${user.id}`, 180, 60); | |
| 39 | + if (retry) throw errors.rateLimited(retry); | |
| 40 | + const body = z.object({ bet: z.number().int(), clientRoundId: z.string().uuid(), input: z.record(z.string(), z.unknown()).default({}) }).parse(req.body); | |
| 41 | + checkBet(def, body.bet); | |
| 42 | + const game = await db.query.games.findFirst({ where: eq(games.slug, slug) }); | |
| 43 | + if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published."); | |
| 44 | + | |
| 45 | + const existing = await db.execute(sql`select round_id, win, multiplier, balance_after, result from game_rounds where user_id = ${user.id} and client_round_id = ${body.clientRoundId}`); | |
| 46 | + if (existing.rows.length) { | |
| 47 | + const r = existing.rows[0] as { round_id: string; win: number; multiplier: string; balance_after: number; result: unknown }; | |
| 48 | + return { roundId: r.round_id, outcome: r.result, win: Number(r.win), multiplier: Number(r.multiplier), balance: Number(r.balance_after), winClass: classifyWin(Number(r.multiplier)), replayed: true }; | |
| 49 | + } | |
| 50 | + const started = Date.now(); | |
| 51 | + const roundId = newRoundId(); | |
| 52 | + const rng = new CryptoRng(); | |
| 53 | + try { | |
| 54 | + const res = await db.transaction(async (tx) => { | |
| 55 | + const wallet = await lockWallet(tx, user.id); | |
| 56 | + if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet); | |
| 57 | + const outcome = resolveInstant(def, body.bet, body.input, rng); | |
| 58 | + await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug }); | |
| 59 | + if (outcome.totalWin > 0) await applyCredit(tx, wallet, "WIN", outcome.totalWin, roundId, { game: slug, multiplier: outcome.multiplier }); | |
| 60 | + const settled = await settleRound(tx, wallet, { | |
| 61 | + roundId, | |
| 62 | + gameId: game.id, | |
| 63 | + gameSlug: slug, | |
| 64 | + gameVersion: def.version, | |
| 65 | + clientRoundId: body.clientRoundId, | |
| 66 | + bet: body.bet, | |
| 67 | + win: outcome.totalWin, | |
| 68 | + multiplier: outcome.multiplier, | |
| 69 | + result: { kind: "arcade", ...outcome } as unknown as Record<string, unknown>, | |
| 70 | + features: outcome.features, | |
| 71 | + freeSpins: false, | |
| 72 | + bonus: outcome.features.some((f) => f === "Deep Drop" || f === "Supernova" || f === "Chain reaction"), | |
| 73 | + jackpotTier: null, | |
| 74 | + rngReference: rng.reference(), | |
| 75 | + durationMs: Date.now() - started, | |
| 76 | + }); | |
| 77 | + await saveWallet(tx, wallet); | |
| 78 | + return { outcome, balance: wallet.balance, settled }; | |
| 79 | + }); | |
| 80 | + redis().zadd("live:players", Date.now(), user.id).catch(() => {}); | |
| 81 | + return { roundId, outcome: res.outcome, win: res.outcome.totalWin, multiplier: res.outcome.multiplier, balance: res.balance, winClass: res.settled.winClass, xp: res.settled.xp, unlocked: res.settled.unlocked }; | |
| 82 | + } catch (e) { | |
| 83 | + if (pgCode(e) === PG_UNIQUE_VIOLATION) { | |
| 84 | + const again = await db.execute(sql`select round_id, win, multiplier, balance_after, result from game_rounds where user_id = ${user.id} and client_round_id = ${body.clientRoundId}`); | |
| 85 | + if (again.rows.length) { | |
| 86 | + const r = again.rows[0] as { round_id: string; win: number; multiplier: string; balance_after: number; result: unknown }; | |
| 87 | + return { roundId: r.round_id, outcome: r.result, win: Number(r.win), multiplier: Number(r.multiplier), balance: Number(r.balance_after), winClass: classifyWin(Number(r.multiplier)), replayed: true }; | |
| 88 | + } | |
| 89 | + } | |
| 90 | + throw e; | |
| 91 | + } | |
| 92 | + }); | |
| 93 | + | |
| 94 | + /* ------------------------------------------------------------- ladder */ | |
| 95 | + app.get("/api/arcade/:slug/current", async (req) => { | |
| 96 | + const user = requireUser(req); | |
| 97 | + const { slug } = req.params as { slug: string }; | |
| 98 | + const row = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.gameSlug, slug), eq(arcadeSessions.status, "running")), orderBy: desc(arcadeSessions.startedAt) }); | |
| 99 | + return { session: row ? { roundId: row.roundId, ...ladderView(row.state as unknown as LadderState) } : null }; | |
| 100 | + }); | |
| 101 | + | |
| 102 | + app.post("/api/arcade/:slug/start", async (req) => { | |
| 103 | + const user = requireUser(req); | |
| 104 | + const { slug } = req.params as { slug: string }; | |
| 105 | + const { def } = guard(slug); | |
| 106 | + if (def.mode !== "ladder") throw errors.badRequest("This game resolves in one shot — use /play."); | |
| 107 | + const retry = await rateLimit(`arcade:${user.id}`, 180, 60); | |
| 108 | + if (retry) throw errors.rateLimited(retry); | |
| 109 | + const body = z.object({ bet: z.number().int(), clientRoundId: z.string().uuid() }).parse(req.body); | |
| 110 | + checkBet(def, body.bet); | |
| 111 | + const game = await db.query.games.findFirst({ where: eq(games.slug, slug) }); | |
| 112 | + if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published."); | |
| 113 | + | |
| 114 | + const existing = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.clientRoundId, body.clientRoundId)) }); | |
| 115 | + if (existing) return { session: { roundId: existing.roundId, ...ladderView(existing.state as unknown as LadderState) }, balance: null, replayed: true }; | |
| 116 | + const running = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.status, "running")) }); | |
| 117 | + if (running) throw errors.conflict("ROUND_IN_PROGRESS", `You have an unfinished ${running.gameSlug} run — finish or cash it out first.`); | |
| 118 | + | |
| 119 | + const roundId = newRoundId(); | |
| 120 | + const rng = new CryptoRng(); | |
| 121 | + try { | |
| 122 | + const result = await db.transaction(async (tx) => { | |
| 123 | + const wallet = await lockWallet(tx, user.id); | |
| 124 | + if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet); | |
| 125 | + await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug }); | |
| 126 | + const state = ladderStart(def, body.bet, rng); | |
| 127 | + let row: SessionRow; | |
| 128 | + [row] = await tx | |
| 129 | + .insert(arcadeSessions) | |
| 130 | + .values({ roundId, userId: user.id, gameId: game.id, gameSlug: slug, gameVersion: def.version, clientRoundId: body.clientRoundId, bet: body.bet, state: state as unknown as Record<string, unknown>, status: state.status }) | |
| 131 | + .returning(); | |
| 132 | + let settled: Awaited<ReturnType<typeof settleRound>> | null = null; | |
| 133 | + if (state.status !== "running") { | |
| 134 | + // Busted (or completed) on the mandatory first step. | |
| 135 | + settled = await finalize(tx, wallet, row, state, def, rng.reference()); | |
| 136 | + [row] = await tx.update(arcadeSessions).set({ status: state.status, win: state.win, settledAt: new Date(), updatedAt: new Date() }).where(eq(arcadeSessions.id, row.id)).returning(); | |
| 137 | + } | |
| 138 | + await saveWallet(tx, wallet); | |
| 139 | + return { row, state, balance: wallet.balance, settled }; | |
| 140 | + }); | |
| 141 | + redis().zadd("live:players", Date.now(), user.id).catch(() => {}); | |
| 142 | + return { session: { roundId, ...ladderView(result.state) }, balance: result.balance, progression: result.settled ? { xp: result.settled.xp, unlocked: result.settled.unlocked } : null }; | |
| 143 | + } catch (e) { | |
| 144 | + if (pgCode(e) === PG_UNIQUE_VIOLATION) { | |
| 145 | + const again = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.clientRoundId, body.clientRoundId)) }); | |
| 146 | + if (again) return { session: { roundId: again.roundId, ...ladderView(again.state as unknown as LadderState) }, balance: null, replayed: true }; | |
| 147 | + } | |
| 148 | + throw e; | |
| 149 | + } | |
| 150 | + }); | |
| 151 | + | |
| 152 | + app.post("/api/arcade/:slug/act", async (req) => { | |
| 153 | + const user = requireUser(req); | |
| 154 | + const { slug } = req.params as { slug: string }; | |
| 155 | + const { def } = guard(slug); | |
| 156 | + const body = z.object({ roundId: z.string(), action: z.object({ type: z.enum(["continue", "cashout"]), offerId: z.string().optional() }) }).parse(req.body); | |
| 157 | + const rng = new CryptoRng(); | |
| 158 | + const res = await db.transaction(async (tx) => { | |
| 159 | + const lock = await tx.execute(sql`select id from arcade_sessions where round_id = ${body.roundId} and user_id = ${user.id} for update`); | |
| 160 | + if (!lock.rows.length) throw errors.notFound("Run not found"); | |
| 161 | + const row = (await tx.query.arcadeSessions.findFirst({ where: eq(arcadeSessions.roundId, body.roundId) }))!; | |
| 162 | + const state = row.state as unknown as LadderState; | |
| 163 | + if (row.status !== "running") { | |
| 164 | + return { row, state, balance: null as number | null, settled: null as Awaited<ReturnType<typeof settleRound>> | null }; | |
| 165 | + } | |
| 166 | + let next: LadderState; | |
| 167 | + try { | |
| 168 | + next = ladderAdvance(def, state, rng, body.action as LadderAction); | |
| 169 | + } catch (e) { | |
| 170 | + throw errors.badRequest((e as Error).message); | |
| 171 | + } | |
| 172 | + let settled: Awaited<ReturnType<typeof settleRound>> | null = null; | |
| 173 | + let balance: number | null = null; | |
| 174 | + if (next.status !== "running") { | |
| 175 | + const wallet = await lockWallet(tx, user.id); | |
| 176 | + settled = await finalize(tx, wallet, row, next, def, rng.reference()); | |
| 177 | + await saveWallet(tx, wallet); | |
| 178 | + balance = wallet.balance; | |
| 179 | + } | |
| 180 | + const [updated] = await tx | |
| 181 | + .update(arcadeSessions) | |
| 182 | + .set({ state: next as unknown as Record<string, unknown>, status: next.status, win: next.win, updatedAt: new Date(), settledAt: next.status !== "running" ? new Date() : null }) | |
| 183 | + .where(eq(arcadeSessions.id, row.id)) | |
| 184 | + .returning(); | |
| 185 | + return { row: updated, state: next, balance, settled }; | |
| 186 | + }); | |
| 187 | + return { session: { roundId: res.row.roundId, ...ladderView(res.state) }, balance: res.balance, winClass: res.state.status === "running" ? null : classifyWin(res.state.win / res.state.bet), progression: res.settled ? { xp: res.settled.xp, unlocked: res.settled.unlocked } : null }; | |
| 188 | + }); | |
| 189 | + | |
| 190 | + app.get("/api/arcade/:slug/history", async (req) => { | |
| 191 | + const { slug } = req.params as { slug: string }; | |
| 192 | + if (!ARCADE_BY_SLUG.has(slug)) throw errors.notFound(); | |
| 193 | + const rows = await db.execute(sql`select multiplier, win, bet, created_at, result->'summary' as summary, result->>'status' as status, (result->>'stage')::int as stage from game_rounds where game_slug = ${slug} order by created_at desc limit 20`); | |
| 194 | + return { history: rows.rows }; | |
| 195 | + }); | |
| 196 | +} | |
| 197 | + | |
| 198 | +/** Credit the win (if any) and write the shared round bookkeeping for a finished ladder run. */ | |
| 199 | +async function finalize(tx: Parameters<typeof settleRound>[0], wallet: Parameters<typeof settleRound>[1], row: SessionRow, state: LadderState, def: ArcadeGameDefinition, rngReference: string) { | |
| 200 | + if (state.win > 0) await applyCredit(tx, wallet, "WIN", state.win, row.roundId, { game: def.slug, multiplier: state.win / row.bet }); | |
| 201 | + return settleRound(tx, wallet, { | |
| 202 | + roundId: row.roundId, | |
| 203 | + gameId: row.gameId, | |
| 204 | + gameSlug: def.slug, | |
| 205 | + gameVersion: def.version, | |
| 206 | + clientRoundId: row.clientRoundId, | |
| 207 | + bet: row.bet, | |
| 208 | + win: state.win, | |
| 209 | + multiplier: row.bet ? state.win / row.bet : 0, | |
| 210 | + result: { kind: "arcade", mode: "ladder", status: state.status, stage: state.stage, current: state.current, log: state.log, extra: state.extra } as unknown as Record<string, unknown>, | |
| 211 | + features: [state.status === "completed" ? (def.slug === "the-vault" ? "Jackpot" : "Summit") : state.status === "cashed" ? "Cash out" : "Bust", ...new Set(state.log.map((l) => l.kind))], | |
| 212 | + freeSpins: false, | |
| 213 | + bonus: state.status === "completed", | |
| 214 | + jackpotTier: state.status === "completed" && def.slug === "the-vault" ? "grand" : null, | |
| 215 | + rngReference, | |
| 216 | + durationMs: Date.now() - row.startedAt.getTime(), | |
| 217 | + }); | |
| 218 | +} | |
modified
apps/api/src/routes/games.ts
+17 −1
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { FastifyInstance } from "fastify"; |
| 2 | 2 | import { and, db, desc, eq, favorites, gameStatistics, games, sql, userGameStats } from "@spinza/database"; |
| 3 | −import { getGame, FIRST_GAME_RECOMMENDATIONS, CRASH_BY_SLUG } from "@spinza/games"; | |
| 3 | +import { getGame, FIRST_GAME_RECOMMENDATIONS, CRASH_BY_SLUG, ARCADE_BY_SLUG } from "@spinza/games"; | |
| 4 | 4 | import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared"; |
| 5 | 5 | import { requireUser } from "../plugins/auth"; |
| 6 | 6 | import { errors } from "../lib/errors"; |
@@ -90,6 +90,22 @@ export async function gameRoutes(app: FastifyInstance) { | ||
| 90 | 90 | }; |
| 91 | 91 | return { game: info, definition: crashDef }; |
| 92 | 92 | } |
| 93 | + const arcadeDef = ARCADE_BY_SLUG.get(slug); | |
| 94 | + if (arcadeDef) { | |
| 95 | + const s = g.summary as Record<string, unknown>; | |
| 96 | + let favorite = false; | |
| 97 | + if (req.user) favorite = !!(await db.query.favorites.findFirst({ where: and(eq(favorites.userId, req.user.id), eq(favorites.gameId, g.id)) })); | |
| 98 | + const info: GameInfo = { | |
| 99 | + ...toCard(g, { favorite }), | |
| 100 | + rtp: arcadeDef.rtp, | |
| 101 | + hitFrequency: ((s.certification as { hitRate?: number } | null)?.hitRate) ?? null, | |
| 102 | + description: arcadeDef.description, | |
| 103 | + rules: arcadeDef.rules, | |
| 104 | + paytable: [], | |
| 105 | + certification: (s.certification as GameInfo["certification"] | null) ?? null, | |
| 106 | + }; | |
| 107 | + return { game: info, definition: arcadeDef }; | |
| 108 | + } | |
| 93 | 109 | const def = getGame(slug); |
| 94 | 110 | if (!def) throw errors.notFound("Game not found"); |
| 95 | 111 | const s = g.summary as Record<string, unknown>; |
added
apps/api/test/arcade.test.ts
+113 −0
@@ -0,0 +1,113 @@ | ||
| 1 | +import { afterAll, beforeAll, describe, expect, it } from "vitest"; | |
| 2 | +import { randomUUID } from "node:crypto"; | |
| 3 | +import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database"; | |
| 4 | +import { syncGames } from "@spinza/database/sync-games"; | |
| 5 | +import { buildApp } from "../src/app"; | |
| 6 | +import { closeRedis, redis } from "../src/lib/redis"; | |
| 7 | +import type { FastifyInstance } from "fastify"; | |
| 8 | + | |
| 9 | +let app: FastifyInstance; | |
| 10 | +let cookie = ""; | |
| 11 | +let userId = ""; | |
| 12 | +const username = `a_${randomUUID().slice(0, 8)}`; | |
| 13 | +const origin = "http://localhost:8230"; | |
| 14 | +const headers = () => ({ origin, cookie: `spinza_session=${cookie}` }); | |
| 15 | + | |
| 16 | +beforeAll(async () => { | |
| 17 | + process.env.NODE_ENV = "test"; | |
| 18 | + app = await buildApp(); | |
| 19 | + await app.ready(); | |
| 20 | + const keys = await redis().keys("rl:*"); | |
| 21 | + if (keys.length) await redis().del(...keys); | |
| 22 | + await syncGames(db); | |
| 23 | + await db.execute(sql`update games set lifecycle = 'published' where slug in ('dropzone','grid-break','orbit','the-vault','escape-99')`); | |
| 24 | + const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } }); | |
| 25 | + cookie = res.cookies.find((c) => c.name === "spinza_session")!.value; | |
| 26 | + userId = res.json().user.id; | |
| 27 | +}); | |
| 28 | + | |
| 29 | +afterAll(async () => { | |
| 30 | + if (userId) await db.delete(users).where(eq(users.id, userId)); | |
| 31 | + await app.close(); | |
| 32 | + await closeDb(); | |
| 33 | + await closeRedis(); | |
| 34 | +}); | |
| 35 | + | |
| 36 | +describe("arcade instant games", () => { | |
| 37 | + it("dropzone resolves a drop and is idempotent", async () => { | |
| 38 | + const id = randomUUID(); | |
| 39 | + const a = await app.inject({ method: "POST", url: "/api/arcade/dropzone/play", headers: headers(), payload: { bet: 100, clientRoundId: id, input: { risk: "high", lane: 0 } } }); | |
| 40 | + expect(a.statusCode).toBe(200); | |
| 41 | + const body = a.json(); | |
| 42 | + expect(body.outcome.steps).toHaveLength(12); | |
| 43 | + expect(body.outcome.summary.startLane).toBe(0); | |
| 44 | + expect(body.outcome.summary.table).toHaveLength(13); | |
| 45 | + const b = await app.inject({ method: "POST", url: "/api/arcade/dropzone/play", headers: headers(), payload: { bet: 100, clientRoundId: id, input: { risk: "high", lane: 0 } } }); | |
| 46 | + expect(b.json().roundId).toBe(body.roundId); | |
| 47 | + expect(b.json().replayed).toBe(true); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + it("grid-break and orbit resolve with valid inputs and reject bad bets", async () => { | |
| 51 | + const g = await app.inject({ method: "POST", url: "/api/arcade/grid-break/play", headers: headers(), payload: { bet: 20, clientRoundId: randomUUID(), input: { column: 3 } } }); | |
| 52 | + expect(g.statusCode).toBe(200); | |
| 53 | + expect(g.json().outcome.steps.length).toBeGreaterThanOrEqual(1); | |
| 54 | + const o = await app.inject({ method: "POST", url: "/api/arcade/orbit/play", headers: headers(), payload: { bet: 20, clientRoundId: randomUUID(), input: { angle: 90 } } }); | |
| 55 | + expect(o.statusCode).toBe(200); | |
| 56 | + expect(o.json().outcome.summary.orbits.length).toBeGreaterThanOrEqual(3); | |
| 57 | + const bad = await app.inject({ method: "POST", url: "/api/arcade/orbit/play", headers: headers(), payload: { bet: 33, clientRoundId: randomUUID(), input: {} } }); | |
| 58 | + expect(bad.statusCode).toBe(400); | |
| 59 | + }); | |
| 60 | +}); | |
| 61 | + | |
| 62 | +describe("arcade ladder games", () => { | |
| 63 | + it("the vault: start resolves the first layer, then secure or bust; ledger stays consistent", async () => { | |
| 64 | + const start = await app.inject({ method: "POST", url: "/api/arcade/the-vault/start", headers: headers(), payload: { bet: 100, clientRoundId: randomUUID() } }); | |
| 65 | + expect(start.statusCode).toBe(200); | |
| 66 | + let s = start.json().session; | |
| 67 | + expect(["running", "busted"]).toContain(s.status); | |
| 68 | + if (s.status === "running") { | |
| 69 | + expect(s.stage).toBe(1); | |
| 70 | + expect(s.current).toBeCloseTo(1.2, 2); | |
| 71 | + expect(s.offers).toHaveLength(1); | |
| 72 | + const act = await app.inject({ method: "POST", url: "/api/arcade/the-vault/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "cashout" } } }); | |
| 73 | + expect(act.statusCode).toBe(200); | |
| 74 | + s = act.json().session; | |
| 75 | + expect(s.status).toBe("cashed"); | |
| 76 | + expect(s.win).toBe(120); | |
| 77 | + expect(act.json().balance).toBeGreaterThan(0); | |
| 78 | + // Acting again on a settled run is a no-op. | |
| 79 | + const again = await app.inject({ method: "POST", url: "/api/arcade/the-vault/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "continue" } } }); | |
| 80 | + expect(again.json().session.status).toBe("cashed"); | |
| 81 | + } | |
| 82 | + const [{ total }] = await db.select({ total: sql<number>`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, userId)); | |
| 83 | + const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, userId) }); | |
| 84 | + expect(Number(total)).toBe(w!.balance); | |
| 85 | + const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId)); | |
| 86 | + expect(rounds.length).toBeGreaterThanOrEqual(4); | |
| 87 | + }); | |
| 88 | + | |
| 89 | + it("escape 99: only one run at a time; climbing advances floors", async () => { | |
| 90 | + const start = await app.inject({ method: "POST", url: "/api/arcade/escape-99/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID() } }); | |
| 91 | + expect(start.statusCode).toBe(200); | |
| 92 | + let s = start.json().session; | |
| 93 | + if (s.status === "running") { | |
| 94 | + const dup = await app.inject({ method: "POST", url: "/api/arcade/escape-99/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID() } }); | |
| 95 | + expect(dup.statusCode).toBe(409); | |
| 96 | + const cur = await app.inject({ method: "GET", url: "/api/arcade/escape-99/current", headers: headers() }); | |
| 97 | + expect(cur.json().session.roundId).toBe(s.roundId); | |
| 98 | + for (let i = 0; i < 5 && s.status === "running"; i++) { | |
| 99 | + const offer = s.offers[0]; | |
| 100 | + const act = await app.inject({ method: "POST", url: "/api/arcade/escape-99/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "continue", offerId: offer.id } } }); | |
| 101 | + expect(act.statusCode).toBe(200); | |
| 102 | + const next = act.json().session; | |
| 103 | + if (next.status === "running") expect(next.stage).toBe(s.stage + offer.advance); | |
| 104 | + s = next; | |
| 105 | + } | |
| 106 | + if (s.status === "running") { | |
| 107 | + const out = await app.inject({ method: "POST", url: "/api/arcade/escape-99/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "cashout" } } }); | |
| 108 | + expect(out.json().session.status).toBe("cashed"); | |
| 109 | + expect(out.json().session.win).toBeGreaterThan(0); | |
| 110 | + } | |
| 111 | + } | |
| 112 | + }); | |
| 113 | +}); | |
modified
apps/api/test/crash.test.ts
+5 −2
@@ -4,7 +4,7 @@ import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } | ||
| 4 | 4 | import { syncGames } from "@spinza/database/sync-games"; |
| 5 | 5 | import { verifyCommitment } from "@spinza/game-core"; |
| 6 | 6 | import { buildApp } from "../src/app"; |
| 7 | −import { closeRedis } from "../src/lib/redis"; | |
| 7 | +import { closeRedis, redis } from "../src/lib/redis"; | |
| 8 | 8 | import type { FastifyInstance } from "fastify"; |
| 9 | 9 | |
| 10 | 10 | let app: FastifyInstance; |
@@ -18,6 +18,9 @@ beforeAll(async () => { | ||
| 18 | 18 | process.env.NODE_ENV = "test"; |
| 19 | 19 | app = await buildApp(); |
| 20 | 20 | await app.ready(); |
| 21 | + // Tests register several accounts per run: reset the per-IP rate limits first. | |
| 22 | + const keys = await redis().keys("rl:*"); | |
| 23 | + if (keys.length) await redis().del(...keys); | |
| 21 | 24 | await syncGames(db); |
| 22 | 25 | await db.execute(sql`update games set lifecycle = 'published' where slug in ('skyfall','elevator-999')`); |
| 23 | 26 | const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } }); |
@@ -54,7 +57,7 @@ describe("crash games", () => { | ||
| 54 | 57 | if (settled.status === "cashed") { |
| 55 | 58 | expect(settled.cashoutMultiplier).toBe(1); |
| 56 | 59 | expect(settled.win).toBe(100); |
| 57 | − expect(out.json().balance).toBe(10000); | |
| 60 | + expect(out.json().balance).toBeGreaterThanOrEqual(10000); // bet returned (+ possible first-round achievement reward) | |
| 58 | 61 | } |
| 59 | 62 | const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId)); |
| 60 | 63 | expect(rounds).toHaveLength(1); |
modified
apps/api/test/wallet.test.ts
+4 −1
@@ -7,7 +7,7 @@ import { randomUUID } from "node:crypto"; | ||
| 7 | 7 | import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database"; |
| 8 | 8 | import { syncGames } from "@spinza/database/sync-games"; |
| 9 | 9 | import { buildApp } from "../src/app"; |
| 10 | −import { closeRedis } from "../src/lib/redis"; | |
| 10 | +import { closeRedis, redis } from "../src/lib/redis"; | |
| 11 | 11 | import type { FastifyInstance } from "fastify"; |
| 12 | 12 | |
| 13 | 13 | let app: FastifyInstance; |
@@ -20,6 +20,9 @@ beforeAll(async () => { | ||
| 20 | 20 | process.env.NODE_ENV = "test"; |
| 21 | 21 | app = await buildApp(); |
| 22 | 22 | await app.ready(); |
| 23 | + // Tests register several accounts per run: reset the per-IP rate limits first. | |
| 24 | + const keys = await redis().keys("rl:*"); | |
| 25 | + if (keys.length) await redis().del(...keys); | |
| 23 | 26 | await syncGames(db); |
| 24 | 27 | // Force-publish one game for tests regardless of certification. |
| 25 | 28 | await db.execute(sql`update games set lifecycle = 'published' where slug = 'neon-vault'`); |
added
apps/api/vitest.config.ts
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { defineConfig } from "vitest/config"; | |
| 2 | + | |
| 3 | +// Integration tests share one Postgres database: run files sequentially so game | |
| 4 | +// library syncs and force-publish statements do not race each other. | |
| 5 | +export default defineConfig({ test: { fileParallelism: false, testTimeout: 20_000 } }); | |
modified
apps/simulator/src/cli.ts
+28 −4
@@ -10,8 +10,8 @@ | ||
| 10 | 10 | import fs from "node:fs"; |
| 11 | 11 | import path from "node:path"; |
| 12 | 12 | import { fileURLToPath } from "node:url"; |
| 13 | −import { certify, certifyCrash, formatCertification, simulateCrash, validateDefinition, DEFAULT_CERTIFICATION_RULES } from "@spinza/game-core"; | |
| 14 | −import { RAW_GAMES, GAMES, CRASH_GAMES, CRASH_BY_SLUG } from "@spinza/games"; | |
| 13 | +import { certify, certifyCrash, certifyArcade, calibrateArcade, formatCertification, simulateCrash, simulateArcade, validateDefinition, DEFAULT_CERTIFICATION_RULES } from "@spinza/game-core"; | |
| 14 | +import { RAW_GAMES, GAMES, CRASH_GAMES, CRASH_BY_SLUG, ARCADE_GAMES, ARCADE_BY_SLUG, RAW_ARCADE_GAMES } from "@spinza/games"; | |
| 15 | 15 | import { calibrate, simulateParallel } from "./index"; |
| 16 | 16 | |
| 17 | 17 | const here = path.dirname(fileURLToPath(import.meta.url)); |
@@ -79,7 +79,21 @@ async function main() { | ||
| 79 | 79 | |
| 80 | 80 | if (cmd === "calibrate") { |
| 81 | 81 | const cal = readCalibration(); |
| 82 | − for (const slug of targets(sel)) { | |
| 82 | + const list = sel === "arcade" ? RAW_ARCADE_GAMES.filter((g) => g.mode === "instant").map((g) => g.slug) : targets(sel); | |
| 83 | + for (const slug of list) { | |
| 84 | + const arcade = RAW_ARCADE_GAMES.find((x) => x.slug === slug); | |
| 85 | + if (arcade) { | |
| 86 | + if (arcade.mode !== "instant") { | |
| 87 | + console.log(`\n${arcade.name} is a ladder game — RTP is analytic, no calibration needed.`); | |
| 88 | + continue; | |
| 89 | + } | |
| 90 | + console.log(`\nCalibrating ${arcade.name} (${slug}@${arcade.version}, arcade) target ${(arcade.rtp * 100).toFixed(2)}%`); | |
| 91 | + const { payScale, result } = calibrateArcade(arcade, num("spins", 400_000), num("iterations", 4), console.log); | |
| 92 | + cal[slug] = { payScale, version: arcade.version, calibratedAt: new Date().toISOString(), spins: result.spins, observedRtp: Number(result.observedRtp.toFixed(5)) }; | |
| 93 | + fs.writeFileSync(calibrationPath, JSON.stringify(cal, null, 2) + "\n"); | |
| 94 | + console.log(` → payScale ${payScale} written (rtp ${(result.observedRtp * 100).toFixed(2)}%, hit ${(result.hitRate * 100).toFixed(1)}%, maxWin ${result.maxWinMultiplier.toFixed(0)}×)`); | |
| 95 | + continue; | |
| 96 | + } | |
| 83 | 97 | const g = RAW_GAMES.find((x) => x.slug === slug)!; |
| 84 | 98 | console.log(`\nCalibrating ${g.name} (${slug}@${g.version}) target ${(g.rtp * 100).toFixed(2)}%`); |
| 85 | 99 | const { payScale, result } = await calibrate(slug, { spins: num("spins", 400_000), iterations: num("iterations", 4), threads, log: console.log }); |
@@ -94,8 +108,18 @@ async function main() { | ||
| 94 | 108 | fs.mkdirSync(certDir, { recursive: true }); |
| 95 | 109 | const spins = num("spins", 1_000_000); |
| 96 | 110 | let failed = 0; |
| 97 | − const list = sel === "crash" ? CRASH_GAMES.map((g) => g.slug) : targets(sel); | |
| 111 | + const list = sel === "crash" ? CRASH_GAMES.map((g) => g.slug) : sel === "arcade" ? ARCADE_GAMES.map((g) => g.slug) : targets(sel); | |
| 98 | 112 | for (const slug of list) { |
| 113 | + const arcadeDef = ARCADE_BY_SLUG.get(slug); | |
| 114 | + if (arcadeDef) { | |
| 115 | + console.log(`\nCertifying ${arcadeDef.name} (${slug}@${arcadeDef.version}, arcade ${arcadeDef.mode}) payScale=${arcadeDef.payScale} — ${spins.toLocaleString("en-US")} rounds`); | |
| 116 | + const res = simulateArcade(arcadeDef, { spins }); | |
| 117 | + const report = certifyArcade(arcadeDef, res, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, spins) }); | |
| 118 | + fs.writeFileSync(path.join(certDir, `${slug}.json`), JSON.stringify(report, null, 2) + "\n"); | |
| 119 | + console.log(formatCertification(report)); | |
| 120 | + if (report.status === "FAIL") failed++; | |
| 121 | + continue; | |
| 122 | + } | |
| 99 | 123 | const crashDef = CRASH_BY_SLUG.get(slug); |
| 100 | 124 | if (crashDef) { |
| 101 | 125 | console.log(`\nCertifying ${crashDef.name} (${slug}@${crashDef.version}, crash) — ${spins.toLocaleString("en-US")} rounds`); |
modified
apps/web/src/app/games/[slug]/page.tsx
+4 −2
@@ -2,10 +2,11 @@ import type { Metadata } from "next"; | ||
| 2 | 2 | import { notFound } from "next/navigation"; |
| 3 | 3 | import { apiServer } from "@/lib/api-server"; |
| 4 | 4 | import type { GameInfo } from "@spinza/shared"; |
| 5 | −import type { CrashGameDefinition } from "@spinza/game-core/client"; | |
| 5 | +import type { ArcadeGameDefinition, CrashGameDefinition } from "@spinza/game-core/client"; | |
| 6 | 6 | import type { ClientDefinition } from "@/components/game/types"; |
| 7 | 7 | import { GameClient } from "@/components/game/game-client"; |
| 8 | 8 | import { CrashClient } from "@/components/crash/crash-client"; |
| 9 | +import { ArcadeClient } from "@/components/arcade/arcade-client"; | |
| 9 | 10 | |
| 10 | 11 | type Params = { params: Promise<{ slug: string }> }; |
| 11 | 12 | |
@@ -18,8 +19,9 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> { | ||
| 18 | 19 | |
| 19 | 20 | export default async function GamePage({ params }: Params) { |
| 20 | 21 | const { slug } = await params; |
| 21 | − const data = await apiServer<{ game: GameInfo; definition: ClientDefinition | CrashGameDefinition }>(`/api/games/${slug}`); | |
| 22 | + const data = await apiServer<{ game: GameInfo; definition: ClientDefinition | CrashGameDefinition | ArcadeGameDefinition }>(`/api/games/${slug}`); | |
| 22 | 23 | if (!data) notFound(); |
| 23 | 24 | if (data.game.kind === "crash") return <CrashClient game={data.game} definition={data.definition as CrashGameDefinition} />; |
| 25 | + if (data.game.kind === "arcade") return <ArcadeClient game={data.game} definition={data.definition as ArcadeGameDefinition} />; | |
| 24 | 26 | return <GameClient game={data.game} definition={data.definition as ClientDefinition} />; |
| 25 | 27 | } |
added
apps/web/src/components/arcade/arcade-client.tsx
+31 −0
@@ -0,0 +1,31 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Dispatcher for the Beyond Slots originals: picks the scene component from | |
| 5 | + * `definition.presentation.scene` and hosts it in the shared ArcadeShell. | |
| 6 | + * (Minimal harness — the integrator owns the final version.) | |
| 7 | + */ | |
| 8 | +import type { ComponentType } from "react"; | |
| 9 | +import type { GameInfo } from "@spinza/shared"; | |
| 10 | +import type { ArcadeGameDefinition } from "@spinza/game-core/client"; | |
| 11 | +import { ArcadeShell } from "./arcade-shell"; | |
| 12 | +import type { ArcadeGameProps } from "./contract"; | |
| 13 | +import { VaultGame } from "./vault"; | |
| 14 | +import { EscapeGame } from "./escape"; | |
| 15 | +import { DropzoneGame } from "./dropzone"; | |
| 16 | +import { GridbreakGame } from "./gridbreak"; | |
| 17 | +import { OrbitGame } from "./orbit"; | |
| 18 | + | |
| 19 | +const SCENES: Partial<Record<ArcadeGameDefinition["presentation"]["scene"], ComponentType<ArcadeGameProps>>> = { | |
| 20 | + vault: VaultGame, | |
| 21 | + escape: EscapeGame, | |
| 22 | + dropzone: DropzoneGame, | |
| 23 | + gridbreak: GridbreakGame, | |
| 24 | + orbit: OrbitGame, | |
| 25 | +}; | |
| 26 | + | |
| 27 | +export function ArcadeClient({ game, definition }: { game: GameInfo; definition: ArcadeGameDefinition }) { | |
| 28 | + const Game = SCENES[definition.presentation.scene]; | |
| 29 | + if (!Game) return null; | |
| 30 | + return <ArcadeShell game={game} definition={definition} Game={Game} />; | |
| 31 | +} | |
added
apps/web/src/components/arcade/arcade-shell.tsx
+200 −0
@@ -0,0 +1,200 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react"; | |
| 4 | +import Link from "next/link"; | |
| 5 | +import { useRouter } from "next/navigation"; | |
| 6 | +import { AnimatePresence, motion } from "framer-motion"; | |
| 7 | +import { ArrowLeft, Heart, Info, Minus, Plus, Volume2, VolumeX } from "lucide-react"; | |
| 8 | +import { BET_LEVELS, classifyWin, formatMultiplier, formatSC, WIN_CLASSES, type GameInfo } from "@spinza/shared"; | |
| 9 | +import type { ArcadeGameDefinition } from "@spinza/game-core/client"; | |
| 10 | +import { api } from "@/lib/api"; | |
| 11 | +import { useSession } from "@/lib/store"; | |
| 12 | +import { cn } from "@/lib/utils"; | |
| 13 | +import { Credits, Sheet, Tabs } from "@/components/ui"; | |
| 14 | +import { getSound } from "@/components/game/sound"; | |
| 15 | +import type { ArcadeGameProps } from "./contract"; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * Shared chrome for the Beyond Slots originals: top bar, bet controls, | |
| 19 | + * balance, win banner, info sheet. The game component fills the middle. | |
| 20 | + */ | |
| 21 | +export function ArcadeShell({ game, definition, Game }: { game: GameInfo; definition: ArcadeGameDefinition; Game: ComponentType<ArcadeGameProps> }) { | |
| 22 | + const router = useRouter(); | |
| 23 | + const { status, wallet, settings } = useSession(); | |
| 24 | + const [bet, setBet] = useState(100); | |
| 25 | + const [busy, setBusy] = useState(false); | |
| 26 | + const [last, setLast] = useState<{ win: number; multiplier: number } | null>(null); | |
| 27 | + const [banner, setBanner] = useState<{ label: string; win: number; multiplier: number } | null>(null); | |
| 28 | + const [infoSheet, setInfoSheet] = useState(false); | |
| 29 | + const [favorite, setFavorite] = useState(!!game.favorite); | |
| 30 | + const soundOn = settings?.soundEnabled ?? true; | |
| 31 | + const palette = definition.presentation.palette; | |
| 32 | + const bets = useMemo(() => (BET_LEVELS as readonly number[]).filter((b) => b >= definition.minBet && b <= definition.maxBet), [definition]); | |
| 33 | + const balance = wallet?.balance ?? 0; | |
| 34 | + const betIndex = bets.indexOf(bet); | |
| 35 | + | |
| 36 | + useEffect(() => { | |
| 37 | + if (status === "guest") router.replace(`/login?next=/games/${game.slug}`); | |
| 38 | + }, [status, router, game.slug]); | |
| 39 | + | |
| 40 | + useEffect(() => { | |
| 41 | + const s = getSound(); | |
| 42 | + s.setLevels({ enabled: soundOn, master: settings?.masterVolume ?? 0.8, music: settings?.musicVolume ?? 0.6, effects: settings?.effectsVolume ?? 0.8 }); | |
| 43 | + s.setAmbience(definition.presentation.ambience); | |
| 44 | + api(`/api/games/${game.slug}/launch`, { method: "POST" }).catch(() => {}); | |
| 45 | + return () => s.destroy(); | |
| 46 | + }, [soundOn, settings?.masterVolume, settings?.musicVolume, settings?.effectsVolume, definition.presentation.ambience, game.slug]); | |
| 47 | + | |
| 48 | + const sound = useCallback<ArcadeGameProps["sound"]>((name) => { | |
| 49 | + const s = getSound(); | |
| 50 | + s.unlock(); | |
| 51 | + switch (name) { | |
| 52 | + case "click": | |
| 53 | + s.click(); | |
| 54 | + break; | |
| 55 | + case "tick": | |
| 56 | + s.tick(); | |
| 57 | + break; | |
| 58 | + case "win": | |
| 59 | + s.win(2); | |
| 60 | + break; | |
| 61 | + case "bigWin": | |
| 62 | + s.bigWin(); | |
| 63 | + break; | |
| 64 | + case "lose": | |
| 65 | + s.error(); | |
| 66 | + break; | |
| 67 | + case "bonus": | |
| 68 | + s.bonus(); | |
| 69 | + break; | |
| 70 | + } | |
| 71 | + }, []); | |
| 72 | + | |
| 73 | + const onResult = useCallback<ArcadeGameProps["onResult"]>((r) => { | |
| 74 | + setLast(r); | |
| 75 | + const cls = classifyWin(r.multiplier); | |
| 76 | + if (cls === "big" || cls === "mega" || cls === "epic" || cls === "legendary") { | |
| 77 | + setBanner({ label: WIN_CLASSES.find((c) => c.id === cls)?.label ?? "WIN", win: r.win, multiplier: r.multiplier }); | |
| 78 | + setTimeout(() => setBanner(null), 2600); | |
| 79 | + } | |
| 80 | + }, []); | |
| 81 | + | |
| 82 | + const toggleFavorite = async () => { | |
| 83 | + setFavorite((f) => !f); | |
| 84 | + try { | |
| 85 | + const r = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" }); | |
| 86 | + setFavorite(r.favorite); | |
| 87 | + } catch { | |
| 88 | + setFavorite((f) => !f); | |
| 89 | + } | |
| 90 | + }; | |
| 91 | + | |
| 92 | + if (status === "guest") return null; | |
| 93 | + | |
| 94 | + return ( | |
| 95 | + <div className="fixed inset-0 flex flex-col" style={{ background: `radial-gradient(120% 80% at 50% 0%, ${palette.surface} 0%, ${palette.bg} 60%, #050608 100%)` }}> | |
| 96 | + <div className="flex items-center justify-between gap-2 px-3 py-2" style={{ paddingTop: "calc(var(--safe-top) + 8px)" }}> | |
| 97 | + <div className="flex items-center gap-2"> | |
| 98 | + <Link href="/" className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Back to lobby"> | |
| 99 | + <ArrowLeft className="h-5 w-5" /> | |
| 100 | + </Link> | |
| 101 | + <div className="leading-tight"> | |
| 102 | + <div className="text-[15px] font-semibold tracking-tight">{game.name}</div> | |
| 103 | + <div className="text-[11px] text-fg-3">Spinza Original · Beyond Slots</div> | |
| 104 | + </div> | |
| 105 | + </div> | |
| 106 | + <div className="flex items-center gap-1"> | |
| 107 | + <button onClick={toggleFavorite} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Favourite"> | |
| 108 | + <Heart className={cn("h-5 w-5", favorite && "fill-danger text-danger")} /> | |
| 109 | + </button> | |
| 110 | + <button onClick={() => setInfoSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Game info"> | |
| 111 | + <Info className="h-5 w-5" /> | |
| 112 | + </button> | |
| 113 | + <button onClick={() => useSession.getState().setSettings({ soundEnabled: !soundOn })} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Sound"> | |
| 114 | + {soundOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />} | |
| 115 | + </button> | |
| 116 | + </div> | |
| 117 | + </div> | |
| 118 | + | |
| 119 | + <div className="relative flex-1 min-h-0"> | |
| 120 | + <Game game={game} definition={definition} bet={bet} onBusy={setBusy} onResult={onResult} sound={sound} reduceMotion={settings?.reduceMotion ?? false} /> | |
| 121 | + <AnimatePresence> | |
| 122 | + {banner ? ( | |
| 123 | + <motion.div key={banner.win} initial={{ opacity: 0, scale: 0.7 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} className="pointer-events-none absolute inset-0 grid place-items-center bg-black/40" style={{ zIndex: 5 }}> | |
| 124 | + <div className="text-center"> | |
| 125 | + <div className="text-[clamp(34px,9vw,72px)] font-extrabold uppercase tracking-tight shimmer-text">{banner.label}</div> | |
| 126 | + <div className="mt-2 text-3xl font-bold tabular text-credit">{formatSC(banner.win)}</div> | |
| 127 | + <div className="text-sm text-fg-2">{formatMultiplier(banner.multiplier)} the bet</div> | |
| 128 | + </div> | |
| 129 | + </motion.div> | |
| 130 | + ) : null} | |
| 131 | + </AnimatePresence> | |
| 132 | + </div> | |
| 133 | + | |
| 134 | + <div className="glass border-x-0 border-b-0 px-3 pt-3" style={{ paddingBottom: "calc(var(--safe-bottom) + 12px)", borderTop: `1px solid ${palette.primary}55` }}> | |
| 135 | + <div className="mx-auto flex max-w-3xl items-center justify-between gap-3"> | |
| 136 | + <div> | |
| 137 | + <div className="eyebrow">Balance</div> | |
| 138 | + <Credits amount={balance} size="md" /> | |
| 139 | + </div> | |
| 140 | + <div className="flex items-center gap-1"> | |
| 141 | + <button disabled={busy || 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"> | |
| 142 | + <Minus className="h-4 w-4" /> | |
| 143 | + </button> | |
| 144 | + <div className="flex h-10 min-w-[92px] flex-col items-center justify-center rounded-md surface-2 px-2"> | |
| 145 | + <span className="text-[10px] uppercase tracking-wider text-fg-3">Bet</span> | |
| 146 | + <span className="text-sm font-bold tabular text-fg">{formatSC(bet)}</span> | |
| 147 | + </div> | |
| 148 | + <button disabled={busy || 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"> | |
| 149 | + <Plus className="h-4 w-4" /> | |
| 150 | + </button> | |
| 151 | + </div> | |
| 152 | + <div className="text-right"> | |
| 153 | + <div className="eyebrow">Last win</div> | |
| 154 | + <div className={cn("text-base font-semibold tabular", last && last.win > 0 ? "text-credit" : "text-fg-4")}>{last && last.win > 0 ? `${formatSC(last.win)} · ${formatMultiplier(last.multiplier)}` : "—"}</div> | |
| 155 | + </div> | |
| 156 | + </div> | |
| 157 | + </div> | |
| 158 | + | |
| 159 | + <InfoSheet open={infoSheet} onClose={() => setInfoSheet(false)} game={game} definition={definition} /> | |
| 160 | + </div> | |
| 161 | + ); | |
| 162 | +} | |
| 163 | + | |
| 164 | +function InfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: ArcadeGameDefinition }) { | |
| 165 | + const [tab, setTab] = useState<"rules" | "about">("rules"); | |
| 166 | + return ( | |
| 167 | + <Sheet open={open} onClose={onClose} title={game.name} side="right"> | |
| 168 | + <Tabs value={tab} onChange={setTab} items={[{ value: "rules", label: "How to play" }, { value: "about", label: "About" }]} className="mb-4" /> | |
| 169 | + {tab === "rules" ? ( | |
| 170 | + <ul className="space-y-3 text-sm text-fg-2"> | |
| 171 | + {game.rules.map((r, i) => ( | |
| 172 | + <li key={i} className="flex gap-3"> | |
| 173 | + <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> | |
| 174 | + <span>{r}</span> | |
| 175 | + </li> | |
| 176 | + ))} | |
| 177 | + </ul> | |
| 178 | + ) : ( | |
| 179 | + <div className="space-y-4 text-sm text-fg-2"> | |
| 180 | + <p>{game.description}</p> | |
| 181 | + <dl className="grid grid-cols-2 gap-3"> | |
| 182 | + {[ | |
| 183 | + ["Volatility", game.volatility], | |
| 184 | + ["RTP", `${(game.rtp * 100).toFixed(2)}%`], | |
| 185 | + ["Max win", formatMultiplier(definition.maxMultiplier)], | |
| 186 | + ["Mode", definition.mode === "ladder" ? "Step by step" : "Instant"], | |
| 187 | + ].map(([k, v]) => ( | |
| 188 | + <div key={k} className="surface rounded-md p-3"> | |
| 189 | + <dt className="eyebrow">{k}</dt> | |
| 190 | + <dd className="mt-1 text-sm font-semibold capitalize text-fg">{v}</dd> | |
| 191 | + </div> | |
| 192 | + ))} | |
| 193 | + </dl> | |
| 194 | + {game.certification ? <p className="text-[12px] text-fg-3">Certified on {game.certification.spins.toLocaleString("en-US")} simulated rounds · observed RTP {(game.certification.observedRtp * 100).toFixed(2)}%.</p> : null} | |
| 195 | + <p className="text-[12px] text-fg-3">Spinza Credits are fictional and have no cash value.</p> | |
| 196 | + </div> | |
| 197 | + )} | |
| 198 | + </Sheet> | |
| 199 | + ); | |
| 200 | +} | |
added
apps/web/src/components/arcade/contract.ts
+175 −0
@@ -0,0 +1,175 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Shared contract for the five "Beyond Slots" arcade games. Each game module | |
| 5 | + * exports a React component with the `ArcadeGameProps` signature and uses the | |
| 6 | + * helpers below to talk to the API. Outcomes are always resolved server-side. | |
| 7 | + */ | |
| 8 | +import { useCallback, useEffect, useRef, useState } from "react"; | |
| 9 | +import type { ArcadeGameDefinition, ArcadeOutcome, LadderState } from "@spinza/game-core/client"; | |
| 10 | +import type { GameInfo } from "@spinza/shared"; | |
| 11 | +import { api, ApiClientError } from "@/lib/api"; | |
| 12 | +import { toast, useSession } from "@/lib/store"; | |
| 13 | + | |
| 14 | +export interface ArcadeGameProps { | |
| 15 | + game: GameInfo; | |
| 16 | + definition: ArcadeGameDefinition; | |
| 17 | + /** Current bet chosen in the shared shell. */ | |
| 18 | + bet: number; | |
| 19 | + /** Tell the shell whether the game is mid-round (locks bet controls). */ | |
| 20 | + onBusy: (busy: boolean) => void; | |
| 21 | + /** Report the last result to the shell (win banner + counters). */ | |
| 22 | + onResult: (r: { win: number; multiplier: number }) => void; | |
| 23 | + /** Play a UI sound: "click" | "win" | "bigWin" | "lose" | "tick" | "bonus". */ | |
| 24 | + sound: (name: "click" | "win" | "bigWin" | "lose" | "tick" | "bonus") => void; | |
| 25 | + reduceMotion: boolean; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export interface Progression { | |
| 29 | + xp: { gained: number; total: number; level: number; leveledUp: boolean; levelReward: number }; | |
| 30 | + unlocked: { achievements: string[]; missions: string[] }; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface InstantResponse<T extends ArcadeOutcome = ArcadeOutcome> { | |
| 34 | + roundId: string; | |
| 35 | + outcome: T; | |
| 36 | + win: number; | |
| 37 | + multiplier: number; | |
| 38 | + balance: number; | |
| 39 | + winClass: string; | |
| 40 | + xp?: Progression["xp"]; | |
| 41 | + unlocked?: Progression["unlocked"]; | |
| 42 | + replayed?: boolean; | |
| 43 | +} | |
| 44 | + | |
| 45 | +export type LadderView = Omit<LadderState, "game" | "version"> & { roundId: string; game: string; version: string; canCashout: boolean }; | |
| 46 | + | |
| 47 | +export interface LadderResponse { | |
| 48 | + session: LadderView; | |
| 49 | + balance: number | null; | |
| 50 | + winClass?: string | null; | |
| 51 | + progression: Progression | null; | |
| 52 | + replayed?: boolean; | |
| 53 | +} | |
| 54 | + | |
| 55 | +function applyProgression(p: Progression | null | undefined) { | |
| 56 | + if (!p) return; | |
| 57 | + const s = useSession.getState(); | |
| 58 | + s.setUserXp(p.xp.total, p.xp.level); | |
| 59 | + if (p.xp.leveledUp) toast({ title: `Level ${p.xp.level} reached`, description: `+${p.xp.levelReward.toLocaleString("en-US")} SC level reward`, tone: "credit" }); | |
| 60 | + for (const a of p.unlocked.achievements) toast({ title: "Achievement unlocked", description: a.replace(/-/g, " "), tone: "success" }); | |
| 61 | + for (const m of p.unlocked.missions) toast({ title: "Mission complete", description: m.replace(/-/g, " "), tone: "success" }); | |
| 62 | +} | |
| 63 | + | |
| 64 | +export function describeArcadeError(e: unknown): string { | |
| 65 | + if (e instanceof ApiClientError) return e.message; | |
| 66 | + return "Connection lost. Try again."; | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** Instant games: one call → resolved outcome. Handles optimistic balance and progression toasts. */ | |
| 70 | +export function useInstantPlay<T extends ArcadeOutcome>(slug: string) { | |
| 71 | + const [busy, setBusy] = useState(false); | |
| 72 | + const [error, setError] = useState<string | null>(null); | |
| 73 | + const setBalance = useSession((s) => s.setBalance); | |
| 74 | + const play = useCallback( | |
| 75 | + async (bet: number, input: Record<string, unknown>): Promise<InstantResponse<T> | null> => { | |
| 76 | + if (busy) return null; | |
| 77 | + const balance = useSession.getState().wallet?.balance ?? 0; | |
| 78 | + if (balance < bet) { | |
| 79 | + setError("Not enough Spinza Credits for this bet."); | |
| 80 | + return null; | |
| 81 | + } | |
| 82 | + setBusy(true); | |
| 83 | + setError(null); | |
| 84 | + setBalance(balance - bet); | |
| 85 | + try { | |
| 86 | + const res = await api<InstantResponse<T>>(`/api/arcade/${slug}/play`, { json: { bet, clientRoundId: crypto.randomUUID(), input } }); | |
| 87 | + setBalance(res.balance); | |
| 88 | + applyProgression(res.xp && res.unlocked ? { xp: res.xp, unlocked: res.unlocked } : null); | |
| 89 | + return res; | |
| 90 | + } catch (e) { | |
| 91 | + setBalance(balance); | |
| 92 | + setError(describeArcadeError(e)); | |
| 93 | + return null; | |
| 94 | + } finally { | |
| 95 | + setBusy(false); | |
| 96 | + } | |
| 97 | + }, | |
| 98 | + [busy, slug, setBalance], | |
| 99 | + ); | |
| 100 | + return { play, busy, error, clearError: () => setError(null) }; | |
| 101 | +} | |
| 102 | + | |
| 103 | +/** Ladder games: start → act(continue|cashout) until the session ends. Resumes a running session on mount. */ | |
| 104 | +export function useLadder(slug: string) { | |
| 105 | + const [session, setSession] = useState<LadderView | null>(null); | |
| 106 | + const [busy, setBusy] = useState(false); | |
| 107 | + const [error, setError] = useState<string | null>(null); | |
| 108 | + const setBalance = useSession((s) => s.setBalance); | |
| 109 | + const mounted = useRef(false); | |
| 110 | + | |
| 111 | + useEffect(() => { | |
| 112 | + if (mounted.current) return; | |
| 113 | + mounted.current = true; | |
| 114 | + api<{ session: LadderView | null }>(`/api/arcade/${slug}/current`) | |
| 115 | + .then((r) => { | |
| 116 | + if (r.session) setSession(r.session); | |
| 117 | + }) | |
| 118 | + .catch(() => {}); | |
| 119 | + }, [slug]); | |
| 120 | + | |
| 121 | + const start = useCallback( | |
| 122 | + async (bet: number): Promise<LadderResponse | null> => { | |
| 123 | + if (busy) return null; | |
| 124 | + const balance = useSession.getState().wallet?.balance ?? 0; | |
| 125 | + if (balance < bet) { | |
| 126 | + setError("Not enough Spinza Credits for this bet."); | |
| 127 | + return null; | |
| 128 | + } | |
| 129 | + setBusy(true); | |
| 130 | + setError(null); | |
| 131 | + try { | |
| 132 | + const res = await api<LadderResponse>(`/api/arcade/${slug}/start`, { json: { bet, clientRoundId: crypto.randomUUID() } }); | |
| 133 | + if (res.balance !== null) setBalance(res.balance); | |
| 134 | + else setBalance(balance - bet); | |
| 135 | + setSession(res.session); | |
| 136 | + applyProgression(res.progression); | |
| 137 | + return res; | |
| 138 | + } catch (e) { | |
| 139 | + if (e instanceof ApiClientError && e.code === "ROUND_IN_PROGRESS") { | |
| 140 | + const cur = await api<{ session: LadderView | null }>(`/api/arcade/${slug}/current`).catch(() => null); | |
| 141 | + if (cur?.session) setSession(cur.session); | |
| 142 | + } | |
| 143 | + setError(describeArcadeError(e)); | |
| 144 | + return null; | |
| 145 | + } finally { | |
| 146 | + setBusy(false); | |
| 147 | + } | |
| 148 | + }, | |
| 149 | + [busy, slug, setBalance], | |
| 150 | + ); | |
| 151 | + | |
| 152 | + const act = useCallback( | |
| 153 | + async (action: { type: "continue"; offerId?: string } | { type: "cashout" }): Promise<LadderResponse | null> => { | |
| 154 | + if (!session || busy) return null; | |
| 155 | + setBusy(true); | |
| 156 | + setError(null); | |
| 157 | + try { | |
| 158 | + const res = await api<LadderResponse>(`/api/arcade/${slug}/act`, { json: { roundId: session.roundId, action } }); | |
| 159 | + setSession(res.session); | |
| 160 | + if (res.balance !== null) setBalance(res.balance); | |
| 161 | + applyProgression(res.progression); | |
| 162 | + return res; | |
| 163 | + } catch (e) { | |
| 164 | + setError(describeArcadeError(e)); | |
| 165 | + return null; | |
| 166 | + } finally { | |
| 167 | + setBusy(false); | |
| 168 | + } | |
| 169 | + }, | |
| 170 | + [session, busy, slug, setBalance], | |
| 171 | + ); | |
| 172 | + | |
| 173 | + const reset = useCallback(() => setSession(null), []); | |
| 174 | + return { session, start, act, reset, busy, error, clearError: () => setError(null) }; | |
| 175 | +} | |
added
apps/web/src/components/arcade/dropzone.tsx
+903 −0
@@ -0,0 +1,903 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * DROPZONE — browser side. The server resolves the whole descent; this file | |
| 5 | + * only animates `outcome.steps` (half-lane x per row, gates, portals), the | |
| 6 | + * landing bucket and the optional Deep Drop extension on a 2D canvas. | |
| 7 | + */ | |
| 8 | +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 9 | +import { AnimatePresence, motion } from "framer-motion"; | |
| 10 | +import { ChevronLeft, ChevronRight } from "lucide-react"; | |
| 11 | +import { formatMultiplier, formatSC } from "@spinza/shared"; | |
| 12 | +import type { ArcadeGameDefinition, ArcadeOutcome, DropStep, DropzoneConfig } from "@spinza/game-core/client"; | |
| 13 | +import { cn } from "@/lib/utils"; | |
| 14 | +import { useInstantPlay, type ArcadeGameProps } from "./contract"; | |
| 15 | + | |
| 16 | +type Risk = "low" | "medium" | "high"; | |
| 17 | + | |
| 18 | +type DropSummary = { | |
| 19 | + risk: Risk; | |
| 20 | + startLane: number; | |
| 21 | + bucket: number; | |
| 22 | + bucketValue: number; | |
| 23 | + gateMult: number; | |
| 24 | + deep: { steps: DropStep[]; bucket: number; value: number } | null; | |
| 25 | + table: number[]; | |
| 26 | +}; | |
| 27 | + | |
| 28 | +interface DropOutcome extends ArcadeOutcome { | |
| 29 | + steps: DropStep[]; | |
| 30 | + summary: DropSummary; | |
| 31 | +} | |
| 32 | + | |
| 33 | +/* ------------------------------------------------------------------ math */ | |
| 34 | +/* Pure replica of the server's displayed bucket values (see game-core/arcade/dropzone.ts). */ | |
| 35 | + | |
| 36 | +function bucketDistribution(cfg: DropzoneConfig, startLane: number): number[] { | |
| 37 | + const width = cfg.lanes * 2; | |
| 38 | + let dist: number[] = new Array<number>(width).fill(0); | |
| 39 | + dist[startLane * 2 + 1] = 1; | |
| 40 | + for (let row = 0; row < cfg.rows; row++) { | |
| 41 | + const next: number[] = new Array<number>(width).fill(0); | |
| 42 | + for (let x = 0; x < width; x++) { | |
| 43 | + if (!dist[x]) continue; | |
| 44 | + next[Math.max(0, x - 1)] += dist[x] / 2; | |
| 45 | + next[Math.min(width - 1, x + 1)] += dist[x] / 2; | |
| 46 | + } | |
| 47 | + dist = next; | |
| 48 | + } | |
| 49 | + const buckets: number[] = new Array<number>(cfg.lanes).fill(0); | |
| 50 | + for (let x = 0; x < width; x++) buckets[Math.min(cfg.lanes - 1, Math.floor(x / 2))] += dist[x]; | |
| 51 | + return buckets; | |
| 52 | +} | |
| 53 | + | |
| 54 | +function laneNormalizer(cfg: DropzoneConfig, risk: Risk, startLane: number): number { | |
| 55 | + const table = cfg.buckets[risk]; | |
| 56 | + const center = Math.floor(cfg.lanes / 2); | |
| 57 | + const ev = (lane: number) => bucketDistribution(cfg, lane).reduce((a, p, k) => a + p * table[k], 0); | |
| 58 | + return ev(center) / ev(startLane); | |
| 59 | +} | |
| 60 | + | |
| 61 | +function displayedBuckets(def: ArcadeGameDefinition, cfg: DropzoneConfig, risk: Risk, startLane: number): number[] { | |
| 62 | + const norm = laneNormalizer(cfg, risk, startLane); | |
| 63 | + return cfg.buckets[risk].map((v) => Math.round(v * norm * def.payScale * 100) / 100); | |
| 64 | +} | |
| 65 | + | |
| 66 | +/* --------------------------------------------------------------- helpers */ | |
| 67 | + | |
| 68 | +const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)); | |
| 69 | +const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); | |
| 70 | +const easeInQuad = (t: number) => t * t; | |
| 71 | +const easeOutBack = (t: number) => 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2); | |
| 72 | +const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v)); | |
| 73 | +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; | |
| 74 | + | |
| 75 | +function tween(ms: number, fn: (t: number) => void, alive: () => boolean): Promise<void> { | |
| 76 | + return new Promise((resolve) => { | |
| 77 | + const start = performance.now(); | |
| 78 | + const frame = (now: number) => { | |
| 79 | + if (!alive()) return resolve(); | |
| 80 | + const t = Math.min(1, (now - start) / Math.max(1, ms)); | |
| 81 | + fn(t); | |
| 82 | + if (t < 1) requestAnimationFrame(frame); | |
| 83 | + else resolve(); | |
| 84 | + }; | |
| 85 | + requestAnimationFrame(frame); | |
| 86 | + }); | |
| 87 | +} | |
| 88 | + | |
| 89 | +function rgba(hex: string, a: number): string { | |
| 90 | + const h = hex.replace("#", ""); | |
| 91 | + const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16); | |
| 92 | + return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; | |
| 93 | +} | |
| 94 | + | |
| 95 | +function mixHex(a: string, b: string, t: number): string { | |
| 96 | + const pa = parseInt(a.replace("#", ""), 16); | |
| 97 | + const pb = parseInt(b.replace("#", ""), 16); | |
| 98 | + const ch = (s: number) => Math.round(lerp((pa >> s) & 255, (pb >> s) & 255, t)); | |
| 99 | + return `rgb(${ch(16)},${ch(8)},${ch(0)})`; | |
| 100 | +} | |
| 101 | + | |
| 102 | +/* ----------------------------------------------------------------- scene */ | |
| 103 | + | |
| 104 | +interface Particle { | |
| 105 | + x: number; | |
| 106 | + y: number; | |
| 107 | + vx: number; | |
| 108 | + vy: number; | |
| 109 | + life: number; | |
| 110 | + max: number; | |
| 111 | + color: string; | |
| 112 | + size: number; | |
| 113 | +} | |
| 114 | + | |
| 115 | +interface Capsule { | |
| 116 | + x: number; // half-lane units | |
| 117 | + y: number; // row units (0 = release line, r+1 = after row r) | |
| 118 | + deep: boolean; | |
| 119 | + visible: boolean; | |
| 120 | + alpha: number; | |
| 121 | + squash: number; // 1 = round | |
| 122 | + glow: number; | |
| 123 | +} | |
| 124 | + | |
| 125 | +interface Scene { | |
| 126 | + t: number; | |
| 127 | + lane: number; | |
| 128 | + risk: Risk; | |
| 129 | + values: number[]; | |
| 130 | + phase: "idle" | "dropping"; | |
| 131 | + camY: number; // in row units | |
| 132 | + capsule: Capsule; | |
| 133 | + trail: { x: number; y: number; deep: boolean; life: number }[]; | |
| 134 | + gates: { x: number; y: number; value: number; at: number }[]; | |
| 135 | + portals: { x: number; to: number; y: number; at: number }[]; | |
| 136 | + landed: number | null; | |
| 137 | + landedAt: number; | |
| 138 | + deepReveal: number; | |
| 139 | + deepLanded: number | null; | |
| 140 | + deepLandedAt: number; | |
| 141 | + particles: Particle[]; | |
| 142 | + flash: { text: string; at: number; color: string } | null; | |
| 143 | + reduceMotion: boolean; | |
| 144 | +} | |
| 145 | + | |
| 146 | +const MARKER_UNITS = 1.2; | |
| 147 | +const BUCKET_UNITS = 1.5; | |
| 148 | +const DEEP_GAP_UNITS = 0.9; | |
| 149 | + | |
| 150 | +interface Layout { | |
| 151 | + w: number; | |
| 152 | + h: number; | |
| 153 | + pad: number; | |
| 154 | + hl: number; | |
| 155 | + rowH: number; | |
| 156 | + left: number; | |
| 157 | + deepLeft: number; | |
| 158 | + deepStart: number; // row units where the deep release line sits | |
| 159 | + totalDeepUnits: number; | |
| 160 | +} | |
| 161 | + | |
| 162 | +function layoutFor(w: number, h: number, cfg: DropzoneConfig): Layout { | |
| 163 | + const mainUnits = MARKER_UNITS + cfg.rows + BUCKET_UNITS + 0.5; | |
| 164 | + let hl = (Math.min(w, 620) - 20) / (cfg.lanes * 2); | |
| 165 | + let rowH = Math.min((h - 20) / mainUnits, hl * 2.1); | |
| 166 | + if (rowH < hl * 1.05) { | |
| 167 | + // very short viewport: shrink horizontally to keep pegs readable | |
| 168 | + hl = rowH / 1.05; | |
| 169 | + rowH = hl * 1.05; | |
| 170 | + } | |
| 171 | + // Centre the tower vertically when the viewport is taller than needed (tall phones). | |
| 172 | + const pad = Math.max(10, (h - mainUnits * rowH) / 2); | |
| 173 | + const left = (w - hl * cfg.lanes * 2) / 2; | |
| 174 | + const deepLeft = left + ((cfg.lanes * 2 - cfg.deepBuckets.length * 2) / 2) * hl; | |
| 175 | + const deepStart = cfg.rows + BUCKET_UNITS + DEEP_GAP_UNITS; | |
| 176 | + return { w, h, pad, hl, rowH, left, deepLeft, deepStart, totalDeepUnits: deepStart + cfg.deepRows + BUCKET_UNITS }; | |
| 177 | +} | |
| 178 | + | |
| 179 | +function toPx(L: Layout, s: Scene, x: number, y: number, deep: boolean): { px: number; py: number } { | |
| 180 | + const px = (deep ? L.deepLeft : L.left) + (x + 0.5) * L.hl; | |
| 181 | + const py = L.pad + (MARKER_UNITS + (deep ? L.deepStart + y : y) - s.camY) * L.rowH; | |
| 182 | + return { px, py }; | |
| 183 | +} | |
| 184 | + | |
| 185 | +function heat(v: number, max: number): number { | |
| 186 | + if (max <= 0) return 0; | |
| 187 | + return clamp(Math.log1p(v) / Math.log1p(max), 0, 1); | |
| 188 | +} | |
| 189 | + | |
| 190 | +/* --------------------------------------------------------------- drawing */ | |
| 191 | + | |
| 192 | +interface Palette { | |
| 193 | + primary: string; | |
| 194 | + secondary: string; | |
| 195 | + glow: string; | |
| 196 | + bg: string; | |
| 197 | + surface: string; | |
| 198 | +} | |
| 199 | + | |
| 200 | +function drawScene(ctx: CanvasRenderingContext2D, L: Layout, s: Scene, cfg: DropzoneConfig, palette: Palette) { | |
| 201 | + const { w, h, hl, rowH } = L; | |
| 202 | + ctx.clearRect(0, 0, w, h); | |
| 203 | + const font = (px: number, weight = 700) => `${weight} ${px}px Geist, "Geist Fallback", system-ui, -apple-system, sans-serif`; | |
| 204 | + | |
| 205 | + // Tower glass backdrop | |
| 206 | + const towerX = L.left - hl * 0.6; | |
| 207 | + const towerW = hl * cfg.lanes * 2 + hl * 1.2; | |
| 208 | + const towerTop = L.pad + (MARKER_UNITS - 0.6 - s.camY) * rowH; | |
| 209 | + const towerBottom = L.pad + (MARKER_UNITS + cfg.rows + BUCKET_UNITS + 0.15 - s.camY) * rowH; | |
| 210 | + const bg = ctx.createLinearGradient(0, towerTop, 0, towerBottom); | |
| 211 | + bg.addColorStop(0, "rgba(255,255,255,0.035)"); | |
| 212 | + bg.addColorStop(1, "rgba(255,255,255,0.012)"); | |
| 213 | + ctx.fillStyle = bg; | |
| 214 | + ctx.beginPath(); | |
| 215 | + ctx.roundRect(towerX, towerTop, towerW, towerBottom - towerTop, 18); | |
| 216 | + ctx.fill(); | |
| 217 | + ctx.strokeStyle = rgba(palette.primary, 0.18); | |
| 218 | + ctx.lineWidth = 1; | |
| 219 | + ctx.stroke(); | |
| 220 | + | |
| 221 | + // Side rails | |
| 222 | + ctx.strokeStyle = rgba(palette.primary, 0.35); | |
| 223 | + ctx.lineWidth = 2; | |
| 224 | + ctx.beginPath(); | |
| 225 | + ctx.moveTo(towerX, towerTop + 14); | |
| 226 | + ctx.lineTo(towerX, towerBottom - 14); | |
| 227 | + ctx.moveTo(towerX + towerW, towerTop + 14); | |
| 228 | + ctx.lineTo(towerX + towerW, towerBottom - 14); | |
| 229 | + ctx.stroke(); | |
| 230 | + | |
| 231 | + // Pegs (main) | |
| 232 | + const pegR = Math.max(2, hl * 0.17); | |
| 233 | + for (let r = 0; r < cfg.rows; r++) { | |
| 234 | + const parity = r % 2 === 0 ? 1 : 0; | |
| 235 | + const y = L.pad + (MARKER_UNITS + r + 0.5 - s.camY) * rowH; | |
| 236 | + if (y < -10 || y > h + 10) continue; | |
| 237 | + for (let x = parity; x < cfg.lanes * 2; x += 2) { | |
| 238 | + const px = L.left + (x + 0.5) * hl; | |
| 239 | + ctx.beginPath(); | |
| 240 | + ctx.arc(px, y, pegR, 0, Math.PI * 2); | |
| 241 | + ctx.fillStyle = "rgba(255,255,255,0.55)"; | |
| 242 | + ctx.fill(); | |
| 243 | + ctx.beginPath(); | |
| 244 | + ctx.arc(px, y, pegR * 2.2, 0, Math.PI * 2); | |
| 245 | + ctx.fillStyle = rgba(palette.primary, 0.08); | |
| 246 | + ctx.fill(); | |
| 247 | + } | |
| 248 | + } | |
| 249 | + | |
| 250 | + // Buckets (main) | |
| 251 | + const maxV = Math.max(...s.values, 1); | |
| 252 | + const bTop = L.pad + (MARKER_UNITS + cfg.rows + 0.15 - s.camY) * rowH; | |
| 253 | + const bH = (BUCKET_UNITS - 0.3) * rowH; | |
| 254 | + for (let k = 0; k < cfg.lanes; k++) { | |
| 255 | + const x0 = L.left + k * 2 * hl + 1.5; | |
| 256 | + const bw = 2 * hl - 3; | |
| 257 | + const v = s.values[k] ?? 0; | |
| 258 | + const t = heat(v, maxV); | |
| 259 | + const base = v >= 50 ? "#ffd66b" : mixHex(palette.primary, palette.secondary, t); | |
| 260 | + const lit = s.landed === k; | |
| 261 | + const pulse = lit ? 0.5 + 0.5 * Math.sin((s.t - s.landedAt) * 8) : 0; | |
| 262 | + ctx.beginPath(); | |
| 263 | + ctx.roundRect(x0, bTop, bw, bH, Math.min(6, hl * 0.35)); | |
| 264 | + ctx.fillStyle = lit ? rgba(base.startsWith("#") ? base : palette.glow, 0.55 + 0.35 * pulse) : rgba("#ffffff", 0.05 + t * 0.06); | |
| 265 | + ctx.fill(); | |
| 266 | + ctx.strokeStyle = lit ? "#ffffff" : base.startsWith("#") ? rgba(base, 0.35 + t * 0.4) : base; | |
| 267 | + ctx.lineWidth = lit ? 2 : 1; | |
| 268 | + ctx.stroke(); | |
| 269 | + // Value | |
| 270 | + const label = v > 0 && v < 0.01 ? "<0.01×" : formatMultiplier(v); | |
| 271 | + const size = Math.max(8, Math.min(13, hl * 0.72)); | |
| 272 | + ctx.font = font(size, 800); | |
| 273 | + ctx.textAlign = "center"; | |
| 274 | + ctx.textBaseline = "middle"; | |
| 275 | + ctx.fillStyle = lit ? "#0b1020" : base.startsWith("#") ? base : mixHex(palette.primary, palette.secondary, t); | |
| 276 | + ctx.fillText(label, x0 + bw / 2, bTop + bH / 2, bw - 2); | |
| 277 | + } | |
| 278 | + | |
| 279 | + // Deep section | |
| 280 | + if (s.deepReveal > 0) { | |
| 281 | + ctx.save(); | |
| 282 | + ctx.globalAlpha = s.deepReveal; | |
| 283 | + const dTop = L.pad + (MARKER_UNITS + L.deepStart - 0.6 - s.camY) * rowH; | |
| 284 | + const dBottom = L.pad + (MARKER_UNITS + L.totalDeepUnits + 0.15 - s.camY) * rowH; | |
| 285 | + const dX = L.deepLeft - hl * 0.6; | |
| 286 | + const dW = hl * cfg.deepBuckets.length * 2 + hl * 1.2; | |
| 287 | + const dg = ctx.createLinearGradient(0, dTop, 0, dBottom); | |
| 288 | + dg.addColorStop(0, rgba(palette.secondary, 0.12)); | |
| 289 | + dg.addColorStop(1, rgba(palette.secondary, 0.03)); | |
| 290 | + ctx.fillStyle = dg; | |
| 291 | + ctx.beginPath(); | |
| 292 | + ctx.roundRect(dX, dTop, dW, dBottom - dTop, 18); | |
| 293 | + ctx.fill(); | |
| 294 | + ctx.strokeStyle = rgba(palette.secondary, 0.4); | |
| 295 | + ctx.lineWidth = 1.5; | |
| 296 | + ctx.stroke(); | |
| 297 | + // Funnel between main buckets and deep tower | |
| 298 | + ctx.strokeStyle = rgba(palette.secondary, 0.5); | |
| 299 | + ctx.setLineDash([4, 6]); | |
| 300 | + ctx.beginPath(); | |
| 301 | + ctx.moveTo(towerX + 8, towerBottom); | |
| 302 | + ctx.lineTo(dX + 8, dTop); | |
| 303 | + ctx.moveTo(towerX + towerW - 8, towerBottom); | |
| 304 | + ctx.lineTo(dX + dW - 8, dTop); | |
| 305 | + ctx.stroke(); | |
| 306 | + ctx.setLineDash([]); | |
| 307 | + ctx.font = font(11, 800); | |
| 308 | + ctx.textAlign = "center"; | |
| 309 | + ctx.textBaseline = "middle"; | |
| 310 | + ctx.fillStyle = palette.secondary; | |
| 311 | + ctx.fillText("DEEP DROP", w / 2, dTop + 14); | |
| 312 | + for (let r = 0; r < cfg.deepRows; r++) { | |
| 313 | + const parity = r % 2 === 0 ? 1 : 0; | |
| 314 | + const y = L.pad + (MARKER_UNITS + L.deepStart + r + 0.5 - s.camY) * rowH; | |
| 315 | + if (y < -10 || y > h + 10) continue; | |
| 316 | + for (let x = parity; x < cfg.deepBuckets.length * 2; x += 2) { | |
| 317 | + const px = L.deepLeft + (x + 0.5) * hl; | |
| 318 | + ctx.beginPath(); | |
| 319 | + ctx.arc(px, y, pegR, 0, Math.PI * 2); | |
| 320 | + ctx.fillStyle = rgba(palette.secondary, 0.8); | |
| 321 | + ctx.fill(); | |
| 322 | + } | |
| 323 | + } | |
| 324 | + const dbTop = L.pad + (MARKER_UNITS + L.deepStart + cfg.deepRows + 0.15 - s.camY) * rowH; | |
| 325 | + for (let k = 0; k < cfg.deepBuckets.length; k++) { | |
| 326 | + const x0 = L.deepLeft + k * 2 * hl + 1.5; | |
| 327 | + const bw = 2 * hl - 3; | |
| 328 | + const v = cfg.deepBuckets[k]; | |
| 329 | + const lit = s.deepLanded === k; | |
| 330 | + const pulse = lit ? 0.5 + 0.5 * Math.sin((s.t - s.deepLandedAt) * 8) : 0; | |
| 331 | + const col = v === 0 ? "#ff5c7a" : v >= 10 ? "#ffd66b" : palette.secondary; | |
| 332 | + ctx.beginPath(); | |
| 333 | + ctx.roundRect(x0, dbTop, bw, bH, Math.min(6, hl * 0.35)); | |
| 334 | + ctx.fillStyle = lit ? rgba(col, 0.55 + 0.35 * pulse) : rgba(col, 0.1); | |
| 335 | + ctx.fill(); | |
| 336 | + ctx.strokeStyle = lit ? "#ffffff" : rgba(col, 0.5); | |
| 337 | + ctx.lineWidth = lit ? 2 : 1; | |
| 338 | + ctx.stroke(); | |
| 339 | + ctx.font = font(Math.max(9, Math.min(13, hl * 0.78)), 800); | |
| 340 | + ctx.fillStyle = lit ? "#0b1020" : col; | |
| 341 | + ctx.fillText(`×${v}`, x0 + bw / 2, dbTop + bH / 2, bw - 2); | |
| 342 | + } | |
| 343 | + ctx.restore(); | |
| 344 | + } | |
| 345 | + | |
| 346 | + // Gate rings | |
| 347 | + for (const g of s.gates) { | |
| 348 | + const age = s.t - g.at; | |
| 349 | + const { px, py } = toPx(L, s, g.x, g.y, false); | |
| 350 | + const k = Math.max(0, 1 - age / 1.6); | |
| 351 | + ctx.save(); | |
| 352 | + ctx.globalAlpha = 0.35 + 0.65 * k; | |
| 353 | + ctx.strokeStyle = palette.secondary; | |
| 354 | + ctx.lineWidth = 2 + 2 * k; | |
| 355 | + ctx.shadowColor = palette.secondary; | |
| 356 | + ctx.shadowBlur = 16 * k; | |
| 357 | + ctx.beginPath(); | |
| 358 | + ctx.ellipse(px, py, hl * (1.05 + 0.4 * Math.min(1, age * 3)), hl * 0.42, 0, 0, Math.PI * 2); | |
| 359 | + ctx.stroke(); | |
| 360 | + ctx.shadowBlur = 0; | |
| 361 | + ctx.font = font(Math.max(11, hl * 0.95), 900); | |
| 362 | + ctx.textAlign = "center"; | |
| 363 | + ctx.textBaseline = "middle"; | |
| 364 | + ctx.fillStyle = "#ffffff"; | |
| 365 | + const rise = Math.min(1, age * 1.4); | |
| 366 | + ctx.fillText(`×${g.value}`, px, py - hl * 1.1 - rise * hl * 0.8); | |
| 367 | + ctx.restore(); | |
| 368 | + } | |
| 369 | + | |
| 370 | + // Portals | |
| 371 | + for (const p of s.portals) { | |
| 372 | + const age = s.t - p.at; | |
| 373 | + const k = Math.max(0, 1 - age / 1.8); | |
| 374 | + const a = toPx(L, s, p.x, p.y, false); | |
| 375 | + const b = toPx(L, s, p.to, p.y, false); | |
| 376 | + ctx.save(); | |
| 377 | + ctx.globalAlpha = 0.25 + 0.75 * k; | |
| 378 | + ctx.strokeStyle = "#a78bfa"; | |
| 379 | + ctx.lineWidth = 1.5; | |
| 380 | + ctx.setLineDash([3, 4]); | |
| 381 | + ctx.beginPath(); | |
| 382 | + ctx.moveTo(a.px, a.py); | |
| 383 | + ctx.lineTo(b.px, b.py); | |
| 384 | + ctx.stroke(); | |
| 385 | + ctx.setLineDash([]); | |
| 386 | + for (const q of [a, b]) { | |
| 387 | + ctx.beginPath(); | |
| 388 | + ctx.ellipse(q.px, q.py, hl * 0.75, hl * 0.35, 0, 0, Math.PI * 2); | |
| 389 | + ctx.strokeStyle = "#c4b5fd"; | |
| 390 | + ctx.lineWidth = 2; | |
| 391 | + ctx.shadowColor = "#a78bfa"; | |
| 392 | + ctx.shadowBlur = 12 * k; | |
| 393 | + ctx.stroke(); | |
| 394 | + } | |
| 395 | + ctx.restore(); | |
| 396 | + } | |
| 397 | + | |
| 398 | + // Trail | |
| 399 | + for (const tr of s.trail) { | |
| 400 | + const { px, py } = toPx(L, s, tr.x, tr.y, tr.deep); | |
| 401 | + ctx.beginPath(); | |
| 402 | + ctx.arc(px, py, hl * 0.32 * tr.life, 0, Math.PI * 2); | |
| 403 | + ctx.fillStyle = rgba(palette.glow, 0.25 * tr.life); | |
| 404 | + ctx.fill(); | |
| 405 | + } | |
| 406 | + | |
| 407 | + // Release marker (idle) | |
| 408 | + if (s.phase === "idle") { | |
| 409 | + const { px, py } = toPx(L, s, s.lane * 2 + 1, -0.75, false); | |
| 410 | + const bob = Math.sin(s.t * 2.2) * 2; | |
| 411 | + ctx.save(); | |
| 412 | + ctx.strokeStyle = rgba(palette.primary, 0.35); | |
| 413 | + ctx.setLineDash([2, 6]); | |
| 414 | + ctx.beginPath(); | |
| 415 | + ctx.moveTo(px, py + hl * 0.6); | |
| 416 | + ctx.lineTo(px, L.pad + (MARKER_UNITS + cfg.rows - s.camY) * rowH); | |
| 417 | + ctx.stroke(); | |
| 418 | + ctx.setLineDash([]); | |
| 419 | + ctx.fillStyle = rgba(palette.primary, 0.9); | |
| 420 | + ctx.beginPath(); | |
| 421 | + ctx.moveTo(px - hl * 0.7, py - hl * 0.35 + bob); | |
| 422 | + ctx.lineTo(px + hl * 0.7, py - hl * 0.35 + bob); | |
| 423 | + ctx.lineTo(px, py + hl * 0.45 + bob); | |
| 424 | + ctx.closePath(); | |
| 425 | + ctx.fill(); | |
| 426 | + ctx.restore(); | |
| 427 | + } | |
| 428 | + | |
| 429 | + // Capsule | |
| 430 | + if (s.capsule.visible && s.capsule.alpha > 0) { | |
| 431 | + const c = s.capsule; | |
| 432 | + const { px, py } = toPx(L, s, c.x, c.y, c.deep); | |
| 433 | + const r = hl * 0.42; | |
| 434 | + ctx.save(); | |
| 435 | + ctx.globalAlpha = c.alpha; | |
| 436 | + ctx.translate(px, py); | |
| 437 | + ctx.scale(1 / Math.sqrt(c.squash), Math.sqrt(c.squash)); | |
| 438 | + ctx.shadowColor = palette.glow; | |
| 439 | + ctx.shadowBlur = 14 + 22 * c.glow; | |
| 440 | + const grad = ctx.createRadialGradient(-r * 0.3, -r * 0.35, r * 0.1, 0, 0, r); | |
| 441 | + grad.addColorStop(0, "#ffffff"); | |
| 442 | + grad.addColorStop(0.35, palette.glow); | |
| 443 | + grad.addColorStop(1, palette.primary); | |
| 444 | + ctx.fillStyle = grad; | |
| 445 | + ctx.beginPath(); | |
| 446 | + ctx.arc(0, 0, r, 0, Math.PI * 2); | |
| 447 | + ctx.fill(); | |
| 448 | + ctx.shadowBlur = 0; | |
| 449 | + ctx.strokeStyle = "rgba(255,255,255,0.75)"; | |
| 450 | + ctx.lineWidth = 1.2; | |
| 451 | + ctx.stroke(); | |
| 452 | + ctx.restore(); | |
| 453 | + } | |
| 454 | + | |
| 455 | + // Particles | |
| 456 | + for (const p of s.particles) { | |
| 457 | + const k = p.life / p.max; | |
| 458 | + ctx.globalAlpha = k; | |
| 459 | + ctx.fillStyle = p.color; | |
| 460 | + ctx.beginPath(); | |
| 461 | + ctx.arc(p.x, p.y, p.size * (0.4 + 0.6 * k), 0, Math.PI * 2); | |
| 462 | + ctx.fill(); | |
| 463 | + } | |
| 464 | + ctx.globalAlpha = 1; | |
| 465 | + | |
| 466 | + // Centre flash text (deep multiplier etc.) | |
| 467 | + if (s.flash) { | |
| 468 | + const age = s.t - s.flash.at; | |
| 469 | + if (age < 1.6) { | |
| 470 | + const k = age < 0.25 ? easeOutBack(age / 0.25) : 1; | |
| 471 | + const fade = age > 1.1 ? 1 - (age - 1.1) / 0.5 : 1; | |
| 472 | + ctx.save(); | |
| 473 | + ctx.globalAlpha = Math.max(0, fade); | |
| 474 | + ctx.translate(w / 2, h * 0.42); | |
| 475 | + ctx.scale(k, k); | |
| 476 | + ctx.font = font(Math.min(54, w * 0.13), 900); | |
| 477 | + ctx.textAlign = "center"; | |
| 478 | + ctx.textBaseline = "middle"; | |
| 479 | + ctx.shadowColor = s.flash.color; | |
| 480 | + ctx.shadowBlur = 30; | |
| 481 | + ctx.fillStyle = s.flash.color; | |
| 482 | + ctx.fillText(s.flash.text, 0, 0); | |
| 483 | + ctx.restore(); | |
| 484 | + } | |
| 485 | + } | |
| 486 | +} | |
| 487 | + | |
| 488 | +/* ------------------------------------------------------------- component */ | |
| 489 | + | |
| 490 | +const RISKS: { id: Risk; label: string }[] = [ | |
| 491 | + { id: "low", label: "Low" }, | |
| 492 | + { id: "medium", label: "Medium" }, | |
| 493 | + { id: "high", label: "High" }, | |
| 494 | +]; | |
| 495 | + | |
| 496 | +export function DropzoneGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) { | |
| 497 | + const cfg = definition.config as unknown as DropzoneConfig; | |
| 498 | + const palette = definition.presentation.palette; | |
| 499 | + const { play, error, clearError } = useInstantPlay<DropOutcome>(definition.slug); | |
| 500 | + const [risk, setRisk] = useState<Risk>("medium"); | |
| 501 | + const [lane, setLane] = useState(Math.floor(cfg.lanes / 2)); | |
| 502 | + const [phase, setPhase] = useState<"idle" | "dropping">("idle"); | |
| 503 | + const [live, setLive] = useState<string | null>(null); | |
| 504 | + const [result, setResult] = useState<{ win: number; multiplier: number; parts: string } | null>(null); | |
| 505 | + const canvasRef = useRef<HTMLCanvasElement>(null); | |
| 506 | + const sceneRef = useRef<Scene | null>(null); | |
| 507 | + const aliveRef = useRef(true); | |
| 508 | + const phaseRef = useRef<"idle" | "dropping">("idle"); | |
| 509 | + const values = useMemo(() => displayedBuckets(definition, cfg, risk, lane), [definition, cfg, risk, lane]); | |
| 510 | + | |
| 511 | + const getScene = useCallback((): Scene => { | |
| 512 | + if (!sceneRef.current) { | |
| 513 | + const mid = Math.floor(cfg.lanes / 2); | |
| 514 | + sceneRef.current = { | |
| 515 | + t: 0, | |
| 516 | + lane: mid, | |
| 517 | + risk: "medium", | |
| 518 | + values: displayedBuckets(definition, cfg, "medium", mid), | |
| 519 | + phase: "idle", | |
| 520 | + camY: 0, | |
| 521 | + capsule: { x: mid * 2 + 1, y: 0, deep: false, visible: false, alpha: 1, squash: 1, glow: 0 }, | |
| 522 | + trail: [], | |
| 523 | + gates: [], | |
| 524 | + portals: [], | |
| 525 | + landed: null, | |
| 526 | + landedAt: 0, | |
| 527 | + deepReveal: 0, | |
| 528 | + deepLanded: null, | |
| 529 | + deepLandedAt: 0, | |
| 530 | + particles: [], | |
| 531 | + flash: null, | |
| 532 | + reduceMotion: false, | |
| 533 | + }; | |
| 534 | + } | |
| 535 | + return sceneRef.current; | |
| 536 | + }, [definition, cfg]); | |
| 537 | + | |
| 538 | + /* Render loop */ | |
| 539 | + useEffect(() => { | |
| 540 | + aliveRef.current = true; | |
| 541 | + const canvas = canvasRef.current; | |
| 542 | + if (!canvas) return; | |
| 543 | + const ctx = canvas.getContext("2d"); | |
| 544 | + if (!ctx) return; | |
| 545 | + const s = getScene(); | |
| 546 | + let raf = 0; | |
| 547 | + let last = performance.now(); | |
| 548 | + const loop = (now: number) => { | |
| 549 | + const dt = Math.min(0.05, (now - last) / 1000); | |
| 550 | + last = now; | |
| 551 | + s.t += dt; | |
| 552 | + const dpr = Math.min(2, window.devicePixelRatio || 1); | |
| 553 | + const rect = canvas.getBoundingClientRect(); | |
| 554 | + const W = Math.max(1, Math.round(rect.width * dpr)); | |
| 555 | + const H = Math.max(1, Math.round(rect.height * dpr)); | |
| 556 | + if (canvas.width !== W || canvas.height !== H) { | |
| 557 | + canvas.width = W; | |
| 558 | + canvas.height = H; | |
| 559 | + } | |
| 560 | + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| 561 | + const L = layoutFor(rect.width, rect.height, cfg); | |
| 562 | + // particles | |
| 563 | + for (let i = s.particles.length - 1; i >= 0; i--) { | |
| 564 | + const p = s.particles[i]; | |
| 565 | + p.life -= dt; | |
| 566 | + p.x += p.vx * dt; | |
| 567 | + p.y += p.vy * dt; | |
| 568 | + p.vy += 420 * dt; | |
| 569 | + p.vx *= 0.98; | |
| 570 | + if (p.life <= 0) s.particles.splice(i, 1); | |
| 571 | + } | |
| 572 | + for (let i = s.trail.length - 1; i >= 0; i--) { | |
| 573 | + s.trail[i].life -= dt * 3.2; | |
| 574 | + if (s.trail[i].life <= 0) s.trail.splice(i, 1); | |
| 575 | + } | |
| 576 | + if (s.phase === "dropping" && s.capsule.visible && !s.reduceMotion) s.trail.push({ x: s.capsule.x, y: s.capsule.y, deep: s.capsule.deep, life: 1 }); | |
| 577 | + s.capsule.glow = Math.max(0, s.capsule.glow - dt * 1.5); | |
| 578 | + drawScene(ctx, L, s, cfg, palette); | |
| 579 | + raf = requestAnimationFrame(loop); | |
| 580 | + }; | |
| 581 | + raf = requestAnimationFrame(loop); | |
| 582 | + return () => { | |
| 583 | + aliveRef.current = false; | |
| 584 | + cancelAnimationFrame(raf); | |
| 585 | + }; | |
| 586 | + }, [cfg, palette, getScene]); | |
| 587 | + | |
| 588 | + /* Sync UI state into the scene */ | |
| 589 | + useEffect(() => { | |
| 590 | + const s = getScene(); | |
| 591 | + s.lane = lane; | |
| 592 | + s.risk = risk; | |
| 593 | + s.values = values; | |
| 594 | + s.reduceMotion = reduceMotion; | |
| 595 | + if (s.phase === "idle") { | |
| 596 | + s.capsule.x = lane * 2 + 1; | |
| 597 | + } | |
| 598 | + }, [lane, risk, values, reduceMotion, getScene]); | |
| 599 | + | |
| 600 | + const laneFromPointer = useCallback( | |
| 601 | + (e: React.PointerEvent<HTMLCanvasElement>) => { | |
| 602 | + const rect = e.currentTarget.getBoundingClientRect(); | |
| 603 | + const L = layoutFor(rect.width, rect.height, cfg); | |
| 604 | + const x = e.clientX - rect.left; | |
| 605 | + return clamp(Math.floor((x - L.left) / (L.hl * 2)), 0, cfg.lanes - 1); | |
| 606 | + }, | |
| 607 | + [cfg], | |
| 608 | + ); | |
| 609 | + | |
| 610 | + const onPointer = useCallback( | |
| 611 | + (e: React.PointerEvent<HTMLCanvasElement>) => { | |
| 612 | + if (phaseRef.current !== "idle") return; | |
| 613 | + if (e.type === "pointermove" && e.buttons === 0) return; | |
| 614 | + const l = laneFromPointer(e); | |
| 615 | + setLane((prev) => { | |
| 616 | + if (prev !== l) sound("tick"); | |
| 617 | + return l; | |
| 618 | + }); | |
| 619 | + }, | |
| 620 | + [laneFromPointer, sound], | |
| 621 | + ); | |
| 622 | + | |
| 623 | + const spawnBurst = useCallback((s: Scene, L: Layout, x: number, y: number, deep: boolean, n: number, color: string, speed: number) => { | |
| 624 | + if (s.reduceMotion) return; | |
| 625 | + const { px, py } = toPx(L, s, x, y, deep); | |
| 626 | + for (let i = 0; i < n; i++) { | |
| 627 | + const a = Math.random() * Math.PI * 2; | |
| 628 | + const v = speed * (0.4 + Math.random() * 0.8); | |
| 629 | + s.particles.push({ x: px, y: py, vx: Math.cos(a) * v, vy: Math.sin(a) * v - speed * 0.4, life: 0.35 + Math.random() * 0.4, max: 0.75, color, size: 1.5 + Math.random() * 2.2 }); | |
| 630 | + } | |
| 631 | + }, []); | |
| 632 | + | |
| 633 | + const animate = useCallback( | |
| 634 | + async (outcome: DropOutcome) => { | |
| 635 | + const s = getScene(); | |
| 636 | + const canvas = canvasRef.current; | |
| 637 | + const alive = () => aliveRef.current; | |
| 638 | + const rm = s.reduceMotion; | |
| 639 | + const rect = canvas?.getBoundingClientRect() ?? { width: 390, height: 600 }; | |
| 640 | + const L = () => layoutFor(rect.width, rect.height, cfg); | |
| 641 | + const rowMs = rm ? 45 : 150; | |
| 642 | + | |
| 643 | + // reset | |
| 644 | + s.phase = "dropping"; | |
| 645 | + s.gates = []; | |
| 646 | + s.portals = []; | |
| 647 | + s.landed = null; | |
| 648 | + s.deepLanded = null; | |
| 649 | + s.deepReveal = 0; | |
| 650 | + s.flash = null; | |
| 651 | + s.particles = []; | |
| 652 | + s.trail = []; | |
| 653 | + s.values = outcome.summary.table; | |
| 654 | + if (s.camY > 0) await tween(rm ? 60 : 320, (t) => (s.camY = lerp(s.camY, 0, easeOutCubic(t))), alive); | |
| 655 | + s.camY = 0; | |
| 656 | + const cap = s.capsule; | |
| 657 | + cap.deep = false; | |
| 658 | + cap.x = outcome.summary.startLane * 2 + 1; | |
| 659 | + cap.y = -0.75; | |
| 660 | + cap.alpha = 1; | |
| 661 | + cap.squash = 1; | |
| 662 | + cap.visible = true; | |
| 663 | + cap.glow = 1; | |
| 664 | + await tween(rm ? 60 : 220, (t) => (cap.y = lerp(-0.75, 0, easeInQuad(t))), alive); | |
| 665 | + | |
| 666 | + let gateMult = 1; | |
| 667 | + const runSteps = async (steps: DropStep[], deep: boolean) => { | |
| 668 | + for (let i = 0; i < steps.length; i++) { | |
| 669 | + if (!alive()) return; | |
| 670 | + const step = steps[i]; | |
| 671 | + const fromX = cap.x; | |
| 672 | + const toX = step.x; | |
| 673 | + const r = step.row; | |
| 674 | + await tween(rowMs, (t) => { | |
| 675 | + if (t < 0.5) { | |
| 676 | + cap.y = lerp(r, r + 0.5, easeInQuad(t / 0.5)); | |
| 677 | + cap.squash = 1; | |
| 678 | + } else { | |
| 679 | + const k = (t - 0.5) / 0.5; | |
| 680 | + cap.x = lerp(fromX, toX, easeOutCubic(k)); | |
| 681 | + cap.y = lerp(r + 0.5, r + 1, k); | |
| 682 | + cap.squash = 1 + 0.45 * Math.sin(k * Math.PI) * (k < 0.5 ? 1 : 0.4); | |
| 683 | + } | |
| 684 | + }, alive); | |
| 685 | + spawnBurst(s, L(), fromX, r + 0.5, deep, 3, "#ffffff", 90); | |
| 686 | + if (i % 2 === 0) sound("tick"); | |
| 687 | + if (!deep && step.event?.type === "gate") { | |
| 688 | + gateMult *= step.event.value; | |
| 689 | + s.gates.push({ x: toX, y: r + 1, value: step.event.value, at: s.t }); | |
| 690 | + cap.glow = 1; | |
| 691 | + spawnBurst(s, L(), toX, r + 1, false, 14, palette.secondary, 160); | |
| 692 | + sound("bonus"); | |
| 693 | + setLive(`Gate ×${step.event.value} · total ×${gateMult}`); | |
| 694 | + await wait(rm ? 80 : 260); | |
| 695 | + } else if (!deep && step.event?.type === "portal") { | |
| 696 | + const to = step.event.to; | |
| 697 | + s.portals.push({ x: toX, to, y: r + 1, at: s.t }); | |
| 698 | + sound("bonus"); | |
| 699 | + setLive(`Portal → lane ${Math.floor(to / 2) + 1}`); | |
| 700 | + await tween(rm ? 40 : 160, (t) => (cap.alpha = 1 - t), alive); | |
| 701 | + spawnBurst(s, L(), toX, r + 1, false, 10, "#a78bfa", 120); | |
| 702 | + cap.x = to; | |
| 703 | + spawnBurst(s, L(), to, r + 1, false, 10, "#a78bfa", 120); | |
| 704 | + await tween(rm ? 40 : 160, (t) => (cap.alpha = t), alive); | |
| 705 | + cap.alpha = 1; | |
| 706 | + } | |
| 707 | + } | |
| 708 | + }; | |
| 709 | + | |
| 710 | + await runSteps(outcome.steps, false); | |
| 711 | + if (!alive()) return; | |
| 712 | + | |
| 713 | + // Landing in the main bucket | |
| 714 | + const bucket = outcome.summary.bucket; | |
| 715 | + const fromX = cap.x; | |
| 716 | + await tween(rm ? 60 : 260, (t) => { | |
| 717 | + cap.x = lerp(fromX, bucket * 2 + 1, easeOutCubic(t)); | |
| 718 | + cap.y = lerp(cfg.rows, cfg.rows + 0.85, easeOutBack(t)); | |
| 719 | + cap.squash = 1 + 0.3 * Math.sin(t * Math.PI); | |
| 720 | + }, alive); | |
| 721 | + s.landed = bucket; | |
| 722 | + s.landedAt = s.t; | |
| 723 | + cap.glow = 1; | |
| 724 | + spawnBurst(s, L(), bucket * 2 + 1, cfg.rows + 0.85, false, 18, palette.glow, 180); | |
| 725 | + const bucketValue = outcome.summary.bucketValue; | |
| 726 | + setLive(gateMult > 1 ? `${formatMultiplier(bucketValue)} bucket × gates ×${gateMult}` : `${formatMultiplier(bucketValue)} bucket`); | |
| 727 | + sound(bucketValue * gateMult >= 1 ? "win" : "tick"); | |
| 728 | + | |
| 729 | + // Deep Drop | |
| 730 | + const deep = outcome.summary.deep; | |
| 731 | + if (deep) { | |
| 732 | + await wait(rm ? 80 : 420); | |
| 733 | + sound("bonus"); | |
| 734 | + s.flash = { text: "DEEP DROP", at: s.t, color: palette.secondary }; | |
| 735 | + const Ld = L(); | |
| 736 | + const visibleUnits = (Ld.h - Ld.pad * 2) / Ld.rowH - MARKER_UNITS; | |
| 737 | + const targetCam = Math.max(0, Ld.totalDeepUnits + 0.4 - visibleUnits); | |
| 738 | + const deepStartX = clamp(bucket * 2 + 1, 0, cfg.deepBuckets.length * 2 - 1); | |
| 739 | + await tween(rm ? 120 : 800, (t) => { | |
| 740 | + const k = easeOutCubic(t); | |
| 741 | + s.deepReveal = Math.min(1, t * 1.6); | |
| 742 | + s.camY = targetCam * k; | |
| 743 | + }, alive); | |
| 744 | + // capsule falls through the bucket floor into the deep release line | |
| 745 | + await tween(rm ? 60 : 360, (t) => { | |
| 746 | + cap.alpha = t < 0.5 ? 1 - t * 2 : (t - 0.5) * 2; | |
| 747 | + if (t >= 0.5 && !cap.deep) { | |
| 748 | + cap.deep = true; | |
| 749 | + cap.x = deepStartX; | |
| 750 | + cap.y = -0.6; | |
| 751 | + } | |
| 752 | + if (t >= 0.5) cap.y = lerp(-0.6, 0, (t - 0.5) * 2); | |
| 753 | + }, alive); | |
| 754 | + cap.alpha = 1; | |
| 755 | + cap.deep = true; | |
| 756 | + cap.x = deepStartX; | |
| 757 | + cap.y = 0; | |
| 758 | + await runSteps(deep.steps, true); | |
| 759 | + if (!alive()) return; | |
| 760 | + const fx = cap.x; | |
| 761 | + await tween(rm ? 60 : 260, (t) => { | |
| 762 | + cap.x = lerp(fx, deep.bucket * 2 + 1, easeOutCubic(t)); | |
| 763 | + cap.y = lerp(cfg.deepRows, cfg.deepRows + 0.85, easeOutBack(t)); | |
| 764 | + cap.squash = 1 + 0.3 * Math.sin(t * Math.PI); | |
| 765 | + }, alive); | |
| 766 | + s.deepLanded = deep.bucket; | |
| 767 | + s.deepLandedAt = s.t; | |
| 768 | + const col = deep.value === 0 ? "#ff5c7a" : deep.value >= 10 ? "#ffd66b" : palette.secondary; | |
| 769 | + s.flash = { text: `×${deep.value}`, at: s.t, color: col }; | |
| 770 | + spawnBurst(s, L(), deep.bucket * 2 + 1, cfg.deepRows + 0.85, true, 24, col, 220); | |
| 771 | + setLive(deep.value === 0 ? "Deep bucket ×0 — vanished" : `Deep bucket ×${deep.value}`); | |
| 772 | + await wait(rm ? 100 : 500); | |
| 773 | + } | |
| 774 | + | |
| 775 | + if (!alive()) return; | |
| 776 | + const parts = gateMult > 1 || deep ? [`${formatMultiplier(bucketValue)} bucket`, gateMult > 1 ? `×${gateMult} gates` : null, deep ? `×${deep.value} deep` : null].filter(Boolean).join(" · ") : `Bucket ${bucket + 1}`; | |
| 777 | + setResult({ win: outcome.totalWin, multiplier: outcome.multiplier, parts }); | |
| 778 | + if (outcome.totalWin <= 0) sound("lose"); | |
| 779 | + else if (outcome.multiplier >= 15) sound("bigWin"); | |
| 780 | + else sound("win"); | |
| 781 | + onResult({ win: outcome.totalWin, multiplier: outcome.multiplier }); | |
| 782 | + s.phase = "idle"; | |
| 783 | + }, | |
| 784 | + [cfg, palette, getScene, sound, onResult, spawnBurst], | |
| 785 | + ); | |
| 786 | + | |
| 787 | + const drop = useCallback(async () => { | |
| 788 | + if (phaseRef.current !== "idle") return; | |
| 789 | + phaseRef.current = "dropping"; | |
| 790 | + setPhase("dropping"); | |
| 791 | + setResult(null); | |
| 792 | + setLive(null); | |
| 793 | + onBusy(true); | |
| 794 | + sound("click"); | |
| 795 | + const res = await play(bet, { risk, lane }); | |
| 796 | + if (res && aliveRef.current) { | |
| 797 | + await animate(res.outcome); | |
| 798 | + } | |
| 799 | + phaseRef.current = "idle"; | |
| 800 | + if (aliveRef.current) { | |
| 801 | + setPhase("idle"); | |
| 802 | + const s = sceneRef.current; | |
| 803 | + if (s) s.phase = "idle"; | |
| 804 | + } | |
| 805 | + onBusy(false); | |
| 806 | + }, [play, bet, risk, lane, animate, onBusy, sound]); | |
| 807 | + | |
| 808 | + const dropping = phase === "dropping"; | |
| 809 | + const maxValue = Math.max(...values); | |
| 810 | + | |
| 811 | + return ( | |
| 812 | + <div className="absolute inset-0 flex flex-col"> | |
| 813 | + {/* Risk selector */} | |
| 814 | + <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-3 pt-2"> | |
| 815 | + <div role="radiogroup" aria-label="Risk profile" className="flex h-11 flex-1 max-w-[330px] rounded-md p-1 surface-2"> | |
| 816 | + {RISKS.map((r) => { | |
| 817 | + const active = risk === r.id; | |
| 818 | + return ( | |
| 819 | + <button | |
| 820 | + key={r.id} | |
| 821 | + role="radio" | |
| 822 | + aria-checked={active} | |
| 823 | + disabled={dropping} | |
| 824 | + onClick={() => { | |
| 825 | + if (risk !== r.id) sound("click"); | |
| 826 | + setRisk(r.id); | |
| 827 | + }} | |
| 828 | + className={cn("flex-1 rounded-[8px] text-[13px] font-semibold transition-all focus-ring disabled:opacity-60", active ? "text-[#061018]" : "text-fg-2 hover:text-fg")} | |
| 829 | + style={active ? { background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary})`, boxShadow: `0 6px 20px -8px ${palette.primary}` } : undefined} | |
| 830 | + > | |
| 831 | + {r.label} | |
| 832 | + </button> | |
| 833 | + ); | |
| 834 | + })} | |
| 835 | + </div> | |
| 836 | + <div className="text-right leading-tight"> | |
| 837 | + <div className="eyebrow">Top bucket</div> | |
| 838 | + <div className="text-sm font-bold tabular" style={{ color: palette.glow }}> | |
| 839 | + {formatMultiplier(maxValue)} | |
| 840 | + </div> | |
| 841 | + </div> | |
| 842 | + </div> | |
| 843 | + | |
| 844 | + {/* Tower */} | |
| 845 | + <div className="relative min-h-0 flex-1"> | |
| 846 | + <canvas | |
| 847 | + ref={canvasRef} | |
| 848 | + className={cn("absolute inset-0 h-full w-full touch-none", dropping ? "cursor-default" : "cursor-pointer")} | |
| 849 | + onPointerDown={onPointer} | |
| 850 | + onPointerMove={onPointer} | |
| 851 | + aria-label="Drop tower. Tap or drag to choose the release lane." | |
| 852 | + role="img" | |
| 853 | + /> | |
| 854 | + <AnimatePresence> | |
| 855 | + {live && dropping ? ( | |
| 856 | + <motion.div key={live} initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute left-1/2 top-2 -translate-x-1/2 whitespace-nowrap rounded-full px-3 py-1 text-[12px] font-bold uppercase tracking-wider" style={{ background: rgba(palette.secondary, 0.18), color: palette.secondary }}> | |
| 857 | + {live} | |
| 858 | + </motion.div> | |
| 859 | + ) : null} | |
| 860 | + {result && !dropping ? ( | |
| 861 | + <motion.div key="result" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute left-1/2 top-2 flex -translate-x-1/2 flex-col items-center glass rounded-lg px-4 py-1.5 text-center leading-tight"> | |
| 862 | + <span className={cn("whitespace-nowrap text-sm font-bold tabular", result.win > 0 ? "text-credit" : "text-fg-3")}>{result.win > 0 ? `+${formatSC(result.win)} · ${formatMultiplier(result.multiplier)}` : "No win this drop"}</span> | |
| 863 | + <span className="whitespace-nowrap text-[11px] text-fg-3">{result.parts}</span> | |
| 864 | + </motion.div> | |
| 865 | + ) : null} | |
| 866 | + {error ? ( | |
| 867 | + <motion.div key="err" 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"> | |
| 868 | + <div className="text-fg-2">{error}</div> | |
| 869 | + <button onClick={clearError} className="mt-2 rounded-sm px-3 py-1.5 text-[13px] font-semibold surface-2 focus-ring"> | |
| 870 | + Dismiss | |
| 871 | + </button> | |
| 872 | + </motion.div> | |
| 873 | + ) : null} | |
| 874 | + </AnimatePresence> | |
| 875 | + </div> | |
| 876 | + | |
| 877 | + {/* Lane + action */} | |
| 878 | + <div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-3 pb-3 pt-2"> | |
| 879 | + <div className="flex items-center gap-1"> | |
| 880 | + <button disabled={dropping || lane <= 0} onClick={() => { sound("tick"); setLane((l) => Math.max(0, l - 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Lane left"> | |
| 881 | + <ChevronLeft className="h-4 w-4" /> | |
| 882 | + </button> | |
| 883 | + <div className="flex h-11 min-w-[72px] flex-col items-center justify-center rounded-md surface-2 px-2"> | |
| 884 | + <span className="text-[10px] uppercase tracking-wider text-fg-3">Lane</span> | |
| 885 | + <span className="text-sm font-bold tabular">{lane + 1} / {cfg.lanes}</span> | |
| 886 | + </div> | |
| 887 | + <button disabled={dropping || lane >= cfg.lanes - 1} onClick={() => { sound("tick"); setLane((l) => Math.min(cfg.lanes - 1, l + 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Lane right"> | |
| 888 | + <ChevronRight className="h-4 w-4" /> | |
| 889 | + </button> | |
| 890 | + </div> | |
| 891 | + <button | |
| 892 | + onClick={() => void drop()} | |
| 893 | + disabled={dropping} | |
| 894 | + className="tap relative h-14 flex-1 rounded-lg text-base font-extrabold uppercase tracking-[0.18em] text-[#061018] transition-transform active:scale-[0.98] disabled:opacity-70 focus-ring" | |
| 895 | + style={{ background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 55%, ${mixHex(palette.primary, "#000000", 0.25)})`, boxShadow: `0 0 0 4px ${rgba(palette.primary, 0.18)}, 0 18px 50px -14px ${palette.primary}` }} | |
| 896 | + aria-label={definition.presentation.verb} | |
| 897 | + > | |
| 898 | + {dropping ? "DROPPING…" : `${definition.presentation.verb} · ${formatSC(bet)}`} | |
| 899 | + </button> | |
| 900 | + </div> | |
| 901 | + </div> | |
| 902 | + ); | |
| 903 | +} | |
added
apps/web/src/components/arcade/escape.tsx
+538 −0
@@ -0,0 +1,538 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Escape 99 — Spinza Original (ladder). A fast roguelike tower: 99 floors, | |
| 5 | + * room by room, cash out on any of them. The tower strip scrolls as the player | |
| 6 | + * climbs; every CLIMB animates the room resolution returned by the server. | |
| 7 | + */ | |
| 8 | +import { useCallback, useEffect, useRef, useState } from "react"; | |
| 9 | +import { AnimatePresence, motion } from "framer-motion"; | |
| 10 | +import { ArrowUp, Crown, DoorOpen, Flag, Flame, Gem, Orbit, RotateCcw, Shield, ShieldCheck, Skull, Sparkles, Sword, TriangleAlert, Zap, type LucideIcon } from "lucide-react"; | |
| 11 | +import { formatMultiplier, formatSC } from "@spinza/shared"; | |
| 12 | +import type { LadderEvent, LadderOffer } from "@spinza/game-core/client"; | |
| 13 | +import { cn } from "@/lib/utils"; | |
| 14 | +import { Button } from "@/components/ui"; | |
| 15 | +import { useLadder, type ArcadeGameProps, type LadderView } from "./contract"; | |
| 16 | + | |
| 17 | +interface EscapeConfig { | |
| 18 | + floors: number; | |
| 19 | + bands: [number, number][]; | |
| 20 | + checkpoints: number[]; | |
| 21 | + rooms: { kind: string; label: string; weight: number; detail: string }[]; | |
| 22 | + forkChance: number; | |
| 23 | + portalChance: number; | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** Room resolution being animated from the returned log tail. */ | |
| 27 | +interface Resolution { | |
| 28 | + ev: LadderEvent; | |
| 29 | + from: number; | |
| 30 | + step: "resolve" | "result"; | |
| 31 | +} | |
| 32 | + | |
| 33 | +type Phase = "idle" | "resolve" | "running" | "cashed" | "busted" | "completed"; | |
| 34 | + | |
| 35 | +const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)); | |
| 36 | +const ROW = 22; | |
| 37 | +const DANGER = "#ff5c7a"; | |
| 38 | + | |
| 39 | +const ICONS: Record<string, LucideIcon> = { chest: Gem, enemy: Sword, trap: TriangleAlert, multiplier: Sparkles, room: DoorOpen, portal: Orbit, "fork-safe": Shield, "fork-risky": Flame, start: DoorOpen, summit: Crown }; | |
| 40 | +const SUCCESS: Record<string, string> = { chest: "Chest opened", enemy: "Guardian defeated", trap: "Trap disarmed", multiplier: "Rune charged", room: "Stairs climbed", portal: "Warped three floors", "fork-safe": "Corridor cleared", "fork-risky": "Corridor cleared", start: "You are in" }; | |
| 41 | + | |
| 42 | +function lastStep(log: LadderEvent[]): LadderEvent | undefined { | |
| 43 | + for (let i = log.length - 1; i >= 0; i--) if (log[i].outcome === "ok" || log[i].outcome === "bust") return log[i]; | |
| 44 | + return undefined; | |
| 45 | +} | |
| 46 | + | |
| 47 | +function pickSafe(offers: LadderOffer[]): LadderOffer | undefined { | |
| 48 | + return offers.find((o) => o.kind === "fork-safe") ?? offers[0]; | |
| 49 | +} | |
| 50 | + | |
| 51 | +export function EscapeGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) { | |
| 52 | + const cfg = definition.config as unknown as EscapeConfig; | |
| 53 | + const palette = definition.presentation.palette; | |
| 54 | + const { session, start, act, reset, busy, error, clearError } = useLadder(definition.slug); | |
| 55 | + const [anim, setAnim] = useState<Resolution | null>(null); | |
| 56 | + const [selected, setSelected] = useState<string | null>(null); | |
| 57 | + const [flourish, setFlourish] = useState<number | null>(null); | |
| 58 | + const [summit, setSummit] = useState(false); | |
| 59 | + const [auto, setAuto] = useState(false); | |
| 60 | + const autoRef = useRef(false); | |
| 61 | + const dur = useCallback((ms: number) => (reduceMotion ? Math.round(ms * 0.35) : ms), [reduceMotion]); | |
| 62 | + const sec = (ms: number) => dur(ms) / 1000; | |
| 63 | + | |
| 64 | + /* ------------------------------------------------------------ derived */ | |
| 65 | + const phase: Phase = anim ? "resolve" : !session ? "idle" : session.status; | |
| 66 | + const running = session?.status === "running"; | |
| 67 | + const locked = busy || anim !== null; | |
| 68 | + const floor = anim ? (anim.step === "resolve" || anim.ev.outcome === "bust" ? anim.from : anim.ev.stage) : (session?.stage ?? 0); | |
| 69 | + const runBet = session?.bet ?? bet; | |
| 70 | + const current = session?.current ?? 0; | |
| 71 | + const offers = session?.offers ?? []; | |
| 72 | + const selectedId = offers.some((o) => o.id === selected) ? selected : offers[0]?.id; | |
| 73 | + const checkpointsHit: number[] = Array.isArray(session?.extra.checkpointsHit) ? (session.extra.checkpointsHit as unknown[]).filter((x): x is number => typeof x === "number") : []; | |
| 74 | + const bustEvent = phase === "busted" && session ? lastStep(session.log) : undefined; | |
| 75 | + const ended = phase === "cashed" || phase === "busted" || phase === "completed"; | |
| 76 | + | |
| 77 | + useEffect(() => { | |
| 78 | + if (running) onBusy(true); | |
| 79 | + }, [running, onBusy]); | |
| 80 | + | |
| 81 | + /* ------------------------------------------------------------ sequence */ | |
| 82 | + const stopAuto = useCallback(() => { | |
| 83 | + autoRef.current = false; | |
| 84 | + setAuto(false); | |
| 85 | + }, []); | |
| 86 | + | |
| 87 | + const finish = useCallback( | |
| 88 | + (s: LadderView) => { | |
| 89 | + stopAuto(); | |
| 90 | + onResult({ win: s.win, multiplier: s.bet ? s.win / s.bet : 0 }); | |
| 91 | + onBusy(false); | |
| 92 | + }, | |
| 93 | + [stopAuto, onResult, onBusy], | |
| 94 | + ); | |
| 95 | + | |
| 96 | + /** Animate the last step of `s` (climbed from floor `from`); settle end states. Returns true when auto-climb should continue. */ | |
| 97 | + const animateStep = useCallback( | |
| 98 | + async (s: LadderView, from: number): Promise<boolean> => { | |
| 99 | + const ev = lastStep(s.log); | |
| 100 | + if (ev) { | |
| 101 | + setAnim({ ev, from, step: "resolve" }); | |
| 102 | + sound("tick"); | |
| 103 | + await wait(dur(ev.outcome === "bust" ? 520 : 420)); | |
| 104 | + const checkpoint = ev.data?.checkpoint === true; | |
| 105 | + if (ev.outcome === "bust") { | |
| 106 | + sound("lose"); | |
| 107 | + setAnim({ ev, from, step: "result" }); | |
| 108 | + await wait(dur(1100)); | |
| 109 | + } else { | |
| 110 | + setAnim({ ev, from, step: "result" }); | |
| 111 | + if (checkpoint) { | |
| 112 | + sound("bonus"); | |
| 113 | + setFlourish(ev.stage); | |
| 114 | + setTimeout(() => setFlourish(null), dur(1500)); | |
| 115 | + } else sound("tick"); | |
| 116 | + await wait(dur(checkpoint ? 700 : 380)); | |
| 117 | + } | |
| 118 | + setAnim(null); | |
| 119 | + if (checkpoint && s.status === "running") stopAuto(); | |
| 120 | + } | |
| 121 | + if (s.status === "completed") { | |
| 122 | + await wait(dur(300)); | |
| 123 | + setSummit(true); | |
| 124 | + sound("bigWin"); | |
| 125 | + } | |
| 126 | + if (s.status !== "running") { | |
| 127 | + finish(s); | |
| 128 | + return false; | |
| 129 | + } | |
| 130 | + if (!autoRef.current) return false; | |
| 131 | + await wait(dur(650)); | |
| 132 | + return autoRef.current; | |
| 133 | + }, | |
| 134 | + [dur, sound, stopAuto, finish], | |
| 135 | + ); | |
| 136 | + | |
| 137 | + /** Animate `first`, then keep climbing the safe path while auto-climb is on (stops at checkpoints / end states). */ | |
| 138 | + const runFrom = useCallback( | |
| 139 | + async (first: LadderView, from: number) => { | |
| 140 | + let s = first; | |
| 141 | + let prev = from; | |
| 142 | + for (;;) { | |
| 143 | + const cont = await animateStep(s, prev); | |
| 144 | + if (!cont) return; | |
| 145 | + prev = s.stage; | |
| 146 | + const res = await act({ type: "continue", offerId: pickSafe(s.offers)?.id }); | |
| 147 | + if (!res) { | |
| 148 | + stopAuto(); | |
| 149 | + return; | |
| 150 | + } | |
| 151 | + s = res.session; | |
| 152 | + } | |
| 153 | + }, | |
| 154 | + [animateStep, act, stopAuto], | |
| 155 | + ); | |
| 156 | + | |
| 157 | + const climb = async (offerId: string | undefined) => { | |
| 158 | + if (!session) return; | |
| 159 | + const res = await act({ type: "continue", offerId }); | |
| 160 | + if (!res) { | |
| 161 | + stopAuto(); | |
| 162 | + return; | |
| 163 | + } | |
| 164 | + await runFrom(res.session, session.stage); | |
| 165 | + }; | |
| 166 | + | |
| 167 | + const onStart = async () => { | |
| 168 | + if (locked) return; | |
| 169 | + sound("click"); | |
| 170 | + setSummit(false); | |
| 171 | + if (session && session.status !== "running") reset(); | |
| 172 | + onBusy(true); | |
| 173 | + const res = await start(bet); | |
| 174 | + if (!res) { | |
| 175 | + onBusy(false); | |
| 176 | + return; | |
| 177 | + } | |
| 178 | + await runFrom(res.session, 0); | |
| 179 | + }; | |
| 180 | + | |
| 181 | + const onClimb = async () => { | |
| 182 | + if (!session || locked) return; | |
| 183 | + sound("click"); | |
| 184 | + await climb(selectedId ?? undefined); | |
| 185 | + }; | |
| 186 | + | |
| 187 | + const onCashout = async () => { | |
| 188 | + if (!session || locked) return; | |
| 189 | + sound("click"); | |
| 190 | + stopAuto(); | |
| 191 | + const res = await act({ type: "cashout" }); | |
| 192 | + if (!res) return; | |
| 193 | + sound("win"); | |
| 194 | + finish(res.session); | |
| 195 | + }; | |
| 196 | + | |
| 197 | + const onToggleAuto = () => { | |
| 198 | + sound("click"); | |
| 199 | + const next = !auto; | |
| 200 | + autoRef.current = next; | |
| 201 | + setAuto(next); | |
| 202 | + if (next && session && !locked) void climb(pickSafe(offers)?.id); | |
| 203 | + }; | |
| 204 | + | |
| 205 | + const onNewRun = () => { | |
| 206 | + sound("click"); | |
| 207 | + setSummit(false); | |
| 208 | + reset(); | |
| 209 | + }; | |
| 210 | + | |
| 211 | + /* -------------------------------------------------------------- render */ | |
| 212 | + const multiplierColor = phase === "busted" ? DANGER : phase === "cashed" || phase === "completed" ? "var(--color-credit)" : "#fff"; | |
| 213 | + const isFork = offers.length > 1; | |
| 214 | + | |
| 215 | + return ( | |
| 216 | + <div className="absolute inset-0 flex flex-col overflow-hidden" data-scene="escape"> | |
| 217 | + <div className="pointer-events-none absolute inset-0" style={{ background: `radial-gradient(70% 50% at 50% 100%, ${palette.surface} 0%, transparent 70%), radial-gradient(60% 40% at 50% 0%, ${palette.primary}14 0%, transparent 70%)` }} /> | |
| 218 | + {/* Bust flash */} | |
| 219 | + <AnimatePresence> | |
| 220 | + {phase === "busted" || (anim && anim.ev.outcome === "bust" && anim.step === "result") ? ( | |
| 221 | + <motion.div key="flash" className="pointer-events-none absolute inset-0 z-[3]" style={{ background: `radial-gradient(70% 60% at 50% 50%, ${DANGER}44 0%, transparent 100%)` }} initial={{ opacity: 0 }} animate={{ opacity: reduceMotion ? [0, 0.7, 0] : [0, 1, 0.2, 0.7, 0] }} exit={{ opacity: 0 }} transition={{ duration: sec(1100) }} /> | |
| 222 | + ) : null} | |
| 223 | + </AnimatePresence> | |
| 224 | + {/* Summit: the tower opens to the sky */} | |
| 225 | + <AnimatePresence> | |
| 226 | + {summit ? ( | |
| 227 | + <motion.div key="sky" className="pointer-events-none absolute inset-0 z-[3]" initial={{ opacity: 0, y: "60%" }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: sec(1600), ease: [0.16, 1, 0.3, 1] }} style={{ background: `linear-gradient(180deg, #e9fff5 0%, ${palette.glow}aa 22%, ${palette.primary}44 55%, transparent 100%)`, mixBlendMode: "screen" }} /> | |
| 228 | + ) : null} | |
| 229 | + </AnimatePresence> | |
| 230 | + | |
| 231 | + <div className="relative z-[2] mx-auto flex h-full w-full max-w-3xl flex-col px-3 pb-2 pt-1"> | |
| 232 | + {/* Stats */} | |
| 233 | + <div className="flex items-end justify-between gap-3"> | |
| 234 | + <div> | |
| 235 | + <div className="eyebrow">{phase === "busted" ? "Run over" : phase === "cashed" ? "Exited" : phase === "completed" ? "Summit" : "Multiplier"}</div> | |
| 236 | + <div className="flex items-baseline gap-2"> | |
| 237 | + <motion.div key={`${phase}-${current}`} initial={{ opacity: 0.4, y: 4 }} animate={{ opacity: 1, y: 0 }} className="text-[clamp(34px,9vw,52px)] font-extrabold leading-none tabular tracking-tight" style={{ color: multiplierColor, textShadow: phase === "running" || phase === "resolve" ? `0 0 28px ${palette.glow}88` : undefined }}> | |
| 238 | + {phase === "idle" ? "—" : formatMultiplier(current)} | |
| 239 | + </motion.div> | |
| 240 | + </div> | |
| 241 | + </div> | |
| 242 | + <div className="text-center"> | |
| 243 | + <div className="eyebrow">Floor</div> | |
| 244 | + <div className="text-2xl font-extrabold leading-none tabular"> | |
| 245 | + <motion.span key={floor} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="inline-block" style={{ color: phase === "busted" ? DANGER : palette.glow }}> | |
| 246 | + {Math.max(1, floor)} | |
| 247 | + </motion.span> | |
| 248 | + <span className="text-sm text-fg-3">/{cfg.floors}</span> | |
| 249 | + </div> | |
| 250 | + </div> | |
| 251 | + <div className="text-right"> | |
| 252 | + <div className="eyebrow">{phase === "busted" ? "Lost" : ended ? "Win" : "Potential win"}</div> | |
| 253 | + <div className={cn("text-lg font-bold tabular sm:text-xl", phase === "busted" ? "text-danger" : "text-credit")}>{phase === "idle" ? formatSC(bet) : phase === "busted" ? `−${formatSC(runBet)}` : ended ? formatSC(session?.win ?? 0) : formatSC(Math.round(runBet * current))}</div> | |
| 254 | + <div className="text-[11px] text-fg-3 tabular">Bet {formatSC(runBet)}</div> | |
| 255 | + </div> | |
| 256 | + </div> | |
| 257 | + | |
| 258 | + {/* Tower + room */} | |
| 259 | + <div className="mt-2 grid min-h-0 flex-1 grid-cols-[64px_1fr] gap-3 sm:grid-cols-[88px_1fr]"> | |
| 260 | + <Tower cfg={cfg} floor={Math.max(1, floor)} busted={phase === "busted"} checkpointsHit={checkpointsHit} palette={palette} sec={sec} /> | |
| 261 | + | |
| 262 | + <div className="relative flex min-h-0 flex-col justify-center"> | |
| 263 | + <AnimatePresence mode="wait"> | |
| 264 | + {phase === "idle" ? ( | |
| 265 | + <motion.div key="idle" initial={{ opacity: 0, scale: 0.94 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.94 }} className="flex flex-col items-center gap-3 text-center"> | |
| 266 | + <button | |
| 267 | + onClick={() => void onStart()} | |
| 268 | + disabled={locked} | |
| 269 | + className="grid h-[112px] w-[112px] place-items-center rounded-full text-[17px] font-extrabold tracking-[0.14em] transition-transform active:scale-95 focus-ring disabled:opacity-60" | |
| 270 | + style={{ background: `radial-gradient(circle at 50% 30%, #e9fff5 0%, ${palette.glow} 18%, ${palette.primary} 60%, #0f6b4a 100%)`, boxShadow: `0 0 0 8px ${palette.primary}2a, 0 0 0 9px ${palette.primary}55, 0 18px 60px -12px ${palette.primary}`, color: "#04140d" }} | |
| 271 | + aria-label="Start run" | |
| 272 | + > | |
| 273 | + START | |
| 274 | + </button> | |
| 275 | + <span className="rounded-full bg-black/50 px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-fg-2 tabular">Bet {formatSC(bet)}</span> | |
| 276 | + <p className="max-w-[260px] text-[12px] text-fg-3">{definition.tagline} You enter floor 1 automatically.</p> | |
| 277 | + </motion.div> | |
| 278 | + ) : anim ? ( | |
| 279 | + <ResolveCard key={`res-${anim.ev.stage}-${anim.ev.kind}`} res={anim} palette={palette} reduceMotion={reduceMotion} /> | |
| 280 | + ) : phase === "running" ? ( | |
| 281 | + <motion.div key={`offers-${session?.stage}`} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: sec(260) }} className="flex flex-col gap-2"> | |
| 282 | + <div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-wider text-fg-3"> | |
| 283 | + <span>{isFork ? "Fork — choose a corridor" : offers[0]?.kind === "portal" ? "Portal detected" : `Floor ${floor + 1}`}</span> | |
| 284 | + <span className="tabular">Next</span> | |
| 285 | + </div> | |
| 286 | + <div className={cn("grid gap-2", isFork ? "grid-cols-2" : "grid-cols-1")}> | |
| 287 | + {offers.map((o) => ( | |
| 288 | + <OfferCard key={o.id} offer={o} bet={runBet} selected={selectedId === o.id} fork={isFork} onSelect={() => setSelected(o.id)} palette={palette} /> | |
| 289 | + ))} | |
| 290 | + </div> | |
| 291 | + </motion.div> | |
| 292 | + ) : phase === "busted" && bustEvent ? ( | |
| 293 | + <motion.div key="bust" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="surface rounded-lg p-4 text-center" style={{ borderColor: `${DANGER}66` }}> | |
| 294 | + <Skull className="mx-auto h-10 w-10" style={{ color: DANGER }} /> | |
| 295 | + <div className="mt-2 text-xl font-extrabold uppercase tracking-tight" style={{ color: DANGER }}> | |
| 296 | + {bustEvent.label} | |
| 297 | + </div> | |
| 298 | + {bustEvent.detail ? <div className="text-[12px] text-fg-3">{bustEvent.detail}</div> : null} | |
| 299 | + <div className="mt-2 text-[12px] text-fg-2"> | |
| 300 | + The run ended on floor {session?.stage ?? 0}. Bet lost: <span className="tabular text-danger">{formatSC(runBet)}</span> | |
| 301 | + </div> | |
| 302 | + </motion.div> | |
| 303 | + ) : phase === "cashed" ? ( | |
| 304 | + <motion.div key="cashed" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="surface rounded-lg p-4 text-center"> | |
| 305 | + <ShieldCheck className="mx-auto h-10 w-10" style={{ color: palette.glow }} /> | |
| 306 | + <div className="mt-2 text-[12px] font-extrabold uppercase tracking-[0.22em]" style={{ color: palette.glow }}> | |
| 307 | + Exited at floor {session?.stage} | |
| 308 | + </div> | |
| 309 | + <div className="text-2xl font-extrabold tabular text-credit">+{formatSC(session?.win ?? 0)}</div> | |
| 310 | + </motion.div> | |
| 311 | + ) : phase === "completed" ? ( | |
| 312 | + <motion.div key="summit" initial={{ opacity: 0, scale: 0.7 }} animate={{ opacity: 1, scale: 1 }} transition={{ type: "spring", stiffness: 200, damping: 16 }} className="text-center"> | |
| 313 | + <Crown className="mx-auto h-12 w-12" style={{ color: palette.glow, filter: `drop-shadow(0 0 18px ${palette.glow})` }} /> | |
| 314 | + <div className="mt-2 text-[clamp(20px,6vw,30px)] font-extrabold uppercase tracking-tight shimmer-text">Floor 99</div> | |
| 315 | + <div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-2">The tower opens to the sky</div> | |
| 316 | + <div className="mt-2 text-2xl font-extrabold tabular text-credit">+{formatSC(session?.win ?? 0)}</div> | |
| 317 | + </motion.div> | |
| 318 | + ) : null} | |
| 319 | + </AnimatePresence> | |
| 320 | + | |
| 321 | + {/* Checkpoint flourish */} | |
| 322 | + <AnimatePresence> | |
| 323 | + {flourish !== null ? ( | |
| 324 | + <motion.div key={`cp-${flourish}`} className="pointer-events-none absolute inset-x-0 top-0 flex justify-center" initial={{ opacity: 0, y: 14, scale: 0.8 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -10 }} transition={{ type: "spring", stiffness: 260, damping: 18 }}> | |
| 325 | + <div className="flex items-center gap-2 rounded-full px-4 py-1.5 text-[12px] font-extrabold uppercase tracking-[0.2em]" style={{ background: `${palette.primary}22`, border: `1px solid ${palette.primary}88`, color: palette.glow, boxShadow: `0 0 30px -6px ${palette.glow}` }}> | |
| 326 | + <Flag className="h-4 w-4" /> Checkpoint {flourish} | |
| 327 | + </div> | |
| 328 | + </motion.div> | |
| 329 | + ) : null} | |
| 330 | + </AnimatePresence> | |
| 331 | + </div> | |
| 332 | + </div> | |
| 333 | + | |
| 334 | + {/* Controls */} | |
| 335 | + <div className="mt-2 min-h-[72px]"> | |
| 336 | + <AnimatePresence mode="wait"> | |
| 337 | + {phase === "running" || phase === "resolve" ? ( | |
| 338 | + <motion.div key="run" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="flex flex-col gap-2"> | |
| 339 | + <div className="grid grid-cols-2 gap-2"> | |
| 340 | + <button onClick={() => void onCashout()} disabled={locked || !session?.canCashout} className="tap flex h-14 flex-col items-center justify-center rounded-md border text-[13px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ borderColor: `${palette.primary}88`, color: palette.glow, background: `${palette.primary}14` }}> | |
| 341 | + {definition.presentation.secondaryVerb ?? "CASH OUT"} | |
| 342 | + <span className="text-[11px] font-semibold normal-case tracking-normal text-fg-2 tabular">+{formatSC(Math.round(runBet * current))}</span> | |
| 343 | + </button> | |
| 344 | + <button onClick={() => void onClimb()} disabled={locked || offers.length === 0} className="tap flex h-14 flex-col items-center justify-center rounded-md text-[13px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 70%, #0f6b4a)`, color: "#04140d", boxShadow: `0 10px 40px -12px ${palette.primary}` }}> | |
| 345 | + <span className="inline-flex items-center gap-1"> | |
| 346 | + <ArrowUp className="h-4 w-4" /> {definition.presentation.verb} | |
| 347 | + </span> | |
| 348 | + {(() => { | |
| 349 | + const o = offers.find((x) => x.id === selectedId); | |
| 350 | + return o ? ( | |
| 351 | + <span className="text-[11px] font-semibold normal-case tracking-normal tabular" style={{ color: "#0a3d2a" }}> | |
| 352 | + {Math.round(o.survival * 100)}% safe · {formatMultiplier(o.next)} | |
| 353 | + </span> | |
| 354 | + ) : null; | |
| 355 | + })()} | |
| 356 | + </button> | |
| 357 | + </div> | |
| 358 | + <button role="switch" aria-checked={auto} onClick={onToggleAuto} className="flex h-9 items-center justify-center gap-2 rounded-md text-[12px] font-semibold text-fg-2 focus-ring"> | |
| 359 | + <span className={cn("relative h-5 w-9 rounded-full border transition-colors", auto ? "border-transparent" : "bg-surface-3 border-line-2")} style={auto ? { background: palette.primary } : undefined}> | |
| 360 | + <span className={cn("absolute top-0.5 h-[14px] w-[14px] rounded-full bg-white transition-transform", auto ? "translate-x-[18px]" : "translate-x-0.5")} /> | |
| 361 | + </span> | |
| 362 | + <Zap className="h-3.5 w-3.5" style={{ color: auto ? palette.glow : undefined }} /> | |
| 363 | + Auto-climb to next checkpoint (safe path) | |
| 364 | + </button> | |
| 365 | + </motion.div> | |
| 366 | + ) : ended ? ( | |
| 367 | + <motion.div key="end" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="grid grid-cols-[1fr_auto] gap-2"> | |
| 368 | + <button onClick={() => void onStart()} disabled={locked} className="tap flex h-14 items-center justify-center gap-2 rounded-md text-[14px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ background: phase === "busted" ? `linear-gradient(180deg, #ff8aa0, ${DANGER})` : `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 70%, #0f6b4a)`, color: "#04140d", boxShadow: `0 10px 40px -12px ${phase === "busted" ? DANGER : palette.primary}` }}> | |
| 369 | + <RotateCcw className="h-4 w-4" /> {phase === "busted" ? "Try again" : "Climb again"} | |
| 370 | + <span className="text-[11px] font-semibold normal-case tracking-normal tabular opacity-70">{formatSC(bet)}</span> | |
| 371 | + </button> | |
| 372 | + <Button variant="secondary" size="lg" className="h-14" onClick={onNewRun}> | |
| 373 | + New run | |
| 374 | + </Button> | |
| 375 | + </motion.div> | |
| 376 | + ) : ( | |
| 377 | + <motion.div key="idle" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex h-14 items-center justify-center gap-3 text-[11px] uppercase tracking-wider text-fg-3"> | |
| 378 | + {cfg.checkpoints.map((c) => ( | |
| 379 | + <span key={c} className="inline-flex items-center gap-1"> | |
| 380 | + <Flag className="h-3 w-3" style={{ color: palette.primary }} /> {c} | |
| 381 | + </span> | |
| 382 | + ))} | |
| 383 | + </motion.div> | |
| 384 | + )} | |
| 385 | + </AnimatePresence> | |
| 386 | + </div> | |
| 387 | + </div> | |
| 388 | + | |
| 389 | + <AnimatePresence> | |
| 390 | + {error ? ( | |
| 391 | + <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute inset-x-4 bottom-3 z-[50] mx-auto max-w-sm glass rounded-md p-3 text-center text-sm"> | |
| 392 | + <div className="text-fg-2">{error}</div> | |
| 393 | + <div className="mt-2 flex justify-center gap-2"> | |
| 394 | + <Button size="sm" variant="secondary" onClick={clearError}> | |
| 395 | + Dismiss | |
| 396 | + </Button> | |
| 397 | + <Button size="sm" variant="accent" href="/rewards"> | |
| 398 | + Get rewards | |
| 399 | + </Button> | |
| 400 | + </div> | |
| 401 | + </motion.div> | |
| 402 | + ) : null} | |
| 403 | + </AnimatePresence> | |
| 404 | + </div> | |
| 405 | + ); | |
| 406 | +} | |
| 407 | + | |
| 408 | +/* ------------------------------------------------------------------ parts */ | |
| 409 | + | |
| 410 | +type Palette = ArcadeGameProps["definition"]["presentation"]["palette"]; | |
| 411 | + | |
| 412 | +function Tower({ cfg, floor, busted, checkpointsHit, palette, sec }: { cfg: EscapeConfig; floor: number; busted: boolean; checkpointsHit: number[]; palette: Palette; sec: (ms: number) => number }) { | |
| 413 | + const floors = Array.from({ length: cfg.floors }, (_, i) => cfg.floors - i); // 99 … 1 | |
| 414 | + const index = cfg.floors - floor; | |
| 415 | + return ( | |
| 416 | + <div className="relative h-full min-h-[180px] overflow-hidden rounded-lg" style={{ background: `linear-gradient(180deg, ${palette.surface} 0%, #050807 100%)`, border: `1px solid ${palette.primary}33`, boxShadow: "inset 0 0 40px -10px #000" }} aria-label={`Tower, floor ${floor}`}> | |
| 417 | + {/* rails */} | |
| 418 | + <div className="pointer-events-none absolute inset-y-0 left-2 w-px" style={{ background: `${palette.primary}33` }} /> | |
| 419 | + <div className="pointer-events-none absolute inset-y-0 right-2 w-px" style={{ background: `${palette.primary}33` }} /> | |
| 420 | + <motion.ol className="absolute inset-x-0 top-1/2" initial={false} animate={{ y: -(index * ROW + ROW / 2) }} transition={{ type: "spring", stiffness: 140, damping: 22, duration: sec(500) }}> | |
| 421 | + {floors.map((f) => { | |
| 422 | + const cp = cfg.checkpoints.includes(f); | |
| 423 | + const cur = f === floor; | |
| 424 | + const done = f < floor; | |
| 425 | + const hit = checkpointsHit.includes(f); | |
| 426 | + return ( | |
| 427 | + <li key={f} className="relative flex items-center justify-center" style={{ height: ROW }}> | |
| 428 | + <span className="absolute inset-x-3 top-1/2 h-px" style={{ background: cur ? (busted ? DANGER : palette.glow) : done ? `${palette.primary}55` : "rgba(255,255,255,0.07)" }} /> | |
| 429 | + <span className={cn("relative z-[1] rounded px-1.5 text-[11px] font-bold tabular leading-none", cur ? "text-black" : done ? "" : "text-fg-4")} style={cur ? { background: busted ? DANGER : palette.glow, boxShadow: `0 0 16px ${busted ? DANGER : palette.glow}` } : done ? { color: palette.primary, background: "#050807" } : { background: "#050807" }}> | |
| 430 | + {f} | |
| 431 | + </span> | |
| 432 | + {cp ? <Flag className="absolute right-3 top-1/2 z-[1] h-3 w-3 -translate-y-1/2" style={{ color: hit ? palette.glow : cur ? palette.glow : palette.secondary, filter: hit ? `drop-shadow(0 0 6px ${palette.glow})` : undefined }} /> : null} | |
| 433 | + </li> | |
| 434 | + ); | |
| 435 | + })} | |
| 436 | + </motion.ol> | |
| 437 | + <div className="pointer-events-none absolute inset-x-0 top-0 h-10" style={{ background: `linear-gradient(180deg, ${palette.surface}, transparent)` }} /> | |
| 438 | + <div className="pointer-events-none absolute inset-x-0 bottom-0 h-10" style={{ background: "linear-gradient(0deg, #050807, transparent)" }} /> | |
| 439 | + {/* current-floor marker */} | |
| 440 | + <span className="pointer-events-none absolute left-0 top-1/2 h-[2px] w-2 -translate-y-1/2" style={{ background: busted ? DANGER : palette.glow }} /> | |
| 441 | + <span className="pointer-events-none absolute right-0 top-1/2 h-[2px] w-2 -translate-y-1/2" style={{ background: busted ? DANGER : palette.glow }} /> | |
| 442 | + </div> | |
| 443 | + ); | |
| 444 | +} | |
| 445 | + | |
| 446 | +function OfferCard({ offer, bet, selected, fork, onSelect, palette }: { offer: LadderOffer; bet: number; selected: boolean; fork: boolean; onSelect: () => void; palette: Palette }) { | |
| 447 | + const Icon = ICONS[offer.kind] ?? DoorOpen; | |
| 448 | + const risky = offer.kind === "fork-risky"; | |
| 449 | + const accent = risky ? palette.secondary : palette.primary; | |
| 450 | + const [title, sub] = offer.label.includes(" — ") ? offer.label.split(" — ") : [offer.label, ""]; | |
| 451 | + return ( | |
| 452 | + <button | |
| 453 | + type="button" | |
| 454 | + onClick={onSelect} | |
| 455 | + aria-pressed={selected} | |
| 456 | + className={cn("tap relative flex w-full flex-col rounded-lg border p-3 text-left transition-all active:scale-[0.99] focus-ring", fork ? "min-h-[132px]" : "min-h-[96px]")} | |
| 457 | + style={{ borderColor: selected ? accent : "rgba(255,255,255,0.1)", background: selected ? `linear-gradient(180deg, ${accent}22, ${accent}0a)` : "linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02))", boxShadow: selected ? `0 0 28px -8px ${accent}` : undefined }} | |
| 458 | + > | |
| 459 | + <div className="flex w-full items-start justify-between gap-2"> | |
| 460 | + <div className="flex min-w-0 items-center gap-2"> | |
| 461 | + <span className="grid h-9 w-9 shrink-0 place-items-center rounded-md" style={{ background: `${accent}22`, color: accent }}> | |
| 462 | + <Icon className="h-5 w-5" /> | |
| 463 | + </span> | |
| 464 | + <div className="min-w-0"> | |
| 465 | + <div className="truncate text-[13px] font-bold text-fg">{sub || title}</div> | |
| 466 | + {sub ? <div className="truncate text-[11px] text-fg-3">{title}</div> : null} | |
| 467 | + </div> | |
| 468 | + </div> | |
| 469 | + <div className="shrink-0 text-right"> | |
| 470 | + <div className="text-lg font-extrabold leading-none tabular" style={{ color: accent }}> | |
| 471 | + {formatMultiplier(offer.next)} | |
| 472 | + </div> | |
| 473 | + <div className="text-[10px] text-fg-3 tabular">{formatSC(Math.round(bet * offer.next))}</div> | |
| 474 | + </div> | |
| 475 | + </div> | |
| 476 | + <div className="mt-auto w-full pt-2"> | |
| 477 | + <div className="flex items-center justify-between text-[11px]"> | |
| 478 | + <span className="text-fg-3">{offer.description}</span> | |
| 479 | + <span className="font-bold tabular" style={{ color: accent }}> | |
| 480 | + {Math.round(offer.survival * 100)}% | |
| 481 | + </span> | |
| 482 | + </div> | |
| 483 | + <div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-white/10"> | |
| 484 | + <div className="h-full rounded-full" style={{ width: `${Math.round(offer.survival * 100)}%`, background: accent }} /> | |
| 485 | + </div> | |
| 486 | + </div> | |
| 487 | + </button> | |
| 488 | + ); | |
| 489 | +} | |
| 490 | + | |
| 491 | +function ResolveCard({ res, palette, reduceMotion }: { res: Resolution; palette: Palette; reduceMotion: boolean }) { | |
| 492 | + const { ev, step } = res; | |
| 493 | + const bust = ev.outcome === "bust"; | |
| 494 | + const Icon = bust && step === "result" ? Skull : (ICONS[ev.kind] ?? DoorOpen); | |
| 495 | + const accent = bust && step === "result" ? DANGER : ev.kind === "fork-risky" ? palette.secondary : palette.glow; | |
| 496 | + const motionFor = (): { animate: Record<string, number | number[]>; transition: Record<string, number | string> } => { | |
| 497 | + if (reduceMotion || step === "result") return { animate: { scale: bust ? 1.15 : 1, rotate: 0 }, transition: { duration: 0.2 } }; | |
| 498 | + switch (ev.kind) { | |
| 499 | + case "chest": | |
| 500 | + return { animate: { scale: [1, 1.35, 1.1], rotate: [0, -12, 8, 0], y: [0, -8, 0] }, transition: { duration: 0.42 } }; | |
| 501 | + case "enemy": | |
| 502 | + return { animate: { rotate: [-50, 40, -10, 0], x: [-14, 14, 0], scale: [1, 1.2, 1] }, transition: { duration: 0.42 } }; | |
| 503 | + case "trap": | |
| 504 | + return { animate: { x: [0, -6, 6, -5, 5, 0], scale: [1, 1.15, 1] }, transition: { duration: 0.42 } }; | |
| 505 | + case "multiplier": | |
| 506 | + return { animate: { scale: [1, 1.5, 1.2], rotate: [0, 180, 360] }, transition: { duration: 0.42 } }; | |
| 507 | + case "portal": | |
| 508 | + return { animate: { rotate: [0, 540], scale: [1, 1.6, 0.6, 1.1] }, transition: { duration: 0.5 } }; | |
| 509 | + case "fork-safe": | |
| 510 | + case "fork-risky": | |
| 511 | + return { animate: { x: ev.kind === "fork-safe" ? [30, -6, 0] : [-30, 6, 0], scale: [0.9, 1.15, 1] }, transition: { duration: 0.4 } }; | |
| 512 | + default: | |
| 513 | + return { animate: { y: [0, -14, 0], scale: [1, 1.1, 1] }, transition: { duration: 0.4 } }; | |
| 514 | + } | |
| 515 | + }; | |
| 516 | + const m = motionFor(); | |
| 517 | + const headline = step === "resolve" ? ev.detail && bust ? ev.detail : ev.label : bust ? ev.label : SUCCESS[ev.kind] ?? "Cleared"; | |
| 518 | + return ( | |
| 519 | + <motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -12 }} transition={{ duration: 0.18 }} className="surface relative overflow-hidden rounded-lg p-4 text-center" style={{ borderColor: `${accent}66` }}> | |
| 520 | + <motion.div key={step} className="mx-auto grid h-16 w-16 place-items-center rounded-full" style={{ background: `${accent}22`, color: accent, boxShadow: `0 0 34px -8px ${accent}` }} animate={m.animate} transition={m.transition}> | |
| 521 | + <Icon className="h-8 w-8" /> | |
| 522 | + </motion.div> | |
| 523 | + {!bust && step === "result" && !reduceMotion ? ( | |
| 524 | + <> | |
| 525 | + {[0, 1, 2, 3, 4, 5].map((i) => ( | |
| 526 | + <motion.span key={i} className="pointer-events-none absolute left-1/2 top-[38px] h-1.5 w-1.5 rounded-full" style={{ background: accent }} initial={{ x: 0, y: 0, opacity: 1 }} animate={{ x: Math.cos((i / 6) * Math.PI * 2) * 70, y: Math.sin((i / 6) * Math.PI * 2) * 70, opacity: 0 }} transition={{ duration: 0.5, ease: "easeOut" }} /> | |
| 527 | + ))} | |
| 528 | + </> | |
| 529 | + ) : null} | |
| 530 | + <motion.div key={`${step}-h`} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="mt-3 text-[15px] font-extrabold uppercase tracking-tight" style={{ color: bust && step === "result" ? DANGER : "var(--color-fg)" }}> | |
| 531 | + {headline} | |
| 532 | + </motion.div> | |
| 533 | + <div className="mt-0.5 h-4 text-[12px] text-fg-3"> | |
| 534 | + {step === "resolve" ? "Resolving…" : bust ? ev.detail ?? "" : ev.data?.checkpoint === true ? "Checkpoint reached" : `Floor ${ev.stage} · ${formatMultiplier(ev.multiplierAfter)}`} | |
| 535 | + </div> | |
| 536 | + </motion.div> | |
| 537 | + ); | |
| 538 | +} | |
added
apps/web/src/components/arcade/gridbreak.tsx
+796 −0
@@ -0,0 +1,796 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * GRID//BREAK — browser side. The server resolves every chain step; this file | |
| 5 | + * replays `outcome.steps` (grid before the step, destroyed blocks, specials, | |
| 6 | + * chain index, step win) on a 2D canvas: wave beam → detonation → gravity/refill. | |
| 7 | + */ | |
| 8 | +import { useCallback, useEffect, useRef, useState } from "react"; | |
| 9 | +import { AnimatePresence, motion } from "framer-motion"; | |
| 10 | +import { ChevronLeft, ChevronRight } from "lucide-react"; | |
| 11 | +import { formatMultiplier, formatSC } from "@spinza/shared"; | |
| 12 | +import type { ArcadeOutcome, GridCell, GridStep, GridbreakConfig } from "@spinza/game-core/client"; | |
| 13 | +import { cn } from "@/lib/utils"; | |
| 14 | +import { useInstantPlay, type ArcadeGameProps } from "./contract"; | |
| 15 | + | |
| 16 | +interface GridOutcome extends ArcadeOutcome { | |
| 17 | + steps: GridStep[]; | |
| 18 | + summary: { column: number; chains: number; blocksDestroyed: number }; | |
| 19 | +} | |
| 20 | + | |
| 21 | +/* --------------------------------------------------------------- helpers */ | |
| 22 | + | |
| 23 | +const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)); | |
| 24 | +const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); | |
| 25 | +const easeInCubic = (t: number) => t * t * t; | |
| 26 | +const easeOutBack = (t: number) => 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2); | |
| 27 | +const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v)); | |
| 28 | +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; | |
| 29 | + | |
| 30 | +function tween(ms: number, fn: (t: number) => void, alive: () => boolean): Promise<void> { | |
| 31 | + return new Promise((resolve) => { | |
| 32 | + const start = performance.now(); | |
| 33 | + const frame = (now: number) => { | |
| 34 | + if (!alive()) return resolve(); | |
| 35 | + const t = Math.min(1, (now - start) / Math.max(1, ms)); | |
| 36 | + fn(t); | |
| 37 | + if (t < 1) requestAnimationFrame(frame); | |
| 38 | + else resolve(); | |
| 39 | + }; | |
| 40 | + requestAnimationFrame(frame); | |
| 41 | + }); | |
| 42 | +} | |
| 43 | + | |
| 44 | +function rgba(hex: string, a: number): string { | |
| 45 | + const h = hex.replace("#", ""); | |
| 46 | + const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16); | |
| 47 | + return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; | |
| 48 | +} | |
| 49 | + | |
| 50 | +function mixHex(a: string, b: string, t: number): string { | |
| 51 | + const pa = parseInt(a.replace("#", ""), 16); | |
| 52 | + const pb = parseInt(b.replace("#", ""), 16); | |
| 53 | + const ch = (s: number) => Math.round(lerp((pa >> s) & 255, (pb >> s) & 255, t)); | |
| 54 | + return `rgb(${ch(16)},${ch(8)},${ch(0)})`; | |
| 55 | +} | |
| 56 | + | |
| 57 | +/* ----------------------------------------------------------------- scene */ | |
| 58 | + | |
| 59 | +interface Block { | |
| 60 | + c: number; | |
| 61 | + s?: GridCell["s"]; | |
| 62 | + x: number; // column | |
| 63 | + y: number; // display row (float during falls) | |
| 64 | + alpha: number; | |
| 65 | + scale: number; | |
| 66 | + flash: number; // 0..1 highlight before detonation | |
| 67 | +} | |
| 68 | + | |
| 69 | +interface Particle { | |
| 70 | + x: number; | |
| 71 | + y: number; | |
| 72 | + vx: number; | |
| 73 | + vy: number; | |
| 74 | + life: number; | |
| 75 | + max: number; | |
| 76 | + color: string; | |
| 77 | + size: number; | |
| 78 | + rot: number; | |
| 79 | + vr: number; | |
| 80 | +} | |
| 81 | + | |
| 82 | +interface Pop { | |
| 83 | + x: number; | |
| 84 | + y: number; | |
| 85 | + text: string; | |
| 86 | + color: string; | |
| 87 | + at: number; | |
| 88 | +} | |
| 89 | + | |
| 90 | +interface Scene { | |
| 91 | + t: number; | |
| 92 | + cols: Block[][]; | |
| 93 | + column: number; | |
| 94 | + phase: "idle" | "firing"; | |
| 95 | + placeholder: boolean; | |
| 96 | + beam: { p: number; alpha: number } | null; | |
| 97 | + shocks: { x: number; y: number; at: number; kind: "bomb" | "line" | "x2" | "miss" }[]; | |
| 98 | + sweeps: { row: number; at: number }[]; | |
| 99 | + particles: Particle[]; | |
| 100 | + pops: Pop[]; | |
| 101 | + reduceMotion: boolean; | |
| 102 | +} | |
| 103 | + | |
| 104 | +interface Layout { | |
| 105 | + w: number; | |
| 106 | + h: number; | |
| 107 | + ox: number; | |
| 108 | + oy: number; | |
| 109 | + cell: number; | |
| 110 | + headH: number; | |
| 111 | +} | |
| 112 | + | |
| 113 | +function layoutFor(w: number, h: number, size: number): Layout { | |
| 114 | + const headH = 26; | |
| 115 | + const side = Math.max(120, Math.min(w - 16, h - headH - 30, 560)); | |
| 116 | + const cell = side / size; | |
| 117 | + const ox = (w - side) / 2; | |
| 118 | + const oy = headH + (h - headH - side) / 2; | |
| 119 | + return { w, h, ox, oy, cell, headH }; | |
| 120 | +} | |
| 121 | + | |
| 122 | +function placeholderGrid(size: number, colors: number): GridCell[][] { | |
| 123 | + const g: GridCell[][] = []; | |
| 124 | + for (let x = 0; x < size; x++) { | |
| 125 | + const col: GridCell[] = []; | |
| 126 | + for (let y = 0; y < size; y++) col.push({ c: (x * 2 + y * 3 + ((x * y) % 5)) % colors }); | |
| 127 | + g.push(col); | |
| 128 | + } | |
| 129 | + return g; | |
| 130 | +} | |
| 131 | + | |
| 132 | +function blocksFrom(grid: GridCell[][]): Block[][] { | |
| 133 | + return grid.map((col, x) => col.map((cell, y) => ({ c: cell.c, s: cell.s, x, y, alpha: 1, scale: 1, flash: 0 }))); | |
| 134 | +} | |
| 135 | + | |
| 136 | +/* --------------------------------------------------------------- drawing */ | |
| 137 | + | |
| 138 | +interface Palette { | |
| 139 | + primary: string; | |
| 140 | + secondary: string; | |
| 141 | + glow: string; | |
| 142 | + bg: string; | |
| 143 | + surface: string; | |
| 144 | +} | |
| 145 | + | |
| 146 | +function blockColors(palette: Palette): string[] { | |
| 147 | + return [palette.secondary, palette.primary, "#fbbf24", "#f472b6", "#a78bfa", "#f8fafc"]; | |
| 148 | +} | |
| 149 | + | |
| 150 | +function drawBlock(ctx: CanvasRenderingContext2D, b: Block, L: Layout, color: string, dim: boolean) { | |
| 151 | + const size = L.cell; | |
| 152 | + const cx = L.ox + (b.x + 0.5) * size; | |
| 153 | + const cy = L.oy + (b.y + 0.5) * size; | |
| 154 | + const s = size * 0.86 * b.scale; | |
| 155 | + ctx.save(); | |
| 156 | + ctx.globalAlpha = b.alpha * (dim ? 0.5 : 1); | |
| 157 | + ctx.translate(cx, cy); | |
| 158 | + const r = Math.min(7, size * 0.18); | |
| 159 | + const grad = ctx.createLinearGradient(0, -s / 2, 0, s / 2); | |
| 160 | + grad.addColorStop(0, rgba(color, 0.95)); | |
| 161 | + grad.addColorStop(1, mixHex(color, "#000000", 0.42)); | |
| 162 | + ctx.fillStyle = grad; | |
| 163 | + ctx.shadowColor = color; | |
| 164 | + ctx.shadowBlur = b.flash > 0 ? 8 + 26 * b.flash : dim ? 0 : size * 0.16; | |
| 165 | + ctx.beginPath(); | |
| 166 | + ctx.roundRect(-s / 2, -s / 2, s, s, r); | |
| 167 | + ctx.fill(); | |
| 168 | + ctx.shadowBlur = 0; | |
| 169 | + // gloss | |
| 170 | + const gloss = ctx.createLinearGradient(0, -s / 2, 0, 0); | |
| 171 | + gloss.addColorStop(0, "rgba(255,255,255,0.35)"); | |
| 172 | + gloss.addColorStop(1, "rgba(255,255,255,0)"); | |
| 173 | + ctx.fillStyle = gloss; | |
| 174 | + ctx.beginPath(); | |
| 175 | + ctx.roundRect(-s / 2 + 2, -s / 2 + 2, s - 4, s / 2, r); | |
| 176 | + ctx.fill(); | |
| 177 | + ctx.strokeStyle = b.flash > 0 ? `rgba(255,255,255,${0.4 + 0.6 * b.flash})` : "rgba(255,255,255,0.18)"; | |
| 178 | + ctx.lineWidth = b.flash > 0 ? 2 : 1; | |
| 179 | + ctx.beginPath(); | |
| 180 | + ctx.roundRect(-s / 2, -s / 2, s, s, r); | |
| 181 | + ctx.stroke(); | |
| 182 | + // Special icons | |
| 183 | + if (b.s === "bomb") { | |
| 184 | + ctx.fillStyle = "#0b0f14"; | |
| 185 | + ctx.beginPath(); | |
| 186 | + ctx.arc(0, s * 0.06, s * 0.24, 0, Math.PI * 2); | |
| 187 | + ctx.fill(); | |
| 188 | + ctx.strokeStyle = "#f8fafc"; | |
| 189 | + ctx.lineWidth = 1.5; | |
| 190 | + ctx.beginPath(); | |
| 191 | + ctx.moveTo(s * 0.08, -s * 0.14); | |
| 192 | + ctx.quadraticCurveTo(s * 0.2, -s * 0.34, s * 0.3, -s * 0.28); | |
| 193 | + ctx.stroke(); | |
| 194 | + ctx.fillStyle = "#fde68a"; | |
| 195 | + ctx.beginPath(); | |
| 196 | + ctx.arc(s * 0.31, -s * 0.29, s * 0.06, 0, Math.PI * 2); | |
| 197 | + ctx.fill(); | |
| 198 | + } else if (b.s === "line") { | |
| 199 | + ctx.strokeStyle = "#0b0f14"; | |
| 200 | + ctx.lineWidth = Math.max(2, s * 0.09); | |
| 201 | + ctx.lineCap = "round"; | |
| 202 | + ctx.beginPath(); | |
| 203 | + ctx.moveTo(-s * 0.3, 0); | |
| 204 | + ctx.lineTo(s * 0.3, 0); | |
| 205 | + ctx.moveTo(-s * 0.3, 0); | |
| 206 | + ctx.lineTo(-s * 0.16, -s * 0.13); | |
| 207 | + ctx.moveTo(-s * 0.3, 0); | |
| 208 | + ctx.lineTo(-s * 0.16, s * 0.13); | |
| 209 | + ctx.moveTo(s * 0.3, 0); | |
| 210 | + ctx.lineTo(s * 0.16, -s * 0.13); | |
| 211 | + ctx.moveTo(s * 0.3, 0); | |
| 212 | + ctx.lineTo(s * 0.16, s * 0.13); | |
| 213 | + ctx.stroke(); | |
| 214 | + } else if (b.s === "x2") { | |
| 215 | + ctx.fillStyle = "#0b0f14"; | |
| 216 | + ctx.font = `900 ${Math.max(10, s * 0.46)}px Geist, "Geist Fallback", system-ui, sans-serif`; | |
| 217 | + ctx.textAlign = "center"; | |
| 218 | + ctx.textBaseline = "middle"; | |
| 219 | + ctx.fillText("×2", 0, 1); | |
| 220 | + } | |
| 221 | + ctx.restore(); | |
| 222 | +} | |
| 223 | + | |
| 224 | +function drawScene(ctx: CanvasRenderingContext2D, L: Layout, s: Scene, cfg: GridbreakConfig, palette: Palette) { | |
| 225 | + const { w, h, ox, oy, cell } = L; | |
| 226 | + const side = cell * cfg.size; | |
| 227 | + ctx.clearRect(0, 0, w, h); | |
| 228 | + const colors = blockColors(palette); | |
| 229 | + const font = (px: number, weight = 700) => `${weight} ${px}px Geist, "Geist Fallback", system-ui, -apple-system, sans-serif`; | |
| 230 | + | |
| 231 | + // Board frame | |
| 232 | + ctx.save(); | |
| 233 | + ctx.fillStyle = "rgba(255,255,255,0.03)"; | |
| 234 | + ctx.strokeStyle = rgba(palette.primary, 0.22); | |
| 235 | + ctx.lineWidth = 1; | |
| 236 | + ctx.beginPath(); | |
| 237 | + ctx.roundRect(ox - 6, oy - 6, side + 12, side + 12, 14); | |
| 238 | + ctx.fill(); | |
| 239 | + ctx.stroke(); | |
| 240 | + // grid lines | |
| 241 | + ctx.strokeStyle = "rgba(255,255,255,0.05)"; | |
| 242 | + for (let i = 1; i < cfg.size; i++) { | |
| 243 | + ctx.beginPath(); | |
| 244 | + ctx.moveTo(ox + i * cell, oy); | |
| 245 | + ctx.lineTo(ox + i * cell, oy + side); | |
| 246 | + ctx.moveTo(ox, oy + i * cell); | |
| 247 | + ctx.lineTo(ox + side, oy + i * cell); | |
| 248 | + ctx.stroke(); | |
| 249 | + } | |
| 250 | + ctx.restore(); | |
| 251 | + | |
| 252 | + // Selected column glow + headers | |
| 253 | + for (let x = 0; x < cfg.size; x++) { | |
| 254 | + const cx = ox + (x + 0.5) * cell; | |
| 255 | + const sel = x === s.column; | |
| 256 | + if (sel) { | |
| 257 | + const g = ctx.createLinearGradient(0, oy, 0, oy + side); | |
| 258 | + g.addColorStop(0, rgba(palette.primary, s.phase === "idle" ? 0.16 : 0.08)); | |
| 259 | + g.addColorStop(1, rgba(palette.primary, 0)); | |
| 260 | + ctx.fillStyle = g; | |
| 261 | + ctx.fillRect(ox + x * cell + 1, oy, cell - 2, side); | |
| 262 | + } | |
| 263 | + // chevron header | |
| 264 | + ctx.save(); | |
| 265 | + ctx.translate(cx, oy - 13 + (sel ? Math.sin(s.t * 3) * 1.5 : 0)); | |
| 266 | + ctx.strokeStyle = sel ? palette.glow : "rgba(255,255,255,0.28)"; | |
| 267 | + ctx.lineWidth = sel ? 2.5 : 1.5; | |
| 268 | + ctx.lineCap = "round"; | |
| 269 | + ctx.beginPath(); | |
| 270 | + ctx.moveTo(-5, -3); | |
| 271 | + ctx.lineTo(0, 3); | |
| 272 | + ctx.lineTo(5, -3); | |
| 273 | + ctx.stroke(); | |
| 274 | + if (sel) { | |
| 275 | + ctx.shadowColor = palette.glow; | |
| 276 | + ctx.shadowBlur = 12; | |
| 277 | + ctx.stroke(); | |
| 278 | + } | |
| 279 | + ctx.restore(); | |
| 280 | + } | |
| 281 | + | |
| 282 | + // Clip to board for falling blocks | |
| 283 | + ctx.save(); | |
| 284 | + ctx.beginPath(); | |
| 285 | + ctx.rect(ox - 2, oy - 2, side + 4, side + 4); | |
| 286 | + ctx.clip(); | |
| 287 | + for (const col of s.cols) for (const b of col) if (b.alpha > 0) drawBlock(ctx, b, L, colors[b.c % colors.length], s.placeholder); | |
| 288 | + ctx.restore(); | |
| 289 | + | |
| 290 | + // Beam | |
| 291 | + if (s.beam && s.beam.alpha > 0) { | |
| 292 | + const x = ox + (s.column + 0.5) * cell; | |
| 293 | + const yEnd = oy + side * s.beam.p; | |
| 294 | + ctx.save(); | |
| 295 | + ctx.globalAlpha = s.beam.alpha; | |
| 296 | + ctx.strokeStyle = palette.glow; | |
| 297 | + ctx.lineWidth = Math.max(3, cell * 0.14); | |
| 298 | + ctx.lineCap = "round"; | |
| 299 | + ctx.shadowColor = palette.primary; | |
| 300 | + ctx.shadowBlur = 24; | |
| 301 | + ctx.beginPath(); | |
| 302 | + ctx.moveTo(x, oy - 8); | |
| 303 | + ctx.lineTo(x, yEnd); | |
| 304 | + ctx.stroke(); | |
| 305 | + ctx.strokeStyle = "#ffffff"; | |
| 306 | + ctx.lineWidth = 1.5; | |
| 307 | + ctx.stroke(); | |
| 308 | + ctx.fillStyle = "#ffffff"; | |
| 309 | + ctx.beginPath(); | |
| 310 | + ctx.arc(x, yEnd, cell * 0.18, 0, Math.PI * 2); | |
| 311 | + ctx.fill(); | |
| 312 | + ctx.restore(); | |
| 313 | + } | |
| 314 | + | |
| 315 | + // Shocks (bomb rings, x2 rings) | |
| 316 | + for (const sh of s.shocks) { | |
| 317 | + const age = s.t - sh.at; | |
| 318 | + if (age > 0.7) continue; | |
| 319 | + const k = age / 0.7; | |
| 320 | + const cx = ox + (sh.x + 0.5) * cell; | |
| 321 | + const cy = oy + (sh.y + 0.5) * cell; | |
| 322 | + ctx.save(); | |
| 323 | + ctx.globalAlpha = 1 - k; | |
| 324 | + if (sh.kind === "bomb") { | |
| 325 | + ctx.strokeStyle = "#fde68a"; | |
| 326 | + ctx.lineWidth = 4 * (1 - k) + 1; | |
| 327 | + ctx.beginPath(); | |
| 328 | + ctx.roundRect(cx - cell * 1.5 * easeOutCubic(k), cy - cell * 1.5 * easeOutCubic(k), cell * 3 * easeOutCubic(k), cell * 3 * easeOutCubic(k), 8); | |
| 329 | + ctx.stroke(); | |
| 330 | + ctx.fillStyle = rgba("#fde68a", 0.25 * (1 - k)); | |
| 331 | + ctx.fill(); | |
| 332 | + } else if (sh.kind === "x2") { | |
| 333 | + ctx.strokeStyle = palette.glow; | |
| 334 | + ctx.lineWidth = 3; | |
| 335 | + ctx.beginPath(); | |
| 336 | + ctx.arc(cx, cy, cell * (0.3 + 1.6 * easeOutCubic(k)), 0, Math.PI * 2); | |
| 337 | + ctx.stroke(); | |
| 338 | + } else if (sh.kind === "miss") { | |
| 339 | + ctx.strokeStyle = "rgba(255,255,255,0.5)"; | |
| 340 | + ctx.lineWidth = 2; | |
| 341 | + ctx.beginPath(); | |
| 342 | + ctx.arc(cx, cy, cell * (0.2 + 0.6 * easeOutCubic(k)), 0, Math.PI * 2); | |
| 343 | + ctx.stroke(); | |
| 344 | + } | |
| 345 | + ctx.restore(); | |
| 346 | + } | |
| 347 | + // Line sweeps | |
| 348 | + for (const sw of s.sweeps) { | |
| 349 | + const age = s.t - sw.at; | |
| 350 | + if (age > 0.6) continue; | |
| 351 | + const k = easeOutCubic(age / 0.6); | |
| 352 | + const y = oy + (sw.row + 0.5) * cell; | |
| 353 | + ctx.save(); | |
| 354 | + ctx.globalAlpha = 1 - age / 0.6; | |
| 355 | + const g = ctx.createLinearGradient(ox, 0, ox + side * k, 0); | |
| 356 | + g.addColorStop(0, rgba(palette.secondary, 0)); | |
| 357 | + g.addColorStop(0.8, rgba(palette.secondary, 0.6)); | |
| 358 | + g.addColorStop(1, "#ffffff"); | |
| 359 | + ctx.fillStyle = g; | |
| 360 | + ctx.fillRect(ox, y - cell * 0.42, side * k, cell * 0.84); | |
| 361 | + ctx.restore(); | |
| 362 | + } | |
| 363 | + | |
| 364 | + // Particles | |
| 365 | + for (const p of s.particles) { | |
| 366 | + const k = p.life / p.max; | |
| 367 | + ctx.save(); | |
| 368 | + ctx.globalAlpha = k; | |
| 369 | + ctx.translate(p.x, p.y); | |
| 370 | + ctx.rotate(p.rot); | |
| 371 | + ctx.fillStyle = p.color; | |
| 372 | + ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size); | |
| 373 | + ctx.restore(); | |
| 374 | + } | |
| 375 | + | |
| 376 | + // Pops | |
| 377 | + for (const pop of s.pops) { | |
| 378 | + const age = s.t - pop.at; | |
| 379 | + if (age > 1.1) continue; | |
| 380 | + const k = age < 0.2 ? easeOutBack(age / 0.2) : 1; | |
| 381 | + const fade = age > 0.7 ? 1 - (age - 0.7) / 0.4 : 1; | |
| 382 | + ctx.save(); | |
| 383 | + ctx.globalAlpha = Math.max(0, fade); | |
| 384 | + ctx.translate(pop.x, pop.y - age * 26); | |
| 385 | + ctx.scale(k, k); | |
| 386 | + ctx.font = font(Math.max(12, cell * 0.5), 900); | |
| 387 | + ctx.textAlign = "center"; | |
| 388 | + ctx.textBaseline = "middle"; | |
| 389 | + ctx.shadowColor = pop.color; | |
| 390 | + ctx.shadowBlur = 14; | |
| 391 | + ctx.fillStyle = pop.color; | |
| 392 | + ctx.fillText(pop.text, 0, 0); | |
| 393 | + ctx.restore(); | |
| 394 | + } | |
| 395 | + | |
| 396 | + // Placeholder watermark | |
| 397 | + if (s.placeholder) { | |
| 398 | + const label = "PREVIEW GRID · FIRE TO REVEAL THE REAL ONE"; | |
| 399 | + ctx.save(); | |
| 400 | + ctx.font = font(11, 700); | |
| 401 | + ctx.textAlign = "center"; | |
| 402 | + ctx.textBaseline = "middle"; | |
| 403 | + const tw = Math.min(side - 16, ctx.measureText(label).width + 28); | |
| 404 | + const cx = ox + side / 2; | |
| 405 | + const cy = oy + side / 2; | |
| 406 | + ctx.fillStyle = "rgba(5,8,12,0.78)"; | |
| 407 | + ctx.strokeStyle = rgba(palette.primary, 0.35); | |
| 408 | + ctx.lineWidth = 1; | |
| 409 | + ctx.beginPath(); | |
| 410 | + ctx.roundRect(cx - tw / 2, cy - 15, tw, 30, 15); | |
| 411 | + ctx.fill(); | |
| 412 | + ctx.stroke(); | |
| 413 | + ctx.fillStyle = "rgba(255,255,255,0.8)"; | |
| 414 | + ctx.fillText(label, cx, cy + 0.5, tw - 20); | |
| 415 | + ctx.restore(); | |
| 416 | + } | |
| 417 | +} | |
| 418 | + | |
| 419 | +/* ------------------------------------------------------------- component */ | |
| 420 | + | |
| 421 | +export function GridbreakGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) { | |
| 422 | + const cfg = definition.config as unknown as GridbreakConfig; | |
| 423 | + const palette = definition.presentation.palette; | |
| 424 | + const { play, error, clearError } = useInstantPlay<GridOutcome>(definition.slug); | |
| 425 | + const [column, setColumn] = useState(Math.floor(cfg.size / 2)); | |
| 426 | + const [phase, setPhase] = useState<"idle" | "firing">("idle"); | |
| 427 | + const [chain, setChain] = useState<number | null>(null); | |
| 428 | + const [running, setRunning] = useState(0); | |
| 429 | + const [live, setLive] = useState<string | null>(null); | |
| 430 | + const [result, setResult] = useState<{ win: number; multiplier: number; chains: number; blocks: number } | null>(null); | |
| 431 | + const canvasRef = useRef<HTMLCanvasElement>(null); | |
| 432 | + const sceneRef = useRef<Scene | null>(null); | |
| 433 | + const aliveRef = useRef(true); | |
| 434 | + const phaseRef = useRef<"idle" | "firing">("idle"); | |
| 435 | + | |
| 436 | + const getScene = useCallback((): Scene => { | |
| 437 | + if (!sceneRef.current) { | |
| 438 | + sceneRef.current = { | |
| 439 | + t: 0, | |
| 440 | + cols: blocksFrom(placeholderGrid(cfg.size, cfg.colors)), | |
| 441 | + column: Math.floor(cfg.size / 2), | |
| 442 | + phase: "idle", | |
| 443 | + placeholder: true, | |
| 444 | + beam: null, | |
| 445 | + shocks: [], | |
| 446 | + sweeps: [], | |
| 447 | + particles: [], | |
| 448 | + pops: [], | |
| 449 | + reduceMotion: false, | |
| 450 | + }; | |
| 451 | + } | |
| 452 | + return sceneRef.current; | |
| 453 | + }, [cfg]); | |
| 454 | + | |
| 455 | + useEffect(() => { | |
| 456 | + aliveRef.current = true; | |
| 457 | + const canvas = canvasRef.current; | |
| 458 | + if (!canvas) return; | |
| 459 | + const ctx = canvas.getContext("2d"); | |
| 460 | + if (!ctx) return; | |
| 461 | + const s = getScene(); | |
| 462 | + let raf = 0; | |
| 463 | + let last = performance.now(); | |
| 464 | + const loop = (now: number) => { | |
| 465 | + const dt = Math.min(0.05, (now - last) / 1000); | |
| 466 | + last = now; | |
| 467 | + s.t += dt; | |
| 468 | + const dpr = Math.min(2, window.devicePixelRatio || 1); | |
| 469 | + const rect = canvas.getBoundingClientRect(); | |
| 470 | + const W = Math.max(1, Math.round(rect.width * dpr)); | |
| 471 | + const H = Math.max(1, Math.round(rect.height * dpr)); | |
| 472 | + if (canvas.width !== W || canvas.height !== H) { | |
| 473 | + canvas.width = W; | |
| 474 | + canvas.height = H; | |
| 475 | + } | |
| 476 | + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| 477 | + const L = layoutFor(rect.width, rect.height, cfg.size); | |
| 478 | + for (let i = s.particles.length - 1; i >= 0; i--) { | |
| 479 | + const p = s.particles[i]; | |
| 480 | + p.life -= dt; | |
| 481 | + p.x += p.vx * dt; | |
| 482 | + p.y += p.vy * dt; | |
| 483 | + p.vy += 900 * dt; | |
| 484 | + p.rot += p.vr * dt; | |
| 485 | + if (p.life <= 0) s.particles.splice(i, 1); | |
| 486 | + } | |
| 487 | + s.shocks = s.shocks.filter((x) => s.t - x.at < 0.8); | |
| 488 | + s.sweeps = s.sweeps.filter((x) => s.t - x.at < 0.7); | |
| 489 | + s.pops = s.pops.filter((x) => s.t - x.at < 1.2); | |
| 490 | + drawScene(ctx, L, s, cfg, palette); | |
| 491 | + raf = requestAnimationFrame(loop); | |
| 492 | + }; | |
| 493 | + raf = requestAnimationFrame(loop); | |
| 494 | + return () => { | |
| 495 | + aliveRef.current = false; | |
| 496 | + cancelAnimationFrame(raf); | |
| 497 | + }; | |
| 498 | + }, [cfg, palette, getScene]); | |
| 499 | + | |
| 500 | + useEffect(() => { | |
| 501 | + const s = getScene(); | |
| 502 | + s.column = column; | |
| 503 | + s.reduceMotion = reduceMotion; | |
| 504 | + }, [column, reduceMotion, getScene]); | |
| 505 | + | |
| 506 | + const onPointer = useCallback( | |
| 507 | + (e: React.PointerEvent<HTMLCanvasElement>) => { | |
| 508 | + if (phaseRef.current !== "idle") return; | |
| 509 | + if (e.type === "pointermove" && e.buttons === 0) return; | |
| 510 | + const rect = e.currentTarget.getBoundingClientRect(); | |
| 511 | + const L = layoutFor(rect.width, rect.height, cfg.size); | |
| 512 | + const col = clamp(Math.floor((e.clientX - rect.left - L.ox) / L.cell), 0, cfg.size - 1); | |
| 513 | + setColumn((prev) => { | |
| 514 | + if (prev !== col) sound("tick"); | |
| 515 | + return col; | |
| 516 | + }); | |
| 517 | + }, | |
| 518 | + [cfg.size, sound], | |
| 519 | + ); | |
| 520 | + | |
| 521 | + const explode = useCallback((s: Scene, L: Layout, b: Block, color: string) => { | |
| 522 | + if (s.reduceMotion) return; | |
| 523 | + const cx = L.ox + (b.x + 0.5) * L.cell; | |
| 524 | + const cy = L.oy + (b.y + 0.5) * L.cell; | |
| 525 | + const n = 7; | |
| 526 | + for (let i = 0; i < n; i++) { | |
| 527 | + const a = Math.random() * Math.PI * 2; | |
| 528 | + const v = 120 + Math.random() * 220; | |
| 529 | + s.particles.push({ x: cx, y: cy, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 160, life: 0.45 + Math.random() * 0.35, max: 0.8, color, size: 2 + Math.random() * L.cell * 0.18, rot: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 12 }); | |
| 530 | + } | |
| 531 | + }, []); | |
| 532 | + | |
| 533 | + const animate = useCallback( | |
| 534 | + async (outcome: GridOutcome) => { | |
| 535 | + const s = getScene(); | |
| 536 | + const alive = () => aliveRef.current; | |
| 537 | + const rm = s.reduceMotion; | |
| 538 | + const canvas = canvasRef.current; | |
| 539 | + const rect = canvas?.getBoundingClientRect() ?? { width: 390, height: 500 }; | |
| 540 | + const L = () => layoutFor(rect.width, rect.height, cfg.size); | |
| 541 | + const colors = blockColors(palette); | |
| 542 | + const steps = outcome.steps; | |
| 543 | + const size = cfg.size; | |
| 544 | + s.phase = "firing"; | |
| 545 | + s.placeholder = false; | |
| 546 | + s.particles = []; | |
| 547 | + s.shocks = []; | |
| 548 | + s.sweeps = []; | |
| 549 | + s.pops = []; | |
| 550 | + s.column = outcome.summary.column; | |
| 551 | + | |
| 552 | + // 1) The real grid drops in. | |
| 553 | + s.cols = blocksFrom(steps[0].grid); | |
| 554 | + for (const col of s.cols) for (const b of col) b.y = b.y - size - 0.5 - Math.random() * 0.8; | |
| 555 | + await tween(rm ? 80 : 380, (t) => { | |
| 556 | + for (const col of s.cols) | |
| 557 | + for (let y = 0; y < col.length; y++) { | |
| 558 | + const b = col[y]; | |
| 559 | + const start = -size - 0.5 - (b.x % 3) * 0.25; | |
| 560 | + b.y = lerp(start, y, easeOutCubic(clamp(t * 1.15 - b.x * 0.02, 0, 1))); | |
| 561 | + } | |
| 562 | + }, alive); | |
| 563 | + for (const col of s.cols) for (let y = 0; y < col.length; y++) col[y].y = y; | |
| 564 | + sound("tick"); | |
| 565 | + | |
| 566 | + // 2) The wave beam down the chosen column. | |
| 567 | + s.beam = { p: 0, alpha: 1 }; | |
| 568 | + await tween(rm ? 80 : 300, (t) => { | |
| 569 | + if (s.beam) s.beam.p = easeInCubic(t); | |
| 570 | + }, alive); | |
| 571 | + const first = steps[0]; | |
| 572 | + if (first.destroyed.length === 0) { | |
| 573 | + s.shocks.push({ x: s.column, y: size - 1, at: s.t, kind: "miss" }); | |
| 574 | + setLive("No cluster on that column"); | |
| 575 | + } | |
| 576 | + await tween(rm ? 60 : 220, (t) => { | |
| 577 | + if (s.beam) s.beam.alpha = 1 - t; | |
| 578 | + }, alive); | |
| 579 | + s.beam = null; | |
| 580 | + | |
| 581 | + let cum = 0; | |
| 582 | + for (let i = 0; i < steps.length - 1; i++) { | |
| 583 | + if (!alive()) return; | |
| 584 | + const step = steps[i]; | |
| 585 | + const next = steps[i + 1]; | |
| 586 | + const destroyed = new Set(step.destroyed.map(([x, y]) => `${x}:${y}`)); | |
| 587 | + if (destroyed.size === 0) break; | |
| 588 | + setChain(step.chain); | |
| 589 | + const chainMult = cfg.chainLadder[Math.min(step.chain, cfg.chainLadder.length - 1)]; | |
| 590 | + const x2s = step.specials.filter((sp) => sp.type === "x2").length; | |
| 591 | + setLive(step.chain > 0 ? `Chain ${step.chain + 1} · ×${chainMult}${x2s ? ` · ×${2 ** x2s} block` : ""}` : x2s ? `×${2 ** x2s} block` : `Wave hit ${destroyed.size} blocks`); | |
| 592 | + | |
| 593 | + // Highlight + specials | |
| 594 | + const hitBlocks: Block[] = []; | |
| 595 | + for (const col of s.cols) for (const b of col) if (destroyed.has(`${b.x}:${Math.round(b.y)}`)) hitBlocks.push(b); | |
| 596 | + await tween(rm ? 50 : 200, (t) => { | |
| 597 | + for (const b of hitBlocks) { | |
| 598 | + b.flash = Math.sin(t * Math.PI); | |
| 599 | + b.scale = 1 + 0.12 * Math.sin(t * Math.PI); | |
| 600 | + } | |
| 601 | + }, alive); | |
| 602 | + for (const sp of step.specials) { | |
| 603 | + if (sp.type === "bomb") { | |
| 604 | + s.shocks.push({ x: sp.at[0], y: sp.at[1], at: s.t, kind: "bomb" }); | |
| 605 | + sound("bonus"); | |
| 606 | + } else if (sp.type === "line") { | |
| 607 | + s.sweeps.push({ row: sp.at[1], at: s.t }); | |
| 608 | + sound("bonus"); | |
| 609 | + } else if (sp.type === "x2") { | |
| 610 | + s.shocks.push({ x: sp.at[0], y: sp.at[1], at: s.t, kind: "x2" }); | |
| 611 | + s.pops.push({ x: L().ox + (sp.at[0] + 0.5) * L().cell, y: L().oy + (sp.at[1] + 0.5) * L().cell, text: "×2", color: palette.glow, at: s.t }); | |
| 612 | + sound("bonus"); | |
| 613 | + } | |
| 614 | + } | |
| 615 | + if (step.specials.length) await wait(rm ? 60 : 260); | |
| 616 | + | |
| 617 | + // Detonate | |
| 618 | + for (const b of hitBlocks) explode(s, L(), b, colors[b.c % colors.length]); | |
| 619 | + sound(step.chain >= 2 ? "bonus" : "tick"); | |
| 620 | + await tween(rm ? 50 : 170, (t) => { | |
| 621 | + for (const b of hitBlocks) { | |
| 622 | + b.alpha = 1 - t; | |
| 623 | + b.scale = 1 + 0.45 * t; | |
| 624 | + b.flash = 1 - t; | |
| 625 | + } | |
| 626 | + }, alive); | |
| 627 | + // Step win counts up | |
| 628 | + const from = cum; | |
| 629 | + cum += step.win; | |
| 630 | + if (step.win > 0) { | |
| 631 | + const Lc = L(); | |
| 632 | + const cxs = step.destroyed.reduce((a, [x]) => a + x, 0) / step.destroyed.length; | |
| 633 | + const cys = step.destroyed.reduce((a, [, y]) => a + y, 0) / step.destroyed.length; | |
| 634 | + s.pops.push({ x: Lc.ox + (cxs + 0.5) * Lc.cell, y: Lc.oy + (cys + 0.5) * Lc.cell, text: `+${formatSC(step.win, { unit: false })}`, color: step.chain >= 2 ? "#ffd66b" : "#ffffff", at: s.t }); | |
| 635 | + } | |
| 636 | + void tween(rm ? 80 : 360, (t) => setRunning(Math.round(lerp(from, cum, easeOutCubic(t)))), alive); | |
| 637 | + | |
| 638 | + // Gravity + refill toward the next grid | |
| 639 | + type Fall = { b: Block; from: number; to: number }; | |
| 640 | + const falls: Fall[] = []; | |
| 641 | + const newCols: Block[][] = []; | |
| 642 | + for (let x = 0; x < size; x++) { | |
| 643 | + const kept = s.cols[x].filter((b) => !destroyed.has(`${b.x}:${Math.round(b.y)}`)).sort((a, b) => a.y - b.y); | |
| 644 | + const freshCount = size - kept.length; | |
| 645 | + const col: Block[] = []; | |
| 646 | + for (let j = 0; j < freshCount; j++) { | |
| 647 | + const cell = next.grid[x][j]; | |
| 648 | + const b: Block = { c: cell.c, s: cell.s, x, y: j - freshCount - 0.3, alpha: 1, scale: 1, flash: 0 }; | |
| 649 | + col.push(b); | |
| 650 | + falls.push({ b, from: b.y, to: j }); | |
| 651 | + } | |
| 652 | + kept.forEach((b, j) => { | |
| 653 | + const target = freshCount + j; | |
| 654 | + const cell = next.grid[x][target]; | |
| 655 | + // trust the server grid for colour/special (they should match) | |
| 656 | + b.c = cell.c; | |
| 657 | + b.s = cell.s; | |
| 658 | + b.alpha = 1; | |
| 659 | + b.scale = 1; | |
| 660 | + b.flash = 0; | |
| 661 | + if (b.y !== target) falls.push({ b, from: b.y, to: target }); | |
| 662 | + col.push(b); | |
| 663 | + }); | |
| 664 | + newCols.push(col); | |
| 665 | + } | |
| 666 | + s.cols = newCols; | |
| 667 | + if (falls.length) { | |
| 668 | + await tween(rm ? 70 : 340, (t) => { | |
| 669 | + for (const f of falls) { | |
| 670 | + const k = easeOutBack(clamp(t * 1.05, 0, 1)); | |
| 671 | + f.b.y = lerp(f.from, f.to, Math.min(1, Math.max(0, k))); | |
| 672 | + } | |
| 673 | + }, alive); | |
| 674 | + for (const f of falls) f.b.y = f.to; | |
| 675 | + sound("tick"); | |
| 676 | + } | |
| 677 | + await wait(rm ? 40 : 140); | |
| 678 | + } | |
| 679 | + | |
| 680 | + if (!alive()) return; | |
| 681 | + // Final grid = last step's grid (already what we show; enforce exactly). | |
| 682 | + s.cols = blocksFrom(steps[steps.length - 1].grid); | |
| 683 | + setRunning(outcome.totalWin); | |
| 684 | + setResult({ win: outcome.totalWin, multiplier: outcome.multiplier, chains: outcome.summary.chains, blocks: outcome.summary.blocksDestroyed }); | |
| 685 | + if (outcome.totalWin <= 0) sound("lose"); | |
| 686 | + else if (outcome.multiplier >= 15) sound("bigWin"); | |
| 687 | + else sound("win"); | |
| 688 | + onResult({ win: outcome.totalWin, multiplier: outcome.multiplier }); | |
| 689 | + s.phase = "idle"; | |
| 690 | + }, | |
| 691 | + [cfg, palette, getScene, sound, onResult, explode], | |
| 692 | + ); | |
| 693 | + | |
| 694 | + const fire = useCallback(async () => { | |
| 695 | + if (phaseRef.current !== "idle") return; | |
| 696 | + phaseRef.current = "firing"; | |
| 697 | + setPhase("firing"); | |
| 698 | + setResult(null); | |
| 699 | + setLive(null); | |
| 700 | + setChain(null); | |
| 701 | + setRunning(0); | |
| 702 | + onBusy(true); | |
| 703 | + sound("click"); | |
| 704 | + const res = await play(bet, { column }); | |
| 705 | + if (res && aliveRef.current) await animate(res.outcome); | |
| 706 | + phaseRef.current = "idle"; | |
| 707 | + if (aliveRef.current) { | |
| 708 | + setPhase("idle"); | |
| 709 | + const s = sceneRef.current; | |
| 710 | + if (s) s.phase = "idle"; | |
| 711 | + } | |
| 712 | + onBusy(false); | |
| 713 | + }, [play, bet, column, animate, onBusy, sound]); | |
| 714 | + | |
| 715 | + const firing = phase === "firing"; | |
| 716 | + | |
| 717 | + return ( | |
| 718 | + <div className="absolute inset-0 flex flex-col"> | |
| 719 | + {/* Chain ladder + running win */} | |
| 720 | + <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-3 pt-2"> | |
| 721 | + <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto scrollbar-none" aria-label="Chain multiplier ladder"> | |
| 722 | + {cfg.chainLadder.map((m, i) => { | |
| 723 | + const lit = chain !== null && i <= chain; | |
| 724 | + const current = chain === i; | |
| 725 | + return ( | |
| 726 | + <span | |
| 727 | + key={i} | |
| 728 | + className={cn("shrink-0 rounded-full px-2 py-0.5 text-[11px] font-bold tabular transition-all", lit ? "text-[#061018]" : "bg-white/5 text-fg-4")} | |
| 729 | + style={lit ? { background: current ? palette.glow : rgba(palette.primary, 0.75), boxShadow: current ? `0 0 18px ${rgba(palette.primary, 0.7)}` : undefined, transform: current ? "scale(1.12)" : undefined } : undefined} | |
| 730 | + > | |
| 731 | + ×{m} | |
| 732 | + </span> | |
| 733 | + ); | |
| 734 | + })} | |
| 735 | + </div> | |
| 736 | + <div className="text-right leading-tight"> | |
| 737 | + <div className="eyebrow">{firing ? "Running" : "Round win"}</div> | |
| 738 | + <div className={cn("text-sm font-bold tabular", running > 0 ? "text-credit" : "text-fg-3")}>{running > 0 ? formatSC(running) : "—"}</div> | |
| 739 | + </div> | |
| 740 | + </div> | |
| 741 | + | |
| 742 | + {/* Grid */} | |
| 743 | + <div className="relative min-h-0 flex-1"> | |
| 744 | + <canvas ref={canvasRef} className={cn("absolute inset-0 h-full w-full touch-none", firing ? "cursor-default" : "cursor-pointer")} onPointerDown={onPointer} onPointerMove={onPointer} role="img" aria-label="Energy grid. Tap a column to aim the wave." /> | |
| 745 | + <AnimatePresence> | |
| 746 | + {live && firing ? ( | |
| 747 | + <motion.div key={live} initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute left-1/2 top-1 -translate-x-1/2 whitespace-nowrap rounded-full px-3 py-1 text-[12px] font-bold uppercase tracking-wider" style={{ background: rgba(palette.secondary, 0.18), color: palette.secondary }}> | |
| 748 | + {live} | |
| 749 | + </motion.div> | |
| 750 | + ) : null} | |
| 751 | + {result && !firing ? ( | |
| 752 | + <motion.div key="result" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute left-1/2 top-1 -translate-x-1/2 glass whitespace-nowrap rounded-full px-4 py-1.5 text-center"> | |
| 753 | + <span className={cn("text-sm font-bold tabular", result.win > 0 ? "text-credit" : "text-fg-3")}>{result.win > 0 ? `+${formatSC(result.win)} · ${formatMultiplier(result.multiplier)}` : "No cluster detonated"}</span> | |
| 754 | + <span className="ml-2 text-[11px] text-fg-3"> | |
| 755 | + {result.chains} chain{result.chains === 1 ? "" : "s"} · {result.blocks} blocks | |
| 756 | + </span> | |
| 757 | + </motion.div> | |
| 758 | + ) : null} | |
| 759 | + {error ? ( | |
| 760 | + <motion.div key="err" 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"> | |
| 761 | + <div className="text-fg-2">{error}</div> | |
| 762 | + <button onClick={clearError} className="mt-2 rounded-sm px-3 py-1.5 text-[13px] font-semibold surface-2 focus-ring"> | |
| 763 | + Dismiss | |
| 764 | + </button> | |
| 765 | + </motion.div> | |
| 766 | + ) : null} | |
| 767 | + </AnimatePresence> | |
| 768 | + </div> | |
| 769 | + | |
| 770 | + {/* Column + action */} | |
| 771 | + <div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-3 pb-3 pt-2"> | |
| 772 | + <div className="flex items-center gap-1"> | |
| 773 | + <button disabled={firing || column <= 0} onClick={() => { sound("tick"); setColumn((c) => Math.max(0, c - 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Column left"> | |
| 774 | + <ChevronLeft className="h-4 w-4" /> | |
| 775 | + </button> | |
| 776 | + <div className="flex h-11 min-w-[72px] flex-col items-center justify-center rounded-md surface-2 px-2"> | |
| 777 | + <span className="text-[10px] uppercase tracking-wider text-fg-3">Column</span> | |
| 778 | + <span className="text-sm font-bold tabular">{column + 1} / {cfg.size}</span> | |
| 779 | + </div> | |
| 780 | + <button disabled={firing || column >= cfg.size - 1} onClick={() => { sound("tick"); setColumn((c) => Math.min(cfg.size - 1, c + 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Column right"> | |
| 781 | + <ChevronRight className="h-4 w-4" /> | |
| 782 | + </button> | |
| 783 | + </div> | |
| 784 | + <button | |
| 785 | + onClick={() => void fire()} | |
| 786 | + disabled={firing} | |
| 787 | + className="tap relative h-14 flex-1 rounded-lg text-base font-extrabold uppercase tracking-[0.18em] text-[#061018] transition-transform active:scale-[0.98] disabled:opacity-70 focus-ring" | |
| 788 | + style={{ background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 55%, ${mixHex(palette.primary, "#000000", 0.25)})`, boxShadow: `0 0 0 4px ${rgba(palette.primary, 0.18)}, 0 18px 50px -14px ${palette.primary}` }} | |
| 789 | + aria-label={definition.presentation.verb} | |
| 790 | + > | |
| 791 | + {firing ? "WAVE ACTIVE…" : `${definition.presentation.verb} · ${formatSC(bet)}`} | |
| 792 | + </button> | |
| 793 | + </div> | |
| 794 | + </div> | |
| 795 | + ); | |
| 796 | +} | |
added
apps/web/src/components/arcade/orbit.tsx
+917 −0
@@ -0,0 +1,917 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * ORBIT — browser side. The server places the objects and resolves the impulse; | |
| 5 | + * this file draws `summary.orbits` (angles in degrees, 0 = up, clockwise) and | |
| 6 | + * replays `outcome.steps` (miss / hit / deflection / spawned orbit / supernova) | |
| 7 | + * on a 2D canvas. Object rotation is purely cosmetic and frozen while a round plays. | |
| 8 | + */ | |
| 9 | +import { useCallback, useEffect, useRef, useState } from "react"; | |
| 10 | +import { AnimatePresence, motion } from "framer-motion"; | |
| 11 | +import { RotateCcw, RotateCw } from "lucide-react"; | |
| 12 | +import { formatMultiplier, formatSC } from "@spinza/shared"; | |
| 13 | +import type { ArcadeOutcome, OrbitConfig, OrbitObject, OrbitStep } from "@spinza/game-core/client"; | |
| 14 | +import { cn } from "@/lib/utils"; | |
| 15 | +import { useInstantPlay, type ArcadeGameProps } from "./contract"; | |
| 16 | + | |
| 17 | +interface OrbitOutcome extends ArcadeOutcome { | |
| 18 | + steps: OrbitStep[]; | |
| 19 | + summary: { angle: number | null; orbits: OrbitObject[][]; supernova: { remaining: number; multiplier: number } | null; hits: number }; | |
| 20 | +} | |
| 21 | + | |
| 22 | +/* --------------------------------------------------------------- helpers */ | |
| 23 | + | |
| 24 | +const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)); | |
| 25 | +const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); | |
| 26 | +const easeInCubic = (t: number) => t * t * t; | |
| 27 | +const easeOutBack = (t: number) => 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2); | |
| 28 | +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; | |
| 29 | +const rad = (deg: number) => ((deg - 90) * Math.PI) / 180; | |
| 30 | +const norm = (deg: number) => ((deg % 360) + 360) % 360; | |
| 31 | + | |
| 32 | +function tween(ms: number, fn: (t: number) => void, alive: () => boolean): Promise<void> { | |
| 33 | + return new Promise((resolve) => { | |
| 34 | + const start = performance.now(); | |
| 35 | + const frame = (now: number) => { | |
| 36 | + if (!alive()) return resolve(); | |
| 37 | + const t = Math.min(1, (now - start) / Math.max(1, ms)); | |
| 38 | + fn(t); | |
| 39 | + if (t < 1) requestAnimationFrame(frame); | |
| 40 | + else resolve(); | |
| 41 | + }; | |
| 42 | + requestAnimationFrame(frame); | |
| 43 | + }); | |
| 44 | +} | |
| 45 | + | |
| 46 | +function rgba(hex: string, a: number): string { | |
| 47 | + const h = hex.replace("#", ""); | |
| 48 | + const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16); | |
| 49 | + return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; | |
| 50 | +} | |
| 51 | + | |
| 52 | +function mixHex(a: string, b: string, t: number): string { | |
| 53 | + const pa = parseInt(a.replace("#", ""), 16); | |
| 54 | + const pb = parseInt(b.replace("#", ""), 16); | |
| 55 | + const ch = (s: number) => Math.round(lerp((pa >> s) & 255, (pb >> s) & 255, t)); | |
| 56 | + return `rgb(${ch(16)},${ch(8)},${ch(0)})`; | |
| 57 | +} | |
| 58 | + | |
| 59 | +/* ----------------------------------------------------------------- scene */ | |
| 60 | + | |
| 61 | +interface Obj { | |
| 62 | + id: string; | |
| 63 | + type: string; | |
| 64 | + angle: number; | |
| 65 | + value: number; // already payScale-scaled (from summary.orbits) | |
| 66 | + alpha: number; | |
| 67 | + scale: number; | |
| 68 | + flash: number; | |
| 69 | + collapse: number; // 0 = on orbit, 1 = in the core | |
| 70 | + hit: boolean; | |
| 71 | +} | |
| 72 | + | |
| 73 | +interface Ring { | |
| 74 | + radius: number; // px, animated | |
| 75 | + target: number; | |
| 76 | + alpha: number; | |
| 77 | + rot: number; | |
| 78 | + speed: number; // deg/s (cosmetic) | |
| 79 | + objs: Obj[]; | |
| 80 | + flashAt: number; | |
| 81 | +} | |
| 82 | + | |
| 83 | +interface Particle { | |
| 84 | + x: number; | |
| 85 | + y: number; | |
| 86 | + vx: number; | |
| 87 | + vy: number; | |
| 88 | + life: number; | |
| 89 | + max: number; | |
| 90 | + color: string; | |
| 91 | + size: number; | |
| 92 | +} | |
| 93 | + | |
| 94 | +interface Pop { | |
| 95 | + x: number; | |
| 96 | + y: number; | |
| 97 | + text: string; | |
| 98 | + color: string; | |
| 99 | + at: number; | |
| 100 | + big?: boolean; | |
| 101 | +} | |
| 102 | + | |
| 103 | +interface Scene { | |
| 104 | + t: number; | |
| 105 | + rings: Ring[]; | |
| 106 | + aim: number; | |
| 107 | + phase: "idle" | "firing"; | |
| 108 | + frozen: boolean; | |
| 109 | + preview: boolean; | |
| 110 | + path: { x: number; y: number }[]; // screen-space polyline (normalised units of Rmax) | |
| 111 | + head: { x: number; y: number } | null; | |
| 112 | + pathAlpha: number; | |
| 113 | + ripples: { angle: number; radius: number; at: number }[]; | |
| 114 | + particles: Particle[]; | |
| 115 | + pops: Pop[]; | |
| 116 | + coreFlare: number; | |
| 117 | + supernova: number; // 0..1 visual intensity | |
| 118 | + reduceMotion: boolean; | |
| 119 | +} | |
| 120 | + | |
| 121 | +interface Layout { | |
| 122 | + w: number; | |
| 123 | + h: number; | |
| 124 | + cx: number; | |
| 125 | + cy: number; | |
| 126 | + rmax: number; | |
| 127 | + rc: number; | |
| 128 | +} | |
| 129 | + | |
| 130 | +function layoutFor(w: number, h: number): Layout { | |
| 131 | + const rmax = Math.max(60, Math.min(w, h) / 2 - 26); | |
| 132 | + return { w, h, cx: w / 2, cy: h / 2, rmax, rc: rmax * 0.13 }; | |
| 133 | +} | |
| 134 | + | |
| 135 | +function ringTargets(n: number, L: Layout): number[] { | |
| 136 | + const out: number[] = []; | |
| 137 | + for (let i = 0; i < n; i++) out.push(L.rc + (L.rmax - L.rc) * ((i + 1) / n)); | |
| 138 | + return out; | |
| 139 | +} | |
| 140 | + | |
| 141 | +function toObj(o: OrbitObject): Obj { | |
| 142 | + return { id: o.id, type: o.type, angle: o.angle, value: o.value, alpha: 1, scale: 1, flash: 0, collapse: 0, hit: false }; | |
| 143 | +} | |
| 144 | + | |
| 145 | +const TYPE_COLORS: Record<string, string> = { | |
| 146 | + debris: "#9ca3af", | |
| 147 | + satellite: "#93c5fd", | |
| 148 | + planet: "#34d399", | |
| 149 | + comet: "#f0abfc", | |
| 150 | + gasgiant: "#fb923c", | |
| 151 | + quasar: "#fde68a", | |
| 152 | +}; | |
| 153 | + | |
| 154 | +const TYPE_SIZE: Record<string, number> = { debris: 0.45, satellite: 0.55, planet: 0.85, comet: 0.7, gasgiant: 1.25, quasar: 1.1 }; | |
| 155 | + | |
| 156 | +/* Deterministic cosmetic preview before the first round. */ | |
| 157 | +function previewOrbits(cfg: OrbitConfig): OrbitObject[][] { | |
| 158 | + const types = cfg.objectTypes; | |
| 159 | + const out: OrbitObject[][] = []; | |
| 160 | + let seq = 0; | |
| 161 | + for (let i = 0; i < cfg.orbits; i++) { | |
| 162 | + const n = 3 + ((i * 2) % 3); | |
| 163 | + const objs: OrbitObject[] = []; | |
| 164 | + for (let j = 0; j < n; j++) { | |
| 165 | + const t = types[(i * 3 + j * 2) % Math.min(types.length, 5)]; | |
| 166 | + objs.push({ id: `p${seq++}`, type: t.id, angle: norm((360 / n) * j + i * 37 + j * 11), value: t.value }); | |
| 167 | + } | |
| 168 | + out.push(objs); | |
| 169 | + } | |
| 170 | + return out; | |
| 171 | +} | |
| 172 | + | |
| 173 | +/* --------------------------------------------------------------- drawing */ | |
| 174 | + | |
| 175 | +interface Palette { | |
| 176 | + primary: string; | |
| 177 | + secondary: string; | |
| 178 | + glow: string; | |
| 179 | + bg: string; | |
| 180 | + surface: string; | |
| 181 | +} | |
| 182 | + | |
| 183 | +function drawObject(ctx: CanvasRenderingContext2D, o: Obj, x: number, y: number, unit: number, t: number, palette: Palette) { | |
| 184 | + const color = TYPE_COLORS[o.type] ?? palette.primary; | |
| 185 | + const r = unit * (TYPE_SIZE[o.type] ?? 0.6) * o.scale; | |
| 186 | + ctx.save(); | |
| 187 | + ctx.globalAlpha = o.alpha; | |
| 188 | + ctx.translate(x, y); | |
| 189 | + if (o.flash > 0) { | |
| 190 | + ctx.shadowColor = "#ffffff"; | |
| 191 | + ctx.shadowBlur = 30 * o.flash; | |
| 192 | + } else { | |
| 193 | + ctx.shadowColor = color; | |
| 194 | + ctx.shadowBlur = o.type === "quasar" || o.type === "comet" ? 18 : 8; | |
| 195 | + } | |
| 196 | + switch (o.type) { | |
| 197 | + case "debris": { | |
| 198 | + ctx.rotate(t * 0.8 + o.angle); | |
| 199 | + ctx.fillStyle = mixHex(color, "#000000", 0.25); | |
| 200 | + ctx.beginPath(); | |
| 201 | + ctx.moveTo(-r, -r * 0.4); | |
| 202 | + ctx.lineTo(-r * 0.2, -r); | |
| 203 | + ctx.lineTo(r * 0.9, -r * 0.5); | |
| 204 | + ctx.lineTo(r, r * 0.5); | |
| 205 | + ctx.lineTo(r * 0.1, r); | |
| 206 | + ctx.lineTo(-r * 0.8, r * 0.6); | |
| 207 | + ctx.closePath(); | |
| 208 | + ctx.fill(); | |
| 209 | + ctx.strokeStyle = "rgba(255,255,255,0.35)"; | |
| 210 | + ctx.lineWidth = 1; | |
| 211 | + ctx.stroke(); | |
| 212 | + break; | |
| 213 | + } | |
| 214 | + case "satellite": { | |
| 215 | + ctx.rotate(rad(o.angle) + Math.PI / 2); | |
| 216 | + ctx.fillStyle = "#1e3a8a"; | |
| 217 | + ctx.fillRect(-r * 2.1, -r * 0.35, r * 1.3, r * 0.7); | |
| 218 | + ctx.fillRect(r * 0.8, -r * 0.35, r * 1.3, r * 0.7); | |
| 219 | + ctx.strokeStyle = color; | |
| 220 | + ctx.lineWidth = 1; | |
| 221 | + ctx.strokeRect(-r * 2.1, -r * 0.35, r * 1.3, r * 0.7); | |
| 222 | + ctx.strokeRect(r * 0.8, -r * 0.35, r * 1.3, r * 0.7); | |
| 223 | + ctx.fillStyle = "#e5e7eb"; | |
| 224 | + ctx.beginPath(); | |
| 225 | + ctx.roundRect(-r * 0.7, -r * 0.6, r * 1.4, r * 1.2, 3); | |
| 226 | + ctx.fill(); | |
| 227 | + ctx.fillStyle = color; | |
| 228 | + ctx.beginPath(); | |
| 229 | + ctx.arc(0, 0, r * 0.28, 0, Math.PI * 2); | |
| 230 | + ctx.fill(); | |
| 231 | + break; | |
| 232 | + } | |
| 233 | + case "planet": { | |
| 234 | + const g = ctx.createRadialGradient(-r * 0.35, -r * 0.35, r * 0.1, 0, 0, r); | |
| 235 | + g.addColorStop(0, "#d1fae5"); | |
| 236 | + g.addColorStop(0.4, color); | |
| 237 | + g.addColorStop(1, mixHex(color, "#000000", 0.6)); | |
| 238 | + ctx.fillStyle = g; | |
| 239 | + ctx.beginPath(); | |
| 240 | + ctx.arc(0, 0, r, 0, Math.PI * 2); | |
| 241 | + ctx.fill(); | |
| 242 | + ctx.strokeStyle = "rgba(255,255,255,0.35)"; | |
| 243 | + ctx.lineWidth = 1.2; | |
| 244 | + ctx.beginPath(); | |
| 245 | + ctx.ellipse(0, r * 0.15, r * 0.9, r * 0.28, -0.3, 0, Math.PI * 2); | |
| 246 | + ctx.stroke(); | |
| 247 | + break; | |
| 248 | + } | |
| 249 | + case "comet": { | |
| 250 | + const dir = rad(o.angle) + Math.PI / 2; // tangent | |
| 251 | + ctx.rotate(dir); | |
| 252 | + const tail = ctx.createLinearGradient(0, 0, r * 4.2, 0); | |
| 253 | + tail.addColorStop(0, rgba(color, 0.9)); | |
| 254 | + tail.addColorStop(1, rgba(color, 0)); | |
| 255 | + ctx.fillStyle = tail; | |
| 256 | + ctx.beginPath(); | |
| 257 | + ctx.moveTo(0, -r * 0.7); | |
| 258 | + ctx.lineTo(r * 4.2, 0); | |
| 259 | + ctx.lineTo(0, r * 0.7); | |
| 260 | + ctx.closePath(); | |
| 261 | + ctx.fill(); | |
| 262 | + const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r); | |
| 263 | + g.addColorStop(0, "#ffffff"); | |
| 264 | + g.addColorStop(1, color); | |
| 265 | + ctx.fillStyle = g; | |
| 266 | + ctx.beginPath(); | |
| 267 | + ctx.arc(0, 0, r * 0.75, 0, Math.PI * 2); | |
| 268 | + ctx.fill(); | |
| 269 | + break; | |
| 270 | + } | |
| 271 | + case "gasgiant": { | |
| 272 | + const g = ctx.createRadialGradient(-r * 0.4, -r * 0.4, r * 0.1, 0, 0, r); | |
| 273 | + g.addColorStop(0, "#fed7aa"); | |
| 274 | + g.addColorStop(0.5, color); | |
| 275 | + g.addColorStop(1, mixHex(color, "#000000", 0.6)); | |
| 276 | + ctx.fillStyle = g; | |
| 277 | + ctx.beginPath(); | |
| 278 | + ctx.arc(0, 0, r, 0, Math.PI * 2); | |
| 279 | + ctx.fill(); | |
| 280 | + ctx.strokeStyle = "rgba(0,0,0,0.25)"; | |
| 281 | + ctx.lineWidth = r * 0.16; | |
| 282 | + for (const k of [-0.45, 0, 0.45]) { | |
| 283 | + ctx.beginPath(); | |
| 284 | + ctx.ellipse(0, r * k, Math.sqrt(1 - k * k) * r, r * 0.08, 0, 0, Math.PI * 2); | |
| 285 | + ctx.stroke(); | |
| 286 | + } | |
| 287 | + ctx.strokeStyle = rgba("#fde68a", 0.8); | |
| 288 | + ctx.lineWidth = 2; | |
| 289 | + ctx.beginPath(); | |
| 290 | + ctx.ellipse(0, 0, r * 1.75, r * 0.45, -0.45, 0, Math.PI * 2); | |
| 291 | + ctx.stroke(); | |
| 292 | + break; | |
| 293 | + } | |
| 294 | + case "quasar": { | |
| 295 | + ctx.rotate(t * 1.6); | |
| 296 | + ctx.strokeStyle = rgba(color, 0.9); | |
| 297 | + ctx.lineWidth = 1.5; | |
| 298 | + for (let i = 0; i < 6; i++) { | |
| 299 | + ctx.rotate(Math.PI / 3); | |
| 300 | + ctx.beginPath(); | |
| 301 | + ctx.moveTo(0, -r * 0.4); | |
| 302 | + ctx.lineTo(0, -r * 1.9); | |
| 303 | + ctx.stroke(); | |
| 304 | + } | |
| 305 | + const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r); | |
| 306 | + g.addColorStop(0, "#ffffff"); | |
| 307 | + g.addColorStop(0.5, color); | |
| 308 | + g.addColorStop(1, rgba(color, 0)); | |
| 309 | + ctx.fillStyle = g; | |
| 310 | + ctx.beginPath(); | |
| 311 | + ctx.arc(0, 0, r, 0, Math.PI * 2); | |
| 312 | + ctx.fill(); | |
| 313 | + break; | |
| 314 | + } | |
| 315 | + default: { | |
| 316 | + ctx.fillStyle = color; | |
| 317 | + ctx.beginPath(); | |
| 318 | + ctx.arc(0, 0, r, 0, Math.PI * 2); | |
| 319 | + ctx.fill(); | |
| 320 | + } | |
| 321 | + } | |
| 322 | + ctx.restore(); | |
| 323 | +} | |
| 324 | + | |
| 325 | +function drawScene(ctx: CanvasRenderingContext2D, L: Layout, s: Scene, cfg: OrbitConfig, palette: Palette) { | |
| 326 | + const { w, h, cx, cy, rmax, rc } = L; | |
| 327 | + ctx.clearRect(0, 0, w, h); | |
| 328 | + const font = (px: number, weight = 700) => `${weight} ${px}px Geist, "Geist Fallback", system-ui, -apple-system, sans-serif`; | |
| 329 | + const unit = rmax * 0.06; | |
| 330 | + | |
| 331 | + // Star dust | |
| 332 | + ctx.save(); | |
| 333 | + ctx.fillStyle = "rgba(255,255,255,0.25)"; | |
| 334 | + for (let i = 0; i < 40; i++) { | |
| 335 | + const a = i * 2.399 + 0.3; | |
| 336 | + const r = ((i * 97) % 100) / 100; | |
| 337 | + const x = cx + Math.cos(a) * r * Math.max(w, h) * 0.7; | |
| 338 | + const y = cy + Math.sin(a) * r * Math.max(w, h) * 0.7; | |
| 339 | + const tw = 0.5 + 0.5 * Math.sin(s.t * (0.6 + (i % 5) * 0.2) + i); | |
| 340 | + ctx.globalAlpha = 0.15 + 0.35 * tw; | |
| 341 | + ctx.fillRect(x, y, 1.5, 1.5); | |
| 342 | + } | |
| 343 | + ctx.restore(); | |
| 344 | + | |
| 345 | + // Aim cone (idle) | |
| 346 | + if (s.phase === "idle" && !s.reduceMotion) { | |
| 347 | + ctx.save(); | |
| 348 | + ctx.translate(cx, cy); | |
| 349 | + const a = rad(s.aim); | |
| 350 | + const half = (cfg.beamHalfWidth * Math.PI) / 180; | |
| 351 | + const g = ctx.createLinearGradient(0, 0, Math.cos(a) * rmax, Math.sin(a) * rmax); | |
| 352 | + g.addColorStop(0, rgba(palette.primary, 0.22)); | |
| 353 | + g.addColorStop(1, rgba(palette.primary, 0)); | |
| 354 | + ctx.fillStyle = g; | |
| 355 | + ctx.beginPath(); | |
| 356 | + ctx.moveTo(0, 0); | |
| 357 | + ctx.arc(0, 0, rmax + 8, a - half, a + half); | |
| 358 | + ctx.closePath(); | |
| 359 | + ctx.fill(); | |
| 360 | + ctx.setLineDash([4, 6]); | |
| 361 | + ctx.strokeStyle = rgba(palette.glow, 0.7); | |
| 362 | + ctx.lineWidth = 1.5; | |
| 363 | + ctx.beginPath(); | |
| 364 | + ctx.moveTo(Math.cos(a) * rc, Math.sin(a) * rc); | |
| 365 | + ctx.lineTo(Math.cos(a) * (rmax + 8), Math.sin(a) * (rmax + 8)); | |
| 366 | + ctx.stroke(); | |
| 367 | + ctx.setLineDash([]); | |
| 368 | + // handle | |
| 369 | + ctx.fillStyle = palette.glow; | |
| 370 | + ctx.shadowColor = palette.glow; | |
| 371 | + ctx.shadowBlur = 16; | |
| 372 | + ctx.beginPath(); | |
| 373 | + ctx.arc(Math.cos(a) * (rmax + 14), Math.sin(a) * (rmax + 14), 7, 0, Math.PI * 2); | |
| 374 | + ctx.fill(); | |
| 375 | + ctx.restore(); | |
| 376 | + } else if (s.phase === "idle") { | |
| 377 | + ctx.save(); | |
| 378 | + ctx.translate(cx, cy); | |
| 379 | + const a = rad(s.aim); | |
| 380 | + ctx.strokeStyle = rgba(palette.glow, 0.7); | |
| 381 | + ctx.lineWidth = 1.5; | |
| 382 | + ctx.beginPath(); | |
| 383 | + ctx.moveTo(Math.cos(a) * rc, Math.sin(a) * rc); | |
| 384 | + ctx.lineTo(Math.cos(a) * (rmax + 8), Math.sin(a) * (rmax + 8)); | |
| 385 | + ctx.stroke(); | |
| 386 | + ctx.fillStyle = palette.glow; | |
| 387 | + ctx.beginPath(); | |
| 388 | + ctx.arc(Math.cos(a) * (rmax + 14), Math.sin(a) * (rmax + 14), 7, 0, Math.PI * 2); | |
| 389 | + ctx.fill(); | |
| 390 | + ctx.restore(); | |
| 391 | + } | |
| 392 | + | |
| 393 | + // Orbit rings | |
| 394 | + for (const ring of s.rings) { | |
| 395 | + if (ring.alpha <= 0) continue; | |
| 396 | + ctx.save(); | |
| 397 | + ctx.globalAlpha = ring.alpha; | |
| 398 | + const flash = Math.max(0, 1 - (s.t - ring.flashAt) / 0.9); | |
| 399 | + ctx.strokeStyle = rgba(palette.primary, 0.18 + 0.6 * flash); | |
| 400 | + ctx.lineWidth = 1 + 2 * flash; | |
| 401 | + ctx.shadowColor = palette.primary; | |
| 402 | + ctx.shadowBlur = 18 * flash; | |
| 403 | + ctx.beginPath(); | |
| 404 | + ctx.arc(cx, cy, ring.radius, 0, Math.PI * 2); | |
| 405 | + ctx.stroke(); | |
| 406 | + ctx.restore(); | |
| 407 | + } | |
| 408 | + | |
| 409 | + // Core | |
| 410 | + const flare = s.coreFlare; | |
| 411 | + const coreR = rc * (1 + 0.06 * Math.sin(s.t * 3)) * (1 + 0.6 * flare) + s.supernova * rmax * 1.3; | |
| 412 | + ctx.save(); | |
| 413 | + const halo = ctx.createRadialGradient(cx, cy, coreR * 0.5, cx, cy, coreR * 2.6); | |
| 414 | + halo.addColorStop(0, rgba(palette.secondary, 0.45 + 0.4 * flare)); | |
| 415 | + halo.addColorStop(1, rgba(palette.secondary, 0)); | |
| 416 | + ctx.fillStyle = halo; | |
| 417 | + ctx.beginPath(); | |
| 418 | + ctx.arc(cx, cy, coreR * 2.6, 0, Math.PI * 2); | |
| 419 | + ctx.fill(); | |
| 420 | + const core = ctx.createRadialGradient(cx - coreR * 0.2, cy - coreR * 0.2, coreR * 0.1, cx, cy, coreR); | |
| 421 | + core.addColorStop(0, "#fff7ed"); | |
| 422 | + core.addColorStop(0.45, palette.secondary); | |
| 423 | + core.addColorStop(1, mixHex(palette.secondary, "#7c2d12", 0.7)); | |
| 424 | + ctx.fillStyle = core; | |
| 425 | + ctx.shadowColor = palette.secondary; | |
| 426 | + ctx.shadowBlur = 30 + 60 * flare; | |
| 427 | + ctx.beginPath(); | |
| 428 | + ctx.arc(cx, cy, coreR, 0, Math.PI * 2); | |
| 429 | + ctx.fill(); | |
| 430 | + ctx.restore(); | |
| 431 | + | |
| 432 | + // Objects | |
| 433 | + for (const ring of s.rings) { | |
| 434 | + for (const o of ring.objs) { | |
| 435 | + if (o.alpha <= 0) continue; | |
| 436 | + const a = rad(o.angle + ring.rot); | |
| 437 | + const r = lerp(ring.radius, 0, o.collapse); | |
| 438 | + drawObject(ctx, o, cx + Math.cos(a) * r, cy + Math.sin(a) * r, unit, s.t, palette); | |
| 439 | + } | |
| 440 | + } | |
| 441 | + | |
| 442 | + // Impulse path | |
| 443 | + if (s.path.length > 1 && s.pathAlpha > 0) { | |
| 444 | + ctx.save(); | |
| 445 | + ctx.globalAlpha = s.pathAlpha; | |
| 446 | + ctx.strokeStyle = palette.glow; | |
| 447 | + ctx.lineWidth = 3; | |
| 448 | + ctx.lineCap = "round"; | |
| 449 | + ctx.lineJoin = "round"; | |
| 450 | + ctx.shadowColor = palette.primary; | |
| 451 | + ctx.shadowBlur = 18; | |
| 452 | + ctx.beginPath(); | |
| 453 | + ctx.moveTo(cx + s.path[0].x * rmax, cy + s.path[0].y * rmax); | |
| 454 | + for (let i = 1; i < s.path.length; i++) ctx.lineTo(cx + s.path[i].x * rmax, cy + s.path[i].y * rmax); | |
| 455 | + if (s.head) ctx.lineTo(cx + s.head.x * rmax, cy + s.head.y * rmax); | |
| 456 | + ctx.stroke(); | |
| 457 | + ctx.strokeStyle = "#ffffff"; | |
| 458 | + ctx.lineWidth = 1; | |
| 459 | + ctx.stroke(); | |
| 460 | + if (s.head && s.phase === "firing") { | |
| 461 | + ctx.fillStyle = "#ffffff"; | |
| 462 | + ctx.shadowBlur = 24; | |
| 463 | + ctx.beginPath(); | |
| 464 | + ctx.arc(cx + s.head.x * rmax, cy + s.head.y * rmax, 5, 0, Math.PI * 2); | |
| 465 | + ctx.fill(); | |
| 466 | + } | |
| 467 | + ctx.restore(); | |
| 468 | + } | |
| 469 | + | |
| 470 | + // Ripples (misses) | |
| 471 | + for (const rp of s.ripples) { | |
| 472 | + const age = s.t - rp.at; | |
| 473 | + if (age > 0.6) continue; | |
| 474 | + const a = rad(rp.angle); | |
| 475 | + ctx.save(); | |
| 476 | + ctx.globalAlpha = 1 - age / 0.6; | |
| 477 | + ctx.strokeStyle = "rgba(255,255,255,0.6)"; | |
| 478 | + ctx.lineWidth = 1.5; | |
| 479 | + ctx.beginPath(); | |
| 480 | + ctx.arc(cx + Math.cos(a) * rp.radius, cy + Math.sin(a) * rp.radius, 4 + 22 * easeOutCubic(age / 0.6), 0, Math.PI * 2); | |
| 481 | + ctx.stroke(); | |
| 482 | + ctx.restore(); | |
| 483 | + } | |
| 484 | + | |
| 485 | + // Particles | |
| 486 | + for (const p of s.particles) { | |
| 487 | + const k = p.life / p.max; | |
| 488 | + ctx.globalAlpha = k; | |
| 489 | + ctx.fillStyle = p.color; | |
| 490 | + ctx.beginPath(); | |
| 491 | + ctx.arc(p.x, p.y, p.size * (0.4 + 0.6 * k), 0, Math.PI * 2); | |
| 492 | + ctx.fill(); | |
| 493 | + } | |
| 494 | + ctx.globalAlpha = 1; | |
| 495 | + | |
| 496 | + // Pops | |
| 497 | + for (const pop of s.pops) { | |
| 498 | + const age = s.t - pop.at; | |
| 499 | + const life = pop.big ? 1.8 : 1.2; | |
| 500 | + if (age > life) continue; | |
| 501 | + const k = age < 0.22 ? easeOutBack(age / 0.22) : 1; | |
| 502 | + const fade = age > life - 0.4 ? (life - age) / 0.4 : 1; | |
| 503 | + ctx.save(); | |
| 504 | + ctx.globalAlpha = Math.max(0, fade); | |
| 505 | + ctx.translate(pop.x, pop.y - (pop.big ? 0 : age * 22)); | |
| 506 | + ctx.scale(k, k); | |
| 507 | + ctx.font = font(pop.big ? Math.min(64, w * 0.15) : 15, 900); | |
| 508 | + ctx.textAlign = "center"; | |
| 509 | + ctx.textBaseline = "middle"; | |
| 510 | + ctx.shadowColor = pop.color; | |
| 511 | + ctx.shadowBlur = pop.big ? 40 : 12; | |
| 512 | + ctx.fillStyle = pop.color; | |
| 513 | + ctx.fillText(pop.text, 0, 0); | |
| 514 | + ctx.restore(); | |
| 515 | + } | |
| 516 | + | |
| 517 | + // Angle ticks | |
| 518 | + ctx.save(); | |
| 519 | + ctx.strokeStyle = "rgba(255,255,255,0.18)"; | |
| 520 | + ctx.lineWidth = 1; | |
| 521 | + for (let d = 0; d < 360; d += 30) { | |
| 522 | + const a = rad(d); | |
| 523 | + ctx.beginPath(); | |
| 524 | + ctx.moveTo(cx + Math.cos(a) * (rmax + 18), cy + Math.sin(a) * (rmax + 18)); | |
| 525 | + ctx.lineTo(cx + Math.cos(a) * (rmax + 24), cy + Math.sin(a) * (rmax + 24)); | |
| 526 | + ctx.stroke(); | |
| 527 | + } | |
| 528 | + ctx.restore(); | |
| 529 | + | |
| 530 | + if (s.preview) { | |
| 531 | + ctx.save(); | |
| 532 | + ctx.font = font(11, 700); | |
| 533 | + ctx.textAlign = "center"; | |
| 534 | + ctx.textBaseline = "bottom"; | |
| 535 | + ctx.fillStyle = "rgba(255,255,255,0.45)"; | |
| 536 | + ctx.fillText("PREVIEW SYSTEM · FIRE TO GENERATE THE REAL ORBITS", w / 2, h - 6); | |
| 537 | + ctx.restore(); | |
| 538 | + } | |
| 539 | +} | |
| 540 | + | |
| 541 | +/* ------------------------------------------------------------- component */ | |
| 542 | + | |
| 543 | +export function OrbitGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) { | |
| 544 | + const cfg = definition.config as unknown as OrbitConfig; | |
| 545 | + const palette = definition.presentation.palette; | |
| 546 | + const payScale = definition.payScale; | |
| 547 | + const { play, error, clearError } = useInstantPlay<OrbitOutcome>(definition.slug); | |
| 548 | + const [aim, setAim] = useState(0); | |
| 549 | + const [phase, setPhase] = useState<"idle" | "firing">("idle"); | |
| 550 | + const [collected, setCollected] = useState(0); | |
| 551 | + const [live, setLive] = useState<string | null>(null); | |
| 552 | + const [result, setResult] = useState<{ win: number; multiplier: number; hits: number; supernova: number | null } | null>(null); | |
| 553 | + const canvasRef = useRef<HTMLCanvasElement>(null); | |
| 554 | + const sceneRef = useRef<Scene | null>(null); | |
| 555 | + const aliveRef = useRef(true); | |
| 556 | + const phaseRef = useRef<"idle" | "firing">("idle"); | |
| 557 | + | |
| 558 | + const getScene = useCallback((): Scene => { | |
| 559 | + if (!sceneRef.current) { | |
| 560 | + const orbits = previewOrbits(cfg); | |
| 561 | + sceneRef.current = { | |
| 562 | + t: 0, | |
| 563 | + rings: orbits.map((objs, i) => ({ radius: 0, target: 0, alpha: 1, rot: 0, speed: (i % 2 === 0 ? 1 : -1) * (5 - i), objs: objs.map(toObj), flashAt: -10 })), | |
| 564 | + aim: 0, | |
| 565 | + phase: "idle", | |
| 566 | + frozen: false, | |
| 567 | + preview: true, | |
| 568 | + path: [], | |
| 569 | + head: null, | |
| 570 | + pathAlpha: 0, | |
| 571 | + ripples: [], | |
| 572 | + particles: [], | |
| 573 | + pops: [], | |
| 574 | + coreFlare: 0, | |
| 575 | + supernova: 0, | |
| 576 | + reduceMotion: false, | |
| 577 | + }; | |
| 578 | + } | |
| 579 | + return sceneRef.current; | |
| 580 | + }, [cfg]); | |
| 581 | + | |
| 582 | + useEffect(() => { | |
| 583 | + aliveRef.current = true; | |
| 584 | + const canvas = canvasRef.current; | |
| 585 | + if (!canvas) return; | |
| 586 | + const ctx = canvas.getContext("2d"); | |
| 587 | + if (!ctx) return; | |
| 588 | + const s = getScene(); | |
| 589 | + let raf = 0; | |
| 590 | + let last = performance.now(); | |
| 591 | + const loop = (now: number) => { | |
| 592 | + const dt = Math.min(0.05, (now - last) / 1000); | |
| 593 | + last = now; | |
| 594 | + s.t += dt; | |
| 595 | + const dpr = Math.min(2, window.devicePixelRatio || 1); | |
| 596 | + const rect = canvas.getBoundingClientRect(); | |
| 597 | + const W = Math.max(1, Math.round(rect.width * dpr)); | |
| 598 | + const H = Math.max(1, Math.round(rect.height * dpr)); | |
| 599 | + if (canvas.width !== W || canvas.height !== H) { | |
| 600 | + canvas.width = W; | |
| 601 | + canvas.height = H; | |
| 602 | + } | |
| 603 | + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| 604 | + const L = layoutFor(rect.width, rect.height); | |
| 605 | + const targets = ringTargets(s.rings.length, L); | |
| 606 | + s.rings.forEach((ring, i) => { | |
| 607 | + ring.target = targets[i]; | |
| 608 | + ring.radius = ring.radius === 0 ? ring.target : lerp(ring.radius, ring.target, Math.min(1, dt * 6)); | |
| 609 | + if (!s.frozen && !s.reduceMotion) ring.rot += ring.speed * dt; | |
| 610 | + }); | |
| 611 | + for (let i = s.particles.length - 1; i >= 0; i--) { | |
| 612 | + const p = s.particles[i]; | |
| 613 | + p.life -= dt; | |
| 614 | + p.x += p.vx * dt; | |
| 615 | + p.y += p.vy * dt; | |
| 616 | + p.vx *= 0.985; | |
| 617 | + p.vy *= 0.985; | |
| 618 | + if (p.life <= 0) s.particles.splice(i, 1); | |
| 619 | + } | |
| 620 | + s.ripples = s.ripples.filter((r) => s.t - r.at < 0.7); | |
| 621 | + s.pops = s.pops.filter((p) => s.t - p.at < 2); | |
| 622 | + s.coreFlare = Math.max(0, s.coreFlare - dt * 1.4); | |
| 623 | + drawScene(ctx, L, s, cfg, palette); | |
| 624 | + raf = requestAnimationFrame(loop); | |
| 625 | + }; | |
| 626 | + raf = requestAnimationFrame(loop); | |
| 627 | + return () => { | |
| 628 | + aliveRef.current = false; | |
| 629 | + cancelAnimationFrame(raf); | |
| 630 | + }; | |
| 631 | + }, [cfg, palette, getScene]); | |
| 632 | + | |
| 633 | + useEffect(() => { | |
| 634 | + const s = getScene(); | |
| 635 | + s.aim = aim; | |
| 636 | + s.reduceMotion = reduceMotion; | |
| 637 | + }, [aim, reduceMotion, getScene]); | |
| 638 | + | |
| 639 | + const onPointer = useCallback( | |
| 640 | + (e: React.PointerEvent<HTMLCanvasElement>) => { | |
| 641 | + if (phaseRef.current !== "idle") return; | |
| 642 | + if (e.type === "pointermove" && e.buttons === 0) return; | |
| 643 | + const rect = e.currentTarget.getBoundingClientRect(); | |
| 644 | + const L = layoutFor(rect.width, rect.height); | |
| 645 | + const dx = e.clientX - rect.left - L.cx; | |
| 646 | + const dy = e.clientY - rect.top - L.cy; | |
| 647 | + if (Math.hypot(dx, dy) < L.rc * 0.6) return; | |
| 648 | + const deg = Math.round(norm((Math.atan2(dy, dx) * 180) / Math.PI + 90)); | |
| 649 | + setAim((prev) => { | |
| 650 | + if (Math.abs(prev - deg) >= 3) sound("tick"); | |
| 651 | + return deg; | |
| 652 | + }); | |
| 653 | + }, | |
| 654 | + [sound], | |
| 655 | + ); | |
| 656 | + | |
| 657 | + const burst = useCallback((s: Scene, x: number, y: number, n: number, color: string, speed: number) => { | |
| 658 | + if (s.reduceMotion) return; | |
| 659 | + for (let i = 0; i < n; i++) { | |
| 660 | + const a = Math.random() * Math.PI * 2; | |
| 661 | + const v = speed * (0.3 + Math.random() * 0.9); | |
| 662 | + s.particles.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 0.4 + Math.random() * 0.5, max: 0.9, color, size: 1.5 + Math.random() * 2.5 }); | |
| 663 | + } | |
| 664 | + }, []); | |
| 665 | + | |
| 666 | + const animate = useCallback( | |
| 667 | + async (outcome: OrbitOutcome) => { | |
| 668 | + const s = getScene(); | |
| 669 | + const alive = () => aliveRef.current; | |
| 670 | + const rm = s.reduceMotion; | |
| 671 | + const canvas = canvasRef.current; | |
| 672 | + const rect = canvas?.getBoundingClientRect() ?? { width: 390, height: 500 }; | |
| 673 | + const L = layoutFor(rect.width, rect.height); | |
| 674 | + const orbits = outcome.summary.orbits; | |
| 675 | + s.phase = "firing"; | |
| 676 | + s.frozen = true; | |
| 677 | + s.preview = false; | |
| 678 | + s.particles = []; | |
| 679 | + s.pops = []; | |
| 680 | + s.ripples = []; | |
| 681 | + s.path = []; | |
| 682 | + s.head = null; | |
| 683 | + s.pathAlpha = 0; | |
| 684 | + s.supernova = 0; | |
| 685 | + | |
| 686 | + // Swap in the real system: old objects fade, new ones appear at the server's angles. | |
| 687 | + const old = s.rings; | |
| 688 | + await tween(rm ? 60 : 260, (t) => { | |
| 689 | + for (const r of old) for (const o of r.objs) o.alpha = 1 - t; | |
| 690 | + }, alive); | |
| 691 | + const initial = Math.min(cfg.orbits, orbits.length); | |
| 692 | + s.rings = orbits.slice(0, initial).map((objs, i) => ({ radius: old[i]?.radius ?? 0, target: 0, alpha: 1, rot: 0, speed: (i % 2 === 0 ? 1 : -1) * (5 - i), objs: objs.map(toObj), flashAt: -10 })); | |
| 693 | + for (const r of s.rings) for (const o of r.objs) o.alpha = 0; | |
| 694 | + await tween(rm ? 60 : 260, (t) => { | |
| 695 | + for (const r of s.rings) for (const o of r.objs) o.alpha = t; | |
| 696 | + }, alive); | |
| 697 | + | |
| 698 | + // Impulse leaves the core. | |
| 699 | + s.coreFlare = 1; | |
| 700 | + sound("click"); | |
| 701 | + const startA = rad(outcome.steps[0]?.angle ?? outcome.summary.angle ?? 0); | |
| 702 | + const unitPt = (angleDeg: number, radiusPx: number) => ({ x: (Math.cos(rad(angleDeg)) * radiusPx) / L.rmax, y: (Math.sin(rad(angleDeg)) * radiusPx) / L.rmax }); | |
| 703 | + s.path = [{ x: (Math.cos(startA) * L.rc) / L.rmax, y: (Math.sin(startA) * L.rc) / L.rmax }]; | |
| 704 | + s.pathAlpha = 1; | |
| 705 | + let spawnIndex = initial; | |
| 706 | + let running = 0; | |
| 707 | + for (let i = 0; i < outcome.steps.length; i++) { | |
| 708 | + if (!alive()) return; | |
| 709 | + const step = outcome.steps[i]; | |
| 710 | + const ring = s.rings[step.orbit]; | |
| 711 | + if (!ring) break; | |
| 712 | + const from = s.path[s.path.length - 1]; | |
| 713 | + const to = unitPt(step.angle, ring.target || ringTargets(s.rings.length, L)[step.orbit]); | |
| 714 | + await tween(rm ? 50 : 240, (t) => { | |
| 715 | + const k = easeInCubic(t) * 0.4 + t * 0.6; | |
| 716 | + s.head = { x: lerp(from.x, to.x, k), y: lerp(from.y, to.y, k) }; | |
| 717 | + }, alive); | |
| 718 | + s.path.push(to); | |
| 719 | + s.head = null; | |
| 720 | + const hx = L.cx + to.x * L.rmax; | |
| 721 | + const hy = L.cy + to.y * L.rmax; | |
| 722 | + if (!step.hit) { | |
| 723 | + s.ripples.push({ angle: step.angle, radius: ring.radius, at: s.t }); | |
| 724 | + continue; | |
| 725 | + } | |
| 726 | + const obj = ring.objs.find((o) => o.id === step.hit?.id); | |
| 727 | + const type = cfg.objectTypes.find((t) => t.id === step.hit?.type); | |
| 728 | + const shownValue = obj?.value ?? Math.round(step.hit.value * payScale * 100) / 100; | |
| 729 | + running += step.hit.value; | |
| 730 | + const color = TYPE_COLORS[step.hit.type] ?? palette.primary; | |
| 731 | + if (obj) { | |
| 732 | + obj.hit = true; | |
| 733 | + await tween(rm ? 40 : 180, (t) => { | |
| 734 | + obj.flash = Math.sin(t * Math.PI); | |
| 735 | + obj.scale = 1 + 0.5 * Math.sin(t * Math.PI); | |
| 736 | + }, alive); | |
| 737 | + burst(s, hx, hy, 16, color, 200); | |
| 738 | + void tween(rm ? 40 : 240, (t) => { | |
| 739 | + obj.alpha = 1 - t; | |
| 740 | + obj.scale = 1 + t; | |
| 741 | + }, alive); | |
| 742 | + } | |
| 743 | + s.pops.push({ x: hx, y: hy - 18, text: `+${formatMultiplier(shownValue)}`, color, at: s.t }); | |
| 744 | + const runningScaled = Math.round(running * payScale * 100) / 100; | |
| 745 | + setCollected(runningScaled); | |
| 746 | + setLive(`${type?.label ?? step.hit.type} +${formatMultiplier(shownValue)}`); | |
| 747 | + sound(step.hit.type === "gasgiant" || step.hit.type === "quasar" ? "bonus" : "tick"); | |
| 748 | + if (step.deflectedTo !== undefined) { | |
| 749 | + setLive(`${type?.label ?? "Object"} deflects the impulse → ${Math.round(step.deflectedTo)}°`); | |
| 750 | + await wait(rm ? 40 : 160); | |
| 751 | + } | |
| 752 | + if (step.spawned && spawnIndex < orbits.length) { | |
| 753 | + const objs = orbits[spawnIndex]; | |
| 754 | + const newRing: Ring = { radius: L.rmax + 40, target: 0, alpha: 0, rot: 0, speed: (spawnIndex % 2 === 0 ? 1 : -1) * Math.max(1.5, 5 - spawnIndex), objs: objs.map(toObj), flashAt: s.t }; | |
| 755 | + for (const o of newRing.objs) o.alpha = 0; | |
| 756 | + s.rings.push(newRing); | |
| 757 | + spawnIndex++; | |
| 758 | + sound("bonus"); | |
| 759 | + setLive(`New orbit spawned · ${s.rings.length} orbits`); | |
| 760 | + await tween(rm ? 60 : 420, (t) => { | |
| 761 | + newRing.alpha = t; | |
| 762 | + for (const o of newRing.objs) o.alpha = t; | |
| 763 | + }, alive); | |
| 764 | + } else { | |
| 765 | + await wait(rm ? 30 : 120); | |
| 766 | + } | |
| 767 | + } | |
| 768 | + | |
| 769 | + if (!alive()) return; | |
| 770 | + // Impulse leaves the system. | |
| 771 | + const last = s.path[s.path.length - 1]; | |
| 772 | + const dirLen = Math.hypot(last.x, last.y) || 1; | |
| 773 | + const out = { x: (last.x / dirLen) * 1.25, y: (last.y / dirLen) * 1.25 }; | |
| 774 | + await tween(rm ? 40 : 200, (t) => { | |
| 775 | + s.head = { x: lerp(last.x, out.x, t), y: lerp(last.y, out.y, t) }; | |
| 776 | + }, alive); | |
| 777 | + s.path.push(out); | |
| 778 | + s.head = null; | |
| 779 | + void tween(rm ? 200 : 1400, (t) => (s.pathAlpha = 1 - t), alive); | |
| 780 | + | |
| 781 | + // Supernova | |
| 782 | + const sn = outcome.summary.supernova; | |
| 783 | + if (sn) { | |
| 784 | + sound("bonus"); | |
| 785 | + setLive(`SUPERNOVA · ${sn.remaining} survivors`); | |
| 786 | + s.pops.push({ x: L.cx, y: L.cy - L.rmax * 0.55, text: "SUPERNOVA", color: palette.secondary, at: s.t, big: true }); | |
| 787 | + await wait(rm ? 80 : 500); | |
| 788 | + const survivors = s.rings.flatMap((r) => r.objs.filter((o) => !o.hit)); | |
| 789 | + await tween(rm ? 120 : 900, (t) => { | |
| 790 | + const k = easeInCubic(t); | |
| 791 | + for (const o of survivors) { | |
| 792 | + o.collapse = k; | |
| 793 | + o.scale = 1 - 0.6 * k; | |
| 794 | + } | |
| 795 | + s.coreFlare = 1; | |
| 796 | + }, alive); | |
| 797 | + for (const o of survivors) o.alpha = 0; | |
| 798 | + burst(s, L.cx, L.cy, 60, "#ffffff", 420); | |
| 799 | + sound("bigWin"); | |
| 800 | + await tween(rm ? 100 : 700, (t) => { | |
| 801 | + s.supernova = Math.sin(t * Math.PI) * 0.9; | |
| 802 | + s.coreFlare = 1; | |
| 803 | + }, alive); | |
| 804 | + s.supernova = 0; | |
| 805 | + s.pops.push({ x: L.cx, y: L.cy, text: `×${sn.multiplier.toFixed(1).replace(/\.0$/, "")}`, color: "#ffd66b", at: s.t, big: true }); | |
| 806 | + setCollected(Math.round(running * sn.multiplier * payScale * 100) / 100); | |
| 807 | + await wait(rm ? 100 : 900); | |
| 808 | + } | |
| 809 | + | |
| 810 | + if (!alive()) return; | |
| 811 | + setCollected(outcome.multiplier); | |
| 812 | + setResult({ win: outcome.totalWin, multiplier: outcome.multiplier, hits: outcome.summary.hits, supernova: sn ? sn.multiplier : null }); | |
| 813 | + if (outcome.totalWin <= 0) sound("lose"); | |
| 814 | + else if (outcome.multiplier >= 15) sound("bigWin"); | |
| 815 | + else sound("win"); | |
| 816 | + onResult({ win: outcome.totalWin, multiplier: outcome.multiplier }); | |
| 817 | + s.phase = "idle"; | |
| 818 | + s.frozen = false; | |
| 819 | + }, | |
| 820 | + [cfg, palette, payScale, getScene, sound, onResult, burst], | |
| 821 | + ); | |
| 822 | + | |
| 823 | + const fire = useCallback(async () => { | |
| 824 | + if (phaseRef.current !== "idle") return; | |
| 825 | + phaseRef.current = "firing"; | |
| 826 | + setPhase("firing"); | |
| 827 | + setResult(null); | |
| 828 | + setLive(null); | |
| 829 | + setCollected(0); | |
| 830 | + onBusy(true); | |
| 831 | + sound("click"); | |
| 832 | + const res = await play(bet, { angle: aim }); | |
| 833 | + if (res && aliveRef.current) await animate(res.outcome); | |
| 834 | + phaseRef.current = "idle"; | |
| 835 | + if (aliveRef.current) { | |
| 836 | + setPhase("idle"); | |
| 837 | + const s = sceneRef.current; | |
| 838 | + if (s) { | |
| 839 | + s.phase = "idle"; | |
| 840 | + s.frozen = false; | |
| 841 | + } | |
| 842 | + } | |
| 843 | + onBusy(false); | |
| 844 | + }, [play, bet, aim, animate, onBusy, sound]); | |
| 845 | + | |
| 846 | + const firing = phase === "firing"; | |
| 847 | + const nudge = (d: number) => { | |
| 848 | + sound("tick"); | |
| 849 | + setAim((a) => norm(a + d)); | |
| 850 | + }; | |
| 851 | + | |
| 852 | + return ( | |
| 853 | + <div className="absolute inset-0 flex flex-col"> | |
| 854 | + {/* Aim + collected */} | |
| 855 | + <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-3 pt-2"> | |
| 856 | + <div className="flex items-center gap-1"> | |
| 857 | + <button disabled={firing} onClick={() => nudge(-5)} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Rotate aim counter-clockwise"> | |
| 858 | + <RotateCcw className="h-4 w-4" /> | |
| 859 | + </button> | |
| 860 | + <div className="flex h-11 min-w-[78px] flex-col items-center justify-center rounded-md surface-2 px-2"> | |
| 861 | + <span className="text-[10px] uppercase tracking-wider text-fg-3">Aim</span> | |
| 862 | + <span className="text-sm font-bold tabular">{aim}°</span> | |
| 863 | + </div> | |
| 864 | + <button disabled={firing} onClick={() => nudge(5)} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Rotate aim clockwise"> | |
| 865 | + <RotateCw className="h-4 w-4" /> | |
| 866 | + </button> | |
| 867 | + </div> | |
| 868 | + <div className="text-right leading-tight"> | |
| 869 | + <div className="eyebrow">{firing ? "Collected" : "Round"}</div> | |
| 870 | + <div className={cn("text-sm font-bold tabular", collected > 0 ? "text-credit" : "text-fg-3")}>{collected > 0 ? formatMultiplier(collected) : "—"}</div> | |
| 871 | + </div> | |
| 872 | + </div> | |
| 873 | + | |
| 874 | + {/* System */} | |
| 875 | + <div className="relative min-h-0 flex-1"> | |
| 876 | + <canvas ref={canvasRef} className={cn("absolute inset-0 h-full w-full touch-none", firing ? "cursor-default" : "cursor-crosshair")} onPointerDown={onPointer} onPointerMove={onPointer} role="img" aria-label="Orbital system. Drag around the core to aim the impulse." /> | |
| 877 | + <AnimatePresence> | |
| 878 | + {live && firing ? ( | |
| 879 | + <motion.div key={live} initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute left-1/2 top-1 -translate-x-1/2 whitespace-nowrap rounded-full px-3 py-1 text-[12px] font-bold uppercase tracking-wider" style={{ background: rgba(palette.secondary, 0.18), color: palette.secondary }}> | |
| 880 | + {live} | |
| 881 | + </motion.div> | |
| 882 | + ) : null} | |
| 883 | + {result && !firing ? ( | |
| 884 | + <motion.div key="result" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute left-1/2 top-1 -translate-x-1/2 glass whitespace-nowrap rounded-full px-4 py-1.5 text-center"> | |
| 885 | + <span className={cn("text-sm font-bold tabular", result.win > 0 ? "text-credit" : "text-fg-3")}>{result.win > 0 ? `+${formatSC(result.win)} · ${formatMultiplier(result.multiplier)}` : "The impulse missed everything"}</span> | |
| 886 | + <span className="ml-2 text-[11px] text-fg-3"> | |
| 887 | + {result.hits} hit{result.hits === 1 ? "" : "s"} | |
| 888 | + {result.supernova ? ` · supernova ×${result.supernova.toFixed(1).replace(/\.0$/, "")}` : ""} | |
| 889 | + </span> | |
| 890 | + </motion.div> | |
| 891 | + ) : null} | |
| 892 | + {error ? ( | |
| 893 | + <motion.div key="err" 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"> | |
| 894 | + <div className="text-fg-2">{error}</div> | |
| 895 | + <button onClick={clearError} className="mt-2 rounded-sm px-3 py-1.5 text-[13px] font-semibold surface-2 focus-ring"> | |
| 896 | + Dismiss | |
| 897 | + </button> | |
| 898 | + </motion.div> | |
| 899 | + ) : null} | |
| 900 | + </AnimatePresence> | |
| 901 | + </div> | |
| 902 | + | |
| 903 | + {/* Action */} | |
| 904 | + <div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-3 pb-3 pt-2"> | |
| 905 | + <button | |
| 906 | + onClick={() => void fire()} | |
| 907 | + disabled={firing} | |
| 908 | + className="tap relative h-14 flex-1 rounded-lg text-base font-extrabold uppercase tracking-[0.18em] text-[#1a0b26] transition-transform active:scale-[0.98] disabled:opacity-70 focus-ring" | |
| 909 | + style={{ background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 55%, ${mixHex(palette.primary, "#000000", 0.25)})`, boxShadow: `0 0 0 4px ${rgba(palette.primary, 0.18)}, 0 18px 50px -14px ${palette.primary}` }} | |
| 910 | + aria-label={definition.presentation.verb} | |
| 911 | + > | |
| 912 | + {firing ? "IMPULSE IN FLIGHT…" : `${definition.presentation.verb} · ${formatSC(bet)}`} | |
| 913 | + </button> | |
| 914 | + </div> | |
| 915 | + </div> | |
| 916 | + ); | |
| 917 | +} | |
added
apps/web/src/components/arcade/vault.tsx
+485 −0
@@ -0,0 +1,485 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The Vault — Spinza Original (ladder). A colossal five-layer vault door: | |
| 5 | + * every layer reveals three digits one by one; after each opened layer the | |
| 6 | + * player SECUREs the current multiplier or OPENs the next lock. Outcomes come | |
| 7 | + * from the server; this component only animates the returned `log` tail. | |
| 8 | + */ | |
| 9 | +import { useCallback, useEffect, useMemo, useState } from "react"; | |
| 10 | +import { AnimatePresence, motion } from "framer-motion"; | |
| 11 | +import { Atom, Clock, Fingerprint, Gauge, Lock, LockOpen, RotateCcw, ShieldCheck, Siren } from "lucide-react"; | |
| 12 | +import { formatMultiplier, formatSC } from "@spinza/shared"; | |
| 13 | +import type { LadderEvent, LadderOffer } from "@spinza/game-core/client"; | |
| 14 | +import { cn } from "@/lib/utils"; | |
| 15 | +import { Button } from "@/components/ui"; | |
| 16 | +import { useLadder, type ArcadeGameProps, type LadderView } from "./contract"; | |
| 17 | + | |
| 18 | +interface VaultConfig { | |
| 19 | + layers: number[]; | |
| 20 | + digitsPerLayer: number; | |
| 21 | + layerNames: string[]; | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** Reveal animation in progress for one layer (drives the digit wheels). */ | |
| 25 | +interface Reveal { | |
| 26 | + layer: number; | |
| 27 | + digits: number[]; | |
| 28 | + shown: number; | |
| 29 | + outcome: "ok" | "bust" | null; | |
| 30 | +} | |
| 31 | + | |
| 32 | +type Phase = "idle" | "reveal" | "running" | "cashed" | "busted" | "completed"; | |
| 33 | +type WheelState = { kind: "idle" } | { kind: "spin" } | { kind: "fail" } | { kind: "locked"; digit: number }; | |
| 34 | + | |
| 35 | +const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)); | |
| 36 | +const EASE = [0.16, 1, 0.3, 1] as const; | |
| 37 | +const LAYER_SIZES = [100, 82, 65, 49, 34]; | |
| 38 | +const LAYER_ICONS = [Lock, Clock, Fingerprint, Gauge, Atom]; | |
| 39 | +const DANGER = "#ff5c7a"; | |
| 40 | +const WHEEL_H = 56; | |
| 41 | + | |
| 42 | +/** Same arithmetic as the server: m₀ = rtp, m_k = floor₂(m_{k−1} / p_k). */ | |
| 43 | +export function vaultLadder(rtp: number, layers: number[]): number[] { | |
| 44 | + const out: number[] = []; | |
| 45 | + let m = rtp; | |
| 46 | + for (const p of layers) { | |
| 47 | + m = m / p; // full precision like the server; displayed values are floored to 2 decimals | |
| 48 | + out.push(Math.floor(m * 100) / 100); | |
| 49 | + } | |
| 50 | + return out; | |
| 51 | +} | |
| 52 | + | |
| 53 | +function lastReveal(log: LadderEvent[]): LadderEvent | undefined { | |
| 54 | + for (let i = log.length - 1; i >= 0; i--) { | |
| 55 | + const k = log[i].kind; | |
| 56 | + if (k === "layer" || k === "alarm") return log[i]; | |
| 57 | + } | |
| 58 | + return undefined; | |
| 59 | +} | |
| 60 | + | |
| 61 | +function digitsOf(ev: LadderEvent | undefined): number[] { | |
| 62 | + const d = ev?.data?.digits; | |
| 63 | + return Array.isArray(d) ? d.filter((x): x is number => typeof x === "number") : []; | |
| 64 | +} | |
| 65 | + | |
| 66 | +export function VaultGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) { | |
| 67 | + const cfg = definition.config as unknown as VaultConfig; | |
| 68 | + const palette = definition.presentation.palette; | |
| 69 | + const { session, start, act, reset, busy, error, clearError } = useLadder(definition.slug); | |
| 70 | + const [anim, setAnim] = useState<Reveal | null>(null); | |
| 71 | + const [celebrate, setCelebrate] = useState(false); | |
| 72 | + const ladder = useMemo(() => vaultLadder(definition.rtp, cfg.layers), [definition.rtp, cfg.layers]); | |
| 73 | + const dur = useCallback((ms: number) => (reduceMotion ? Math.round(ms * 0.35) : ms), [reduceMotion]); | |
| 74 | + const sec = (ms: number) => dur(ms) / 1000; | |
| 75 | + | |
| 76 | + /* ------------------------------------------------------------ derived */ | |
| 77 | + const phase: Phase = anim ? "reveal" : !session ? "idle" : session.status; | |
| 78 | + const layersOpen = anim ? anim.layer : session ? Number(session.extra.layersOpen ?? 0) : 0; | |
| 79 | + const runBet = session?.bet ?? bet; | |
| 80 | + // While a layer is being revealed, keep showing the multiplier secured before it. | |
| 81 | + const current = anim ? (session?.log.find((e) => e.kind === "layer" && e.stage === anim.layer)?.multiplierAfter ?? (anim.layer > 0 ? ladder[anim.layer - 1] : definition.rtp)) : (session?.current ?? 0); | |
| 82 | + const offer: LadderOffer | undefined = session?.offers[0]; | |
| 83 | + const running = session?.status === "running"; | |
| 84 | + const locked = busy || anim !== null; | |
| 85 | + const revealedDigits: number[] = useMemo(() => { | |
| 86 | + const d = session?.extra.digits; | |
| 87 | + return Array.isArray(d) ? d.filter((x): x is number => typeof x === "number") : []; | |
| 88 | + }, [session]); | |
| 89 | + const failedEvent = phase === "busted" && session ? lastReveal(session.log) : undefined; | |
| 90 | + const failedDigits = digitsOf(failedEvent); | |
| 91 | + const activeLayer = Math.min(layersOpen, cfg.layers.length - 1); | |
| 92 | + | |
| 93 | + const wheels: WheelState[] = Array.from({ length: cfg.digitsPerLayer }, (_, i) => { | |
| 94 | + if (anim) { | |
| 95 | + if (i < anim.shown) return { kind: "locked", digit: anim.digits[i] ?? 0 }; | |
| 96 | + if (anim.outcome === "bust") return i === anim.digits.length ? { kind: "fail" } : { kind: "idle" }; | |
| 97 | + return { kind: "spin" }; | |
| 98 | + } | |
| 99 | + if (failedEvent) { | |
| 100 | + if (i < failedDigits.length) return { kind: "locked", digit: failedDigits[i] }; | |
| 101 | + if (i === failedDigits.length) return { kind: "fail" }; | |
| 102 | + } | |
| 103 | + return { kind: "idle" }; | |
| 104 | + }); | |
| 105 | + | |
| 106 | + // Resume: a running session locks the shell's bet controls. | |
| 107 | + useEffect(() => { | |
| 108 | + if (running) onBusy(true); | |
| 109 | + }, [running, onBusy]); | |
| 110 | + | |
| 111 | + /* ------------------------------------------------------------ sequence */ | |
| 112 | + const playTail = useCallback( | |
| 113 | + async (s: LadderView) => { | |
| 114 | + const ev = lastReveal(s.log); | |
| 115 | + if (ev) { | |
| 116 | + const digits = digitsOf(ev); | |
| 117 | + setAnim({ layer: Math.max(0, ev.stage - 1), digits, shown: 0, outcome: null }); | |
| 118 | + await wait(dur(320)); | |
| 119 | + for (let i = 0; i < digits.length; i++) { | |
| 120 | + await wait(dur(350)); | |
| 121 | + sound("tick"); | |
| 122 | + setAnim((a) => (a ? { ...a, shown: i + 1 } : a)); | |
| 123 | + } | |
| 124 | + if (ev.outcome === "bust") { | |
| 125 | + await wait(dur(420)); | |
| 126 | + sound("lose"); | |
| 127 | + setAnim((a) => (a ? { ...a, outcome: "bust" } : a)); | |
| 128 | + await wait(dur(1300)); | |
| 129 | + } else { | |
| 130 | + await wait(dur(260)); | |
| 131 | + sound("bonus"); | |
| 132 | + setAnim((a) => (a ? { ...a, outcome: "ok" } : a)); | |
| 133 | + await wait(dur(760)); | |
| 134 | + } | |
| 135 | + setAnim(null); | |
| 136 | + } | |
| 137 | + if (s.status === "completed") { | |
| 138 | + await wait(dur(600)); | |
| 139 | + setCelebrate(true); | |
| 140 | + sound("bigWin"); | |
| 141 | + } | |
| 142 | + if (s.status !== "running") { | |
| 143 | + onResult({ win: s.win, multiplier: s.bet ? s.win / s.bet : 0 }); | |
| 144 | + onBusy(false); | |
| 145 | + } | |
| 146 | + }, | |
| 147 | + [dur, sound, onResult, onBusy], | |
| 148 | + ); | |
| 149 | + | |
| 150 | + const onStart = async () => { | |
| 151 | + if (locked) return; | |
| 152 | + sound("click"); | |
| 153 | + setCelebrate(false); | |
| 154 | + if (session && session.status !== "running") reset(); | |
| 155 | + onBusy(true); | |
| 156 | + const res = await start(bet); | |
| 157 | + if (!res) { | |
| 158 | + onBusy(false); | |
| 159 | + return; | |
| 160 | + } | |
| 161 | + await playTail(res.session); | |
| 162 | + }; | |
| 163 | + | |
| 164 | + const onOpen = async () => { | |
| 165 | + if (!session || locked) return; | |
| 166 | + sound("click"); | |
| 167 | + const res = await act({ type: "continue" }); | |
| 168 | + if (res) await playTail(res.session); | |
| 169 | + }; | |
| 170 | + | |
| 171 | + const onSecure = async () => { | |
| 172 | + if (!session || locked) return; | |
| 173 | + sound("click"); | |
| 174 | + const res = await act({ type: "cashout" }); | |
| 175 | + if (!res) return; | |
| 176 | + sound("win"); | |
| 177 | + onResult({ win: res.session.win, multiplier: res.session.bet ? res.session.win / res.session.bet : 0 }); | |
| 178 | + onBusy(false); | |
| 179 | + }; | |
| 180 | + | |
| 181 | + const onNewRun = () => { | |
| 182 | + sound("click"); | |
| 183 | + setCelebrate(false); | |
| 184 | + reset(); | |
| 185 | + }; | |
| 186 | + | |
| 187 | + /* -------------------------------------------------------------- render */ | |
| 188 | + const ended = phase === "cashed" || phase === "busted" || phase === "completed"; | |
| 189 | + const strobe = phase === "busted" || anim?.outcome === "bust"; | |
| 190 | + const ActiveIcon = LAYER_ICONS[activeLayer] ?? Lock; | |
| 191 | + const multiplierColor = phase === "busted" ? DANGER : phase === "cashed" || phase === "completed" ? "var(--color-credit)" : "#fff"; | |
| 192 | + | |
| 193 | + return ( | |
| 194 | + <div className="absolute inset-0 flex flex-col overflow-hidden" data-scene="vault"> | |
| 195 | + {/* Ambient light */} | |
| 196 | + <div | |
| 197 | + className="pointer-events-none absolute inset-0" | |
| 198 | + style={{ | |
| 199 | + background: `radial-gradient(60% 45% at 50% 42%, ${palette.primary}${phase === "completed" ? "55" : "22"} 0%, transparent 70%), radial-gradient(80% 40% at 50% 100%, ${palette.surface} 0%, transparent 70%)`, | |
| 200 | + transition: "background 900ms", | |
| 201 | + }} | |
| 202 | + /> | |
| 203 | + {/* Alarm strobe */} | |
| 204 | + <AnimatePresence> | |
| 205 | + {strobe ? ( | |
| 206 | + <motion.div key="strobe" className="pointer-events-none absolute inset-0 z-[3]" style={{ background: `radial-gradient(70% 60% at 50% 50%, ${DANGER}55 0%, ${DANGER}22 60%, transparent 100%)` }} initial={{ opacity: 0 }} animate={{ opacity: reduceMotion ? [0, 0.7, 0] : [0, 1, 0, 0.8, 0, 0.5, 0, 0.3, 0] }} exit={{ opacity: 0 }} transition={{ duration: sec(1800), ease: "linear" }} /> | |
| 207 | + ) : null} | |
| 208 | + </AnimatePresence> | |
| 209 | + | |
| 210 | + <div className="relative z-[2] mx-auto flex h-full w-full max-w-3xl flex-col px-3 pb-2 pt-1"> | |
| 211 | + {/* Stats */} | |
| 212 | + <div className="flex items-end justify-between gap-3"> | |
| 213 | + <div> | |
| 214 | + <div className="eyebrow">{phase === "busted" ? "Alarm" : phase === "cashed" ? "Secured" : phase === "completed" ? "Jackpot" : "Secured multiplier"}</div> | |
| 215 | + <motion.div key={`${phase}-${current}`} initial={{ opacity: 0.4, y: 4 }} animate={{ opacity: 1, y: 0 }} className="text-[clamp(34px,9vw,52px)] font-extrabold leading-none tabular tracking-tight" style={{ color: multiplierColor, textShadow: phase === "running" || phase === "reveal" ? `0 0 28px ${palette.glow}88` : undefined }}> | |
| 216 | + {phase === "idle" ? "—" : formatMultiplier(current)} | |
| 217 | + </motion.div> | |
| 218 | + </div> | |
| 219 | + <div className="text-right"> | |
| 220 | + <div className="eyebrow">{phase === "busted" ? "Lost" : ended ? "Win" : phase === "idle" ? "After layer 1" : "Potential win"}</div> | |
| 221 | + <div className={cn("text-lg font-bold tabular sm:text-xl", phase === "busted" ? "text-danger" : "text-credit")}>{phase === "idle" ? formatSC(Math.round(bet * (ladder[0] ?? 1))) : phase === "busted" ? `−${formatSC(runBet)}` : ended ? formatSC(session?.win ?? 0) : formatSC(Math.round(runBet * current))}</div> | |
| 222 | + <div className="text-[11px] text-fg-3 tabular">Bet {formatSC(runBet)}</div> | |
| 223 | + </div> | |
| 224 | + </div> | |
| 225 | + | |
| 226 | + {/* Door */} | |
| 227 | + <div className="relative grid min-h-0 flex-1 place-items-center py-2"> | |
| 228 | + <div className="relative aspect-square select-none" style={{ width: "min(86vw, 44dvh, 460px)" }}> | |
| 229 | + <div className="pointer-events-none absolute -inset-[6%] rounded-full" style={{ background: `radial-gradient(circle, ${palette.glow}33 0%, transparent 62%)`, filter: "blur(10px)" }} /> | |
| 230 | + {LAYER_SIZES.map((size, i) => ( | |
| 231 | + <Frame key={`f${i}`} size={size} open={i < layersOpen} primary={palette.primary} glow={palette.glow} sec={sec} /> | |
| 232 | + ))} | |
| 233 | + {/* Core light (visible once the last disc opens) */} | |
| 234 | + <div className="absolute rounded-full" style={{ width: `${LAYER_SIZES[4]}%`, height: `${LAYER_SIZES[4]}%`, left: `${(100 - LAYER_SIZES[4]) / 2}%`, top: `${(100 - LAYER_SIZES[4]) / 2}%`, background: `radial-gradient(circle, #fff7dc 0%, ${palette.glow} 30%, ${palette.primary} 65%, transparent 100%)`, zIndex: 5, boxShadow: `0 0 80px 20px ${palette.glow}88` }} /> | |
| 235 | + {[...LAYER_SIZES].map((_, k) => LAYER_SIZES.length - 1 - k).map((i) => ( | |
| 236 | + <Disc key={`d${i}`} index={i} size={LAYER_SIZES[i]} open={i < layersOpen} active={i === layersOpen && phase !== "idle"} outcome={anim && anim.layer === i ? anim.outcome : null} sealed={phase === "busted" && i === layersOpen} palette={palette} sec={sec} /> | |
| 237 | + ))} | |
| 238 | + | |
| 239 | + {/* Jackpot burst */} | |
| 240 | + <AnimatePresence> | |
| 241 | + {celebrate ? ( | |
| 242 | + <motion.div key="burst" className="pointer-events-none absolute inset-0 grid place-items-center" style={{ zIndex: 30 }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}> | |
| 243 | + {[0, 1, 2].map((r) => ( | |
| 244 | + <motion.div key={r} className="absolute rounded-full" style={{ width: "34%", height: "34%", border: `2px solid ${palette.glow}` }} initial={{ scale: 0.6, opacity: 0.9 }} animate={{ scale: 3.2, opacity: 0 }} transition={{ duration: sec(1600), delay: r * sec(260), ease: "easeOut", repeat: Infinity, repeatDelay: sec(400) }} /> | |
| 245 | + ))} | |
| 246 | + <motion.div className="absolute inset-0 rounded-full" style={{ background: `conic-gradient(from 0deg, transparent 0deg, ${palette.glow}44 8deg, transparent 16deg, transparent 60deg, ${palette.glow}33 68deg, transparent 76deg, transparent 120deg, ${palette.glow}44 128deg, transparent 136deg, transparent 180deg, ${palette.glow}33 188deg, transparent 196deg, transparent 240deg, ${palette.glow}44 248deg, transparent 256deg, transparent 300deg, ${palette.glow}33 308deg, transparent 316deg)` }} animate={{ rotate: 360 }} transition={{ duration: reduceMotion ? 40 : 14, ease: "linear", repeat: Infinity }} /> | |
| 247 | + </motion.div> | |
| 248 | + ) : null} | |
| 249 | + </AnimatePresence> | |
| 250 | + | |
| 251 | + {/* Center overlay */} | |
| 252 | + <div className="absolute inset-0 grid place-items-center" style={{ zIndex: 40 }}> | |
| 253 | + <AnimatePresence mode="wait"> | |
| 254 | + {phase === "idle" ? ( | |
| 255 | + <motion.div key="idle" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} className="flex flex-col items-center gap-2"> | |
| 256 | + <button | |
| 257 | + onClick={() => void onStart()} | |
| 258 | + disabled={locked} | |
| 259 | + className="grid h-[112px] w-[112px] place-items-center rounded-full text-[17px] font-extrabold tracking-[0.14em] transition-transform active:scale-95 focus-ring disabled:opacity-60" | |
| 260 | + style={{ background: `radial-gradient(circle at 50% 30%, #fff3cf 0%, ${palette.glow} 18%, ${palette.primary} 60%, #8a6d2f 100%)`, boxShadow: `0 0 0 8px ${palette.primary}2a, 0 0 0 9px ${palette.primary}55, 0 18px 60px -12px ${palette.primary}`, color: "#1a1406" }} | |
| 261 | + aria-label="Start run" | |
| 262 | + > | |
| 263 | + START | |
| 264 | + </button> | |
| 265 | + <span className="rounded-full bg-black/50 px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-fg-2 tabular">Bet {formatSC(bet)}</span> | |
| 266 | + </motion.div> | |
| 267 | + ) : phase === "cashed" ? ( | |
| 268 | + <motion.div key="cashed" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} className="flex flex-col items-center text-center"> | |
| 269 | + <ShieldCheck className="h-9 w-9" style={{ color: palette.glow }} /> | |
| 270 | + <div className="mt-1 text-[13px] font-extrabold uppercase tracking-[0.22em]" style={{ color: palette.glow }}> | |
| 271 | + Secured | |
| 272 | + </div> | |
| 273 | + <div className="text-2xl font-extrabold tabular text-credit">+{formatSC(session?.win ?? 0)}</div> | |
| 274 | + </motion.div> | |
| 275 | + ) : phase === "completed" ? ( | |
| 276 | + <motion.div key="jackpot" initial={{ opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: 1 }} transition={{ type: "spring", stiffness: 220, damping: 16 }} className="flex flex-col items-center text-center"> | |
| 277 | + <div className="text-[clamp(22px,6vw,34px)] font-extrabold uppercase tracking-tight shimmer-text">Core open</div> | |
| 278 | + <div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-2">Fictional jackpot</div> | |
| 279 | + <div className="mt-1 text-2xl font-extrabold tabular text-credit">+{formatSC(session?.win ?? 0)}</div> | |
| 280 | + </motion.div> | |
| 281 | + ) : ( | |
| 282 | + <motion.div key="wheels" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex flex-col items-center gap-2"> | |
| 283 | + <div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-[0.2em]" style={{ color: phase === "busted" ? DANGER : palette.glow }}> | |
| 284 | + <ActiveIcon className="h-3.5 w-3.5" /> | |
| 285 | + {cfg.layerNames[activeLayer]} | |
| 286 | + </div> | |
| 287 | + <div className="flex gap-2"> | |
| 288 | + {wheels.map((w, i) => ( | |
| 289 | + <DigitWheel key={i} w={w} primary={palette.primary} glow={palette.glow} reduceMotion={reduceMotion} /> | |
| 290 | + ))} | |
| 291 | + </div> | |
| 292 | + <div className="h-5 text-[11px] font-semibold uppercase tracking-wider"> | |
| 293 | + {phase === "busted" ? ( | |
| 294 | + <motion.span initial={{ scale: 1.6, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="inline-flex items-center gap-1 rounded-sm border-2 px-2 py-0.5 text-[12px] font-extrabold tracking-[0.3em]" style={{ borderColor: DANGER, color: DANGER, transform: "rotate(-6deg)" }}> | |
| 295 | + <Siren className="h-3.5 w-3.5" /> Sealed | |
| 296 | + </motion.span> | |
| 297 | + ) : anim ? ( | |
| 298 | + <span className="text-fg-2">{anim.outcome === "ok" ? "Lock yields" : anim.outcome === "bust" ? "Alarm" : "Decoding…"}</span> | |
| 299 | + ) : offer ? ( | |
| 300 | + <span className="text-fg-3 tabular"> | |
| 301 | + {Math.round(offer.survival * 100)}% · <span style={{ color: palette.glow }}>{formatMultiplier(offer.next)}</span> | |
| 302 | + </span> | |
| 303 | + ) : null} | |
| 304 | + </div> | |
| 305 | + </motion.div> | |
| 306 | + )} | |
| 307 | + </AnimatePresence> | |
| 308 | + </div> | |
| 309 | + </div> | |
| 310 | + </div> | |
| 311 | + | |
| 312 | + {/* Layer map */} | |
| 313 | + <LayerMap cfg={cfg} ladder={ladder} layersOpen={layersOpen} phase={phase} digits={anim ? [...revealedDigits.slice(0, anim.layer * cfg.digitsPerLayer), ...anim.digits.slice(0, anim.shown)] : revealedDigits} failed={failedDigits} palette={palette} /> | |
| 314 | + | |
| 315 | + {/* Controls */} | |
| 316 | + <div className="mt-2 min-h-[72px]"> | |
| 317 | + <AnimatePresence mode="wait"> | |
| 318 | + {phase === "running" || phase === "reveal" ? ( | |
| 319 | + <motion.div key="run" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="grid grid-cols-2 gap-2"> | |
| 320 | + <button onClick={() => void onSecure()} disabled={locked || !session?.canCashout} className="tap flex h-14 flex-col items-center justify-center rounded-md border text-[13px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ borderColor: `${palette.primary}88`, color: palette.glow, background: `${palette.primary}14` }}> | |
| 321 | + {definition.presentation.secondaryVerb ?? "SECURE"} | |
| 322 | + <span className="text-[11px] font-semibold normal-case tracking-normal text-fg-2 tabular">+{formatSC(Math.round(runBet * current))}</span> | |
| 323 | + </button> | |
| 324 | + <button onClick={() => void onOpen()} disabled={locked || !offer} className="tap flex h-14 flex-col items-center justify-center rounded-md text-[13px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 70%, #9a7b3a)`, color: "#1a1406", boxShadow: `0 10px 40px -12px ${palette.primary}` }}> | |
| 325 | + {definition.presentation.verb} | |
| 326 | + <span className="text-[11px] font-semibold normal-case tracking-normal tabular" style={{ color: "#3a2d0c" }}> | |
| 327 | + {anim ? "Decoding…" : offer ? `${Math.round(offer.survival * 100)}% yields · ${formatMultiplier(offer.next)}` : ""} | |
| 328 | + </span> | |
| 329 | + </button> | |
| 330 | + </motion.div> | |
| 331 | + ) : ended ? ( | |
| 332 | + <motion.div key="end" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} className="grid grid-cols-[1fr_auto] gap-2"> | |
| 333 | + <button onClick={() => void onStart()} disabled={locked} className="tap flex h-14 items-center justify-center gap-2 rounded-md text-[14px] font-extrabold uppercase tracking-[0.12em] transition-all active:scale-[0.98] focus-ring disabled:opacity-40" style={{ background: phase === "busted" ? `linear-gradient(180deg, #ff8aa0, ${DANGER})` : `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 70%, #9a7b3a)`, color: "#1a1406", boxShadow: `0 10px 40px -12px ${phase === "busted" ? DANGER : palette.primary}` }}> | |
| 334 | + <RotateCcw className="h-4 w-4" /> {phase === "busted" ? "Try again" : "Play again"} | |
| 335 | + <span className="text-[11px] font-semibold normal-case tracking-normal tabular opacity-70">{formatSC(bet)}</span> | |
| 336 | + </button> | |
| 337 | + <Button variant="secondary" size="lg" className="h-14" onClick={onNewRun}> | |
| 338 | + New run | |
| 339 | + </Button> | |
| 340 | + </motion.div> | |
| 341 | + ) : ( | |
| 342 | + <motion.div key="idle" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex h-14 items-center justify-center gap-2 text-center text-[12px] text-fg-3"> | |
| 343 | + <LockOpen className="h-3.5 w-3.5" /> {definition.tagline} · First layer opens automatically. | |
| 344 | + </motion.div> | |
| 345 | + )} | |
| 346 | + </AnimatePresence> | |
| 347 | + </div> | |
| 348 | + </div> | |
| 349 | + | |
| 350 | + <AnimatePresence> | |
| 351 | + {error ? ( | |
| 352 | + <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute inset-x-4 bottom-3 z-[50] mx-auto max-w-sm glass rounded-md p-3 text-center text-sm"> | |
| 353 | + <div className="text-fg-2">{error}</div> | |
| 354 | + <div className="mt-2 flex justify-center gap-2"> | |
| 355 | + <Button size="sm" variant="secondary" onClick={clearError}> | |
| 356 | + Dismiss | |
| 357 | + </Button> | |
| 358 | + <Button size="sm" variant="accent" href="/rewards"> | |
| 359 | + Get rewards | |
| 360 | + </Button> | |
| 361 | + </div> | |
| 362 | + </motion.div> | |
| 363 | + ) : null} | |
| 364 | + </AnimatePresence> | |
| 365 | + </div> | |
| 366 | + ); | |
| 367 | +} | |
| 368 | + | |
| 369 | +/* ------------------------------------------------------------------ parts */ | |
| 370 | + | |
| 371 | +function Frame({ size, open, primary, glow, sec }: { size: number; open: boolean; primary: string; glow: string; sec: (ms: number) => number }) { | |
| 372 | + const inset = (100 - size) / 2; | |
| 373 | + return ( | |
| 374 | + <motion.div className="absolute rounded-full" style={{ width: `${size}%`, height: `${size}%`, left: `${inset}%`, top: `${inset}%`, border: `2px solid ${primary}77`, boxShadow: `0 0 26px -6px ${glow}77, inset 0 0 22px -8px ${glow}55`, zIndex: 4 }} initial={false} animate={{ opacity: open ? 1 : 0 }} transition={{ duration: sec(700), delay: open ? sec(300) : 0 }} /> | |
| 375 | + ); | |
| 376 | +} | |
| 377 | + | |
| 378 | +function Disc({ index, size, open, active, outcome, sealed, palette, sec }: { index: number; size: number; open: boolean; active: boolean; outcome: "ok" | "bust" | null; sealed: boolean; palette: ArcadeGameProps["definition"]["presentation"]["palette"]; sec: (ms: number) => number }) { | |
| 379 | + const inset = (100 - size) / 2; | |
| 380 | + const Icon = LAYER_ICONS[index] ?? Lock; | |
| 381 | + const rim = sealed || outcome === "bust" ? DANGER : palette.primary; | |
| 382 | + const bolts = 12 - index * 2; | |
| 383 | + const glowShadow = outcome === "ok" ? `0 0 70px 10px ${palette.glow}aa` : active ? `0 0 44px -8px ${palette.glow}88` : "0 18px 50px -20px #000"; | |
| 384 | + return ( | |
| 385 | + <motion.div | |
| 386 | + className="absolute rounded-full" | |
| 387 | + style={{ width: `${size}%`, height: `${size}%`, left: `${inset}%`, top: `${inset}%`, zIndex: 10 + (LAYER_SIZES.length - index) }} | |
| 388 | + initial={false} | |
| 389 | + animate={open ? { opacity: 0, scale: 1.14, rotate: 32 } : { opacity: 1, scale: 1, rotate: 0 }} | |
| 390 | + transition={{ duration: sec(950), ease: EASE }} | |
| 391 | + > | |
| 392 | + <motion.div | |
| 393 | + className="absolute inset-0 rounded-full" | |
| 394 | + style={{ background: `radial-gradient(circle at 35% 28%, #3b3441 0%, #221d27 40%, #110f14 100%)` }} | |
| 395 | + animate={{ boxShadow: `inset 0 0 0 2px ${rim}88, inset 0 0 0 7px #0b0a0d, inset 0 0 0 8px ${rim}44, inset 0 -18px 40px -20px #000, ${glowShadow}` }} | |
| 396 | + transition={{ duration: sec(500) }} | |
| 397 | + /> | |
| 398 | + {/* brushed metal sheen */} | |
| 399 | + <div className="absolute inset-0 rounded-full opacity-60" style={{ background: `conic-gradient(from 210deg, transparent 0deg, rgba(255,255,255,0.07) 40deg, transparent 90deg, rgba(255,255,255,0.05) 200deg, transparent 260deg)` }} /> | |
| 400 | + {/* tick ring */} | |
| 401 | + <div className="absolute inset-[9%] rounded-full" style={{ border: `1.5px dashed ${rim}${active ? "aa" : "40"}` }} /> | |
| 402 | + {/* bolts */} | |
| 403 | + {Array.from({ length: bolts }, (_, b) => { | |
| 404 | + const a = (b / bolts) * Math.PI * 2 - Math.PI / 2; | |
| 405 | + // Rounded so the SSR string matches the browser's normalised value (hydration). | |
| 406 | + const bx = Math.round((50 + 45.5 * Math.cos(a) - 2.25) * 1000) / 1000; | |
| 407 | + const by = Math.round((50 + 45.5 * Math.sin(a) - 2.25) * 1000) / 1000; | |
| 408 | + return <span key={b} className="absolute h-[4.5%] w-[4.5%] rounded-full" style={{ left: `${bx}%`, top: `${by}%`, background: `radial-gradient(circle at 35% 35%, ${palette.glow}, ${palette.primary} 55%, #5b4a22)`, boxShadow: "0 1px 2px #000" }} />; | |
| 409 | + })} | |
| 410 | + {/* engraved layer mark (12 o'clock) */} | |
| 411 | + <div className="absolute left-1/2 top-[16%] -translate-x-1/2 opacity-70" style={{ color: rim }}> | |
| 412 | + <Icon style={{ width: `${Math.max(14, 30 - index * 3)}px`, height: `${Math.max(14, 30 - index * 3)}px` }} /> | |
| 413 | + </div> | |
| 414 | + {sealed ? <div className="absolute inset-0 rounded-full" style={{ background: `radial-gradient(circle, ${DANGER}22 0%, ${DANGER}0f 70%, transparent 100%)` }} /> : null} | |
| 415 | + </motion.div> | |
| 416 | + ); | |
| 417 | +} | |
| 418 | + | |
| 419 | +function DigitWheel({ w, primary, glow, reduceMotion }: { w: WheelState; primary: string; glow: string; reduceMotion: boolean }) { | |
| 420 | + const column = Array.from({ length: 30 }, (_, i) => i % 10); | |
| 421 | + const ring = w.kind === "fail" ? DANGER : w.kind === "locked" ? glow : `${primary}66`; | |
| 422 | + return ( | |
| 423 | + <div className="relative overflow-hidden rounded-md" style={{ height: WHEEL_H, width: 42, background: "linear-gradient(180deg,#07060a 0%,#1b1720 50%,#07060a 100%)", boxShadow: `inset 0 0 0 1px ${ring}, inset 0 10px 12px -8px #000, inset 0 -10px 12px -8px #000` }}> | |
| 424 | + {w.kind === "idle" ? <div className="grid h-full place-items-center text-lg text-fg-4">•</div> : null} | |
| 425 | + {w.kind === "fail" ? ( | |
| 426 | + <motion.div initial={{ x: 0 }} animate={{ x: reduceMotion ? 0 : [0, -5, 5, -4, 4, 0] }} transition={{ duration: 0.5 }} className="grid h-full place-items-center text-2xl font-extrabold" style={{ color: DANGER, textShadow: `0 0 16px ${DANGER}` }}> | |
| 427 | + ✕ | |
| 428 | + </motion.div> | |
| 429 | + ) : null} | |
| 430 | + {w.kind === "spin" ? ( | |
| 431 | + <motion.div key="spin" className="absolute inset-x-0 top-0" animate={{ y: [0, -10 * WHEEL_H] }} transition={{ duration: reduceMotion ? 1.2 : 0.42, ease: "linear", repeat: Infinity }} style={{ filter: reduceMotion ? undefined : "blur(0.6px)" }}> | |
| 432 | + {column.map((d, i) => ( | |
| 433 | + <div key={i} className="grid place-items-center text-2xl font-extrabold tabular text-fg-3" style={{ height: WHEEL_H }}> | |
| 434 | + {d} | |
| 435 | + </div> | |
| 436 | + ))} | |
| 437 | + </motion.div> | |
| 438 | + ) : null} | |
| 439 | + {w.kind === "locked" ? ( | |
| 440 | + <motion.div key="locked" className="absolute inset-x-0 top-0" initial={{ y: -w.digit * WHEEL_H }} animate={{ y: -(10 + w.digit) * WHEEL_H }} transition={reduceMotion ? { duration: 0.01 } : { type: "spring", stiffness: 120, damping: 17, mass: 0.8 }}> | |
| 441 | + {column.map((d, i) => ( | |
| 442 | + <div key={i} className="grid place-items-center text-2xl font-extrabold tabular" style={{ height: WHEEL_H, color: glow, textShadow: `0 0 14px ${glow}99` }}> | |
| 443 | + {d} | |
| 444 | + </div> | |
| 445 | + ))} | |
| 446 | + </motion.div> | |
| 447 | + ) : null} | |
| 448 | + </div> | |
| 449 | + ); | |
| 450 | +} | |
| 451 | + | |
| 452 | +function LayerMap({ cfg, ladder, layersOpen, phase, digits, failed, palette }: { cfg: VaultConfig; ladder: number[]; layersOpen: number; phase: Phase; digits: number[]; failed: number[]; palette: ArcadeGameProps["definition"]["presentation"]["palette"] }) { | |
| 453 | + const n = cfg.layers.length; | |
| 454 | + return ( | |
| 455 | + <div className="relative mt-1"> | |
| 456 | + <div className="absolute left-[10%] right-[10%] top-[15px] h-px" style={{ background: `linear-gradient(90deg, ${palette.primary}66, ${palette.primary}22)` }} /> | |
| 457 | + <div className="absolute left-[10%] top-[15px] h-px" style={{ width: `${(Math.max(0, Math.min(n - 1, layersOpen)) / (n - 1)) * 80}%`, background: palette.glow, boxShadow: `0 0 8px ${palette.glow}`, transition: "width 900ms cubic-bezier(0.16,1,0.3,1)" }} /> | |
| 458 | + <ol className="relative grid" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}> | |
| 459 | + {cfg.layers.map((p, i) => { | |
| 460 | + const open = i < layersOpen; | |
| 461 | + const active = i === layersOpen && phase !== "idle"; | |
| 462 | + const sealed = active && phase === "busted"; | |
| 463 | + const Icon = LAYER_ICONS[i] ?? Lock; | |
| 464 | + const code = open ? digits.slice(i * cfg.digitsPerLayer, (i + 1) * cfg.digitsPerLayer) : active && phase !== "busted" ? digits.slice(i * cfg.digitsPerLayer) : sealed ? failed : []; | |
| 465 | + const colour = sealed ? DANGER : open ? palette.glow : active ? palette.primary : "rgba(255,255,255,0.28)"; | |
| 466 | + return ( | |
| 467 | + <li key={i} className="flex flex-col items-center text-center"> | |
| 468 | + <motion.div className="grid h-[30px] w-[30px] place-items-center rounded-full" animate={{ scale: active && phase !== "busted" ? [1, 1.08, 1] : 1 }} transition={{ duration: 1.6, repeat: active && phase !== "busted" ? Infinity : 0 }} style={{ background: open ? `radial-gradient(circle at 40% 35%, ${palette.glow}, ${palette.primary})` : "#0f0d12", border: `1.5px solid ${colour}`, color: open ? "#1a1406" : colour, boxShadow: open || active ? `0 0 14px -2px ${colour}` : undefined }}> | |
| 469 | + <Icon className="h-3.5 w-3.5" /> | |
| 470 | + </motion.div> | |
| 471 | + <div className="mt-1 text-[11px] font-bold tabular" style={{ color: open ? palette.glow : sealed ? DANGER : "var(--color-fg-3)" }}> | |
| 472 | + {formatMultiplier(ladder[i])} | |
| 473 | + </div> | |
| 474 | + <div className="text-[9px] uppercase tracking-wider text-fg-4">{Math.round(p * 100)}%</div> | |
| 475 | + <div className="h-[14px] font-mono text-[11px] tabular" style={{ color: sealed ? DANGER : palette.glow, letterSpacing: "0.15em" }}> | |
| 476 | + {code.join("")} | |
| 477 | + {sealed ? "✕" : ""} | |
| 478 | + </div> | |
| 479 | + </li> | |
| 480 | + ); | |
| 481 | + })} | |
| 482 | + </ol> | |
| 483 | + </div> | |
| 484 | + ); | |
| 485 | +} | |
modified
apps/web/src/components/lobby/game-art.tsx
+1048 −21
@@ -1,15 +1,17 @@ | ||
| 1 | 1 | import * as React from "react"; |
| 2 | −import { GAMES_BY_SLUG } from "@spinza/games/client"; | |
| 3 | −import type { GameDefinition, SymbolStyle, SymbolTier } from "@spinza/game-core/client"; | |
| 2 | +import { ARCADE_GAMES, CRASH_GAMES, GAMES_BY_SLUG } from "@spinza/games/client"; | |
| 3 | +import type { GameDefinition, SymbolShape, SymbolStyle, SymbolTier } from "@spinza/game-core/client"; | |
| 4 | 4 | import { cn } from "@/lib/utils"; |
| 5 | 5 | import { SymbolSvg, svgArc, svgPolygon, svgStar } from "./symbol-svg"; |
| 6 | 6 | |
| 7 | 7 | /* ------------------------------------------------------------------------ |
| 8 | − Spinza key art — 20 hand-composed posters, one scene per game. | |
| 9 | − Every poster is built from the game's own reel symbols (SymbolSvg mirrors | |
| 10 | − the PixiJS renderer) arranged in a composition unique to that title, on a | |
| 11 | − theme-specific backdrop. Pure inline SVG, deterministic (seeded PRNG only | |
| 12 | − for star fields / particles), no images, filters used sparingly. | |
| 8 | + Spinza key art — 35 hand-composed posters, one scene per game. | |
| 9 | + Slot posters are built from the game's own reel symbols (SymbolSvg mirrors | |
| 10 | + the PixiJS renderer); the 10 Risk Games and 5 Beyond Slots originals have | |
| 11 | + no reels, so their scenes are composed from primitives around each game's | |
| 12 | + mechanic (rising multiplier ladder + the big-button verb as a solid pill). | |
| 13 | + Pure inline SVG, deterministic (seeded PRNG only for star fields / | |
| 14 | + particles), no images, filters used sparingly. | |
| 13 | 15 | ------------------------------------------------------------------------ */ |
| 14 | 16 | |
| 15 | 17 | export type ArtVariant = "card" | "hero" | "banner" | "tile"; |
@@ -35,8 +37,14 @@ export interface GameArtProps { | ||
| 35 | 37 | label?: string; |
| 36 | 38 | } |
| 37 | 39 | |
| 38 | −/** Presentation hints per game, derived from the game library. */ | |
| 39 | −export const GAME_PRESENTATION: Record<string, { backdrop: string; particles: string }> = Object.fromEntries([...GAMES_BY_SLUG.values()].map((g) => [g.slug, { backdrop: g.presentation.backdrop, particles: g.presentation.particles }])); | |
| 40 | +/** Ambience → particle system for the non-slot games (their definitions carry no `particles`). */ | |
| 41 | +const AMBIENCE_PARTICLES: Record<string, string> = { lunar: "stars", ocean: "bubbles", space: "stars", heist: "coins", fire: "fire", void: "stars", reactor: "energy", obsidian: "dust", arcade: "confetti", quantum: "energy", jungle: "dust" }; | |
| 42 | + | |
| 43 | +/** Presentation hints per game, derived from the game library (slots, risk games and originals). */ | |
| 44 | +export const GAME_PRESENTATION: Record<string, { backdrop: string; particles: string }> = Object.fromEntries([ | |
| 45 | + ...[...GAMES_BY_SLUG.values()].map((g) => [g.slug, { backdrop: g.presentation.backdrop, particles: g.presentation.particles }] as const), | |
| 46 | + ...[...CRASH_GAMES, ...ARCADE_GAMES].map((g) => [g.slug, { backdrop: g.presentation.scene, particles: AMBIENCE_PARTICLES[g.presentation.ambience] ?? "stars" }] as const), | |
| 47 | +]); | |
| 40 | 48 | |
| 41 | 49 | /* ------------------------------------------------------------ seeded PRNG */ |
| 42 | 50 | |
@@ -63,12 +71,14 @@ function mulberry32(seed: number): () => number { | ||
| 63 | 71 | /* ------------------------------------------------------------------ stage */ |
| 64 | 72 | |
| 65 | 73 | const FONT = "var(--font-geist-sans), Geist, Inter, ui-sans-serif, system-ui, sans-serif"; |
| 74 | +const MONO = "var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace"; | |
| 66 | 75 | const f = (n: number) => Math.round(n * 100) / 100; |
| 67 | 76 | |
| 68 | 77 | interface Stage { |
| 69 | 78 | W: number; |
| 70 | 79 | H: number; |
| 71 | 80 | v: ArtVariant; |
| 81 | + slug: string; | |
| 72 | 82 | /** Focal centre and unit radius of the composition. */ |
| 73 | 83 | cx: number; |
| 74 | 84 | cy: number; |
@@ -1173,6 +1183,967 @@ function SpinzaOriginal(s: Stage) { | ||
| 1173 | 1183 | ); |
| 1174 | 1184 | } |
| 1175 | 1185 | |
| 1186 | +/* ======================================================================= */ | |
| 1187 | +/* RISK GAMES + BEYOND SLOTS — no reel symbols. Shared grammar: a rising */ | |
| 1188 | +/* multiplier ladder (`Rungs`) and the big-button verb as a solid pill */ | |
| 1189 | +/* (`Verb`) so the cash-out / original category reads instantly. */ | |
| 1190 | +/* ======================================================================= */ | |
| 1191 | + | |
| 1192 | +/** Ad-hoc glyph from the shared symbol vocabulary (no reel plate, no halo). */ | |
| 1193 | +function Shape({ shape, color, accent, x, y, size, rotate, opacity, label }: { shape: SymbolShape; color: string; accent?: string; x: number; y: number; size: number; rotate?: number; opacity?: number; label?: string }) { | |
| 1194 | + return <SymbolSvg style={{ shape, color, accent, label, glow: 0 }} size={size} x={x} y={y} rotate={rotate} opacity={opacity} plate={false} halo={false} />; | |
| 1195 | +} | |
| 1196 | + | |
| 1197 | +/** The game's big-button verb as a solid pill with a "button" dot. */ | |
| 1198 | +function Verb({ x, y, text, color, ink = "#0b0d14", size = 12, opacity = 1 }: { x: number; y: number; text: string; color: string; ink?: string; size?: number; opacity?: number }) { | |
| 1199 | + const width = text.length * size * 0.7 + size * 2.6; | |
| 1200 | + const h = size * 1.9; | |
| 1201 | + const left = x - width / 2; | |
| 1202 | + return ( | |
| 1203 | + <g opacity={opacity}> | |
| 1204 | + <rect x={f(left)} y={f(y - h / 2)} width={f(width)} height={f(h)} rx={f(h / 2)} fill={color} stroke="#fff" strokeOpacity={0.35} strokeWidth={1} /> | |
| 1205 | + <circle cx={f(left + h * 0.58)} cy={f(y)} r={f(size * 0.24)} fill={ink} fillOpacity={0.75} /> | |
| 1206 | + <text x={f(x + size * 0.42)} y={f(y)} textAnchor="middle" dominantBaseline="central" fontSize={f(size)} fontWeight={800} fill={ink} style={{ fontFamily: FONT, letterSpacing: "0.1em" }}> | |
| 1207 | + {text} | |
| 1208 | + </text> | |
| 1209 | + </g> | |
| 1210 | + ); | |
| 1211 | +} | |
| 1212 | + | |
| 1213 | +/** Multiplier ladder labels: opacity and size grow along the list, the last rung is hot. */ | |
| 1214 | +function Rungs({ items, color, hot, size, anchor = "middle", weight = 700 }: { items: Array<[number, number, string]>; color: string; hot: string; size: number; anchor?: "start" | "middle" | "end"; weight?: 600 | 700 | 800 }) { | |
| 1215 | + const n = Math.max(1, items.length - 1); | |
| 1216 | + return ( | |
| 1217 | + <> | |
| 1218 | + {items.map(([x, y, t], i) => ( | |
| 1219 | + <Small key={`${t}${i}`} x={x} y={y} text={t} color={i === n ? hot : color} size={i === n ? size * 1.3 : size} anchor={anchor} weight={i === n ? 800 : weight} tracking="0.06em" opacity={0.5 + (i / n) * 0.5} /> | |
| 1220 | + ))} | |
| 1221 | + </> | |
| 1222 | + ); | |
| 1223 | +} | |
| 1224 | + | |
| 1225 | +/* 21 — SKYFALL: a jet climbs from the cloud deck through the storm into space; altitude rungs on the left */ | |
| 1226 | +function Skyfall(s: Stage) { | |
| 1227 | + const { W, H, cx, cy, R, id, p, compact } = s; | |
| 1228 | + const stars = scatterDots(s, 55, [0, 0, W, Math.max(1, cy - R * 0.45)], 0.5, 1.4); | |
| 1229 | + const cloudY = cy + R * 1.02; | |
| 1230 | + const clouds: Array<[number, number, number]> = []; | |
| 1231 | + for (let i = 0; i < 12; i++) clouds.push([cx - R * 1.4 + i * R * 0.26 + s.rnd() * R * 0.1, cloudY + s.rnd() * R * 0.2, R * (0.12 + s.rnd() * 0.15)]); | |
| 1232 | + const jx = cx + R * 0.52; | |
| 1233 | + const jy = cy - R * 0.48; | |
| 1234 | + const trail = `M${f(cx - R * 1.15)} ${f(cy + R * 0.98)} C${f(cx - R * 0.1)} ${f(cy + R * 0.98)} ${f(cx + R * 0.05)} ${f(cy + R * 0.25)} ${f(jx - R * 0.1)} ${f(jy + R * 0.1)}`; | |
| 1235 | + const jet = `M${f(R * 0.72)} 0 L${f(R * 0.12)} ${f(-R * 0.075)} L${f(-R * 0.56)} ${f(-R * 0.06)} L${f(-R * 0.62)} 0 L${f(-R * 0.56)} ${f(R * 0.06)} L${f(R * 0.12)} ${f(R * 0.075)} Z`; | |
| 1236 | + const wings = `M${f(R * 0.06)} ${f(-R * 0.05)} L${f(-R * 0.48)} ${f(-R * 0.44)} L${f(-R * 0.62)} ${f(-R * 0.42)} L${f(-R * 0.44)} ${f(-R * 0.05)} Z M${f(R * 0.06)} ${f(R * 0.05)} L${f(-R * 0.48)} ${f(R * 0.44)} L${f(-R * 0.62)} ${f(R * 0.42)} L${f(-R * 0.44)} ${f(R * 0.05)} Z`; | |
| 1237 | + const bolt = `M${f(cx - R * 0.62)} ${f(cy + R * 0.15)} l${f(R * 0.1)} ${f(R * 0.2)} l${f(-R * 0.09)} ${f(R * 0.02)} l${f(R * 0.14)} ${f(R * 0.26)}`; | |
| 1238 | + const lx = cx - R * 1.14; | |
| 1239 | + const rungs: Array<[number, number, string]> = [[lx, cy + R * 0.78, "1.5× CLOUDS"], [lx, cy + R * 0.38, "3× STORM FRONT"], [lx, cy - R * 0.02, "8× STRATOSPHERE"], [lx, cy - R * 0.42, "25× EDGE OF SPACE"], [lx, cy - R * 0.82, "100× ORBIT"]]; | |
| 1240 | + let ticks = ""; | |
| 1241 | + for (const [x, y] of rungs) ticks += `M${f(x - R * 0.06)} ${f(y)}h${f(R * 0.04)} M${f(x - R * 0.04)} ${f(y - R * 0.2)}v${f(R * 0.4)} `; | |
| 1242 | + return ( | |
| 1243 | + <g> | |
| 1244 | + <defs> | |
| 1245 | + <Linear id={`${id}-sf1`} stops={[[0, "#020617"], [0.32, "#1e1b4b"], [0.58, "#334155"], [0.82, "#94a3b8"], [1, "#e2e8f0"]]} /> | |
| 1246 | + <Linear id={`${id}-sf2`} stops={[[0, p.secondary, 0], [1, p.secondary, 1]]} dir="h" /> | |
| 1247 | + <Radial id={`${id}-sf3`} color={p.secondary} o0={0.9} /> | |
| 1248 | + </defs> | |
| 1249 | + <rect width={W} height={H} fill={`url(#${id}-sf1)`} /> | |
| 1250 | + <path d={stars} fill="#fff" fillOpacity={0.8} /> | |
| 1251 | + <rect x={0} y={f(cy + R * 0.05)} width={W} height={f(R * 0.7)} fill="#0f172a" fillOpacity={0.35} /> | |
| 1252 | + <path d={bolt} stroke={p.glow} strokeWidth={2} fill="none" strokeLinecap="round" strokeLinejoin="round" opacity={0.9} /> | |
| 1253 | + <path d={dots(clouds)} fill="#f8fafc" fillOpacity={0.9} /> | |
| 1254 | + <path d={dots(clouds.map(([x, y, r]) => [x + r * 0.2, y + r * 0.35, r * 0.8]))} fill="#cbd5e1" fillOpacity={0.6} /> | |
| 1255 | + <path d={trail} fill="none" stroke={`url(#${id}-sf2)`} strokeWidth={f(R * 0.05)} strokeLinecap="round" /> | |
| 1256 | + <path d={trail} fill="none" stroke="#fff" strokeOpacity={0.8} strokeWidth={1.5} strokeDasharray="3 8" /> | |
| 1257 | + {!compact ? <path d={ticks} stroke={p.primary} strokeOpacity={0.6} strokeWidth={1.5} fill="none" /> : null} | |
| 1258 | + {!compact ? <Rungs items={rungs} color={p.primary} hot="#fff" size={R * 0.062} anchor="start" /> : null} | |
| 1259 | + <circle cx={f(jx - R * 0.45 * Math.cos(0.61))} cy={f(jy + R * 0.45 * Math.sin(0.61))} r={f(R * 0.14)} fill={`url(#${id}-sf3)`} /> | |
| 1260 | + <g transform={`translate(${f(jx)} ${f(jy)}) rotate(-35)`}> | |
| 1261 | + <path d={wings} fill="#94a3b8" stroke="#e2e8f0" strokeWidth={1} /> | |
| 1262 | + <path d={jet} fill="#f1f5f9" stroke="#64748b" strokeWidth={1} /> | |
| 1263 | + <ellipse cx={f(R * 0.28)} cy={f(-R * 0.02)} rx={f(R * 0.12)} ry={f(R * 0.045)} fill={p.secondary} /> | |
| 1264 | + <path d={`M${f(-R * 0.62)} 0 h${f(-R * 0.28)}`} stroke={p.secondary} strokeWidth={f(R * 0.05)} strokeLinecap="round" strokeOpacity={0.9} /> | |
| 1265 | + </g> | |
| 1266 | + {!compact ? <Verb x={cx + R * 0.72} y={cy + R * 0.26} text="CASH OUT" color={p.secondary} size={R * 0.075} /> : null} | |
| 1267 | + </g> | |
| 1268 | + ); | |
| 1269 | +} | |
| 1270 | + | |
| 1271 | +/* 22 — DEEP DIVE: research sub sinking past depth markers, a leviathan waiting in the abyss */ | |
| 1272 | +function DeepDive(s: Stage) { | |
| 1273 | + const { W, H, cx, cy, R, id, p, compact } = s; | |
| 1274 | + const surface = cy - R * 1.05; | |
| 1275 | + const bubbles: Array<[number, number, number]> = []; | |
| 1276 | + for (let i = 0; i < 22; i++) bubbles.push([cx - R * 1.1 + s.rnd() * R * 2.2, surface + R * 0.2 + s.rnd() * R * 2.0, 1.5 + s.rnd() * R * 0.03]); | |
| 1277 | + let waves = `M0 ${f(surface)}`; | |
| 1278 | + for (let i = 0; i < 10; i++) waves += ` q${f(W / 20)} ${f(-R * 0.06)} ${f(W / 10)} 0`; | |
| 1279 | + const sx = cx - R * 0.3; | |
| 1280 | + const sy = cy - R * 0.28; | |
| 1281 | + const rx = cx + R * 1.12; | |
| 1282 | + const rungs: Array<[number, number, string]> = [[rx, cy + R * 0.02, "1,000 m ×2"], [rx, cy + R * 0.38, "3,000 m ×5"], [rx, cy + R * 0.74, "ABYSS ×20"]]; | |
| 1283 | + let markers = ""; | |
| 1284 | + for (const [, y] of rungs) markers += `M${f(cx - R * 1.2)} ${f(y + R * 0.11)}H${f(cx + R * 1.2)} `; | |
| 1285 | + const body = `M${f(cx + R * 1.45)} ${f(cy + R * 1.45)} C${f(cx + R * 1.0)} ${f(cy + R * 0.75)} ${f(cx + R * 0.35)} ${f(cy + R * 1.7)} ${f(cx - R * 0.35)} ${f(cy + R * 1.12)}`; | |
| 1286 | + const spines = `M${f(cx + R * 0.95)} ${f(cy + R * 1.0)}l${f(R * 0.05)} ${f(-R * 0.24)}l${f(R * 0.1)} ${f(R * 0.2)} M${f(cx + R * 0.72)} ${f(cy + R * 1.0)}l${f(R * 0.03)} ${f(-R * 0.22)}l${f(R * 0.1)} ${f(R * 0.18)} M${f(cx + R * 1.2)} ${f(cy + R * 1.12)}l${f(R * 0.07)} ${f(-R * 0.22)}l${f(R * 0.09)} ${f(R * 0.22)}`; | |
| 1287 | + return ( | |
| 1288 | + <g> | |
| 1289 | + <defs> | |
| 1290 | + <Linear id={`${id}-dd1`} stops={[[0, "#0e7490", 0.55], [0.3, "#0c4a6e", 0.35], [1, "#000", 0]]} /> | |
| 1291 | + <Linear id={`${id}-dd2`} stops={[[0, p.glow, 0.5], [1, p.glow, 0]]} /> | |
| 1292 | + <Radial id={`${id}-dd3`} color={p.secondary} o0={0.9} /> | |
| 1293 | + </defs> | |
| 1294 | + <rect x={0} y={f(surface)} width={W} height={f(H - surface)} fill={`url(#${id}-dd1)`} /> | |
| 1295 | + <polygon points={`${f(cx - R * 0.9)},${f(surface)} ${f(cx - R * 0.45)},${f(surface)} ${f(cx + R * 0.2)},${f(cy + R * 1.3)} ${f(cx - R * 0.75)},${f(cy + R * 1.3)}`} fill={`url(#${id}-dd2)`} opacity={0.45} /> | |
| 1296 | + <path d={waves} fill="none" stroke="#e0fbff" strokeOpacity={0.9} strokeWidth={2} /> | |
| 1297 | + {!compact ? <path d={markers} stroke={p.primary} strokeOpacity={0.4} strokeWidth={1} strokeDasharray="6 6" /> : null} | |
| 1298 | + {!compact ? <Rungs items={rungs} color={p.primary} hot={p.secondary} size={R * 0.065} anchor="end" /> : null} | |
| 1299 | + <path d={body} fill="none" stroke={p.secondary} strokeOpacity={0.5} strokeWidth={f(R * 0.33)} strokeLinecap="round" /> | |
| 1300 | + <path d={body} fill="none" stroke="#02161f" strokeWidth={f(R * 0.3)} strokeLinecap="round" /> | |
| 1301 | + <path d={body} fill="none" stroke={p.secondary} strokeOpacity={0.3} strokeWidth={f(R * 0.3)} strokeLinecap="round" strokeDasharray="2 14" /> | |
| 1302 | + <path d={spines} fill="#02161f" stroke={p.secondary} strokeOpacity={0.7} strokeWidth={1.2} /> | |
| 1303 | + <ellipse cx={f(cx - R * 0.42)} cy={f(cy + R * 1.12)} rx={f(R * 0.3)} ry={f(R * 0.2)} fill="#02161f" stroke={p.secondary} strokeOpacity={0.5} strokeWidth={1.2} /> | |
| 1304 | + <polygon points={`${f(cx - R * 0.68)},${f(cy + R * 1.09)} ${f(cx - R * 0.98)},${f(cy + R * 1.25)} ${f(cx - R * 0.6)},${f(cy + R * 1.27)}`} fill="#02161f" stroke={p.secondary} strokeOpacity={0.5} strokeWidth={1.2} /> | |
| 1305 | + <circle cx={f(cx - R * 0.55)} cy={f(cy + R * 1.05)} r={f(R * 0.035)} fill={p.secondary} /> | |
| 1306 | + <circle cx={f(cx - R * 0.55)} cy={f(cy + R * 1.05)} r={f(R * 0.1)} fill={`url(#${id}-dd3)`} /> | |
| 1307 | + <polygon points={`${f(sx + R * 0.55)},${f(sy + R * 0.05)} ${f(sx + R * 1.25)},${f(sy + R * 0.65)} ${f(sx + R * 0.7)},${f(sy + R * 0.95)}`} fill={p.primary} fillOpacity={0.16} /> | |
| 1308 | + <g transform={`translate(${f(sx)} ${f(sy)}) rotate(14)`}> | |
| 1309 | + <rect x={f(-R * 0.22)} y={f(-R * 0.42)} width={f(R * 0.26)} height={f(R * 0.3)} rx={f(R * 0.04)} fill="#0b3444" stroke={p.primary} strokeWidth={1.5} /> | |
| 1310 | + <path d={`M${f(-R * 0.09)} ${f(-R * 0.42)}v${f(-R * 0.16)}h${f(R * 0.14)}`} stroke={p.primary} strokeWidth={2} fill="none" /> | |
| 1311 | + <rect x={f(-R * 0.7)} y={f(-R * 0.2)} width={f(R * 1.35)} height={f(R * 0.42)} rx={f(R * 0.21)} fill="#0b3444" stroke={p.primary} strokeWidth={2} /> | |
| 1312 | + <path d={dots([[-R * 0.3, 0, R * 0.055], [-R * 0.05, 0, R * 0.055], [R * 0.2, 0, R * 0.055]])} fill={p.secondary} stroke="#e0fbff" strokeWidth={1} /> | |
| 1313 | + <path d={`M${f(-R * 0.7)} ${f(-R * 0.16)}l${f(-R * 0.16)} ${f(-R * 0.1)}v${f(R * 0.52)}l${f(R * 0.16)} ${f(-R * 0.1)}`} fill="#0b3444" stroke={p.primary} strokeWidth={1.5} /> | |
| 1314 | + <path d={`M${f(-R * 0.86)} ${f(-R * 0.16)}v${f(R * 0.32)}`} stroke={p.primary} strokeWidth={3} strokeLinecap="round" /> | |
| 1315 | + <circle cx={f(R * 0.6)} cy={f(R * 0.02)} r={f(R * 0.06)} fill={p.glow} /> | |
| 1316 | + </g> | |
| 1317 | + <path d={dots(bubbles)} fill="none" stroke="#dffaff" strokeOpacity={0.5} strokeWidth={1.2} /> | |
| 1318 | + {!compact ? <Verb x={cx + R * 0.62} y={surface + R * 0.32} text="SURFACE" color={p.secondary} size={R * 0.075} /> : null} | |
| 1319 | + </g> | |
| 1320 | + ); | |
| 1321 | +} | |
| 1322 | + | |
| 1323 | +/* 23 — ROCKET RUN: launch pad, spent stage tumbling away, trajectory milestones toward a big Mars */ | |
| 1324 | +function RocketRun(s: Stage) { | |
| 1325 | + const { W, H, cx, cy, R, id, p, compact } = s; | |
| 1326 | + const stars = scatterDots(s, 70, [0, 0, W, H], 0.5, 1.4); | |
| 1327 | + const mx = cx + R * 0.86; | |
| 1328 | + const my = cy - R * 0.84; | |
| 1329 | + const mr = R * 0.5; | |
| 1330 | + const ground = cy + R * 1.08; | |
| 1331 | + const px = cx - R * 0.92; | |
| 1332 | + const bez = (t: number): [number, number] => { | |
| 1333 | + const x0 = px + R * 0.12; | |
| 1334 | + const y0 = ground - R * 0.1; | |
| 1335 | + const x1 = cx - R * 0.6; | |
| 1336 | + const y1 = cy - R * 1.4; | |
| 1337 | + const x2 = mx - mr * 0.55; | |
| 1338 | + const y2 = my + mr * 0.75; | |
| 1339 | + const u = 1 - t; | |
| 1340 | + return [u * u * x0 + 2 * u * t * x1 + t * t * x2, u * u * y0 + 2 * u * t * y1 + t * t * y2]; | |
| 1341 | + }; | |
| 1342 | + const traj = `M${f(bez(0)[0])} ${f(bez(0)[1])} Q${f(cx - R * 0.6)} ${f(cy - R * 1.4)} ${f(bez(1)[0])} ${f(bez(1)[1])}`; | |
| 1343 | + const ms: Array<{ t: number; l: string; side: 1 | 0 }> = [ | |
| 1344 | + { t: 0.14, l: "2× MAX-Q", side: 1 }, | |
| 1345 | + { t: 0.28, l: "5× SEPARATION", side: 1 }, | |
| 1346 | + { t: 0.74, l: "15× ESCAPE", side: 0 }, | |
| 1347 | + { t: 0.9, l: "60× TMI", side: 0 }, | |
| 1348 | + ]; | |
| 1349 | + const [rkx, rky] = bez(0.44); | |
| 1350 | + const smoke = scatterDots(s, 14, [px - R * 0.3, ground - R * 0.35, R * 0.9, R * 0.35], R * 0.03, R * 0.1); | |
| 1351 | + return ( | |
| 1352 | + <g> | |
| 1353 | + <defs> | |
| 1354 | + <Radial id={`${id}-rr1`} color="#f97316" o0={1} mid={[0.55, "#c2410c", 1]} o1={0.9} /> | |
| 1355 | + <Radial id={`${id}-rr2`} color={p.glow} o0={0.55} /> | |
| 1356 | + <Radial id={`${id}-rr3`} color={p.glow} o0={1} mid={[0.35, p.primary, 0.8]} /> | |
| 1357 | + </defs> | |
| 1358 | + <path d={stars} fill="#fff" fillOpacity={0.75} /> | |
| 1359 | + <circle cx={f(mx)} cy={f(my)} r={f(mr * 1.7)} fill={`url(#${id}-rr2)`} /> | |
| 1360 | + <circle cx={f(mx)} cy={f(my)} r={f(mr)} fill={`url(#${id}-rr1)`} /> | |
| 1361 | + <ellipse cx={f(mx - mr * 0.25)} cy={f(my + mr * 0.2)} rx={f(mr * 0.42)} ry={f(mr * 0.18)} fill="#7c2d12" fillOpacity={0.7} /> | |
| 1362 | + <ellipse cx={f(mx + mr * 0.35)} cy={f(my - mr * 0.3)} rx={f(mr * 0.25)} ry={f(mr * 0.14)} fill="#7c2d12" fillOpacity={0.6} /> | |
| 1363 | + <ellipse cx={f(mx)} cy={f(my - mr * 0.88)} rx={f(mr * 0.3)} ry={f(mr * 0.08)} fill="#fff7ed" fillOpacity={0.8} /> | |
| 1364 | + <line x1={0} y1={f(ground)} x2={W} y2={f(ground)} stroke="#3b2a4a" strokeWidth={2} /> | |
| 1365 | + <rect x={f(px - R * 0.05)} y={f(ground - R * 0.95)} width={f(R * 0.1)} height={f(R * 0.95)} fill="#2a1d3a" stroke={p.primary} strokeOpacity={0.5} strokeWidth={1} /> | |
| 1366 | + <path d={`M${f(px)} ${f(ground - R * 0.85)}h${f(R * 0.3)} M${f(px)} ${f(ground - R * 0.6)}h${f(R * 0.3)} M${f(px)} ${f(ground - R * 0.35)}h${f(R * 0.3)}`} stroke={p.primary} strokeOpacity={0.55} strokeWidth={2} /> | |
| 1367 | + <path d={smoke} fill="#94a3b8" fillOpacity={0.35} /> | |
| 1368 | + <path d={traj} fill="none" stroke={p.primary} strokeOpacity={0.6} strokeWidth={2} strokeDasharray="5 9" /> | |
| 1369 | + {!compact ? <path d={dots(ms.map((m) => [bez(m.t)[0], bez(m.t)[1], R * 0.02]))} fill={p.glow} /> : null} | |
| 1370 | + {!compact | |
| 1371 | + ? ms.map((m, i) => { | |
| 1372 | + const [x, y] = bez(m.t); | |
| 1373 | + return <Small key={m.l} x={x + m.side * R * 0.09} y={m.side ? y - R * 0.06 : y + R * 0.15} text={m.l} color={i === 3 ? p.secondary : p.primary} size={R * (i === 3 ? 0.072 : 0.06)} anchor={m.side ? "start" : "middle"} weight={i === 3 ? 800 : 700} tracking="0.06em" opacity={0.6 + i * 0.13} />; | |
| 1374 | + }) | |
| 1375 | + : null} | |
| 1376 | + {!compact ? <Pill x={mx - mr * 0.1} y={my + mr * 0.35} text="300× MARS" color={p.secondary} ink="#fff" bg="#3b0a1a" size={R * 0.062} /> : null} | |
| 1377 | + <g transform={`translate(${f(cx - R * 0.72)} ${f(cy + R * 0.5)}) rotate(70)`}> | |
| 1378 | + <rect x={f(-R * 0.09)} y={f(-R * 0.22)} width={f(R * 0.18)} height={f(R * 0.44)} rx={f(R * 0.03)} fill="#c8ccd6" /> | |
| 1379 | + <polygon points={`${f(-R * 0.09)},${f(R * 0.1)} ${f(-R * 0.2)},${f(R * 0.25)} ${f(-R * 0.09)},${f(R * 0.22)} ${f(R * 0.09)},${f(R * 0.1)} ${f(R * 0.2)},${f(R * 0.25)} ${f(R * 0.09)},${f(R * 0.22)}`} fill={p.secondary} /> | |
| 1380 | + <path d={`M${f(-R * 0.05)} ${f(R * 0.22)}l${f(R * 0.05)} ${f(R * 0.18)}l${f(R * 0.05)} ${f(-R * 0.18)}`} fill="#ffb347" fillOpacity={0.85} /> | |
| 1381 | + </g> | |
| 1382 | + <ellipse cx={f(rkx - R * 0.3)} cy={f(rky + R * 0.5)} rx={f(R * 0.12)} ry={f(R * 0.32)} fill={`url(#${id}-rr3)`} transform={`rotate(32 ${f(rkx - R * 0.3)} ${f(rky + R * 0.5)})`} /> | |
| 1383 | + <Shape shape="rocket" color="#f1f5f9" accent={p.secondary} x={rkx} y={rky} size={R * 1.15} rotate={32} /> | |
| 1384 | + {!compact ? <Verb x={cx + R * 0.5} y={cy + R * 0.85} text="SECURE PAYLOAD" color={p.secondary} size={R * 0.068} /> : null} | |
| 1385 | + </g> | |
| 1386 | + ); | |
| 1387 | +} | |
| 1388 | + | |
| 1389 | +/* 24 — BANK HEIST: inside the vault, safe-deposit wall, growing SC bags, red/blue siren sweep */ | |
| 1390 | +function BankHeist(s: Stage) { | |
| 1391 | + const { W, H, cx, cy, R, id, p, compact } = s; | |
| 1392 | + const floor = cy + R * 0.98; | |
| 1393 | + const lampX = cx + R * 0.1; | |
| 1394 | + const lampY = cy - R * 1.22; | |
| 1395 | + const bags = [ | |
| 1396 | + { x: cx - R * 0.82, y: floor - R * 0.02, k: 0.5, m: "×1.4" }, | |
| 1397 | + { x: cx - R * 0.28, y: floor - R * 0.06, k: 0.68, m: "×2" }, | |
| 1398 | + { x: cx + R * 0.45, y: floor - R * 0.12, k: 1.0, m: "×4" }, | |
| 1399 | + ]; | |
| 1400 | + let bars = ""; | |
| 1401 | + for (let i = 0; i < 6; i++) bars += `M${f(cx + R * 0.95 + (i % 3) * R * 0.02)} ${f(floor - R * 0.09 * (Math.floor(i / 3) + 1))}h${f(R * 0.26)}v${f(R * 0.08)}h${f(-R * 0.26)}z `; | |
| 1402 | + return ( | |
| 1403 | + <g> | |
| 1404 | + <defs> | |
| 1405 | + <pattern id={`${id}-bh0`} width={f(R * 0.2)} height={f(R * 0.14)} patternUnits="userSpaceOnUse"> | |
| 1406 | + <rect x={1} y={1} width={f(R * 0.2 - 2)} height={f(R * 0.14 - 2)} rx={2} fill="#1b1811" stroke="#3a3324" strokeWidth={1} /> | |
| 1407 | + <circle cx={f(R * 0.1)} cy={f(R * 0.07)} r={1.2} fill={p.primary} fillOpacity={0.55} /> | |
| 1408 | + </pattern> | |
| 1409 | + <Linear id={`${id}-bh1`} stops={[[0, "#3a2f1a"], [1, "#0b0a08"]]} /> | |
| 1410 | + <Radial id={`${id}-bh2`} color={p.glow} o0={0.5} /> | |
| 1411 | + </defs> | |
| 1412 | + <rect x={0} y={0} width={W} height={f(floor)} fill={`url(#${id}-bh0)`} /> | |
| 1413 | + <rect x={0} y={f(floor)} width={W} height={f(H - floor)} fill={`url(#${id}-bh1)`} /> | |
| 1414 | + <line x1={0} y1={f(floor)} x2={W} y2={f(floor)} stroke={p.primary} strokeOpacity={0.7} strokeWidth={2} /> | |
| 1415 | + <polygon points={`${f(lampX)},${f(lampY)} ${f(cx - R * 1.5)},${f(floor + R * 0.5)} ${f(cx - R * 0.55)},${f(floor + R * 0.5)}`} fill="#ef4444" fillOpacity={0.22} /> | |
| 1416 | + <polygon points={`${f(lampX)},${f(lampY)} ${f(cx + R * 0.7)},${f(floor + R * 0.5)} ${f(cx + R * 1.6)},${f(floor + R * 0.5)}`} fill="#3b82f6" fillOpacity={0.22} /> | |
| 1417 | + <path d={`M${f(lampX - R * 0.16)} ${f(lampY + R * 0.1)}a${f(R * 0.16)} ${f(R * 0.16)} 0 0 1 ${f(R * 0.32)} 0z`} fill="#ef4444" /> | |
| 1418 | + <path d={`M${f(lampX)} ${f(lampY + R * 0.1)}a${f(R * 0.16)} ${f(R * 0.16)} 0 0 1 ${f(R * 0.16)} 0z`} fill="#3b82f6" /> | |
| 1419 | + <rect x={f(lampX - R * 0.2)} y={f(lampY + R * 0.1)} width={f(R * 0.4)} height={f(R * 0.05)} fill="#52525b" /> | |
| 1420 | + <circle cx={f(cx + R * 1.55)} cy={f(cy - R * 0.1)} r={f(R * 1.05)} fill="#14110c" stroke={p.primary} strokeWidth={f(R * 0.06)} /> | |
| 1421 | + <circle cx={f(cx + R * 1.55)} cy={f(cy - R * 0.1)} r={f(R * 0.86)} fill="none" stroke={p.primary} strokeOpacity={0.5} strokeWidth={2} /> | |
| 1422 | + <path d={dots([[cx + R * 0.55, cy - R * 0.1, R * 0.03], [cx + R * 0.66, cy - R * 0.62, R * 0.03], [cx + R * 0.66, cy + R * 0.42, R * 0.03]])} fill={p.glow} /> | |
| 1423 | + <circle cx={f(cx + R * 0.45)} cy={f(floor - R * 0.4)} r={f(R * 0.7)} fill={`url(#${id}-bh2)`} /> | |
| 1424 | + <path d={bars} fill={p.primary} stroke="#5a4520" strokeWidth={1} /> | |
| 1425 | + {(compact ? bags.slice(2) : bags).map((b) => ( | |
| 1426 | + <g key={b.m}> | |
| 1427 | + <Shape shape="bag" color={p.primary} accent="#8a6a2a" x={b.x} y={b.y - R * 0.34 * b.k} size={R * 0.95 * b.k} /> | |
| 1428 | + <Small x={b.x} y={b.y - R * 0.3 * b.k} text="SC" color="#3a2a08" size={R * 0.16 * b.k} weight={800} tracking="0.02em" /> | |
| 1429 | + {!compact ? <Pill x={b.x} y={b.y + R * 0.12} text={b.m} color={p.primary} ink={p.glow} size={R * 0.07} /> : null} | |
| 1430 | + </g> | |
| 1431 | + ))} | |
| 1432 | + {!compact ? <Small x={cx - R * 1.1} y={cy - R * 0.38} text="×1.1 ×1.4 ×2 ×3 ×4 ×12 ×50" color={p.glow} size={R * 0.062} tracking="0.12em" anchor="start" opacity={0.8} /> : null} | |
| 1433 | + {!compact ? <Verb x={cx - R * 0.6} y={cy - R * 0.66} text="ESCAPE NOW" color={p.secondary} ink="#fff" size={R * 0.078} /> : null} | |
| 1434 | + </g> | |
| 1435 | + ); | |
| 1436 | +} | |
| 1437 | + | |
| 1438 | +/* 25 — VOLCANO: explorer on a rope in a crystal-lined shaft, lava rising from below */ | |
| 1439 | +function Volcano(s: Stage) { | |
| 1440 | + const { W, H, cx, cy, R, id, p, compact } = s; | |
| 1441 | + const lavaY = cy + R * 0.82; | |
| 1442 | + const wall = (side: 1 | -1) => { | |
| 1443 | + let d = `M${f(cx + side * W)} 0 L${f(cx + side * R * 0.62)} 0`; | |
| 1444 | + let y = 0; | |
| 1445 | + while (y < H) { | |
| 1446 | + y += R * 0.22; | |
| 1447 | + d += ` L${f(cx + side * R * (0.7 + s.rnd() * 0.22))} ${f(y)}`; | |
| 1448 | + } | |
| 1449 | + return d + ` L${f(cx + side * W)} ${f(H)} Z`; | |
| 1450 | + }; | |
| 1451 | + const left = wall(-1); | |
| 1452 | + const right = wall(1); | |
| 1453 | + const embers = scatterDots(s, 30, [cx - R * 0.8, lavaY - R * 1.4, R * 1.6, R * 1.4], 0.8, 2.4); | |
| 1454 | + let lava = `M${f(cx - R * 1.3)} ${f(lavaY)}`; | |
| 1455 | + for (let i = 0; i < 8; i++) lava += ` q${f(R * 0.16)} ${f(i % 2 ? R * 0.08 : -R * 0.08)} ${f(R * 0.33)} 0`; | |
| 1456 | + lava += ` V${f(H)} H${f(cx - R * 1.3)} Z`; | |
| 1457 | + const ex = cx - R * 0.12; | |
| 1458 | + const ey = cy - R * 0.02; | |
| 1459 | + const rx = cx + R * 0.5; | |
| 1460 | + const rungs: Array<[number, number, string]> = [[rx, cy - R * 0.98, "1.5× ASH CAVES"], [rx, cy - R * 0.48, "3× OBSIDIAN"], [rx, cy + R * 0.02, "8× CRYSTALS"], [rx, cy + R * 0.52, "30× MAGMA"]]; | |
| 1461 | + return ( | |
| 1462 | + <g> | |
| 1463 | + <defs> | |
| 1464 | + <Linear id={`${id}-vc1`} stops={[[0, "#fde047"], [0.35, p.glow], [1, "#7f1d1d"]]} /> | |
| 1465 | + <Radial id={`${id}-vc2`} color={p.primary} o0={0.75} /> | |
| 1466 | + </defs> | |
| 1467 | + <ellipse cx={f(cx)} cy={f(lavaY)} rx={f(R * 1.4)} ry={f(R * 1.1)} fill={`url(#${id}-vc2)`} /> | |
| 1468 | + <path d={left} fill="#170705" stroke="#4a1a10" strokeWidth={1.5} strokeLinejoin="round" /> | |
| 1469 | + <path d={right} fill="#170705" stroke="#4a1a10" strokeWidth={1.5} strokeLinejoin="round" /> | |
| 1470 | + {!compact ? ( | |
| 1471 | + <> | |
| 1472 | + <Shape shape="crystal" color={p.secondary} accent="#fff7d6" x={cx - R * 0.82} y={cy - R * 0.62} size={R * 0.5} rotate={40} /> | |
| 1473 | + <Shape shape="crystal" color={p.secondary} accent="#fff7d6" x={cx + R * 0.86} y={cy + R * 0.32} size={R * 0.55} rotate={-38} /> | |
| 1474 | + <Shape shape="crystal" color="#f0abfc" accent="#fdf4ff" x={cx - R * 0.94} y={cy + R * 0.12} size={R * 0.4} rotate={62} /> | |
| 1475 | + <Shape shape="crystal" color={p.secondary} accent="#fff7d6" x={cx + R * 0.9} y={cy - R * 0.72} size={R * 0.36} rotate={-70} /> | |
| 1476 | + </> | |
| 1477 | + ) : null} | |
| 1478 | + <line x1={f(ex)} y1={0} x2={f(ex)} y2={f(ey - R * 0.3)} stroke="#d6b07a" strokeWidth={2.5} /> | |
| 1479 | + <line x1={f(ex)} y1={0} x2={f(ex)} y2={f(ey - R * 0.3)} stroke="#6b4a1a" strokeWidth={2.5} strokeDasharray="3 5" /> | |
| 1480 | + <g fill="#120404" stroke={p.glow} strokeWidth={1.2} strokeLinejoin="round"> | |
| 1481 | + <circle cx={f(ex)} cy={f(ey - R * 0.22)} r={f(R * 0.085)} /> | |
| 1482 | + <rect x={f(ex - R * 0.09)} y={f(ey - R * 0.13)} width={f(R * 0.18)} height={f(R * 0.3)} rx={f(R * 0.04)} /> | |
| 1483 | + <path d={`M${f(ex - R * 0.06)} ${f(ey - R * 0.1)}L${f(ex - R * 0.03)} ${f(ey - R * 0.38)} M${f(ex + R * 0.06)} ${f(ey - R * 0.1)}L${f(ex + R * 0.04)} ${f(ey - R * 0.38)} M${f(ex - R * 0.05)} ${f(ey + R * 0.17)}L${f(ex - R * 0.16)} ${f(ey + R * 0.42)} M${f(ex + R * 0.05)} ${f(ey + R * 0.17)}L${f(ex + R * 0.12)} ${f(ey + R * 0.44)}`} fill="none" strokeWidth={f(R * 0.05)} strokeLinecap="round" /> | |
| 1484 | + </g> | |
| 1485 | + <polygon points={`${f(ex + R * 0.06)},${f(ey - R * 0.24)} ${f(ex + R * 0.55)},${f(ey - R * 0.02)} ${f(ex + R * 0.5)},${f(ey + R * 0.3)}`} fill="#fde68a" fillOpacity={0.22} /> | |
| 1486 | + <circle cx={f(ex + R * 0.07)} cy={f(ey - R * 0.24)} r={f(R * 0.03)} fill="#fde68a" /> | |
| 1487 | + <path d={lava} fill={`url(#${id}-vc1)`} /> | |
| 1488 | + <path d={lava} fill="none" stroke="#fff7d6" strokeOpacity={0.7} strokeWidth={2} /> | |
| 1489 | + <path d={`M${f(cx - R * 0.3)} ${f(lavaY - R * 0.3)}l${f(R * 0.1)} ${f(-R * 0.12)}l${f(R * 0.1)} ${f(R * 0.12)} M${f(cx + R * 0.15)} ${f(lavaY - R * 0.24)}l${f(R * 0.1)} ${f(-R * 0.12)}l${f(R * 0.1)} ${f(R * 0.12)}`} fill="none" stroke={p.secondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" /> | |
| 1490 | + <path d={embers} fill={p.secondary} fillOpacity={0.85} /> | |
| 1491 | + {!compact ? <Rungs items={rungs} color={p.secondary} hot="#fff" size={R * 0.06} anchor="start" /> : null} | |
| 1492 | + {!compact ? <Verb x={cx - R * 0.62} y={cy + R * 0.52} text="EVACUATE" color={p.secondary} size={R * 0.078} /> : null} | |
| 1493 | + </g> | |
| 1494 | + ); | |
| 1495 | +} | |
| 1496 | + | |
| 1497 | +/* 26 — BLACK HOLE: lensed disc, time-dilation streaks, a ship spiralling to the horizon */ | |
| 1498 | +function BlackHole(s: Stage) { | |
| 1499 | + const { W, H, cx, cy, R, id, p, compact } = s; | |
| 1500 | + const bx = cx + R * 0.12; | |
| 1501 | + const by = cy + R * 0.12; | |
| 1502 | + const rb = R * 0.4; | |
| 1503 | + const stars = scatterDots(s, 60, [0, 0, W, H], 0.5, 1.3); | |
| 1504 | + let streaks = ""; | |
| 1505 | + for (let i = 0; i < 44; i++) { | |
| 1506 | + const a = s.rnd() * PI * 2; | |
| 1507 | + const r0 = rb * 1.7 + s.rnd() * R * 0.5; | |
| 1508 | + const r1 = r0 + R * (0.25 + s.rnd() * 0.9); | |
| 1509 | + streaks += `M${f(bx + Math.cos(a) * r0)} ${f(by + Math.sin(a) * r0)}L${f(bx + Math.cos(a) * r1)} ${f(by + Math.sin(a) * r1)} `; | |
| 1510 | + } | |
| 1511 | + const sp = (t: number): [number, number] => { | |
| 1512 | + const r = R * 1.3 * Math.exp(-0.42 * t); | |
| 1513 | + return [bx + Math.cos(t - 2.6) * r, by + Math.sin(t - 2.6) * r * 0.92]; | |
| 1514 | + }; | |
| 1515 | + let spiral = ""; | |
| 1516 | + for (let i = 0; i <= 60; i++) { | |
| 1517 | + const [x, y] = sp((i / 60) * PI * 2.4); | |
| 1518 | + spiral += `${i ? "L" : "M"}${f(x)} ${f(y)} `; | |
| 1519 | + } | |
| 1520 | + const [shx, shy] = sp(PI * 1.35); | |
| 1521 | + const [nx, ny] = sp(PI * 1.42); | |
| 1522 | + const ang = (Math.atan2(ny - shy, nx - shx) * 180) / PI; | |
| 1523 | + const rungs: Array<[number, number, string]> = ([0.12, 0.5, 0.86, 1.15] as const).map((k, i) => { | |
| 1524 | + const [x, y] = sp(PI * k); | |
| 1525 | + return [x, y - R * 0.08, ["1.5×", "4×", "15×", "60×"][i]]; | |
| 1526 | + }); | |
| 1527 | + const disc = `rotate(-14 ${f(bx)} ${f(by)})`; | |
| 1528 | + return ( | |
| 1529 | + <g> | |
| 1530 | + <defs> | |
| 1531 | + <Linear id={`${id}-bk1`} stops={[[0, p.primary, 0], [0.35, "#fff", 0.95], [0.7, p.secondary, 0.9], [1, p.primary, 0]]} dir="h" /> | |
| 1532 | + <Radial id={`${id}-bk2`} color={p.primary} o0={0.4} /> | |
| 1533 | + </defs> | |
| 1534 | + <path d={stars} fill="#fff" fillOpacity={0.7} /> | |
| 1535 | + <circle cx={f(bx)} cy={f(by)} r={f(R * 1.5)} fill={`url(#${id}-bk2)`} /> | |
| 1536 | + <path d={streaks} stroke={p.primary} strokeOpacity={0.5} strokeWidth={1.2} strokeLinecap="round" /> | |
| 1537 | + <g transform={disc}> | |
| 1538 | + <path d={`M${f(bx - R * 1.3)} ${f(by)} A${f(R * 1.3)} ${f(R * 0.24)} 0 0 1 ${f(bx + R * 1.3)} ${f(by)}`} fill="none" stroke={`url(#${id}-bk1)`} strokeWidth={f(R * 0.09)} strokeOpacity={0.65} /> | |
| 1539 | + </g> | |
| 1540 | + <circle cx={f(bx)} cy={f(by)} r={f(rb * 1.12)} fill="none" stroke="#fff" strokeOpacity={0.95} strokeWidth={f(R * 0.02)} /> | |
| 1541 | + <circle cx={f(bx)} cy={f(by)} r={f(rb * 1.25)} fill="none" stroke={p.secondary} strokeOpacity={0.55} strokeWidth={f(R * 0.05)} /> | |
| 1542 | + <circle cx={f(bx)} cy={f(by)} r={f(rb)} fill="#000" /> | |
| 1543 | + <g transform={disc}> | |
| 1544 | + <path d={`M${f(bx - R * 1.3)} ${f(by)} A${f(R * 1.3)} ${f(R * 0.24)} 0 0 0 ${f(bx + R * 1.3)} ${f(by)}`} fill="none" stroke={`url(#${id}-bk1)`} strokeWidth={f(R * 0.11)} /> | |
| 1545 | + <path d={`M${f(bx - R * 1.3)} ${f(by)} A${f(R * 1.3)} ${f(R * 0.24)} 0 0 0 ${f(bx + R * 1.3)} ${f(by)}`} fill="none" stroke="#fff" strokeOpacity={0.85} strokeWidth={1.5} /> | |
| 1546 | + </g> | |
| 1547 | + <path d={spiral} fill="none" stroke={p.secondary} strokeOpacity={0.85} strokeWidth={1.6} strokeDasharray="4 6" /> | |
| 1548 | + {!compact ? <Rungs items={rungs} color={p.glow} hot="#fff" size={R * 0.07} /> : null} | |
| 1549 | + <g transform={`translate(${f(shx)} ${f(shy)}) rotate(${f(ang)})`}> | |
| 1550 | + <path d={`M${f(-R * 0.55)} 0 h${f(-R * 0.35)}`} stroke={p.secondary} strokeWidth={f(R * 0.05)} strokeLinecap="round" strokeOpacity={0.7} /> | |
| 1551 | + <polygon points={`${f(R * 0.16)},0 ${f(-R * 0.12)},${f(-R * 0.1)} ${f(-R * 0.06)},0 ${f(-R * 0.12)},${f(R * 0.1)}`} fill="#f5f3ff" stroke={p.primary} strokeWidth={1} /> | |
| 1552 | + </g> | |
| 1553 | + {!compact ? <Verb x={cx - R * 0.75} y={cy + R * 0.88} text="WARP OUT" color={p.secondary} size={R * 0.078} /> : null} | |
| 1554 | + </g> | |
| 1555 | + ); | |
| 1556 | +} | |
| 1557 | + | |
| 1558 | +/* 27 — FREEFALL: skydiver above a patchwork of fields rushing up, ghost of the unopened chute */ | |
| 1559 | +function Freefall(s: Stage) { | |
| 1560 | + const { W, H, cx, cy, R, id, p, compact } = s; | |
| 1561 | + const y0 = cy + R * 0.3; | |
| 1562 | + const cols = 8; | |
| 1563 | + const tints = ["#365314", "#65a30d", "#a16207", "#4d7c0f", "#ca8a04", "#1a2e05"]; | |
| 1564 | + const paths = tints.map(() => ""); | |
| 1565 | + for (let k = 0; k < 6; k++) { | |
| 1566 | + const t0 = Math.pow(k / 6, 1.55); | |
| 1567 | + const t1 = Math.pow((k + 1) / 6, 1.55); | |
| 1568 | + const ya = y0 + (H - y0) * t0; | |
| 1569 | + const yb = y0 + (H - y0) * t1; | |
| 1570 | + const spreadA = 1 + k * 0.16; | |
| 1571 | + const spreadB = 1 + (k + 1) * 0.16; | |
| 1572 | + const cw = R * 0.36; | |
| 1573 | + for (let j = 0; j < cols; j++) { | |
| 1574 | + const c = Math.floor(s.rnd() * tints.length); | |
| 1575 | + const xa0 = cx + (j - cols / 2) * cw * spreadA; | |
| 1576 | + const xa1 = cx + (j + 1 - cols / 2) * cw * spreadA; | |
| 1577 | + const xb0 = cx + (j - cols / 2) * cw * spreadB; | |
| 1578 | + const xb1 = cx + (j + 1 - cols / 2) * cw * spreadB; | |
| 1579 | + paths[c] += `M${f(xa0)} ${f(ya)}L${f(xa1)} ${f(ya)}L${f(xb1)} ${f(yb)}L${f(xb0)} ${f(yb)}Z `; | |
| 1580 | + } | |
| 1581 | + } | |
| 1582 | + let speed = ""; | |
| 1583 | + for (let i = 0; i < 14; i++) { | |
| 1584 | + const x = cx - R * 1.2 + s.rnd() * R * 2.4; | |
| 1585 | + const y = cy - R * 1.2 + s.rnd() * R * 1.4; | |
| 1586 | + speed += `M${f(x)} ${f(y)}v${f(R * (0.15 + s.rnd() * 0.35))} `; | |
| 1587 | + } | |
| 1588 | + const dx = cx; | |
| 1589 | + const dy = cy - R * 0.32; | |
| 1590 | + const rx = cx + R * 1.12; | |
| 1591 | + const rungs: Array<[number, number, string]> = [[rx, cy - R * 0.95, "1.3× TERMINAL"], [rx, cy - R * 0.45, "3× CLOUD DECK"], [rx, cy + R * 0.05, "10× TREETOPS"], [rx, cy + R * 0.55, "40× GROUND RUSH"]]; | |
| 1592 | + return ( | |
| 1593 | + <g> | |
| 1594 | + <defs> | |
| 1595 | + <Linear id={`${id}-ff1`} stops={[[0, "#0b1a34"], [1, "#1e3a5f"]]} /> | |
| 1596 | + <Radial id={`${id}-ff2`} color="#fff" o0={0.18} /> | |
| 1597 | + </defs> | |
| 1598 | + <rect width={W} height={H} fill={`url(#${id}-ff1)`} /> | |
| 1599 | + {paths.map((d, i) => (d ? <path key={i} d={d} fill={tints[i]} stroke="#0b1a1a" strokeWidth={1} /> : null))} | |
| 1600 | + <rect x={0} y={f(y0)} width={W} height={f(H - y0)} fill={`url(#${id}-ff1)`} opacity={0.35} /> | |
| 1601 | + <ellipse cx={f(cx - R * 0.7)} cy={f(cy + R * 0.42)} rx={f(R * 0.5)} ry={f(R * 0.12)} fill={`url(#${id}-ff2)`} /> | |
| 1602 | + <ellipse cx={f(cx + R * 0.55)} cy={f(cy + R * 0.62)} rx={f(R * 0.42)} ry={f(R * 0.1)} fill={`url(#${id}-ff2)`} /> | |
| 1603 | + <path d={speed} stroke={p.glow} strokeOpacity={0.5} strokeWidth={1.5} strokeLinecap="round" /> | |
| 1604 | + {!compact ? ( | |
| 1605 | + <> | |
| 1606 | + <path d={`M${f(dx - R * 0.72)} ${f(dy - R * 0.62)} A${f(R * 0.72)} ${f(R * 0.5)} 0 0 1 ${f(dx + R * 0.72)} ${f(dy - R * 0.62)}`} fill="none" stroke={p.secondary} strokeOpacity={0.75} strokeWidth={2} strokeDasharray="5 6" /> | |
| 1607 | + <path d={`M${f(dx - R * 0.72)} ${f(dy - R * 0.62)}L${f(dx - R * 0.1)} ${f(dy - R * 0.02)} M${f(dx + R * 0.72)} ${f(dy - R * 0.62)}L${f(dx + R * 0.1)} ${f(dy - R * 0.02)} M${f(dx - R * 0.25)} ${f(dy - R * 0.98)}L${f(dx - R * 0.03)} ${f(dy - R * 0.05)} M${f(dx + R * 0.25)} ${f(dy - R * 0.98)}L${f(dx + R * 0.03)} ${f(dy - R * 0.05)}`} stroke={p.secondary} strokeOpacity={0.45} strokeWidth={1} strokeDasharray="3 5" /> | |
| 1608 | + </> | |
| 1609 | + ) : null} | |
| 1610 | + <g stroke="#0b1a34" strokeWidth={1.2} strokeLinejoin="round" strokeLinecap="round"> | |
| 1611 | + <path d={`M${f(dx - R * 0.1)} ${f(dy - R * 0.04)}L${f(dx - R * 0.5)} ${f(dy - R * 0.3)} M${f(dx + R * 0.1)} ${f(dy - R * 0.04)}L${f(dx + R * 0.5)} ${f(dy - R * 0.3)} M${f(dx - R * 0.08)} ${f(dy + R * 0.3)}L${f(dx - R * 0.42)} ${f(dy + R * 0.62)} M${f(dx + R * 0.08)} ${f(dy + R * 0.3)}L${f(dx + R * 0.42)} ${f(dy + R * 0.62)}`} fill="none" stroke={p.secondary} strokeWidth={f(R * 0.09)} /> | |
| 1612 | + <rect x={f(dx - R * 0.14)} y={f(dy - R * 0.14)} width={f(R * 0.28)} height={f(R * 0.5)} rx={f(R * 0.08)} fill={p.secondary} /> | |
| 1613 | + <rect x={f(dx - R * 0.08)} y={f(dy - R * 0.1)} width={f(R * 0.16)} height={f(R * 0.42)} rx={f(R * 0.05)} fill="#1e293b" stroke="none" /> | |
| 1614 | + <circle cx={f(dx)} cy={f(dy - R * 0.27)} r={f(R * 0.12)} fill="#f8fafc" /> | |
| 1615 | + <path d={`M${f(dx - R * 0.1)} ${f(dy - R * 0.28)}h${f(R * 0.2)}`} stroke="#1e293b" strokeWidth={f(R * 0.06)} /> | |
| 1616 | + </g> | |
| 1617 | + {!compact ? <Rungs items={rungs} color={p.glow} hot={p.secondary} size={R * 0.06} anchor="end" /> : null} | |
| 1618 | + {!compact ? <Verb x={cx - R * 0.74} y={cy - R * 0.02} text="OPEN CHUTE" color={p.secondary} size={R * 0.078} /> : null} | |
| 1619 | + </g> | |
| 1620 | + ); | |
| 1621 | +} | |
| 1622 | + | |
| 1623 | +/* 28 — CORE MELTDOWN: reactor vessel with glowing fuel rods, vertical temperature gauge with a red limit */ | |
| 1624 | +function CoreMeltdown(s: Stage) { | |
| 1625 | + const { cx, cy, R, id, p, compact } = s; | |
| 1626 | + const vx = cx - R * 0.32; | |
| 1627 | + const vw = R * 1.1; | |
| 1628 | + const top = cy - R * 1.02; | |
| 1629 | + const bottom = cy + R * 0.98; | |
| 1630 | + let rods = ""; | |
| 1631 | + for (let i = 0; i < 5; i++) rods += `M${f(vx - vw * 0.36 + i * vw * 0.18)} ${f(top + R * 0.25)}h${f(vw * 0.08)}v${f(bottom - top - R * 0.5)}h${f(-vw * 0.08)}z `; | |
| 1632 | + let bands = ""; | |
| 1633 | + for (let i = 1; i < 4; i++) bands += `M${f(vx - vw / 2)} ${f(top + ((bottom - top) * i) / 4)}h${f(vw)} `; | |
| 1634 | + const gx = cx + R * 0.7; | |
| 1635 | + const gw = R * 0.22; | |
| 1636 | + const gTop = cy - R * 1.02; | |
| 1637 | + const gBot = cy + R * 0.9; | |
| 1638 | + const level = gBot - (gBot - gTop) * 0.74; | |
| 1639 | + const limit = gBot - (gBot - gTop) * 0.82; | |
| 1640 | + let grad = ""; | |
| 1641 | + for (let i = 0; i <= 20; i++) grad += `M${f(gx + gw / 2 + 2)} ${f(gBot - ((gBot - gTop) * i) / 20)}h${f(i % 5 === 0 ? R * 0.07 : R * 0.035)} `; | |
| 1642 | + const rungs: Array<[number, number, string]> = [[gx + gw / 2 + R * 0.12, gBot - (gBot - gTop) * 0.2, "1.5×"], [gx + gw / 2 + R * 0.12, gBot - (gBot - gTop) * 0.45, "3×"], [gx + gw / 2 + R * 0.12, gBot - (gBot - gTop) * 0.65, "8×"], [gx + gw / 2 + R * 0.12, gBot - (gBot - gTop) * 0.8, "25×"]]; | |
| 1643 | + let readouts = ""; | |
| 1644 | + for (let i = 0; i < 9; i++) readouts += `M${f(cx - R * 1.1 + i * R * 0.25)} ${f(bottom + R * 0.12)}h${f(R * 0.16)}v${f(R * 0.05)}h${f(-R * 0.16)}z `; | |
| 1645 | + return ( | |
| 1646 | + <g> | |
| 1647 | + <defs> | |
| 1648 | + <Linear id={`${id}-cm1`} stops={[[0, "#ef4444"], [0.25, p.secondary], [1, p.primary]]} /> | |
| 1649 | + <Radial id={`${id}-cm2`} color={p.primary} o0={0.55} /> | |
| 1650 | + <Linear id={`${id}-cm3`} stops={[[0, "#fff", 0.35], [0.5, p.glow, 0.95], [1, "#fff", 0.35]]} dir="h" /> | |
| 1651 | + <pattern id={`${id}-cm4`} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(-45)"> | |
| 1652 | + <rect width="3" height="6" fill="#ef4444" /> | |
| 1653 | + </pattern> | |
| 1654 | + </defs> | |
| 1655 | + <ellipse cx={f(vx)} cy={f(cy)} rx={f(R * 1.2)} ry={f(R * 1.3)} fill={`url(#${id}-cm2)`} /> | |
| 1656 | + <rect x={f(vx - vw / 2 - R * 0.1)} y={f(top - R * 0.12)} width={f(vw + R * 0.2)} height={f(R * 0.14)} rx={f(R * 0.03)} fill="#26332a" stroke={p.primary} strokeOpacity={0.5} strokeWidth={1} /> | |
| 1657 | + <rect x={f(vx - vw / 2 - R * 0.1)} y={f(bottom - R * 0.02)} width={f(vw + R * 0.2)} height={f(R * 0.14)} rx={f(R * 0.03)} fill="#26332a" stroke={p.primary} strokeOpacity={0.5} strokeWidth={1} /> | |
| 1658 | + <rect x={f(vx - vw / 2)} y={f(top)} width={f(vw)} height={f(bottom - top)} rx={f(R * 0.08)} fill="#0b1408" stroke={p.primary} strokeOpacity={0.9} strokeWidth={2} /> | |
| 1659 | + <path d={rods} fill={`url(#${id}-cm3)`} /> | |
| 1660 | + <path d={bands} stroke="#26332a" strokeWidth={f(R * 0.05)} /> | |
| 1661 | + <path d={bands} stroke={p.primary} strokeOpacity={0.4} strokeWidth={1} /> | |
| 1662 | + <path d={`M${f(vx - vw / 2)} ${f(cy - R * 0.5)}h${f(-R * 0.35)}v${f(R * 1.35)}h${f(R * 0.35)} M${f(vx + vw / 2)} ${f(cy - R * 0.2)}h${f(R * 0.22)}`} fill="none" stroke="#4b5563" strokeWidth={f(R * 0.06)} /> | |
| 1663 | + <rect x={f(gx - gw / 2)} y={f(gTop)} width={f(gw)} height={f(gBot - gTop)} rx={f(gw / 2)} fill="#0f1a0c" stroke="#3f4a3a" strokeWidth={2} /> | |
| 1664 | + <rect x={f(gx - gw / 2 + 3)} y={f(level)} width={f(gw - 6)} height={f(gBot - level - 3)} rx={f(gw / 2 - 3)} fill={`url(#${id}-cm1)`} /> | |
| 1665 | + <rect x={f(gx - gw / 2 + 3)} y={f(gTop + 3)} width={f(gw - 6)} height={f(limit - gTop - 3)} rx={f(gw / 2 - 3)} fill={`url(#${id}-cm4)`} opacity={0.75} /> | |
| 1666 | + <line x1={f(gx - gw / 2 - R * 0.06)} y1={f(limit)} x2={f(gx + gw / 2 + R * 0.06)} y2={f(limit)} stroke="#ef4444" strokeWidth={2.5} /> | |
| 1667 | + <path d={grad} stroke="#9ca3af" strokeOpacity={0.6} strokeWidth={1} /> | |
| 1668 | + {!compact ? <Rungs items={rungs} color={p.primary} hot={p.secondary} size={R * 0.065} anchor="start" /> : null} | |
| 1669 | + {!compact ? <Small x={gx + gw / 2 + R * 0.12} y={limit - R * 0.12} text="LIMIT" color="#ef4444" size={R * 0.06} anchor="start" weight={800} tracking="0.2em" /> : null} | |
| 1670 | + {!compact ? <Shape shape="warning" color={p.secondary} x={cx - R * 1.02} y={cy + R * 0.1} size={R * 0.46} /> : null} | |
| 1671 | + {!compact ? <path d={readouts} fill={p.primary} fillOpacity={0.35} /> : null} | |
| 1672 | + {!compact ? <Verb x={cx - R * 0.72} y={cy + R * 0.72} text="SHUT DOWN" color="#ef4444" ink="#fff" size={R * 0.078} /> : null} | |
| 1673 | + </g> | |
| 1674 | + ); | |
| 1675 | +} | |
| 1676 | + | |
| 1677 | +/* 29 — STORM CHASE: truck racing down a highway into a tornado, debris and lightning */ | |
| 1678 | +function StormChase(s: Stage) { | |
| 1679 | + const { W, H, cx, cy, R, id, p, compact } = s; | |
| 1680 | + const ground = cy + R * 0.72; | |
| 1681 | + const fx = cx + R * 0.42; | |
| 1682 | + const funnel = `M${f(fx - R * 0.95)} ${f(cy - R * 1.1)} C${f(fx - R * 0.6)} ${f(cy - R * 0.4)} ${f(fx - R * 0.35)} ${f(cy + R * 0.2)} ${f(fx - R * 0.12)} ${f(ground)} L${f(fx + R * 0.12)} ${f(ground)} C${f(fx + R * 0.3)} ${f(cy + R * 0.2)} ${f(fx + R * 0.55)} ${f(cy - R * 0.4)} ${f(fx + R * 0.95)} ${f(cy - R * 1.1)} Z`; | |
| 1683 | + let ribs = ""; | |
| 1684 | + for (let i = 1; i < 6; i++) { | |
| 1685 | + const t = i / 6; | |
| 1686 | + const y = cy - R * 1.1 + (ground - (cy - R * 1.1)) * t; | |
| 1687 | + const hw = R * (0.95 - 0.83 * t); | |
| 1688 | + ribs += `M${f(fx - hw)} ${f(y)} q${f(hw)} ${f(R * 0.1)} ${f(hw * 2)} 0 `; | |
| 1689 | + } | |
| 1690 | + let debris = ""; | |
| 1691 | + for (let i = 0; i < 22; i++) { | |
| 1692 | + const a = s.rnd() * PI * 2; | |
| 1693 | + const d = R * (0.35 + s.rnd() * 0.85); | |
| 1694 | + const x = fx + Math.cos(a) * d; | |
| 1695 | + const y = cy - R * 0.2 + Math.sin(a) * d * 0.7; | |
| 1696 | + const k = R * (0.02 + s.rnd() * 0.04); | |
| 1697 | + debris += `M${f(x)} ${f(y)}l${f(k * 1.5)} ${f(k * 0.6)}l${f(-k * 0.6)} ${f(k * 1.2)}l${f(-k * 1.5)} ${f(-k * 0.6)}z `; | |
| 1698 | + } | |
| 1699 | + const bolt = `M${f(cx - R * 0.72)} ${f(cy - R * 1.15)} l${f(R * 0.12)} ${f(R * 0.4)} l${f(-R * 0.14)} ${f(R * 0.04)} l${f(R * 0.2)} ${f(R * 0.5)} l${f(-R * 0.12)} ${f(0)} l${f(R * 0.14)} ${f(R * 0.45)}`; | |
| 1700 | + const roadL = `M${f(cx - R * 1.6)} ${f(H)} L${f(fx - R * 0.1)} ${f(ground)} L${f(fx + R * 0.1)} ${f(ground)} L${f(cx + R * 0.7)} ${f(H)} Z`; | |
| 1701 | + let dashes = ""; | |
| 1702 | + for (let i = 0; i < 7; i++) { | |
| 1703 | + const t0 = Math.pow(i / 7, 1.4); | |
| 1704 | + const t1 = Math.pow((i + 0.45) / 7, 1.4); | |
| 1705 | + const px0 = cx - R * 0.45 + (fx - (cx - R * 0.45)) * t0; | |
| 1706 | + const py0 = H + (ground - H) * t0; | |
| 1707 | + const px1 = cx - R * 0.45 + (fx - (cx - R * 0.45)) * t1; | |
| 1708 | + const py1 = H + (ground - H) * t1; | |
| 1709 | + dashes += `M${f(px0)} ${f(py0)}L${f(px1)} ${f(py1)} `; | |
| 1710 | + } | |
| 1711 | + const tx = cx - R * 0.42; | |
| 1712 | + const ty = cy + R * 1.05; | |
| 1713 | + const rx = cx + R * 1.14; | |
| 1714 | + const rungs: Array<[number, number, string]> = [[rx, cy + R * 1.06, "1.5× OUTFLOW"], [rx, cy + R * 0.88, "3× DEBRIS FIELD"], [rx, cy + R * 0.7, "8× WALL CLOUD"], [rx, cy + R * 0.5, "30× VORTEX"]]; | |
| 1715 | + return ( | |
| 1716 | + <g> | |
| 1717 | + <defs> | |
| 1718 | + <Linear id={`${id}-sc1`} stops={[[0, "#0b0f16"], [0.5, "#1f2937"], [1, "#0f172a"]]} /> | |
| 1719 | + <Linear id={`${id}-sc2`} stops={[[0, "#475569"], [1, "#0f172a"]]} /> | |
| 1720 | + <Radial id={`${id}-sc3`} color={p.secondary} o0={0.45} /> | |
| 1721 | + </defs> | |
| 1722 | + <rect width={W} height={H} fill={`url(#${id}-sc1)`} /> | |
| 1723 | + <ellipse cx={f(cx - R * 0.2)} cy={f(cy - R * 1.2)} rx={f(R * 1.5)} ry={f(R * 0.45)} fill="#111827" /> | |
| 1724 | + <ellipse cx={f(fx + R * 0.3)} cy={f(cy - R * 1.05)} rx={f(R * 1.2)} ry={f(R * 0.4)} fill="#1f2937" /> | |
| 1725 | + <circle cx={f(cx - R * 0.6)} cy={f(cy - R * 0.4)} r={f(R * 0.7)} fill={`url(#${id}-sc3)`} /> | |
| 1726 | + <path d={bolt} fill="none" stroke={p.secondary} strokeOpacity={0.35} strokeWidth={7} strokeLinecap="round" strokeLinejoin="round" /> | |
| 1727 | + <path d={bolt} fill="none" stroke="#f7fee7" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> | |
| 1728 | + <rect x={0} y={f(ground)} width={W} height={f(H - ground)} fill="#0b1210" /> | |
| 1729 | + <path d={funnel} fill={`url(#${id}-sc2)`} stroke={p.glow} strokeOpacity={0.55} strokeWidth={1.5} /> | |
| 1730 | + <path d={ribs} fill="none" stroke={p.glow} strokeOpacity={0.35} strokeWidth={1.2} /> | |
| 1731 | + <path d={debris} fill="#64748b" stroke="#cbd5e1" strokeOpacity={0.6} strokeWidth={0.8} /> | |
| 1732 | + <path d={roadL} fill="#1e293b" stroke="#475569" strokeWidth={1} /> | |
| 1733 | + <path d={dashes} stroke="#fde68a" strokeWidth={f(R * 0.03)} strokeLinecap="round" /> | |
| 1734 | + {!compact ? <Rungs items={rungs} color={p.glow} hot={p.secondary} size={R * 0.058} anchor="end" /> : null} | |
| 1735 | + <polygon points={`${f(tx - R * 0.2)},${f(ty - R * 0.2)} ${f(tx + R * 0.2)},${f(ty - R * 0.2)} ${f(fx + R * 0.05)},${f(ground + R * 0.05)} ${f(fx - R * 0.35)},${f(ground + R * 0.05)}`} fill="#fef3c7" fillOpacity={0.12} /> | |
| 1736 | + <g> | |
| 1737 | + <rect x={f(tx - R * 0.42)} y={f(ty - R * 0.28)} width={f(R * 0.84)} height={f(R * 0.4)} rx={f(R * 0.05)} fill="#1c1f26" stroke="#94a3b8" strokeWidth={1.5} /> | |
| 1738 | + <rect x={f(tx - R * 0.3)} y={f(ty - R * 0.55)} width={f(R * 0.6)} height={f(R * 0.3)} rx={f(R * 0.05)} fill="#1c1f26" stroke="#94a3b8" strokeWidth={1.5} /> | |
| 1739 | + <rect x={f(tx - R * 0.24)} y={f(ty - R * 0.5)} width={f(R * 0.48)} height={f(R * 0.18)} rx={f(R * 0.03)} fill="#334155" /> | |
| 1740 | + <path d={dots([[tx - R * 0.34, ty - R * 0.02, R * 0.04], [tx + R * 0.34, ty - R * 0.02, R * 0.04]])} fill="#ef4444" /> | |
| 1741 | + <rect x={f(tx - R * 0.44)} y={f(ty + R * 0.08)} width={f(R * 0.18)} height={f(R * 0.12)} rx={f(R * 0.03)} fill="#000" /> | |
| 1742 | + <rect x={f(tx + R * 0.26)} y={f(ty + R * 0.08)} width={f(R * 0.18)} height={f(R * 0.12)} rx={f(R * 0.03)} fill="#000" /> | |
| 1743 | + <path d={`M${f(tx - R * 0.3)} ${f(ty - R * 0.55)}v${f(-R * 0.22)}h${f(R * 0.06)}`} stroke="#94a3b8" strokeWidth={1.5} fill="none" /> | |
| 1744 | + <circle cx={f(tx - R * 0.24)} cy={f(ty - R * 0.78)} r={f(R * 0.03)} fill={p.secondary} /> | |
| 1745 | + </g> | |
| 1746 | + {!compact ? <Verb x={cx - R * 0.7} y={cy - R * 0.02} text="ESCAPE" color={p.secondary} size={R * 0.078} /> : null} | |
| 1747 | + </g> | |
| 1748 | + ); | |
| 1749 | +} | |
| 1750 | + | |
| 1751 | +/* 30 — ELEVATOR 999: a lit cabin in an endless shaft, tall condensed floor numbers, express floors */ | |
| 1752 | +function Elevator999(s: Stage) { | |
| 1753 | + const { H, cx, cy, R, id, p, compact } = s; | |
| 1754 | + const railL = cx - R * 0.66; | |
| 1755 | + const railR = cx + R * 0.66; | |
| 1756 | + let beams = ""; | |
| 1757 | + for (let y = -R * 0.1; y < H + R; y += R * 0.32) beams += `M${f(railL)} ${f(y)}H${f(railR)} `; | |
| 1758 | + const cabW = R * 0.98; | |
| 1759 | + const cabH = R * 1.22; | |
| 1760 | + const cabY = cy + R * 0.02; | |
| 1761 | + const floors = [ | |
| 1762 | + { n: "25", m: "×3.4", y: cy + R * 1.0 }, | |
| 1763 | + { n: "50", m: "×11", y: cy + R * 0.52 }, | |
| 1764 | + { n: "100", m: "×131", y: cy - R * 0.32 }, | |
| 1765 | + { n: "250", m: "×198k", y: cy - R * 0.76 }, | |
| 1766 | + ]; | |
| 1767 | + let chevrons = ""; | |
| 1768 | + for (let i = 0; i < 3; i++) chevrons += `M${f(railR + R * 0.2)} ${f(cy - R * 0.05 - i * R * 0.16)}l${f(R * 0.08)} ${f(-R * 0.09)}l${f(R * 0.08)} ${f(R * 0.09)} `; | |
| 1769 | + return ( | |
| 1770 | + <g> | |
| 1771 | + <defs> | |
| 1772 | + <Linear id={`${id}-el1`} stops={[[0, "#000", 1], [0.28, p.bg, 0.1], [1, p.bg, 0]]} /> | |
| 1773 | + <Linear id={`${id}-el2`} stops={[[0, p.glow, 0.9], [1, p.primary, 0.2]]} /> | |
| 1774 | + <Radial id={`${id}-el3`} color={p.glow} o0={0.5} /> | |
| 1775 | + </defs> | |
| 1776 | + <rect x={f(railL)} y={0} width={f(railR - railL)} height={H} fill="#0c0d13" /> | |
| 1777 | + <path d={beams} stroke="#2a2d3a" strokeWidth={1.5} /> | |
| 1778 | + <line x1={f(railL)} y1={0} x2={f(railL)} y2={H} stroke={p.primary} strokeOpacity={0.7} strokeWidth={3} /> | |
| 1779 | + <line x1={f(railR)} y1={0} x2={f(railR)} y2={H} stroke={p.primary} strokeOpacity={0.7} strokeWidth={3} /> | |
| 1780 | + <rect x={f(railL)} y={0} width={f(railR - railL)} height={f(cy)} fill={`url(#${id}-el1)`} /> | |
| 1781 | + <path d={`M${f(cx - R * 0.2)} 0V${f(cabY - cabH / 2)} M${f(cx + R * 0.2)} 0V${f(cabY - cabH / 2)}`} stroke="#9aa0b4" strokeWidth={1.5} /> | |
| 1782 | + <ellipse cx={f(cx)} cy={f(cabY)} rx={f(R * 0.9)} ry={f(R * 0.95)} fill={`url(#${id}-el3)`} /> | |
| 1783 | + <rect x={f(cx - cabW / 2)} y={f(cabY - cabH / 2)} width={f(cabW)} height={f(cabH)} rx={f(R * 0.05)} fill="#151821" stroke={p.primary} strokeWidth={2.5} /> | |
| 1784 | + <rect x={f(cx - cabW / 2 + R * 0.08)} y={f(cabY - cabH / 2 + R * 0.2)} width={f(cabW - R * 0.16)} height={f(cabH - R * 0.28)} fill={`url(#${id}-el2)`} /> | |
| 1785 | + <rect x={f(cx - cabW / 2 + R * 0.08)} y={f(cabY - cabH / 2 + R * 0.2)} width={f(cabW / 2 - R * 0.11)} height={f(cabH - R * 0.28)} fill="#1b1f2c" stroke={p.primary} strokeOpacity={0.6} strokeWidth={1} /> | |
| 1786 | + <rect x={f(cx + R * 0.03)} y={f(cabY - cabH / 2 + R * 0.2)} width={f(cabW / 2 - R * 0.11)} height={f(cabH - R * 0.28)} fill="#1b1f2c" stroke={p.primary} strokeOpacity={0.6} strokeWidth={1} /> | |
| 1787 | + <rect x={f(cx - cabW / 2 + R * 0.2)} y={f(cabY - cabH / 2 + R * 0.05)} width={f(cabW - R * 0.4)} height={f(R * 0.12)} rx={2} fill="#05060a" stroke={p.secondary} strokeOpacity={0.8} strokeWidth={1} /> | |
| 1788 | + <Small x={cx} y={cabY - cabH / 2 + R * 0.11} text="▲ 137" color={p.secondary} size={R * 0.075} weight={800} tracking="0.2em" /> | |
| 1789 | + {!compact ? ( | |
| 1790 | + <> | |
| 1791 | + {floors.map((fl) => ( | |
| 1792 | + <g key={fl.n}> | |
| 1793 | + <line x1={f(railL - R * 0.5)} y1={f(fl.y + R * 0.1)} x2={f(railL - R * 0.04)} y2={f(fl.y + R * 0.1)} stroke={p.primary} strokeOpacity={0.5} strokeWidth={1} /> | |
| 1794 | + <text x={f(railL - R * 0.08)} y={f(fl.y)} textAnchor="end" dominantBaseline="central" fontSize={f(R * 0.3)} fontWeight={800} fill={p.primary} fillOpacity={fl.n === "250" ? 1 : 0.75} transform={`translate(${f(railL - R * 0.08)} 0) scale(0.66 1) translate(${f(-(railL - R * 0.08))} 0)`} style={{ fontFamily: FONT, letterSpacing: "-0.04em" }}> | |
| 1795 | + {fl.n} | |
| 1796 | + </text> | |
| 1797 | + <Small x={railR + R * 0.1} y={fl.y} text={fl.m} color={fl.n === "250" ? p.secondary : p.glow} size={R * (fl.n === "250" ? 0.085 : 0.065)} anchor="start" weight={700} tracking="0.04em" opacity={fl.n === "250" ? 1 : 0.7} /> | |
| 1798 | + </g> | |
| 1799 | + ))} | |
| 1800 | + <path d={chevrons} fill="none" stroke={p.secondary} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" /> | |
| 1801 | + <Verb x={cx} y={cabY + cabH / 2 + R * 0.28} text="STEP OUT" color={p.secondary} size={R * 0.078} /> | |
| 1802 | + </> | |
| 1803 | + ) : null} | |
| 1804 | + </g> | |
| 1805 | + ); | |
| 1806 | +} | |
| 1807 | + | |
| 1808 | +/* ---------------------------------------------------- Beyond Slots originals */ | |
| 1809 | + | |
| 1810 | +/* 31 — DROPZONE: capsule ricocheting through a peg tower, energy gates, value buckets, Deep Drop below */ | |
| 1811 | +function Dropzone(s: Stage) { | |
| 1812 | + const { cx, cy, R, id, p, compact } = s; | |
| 1813 | + const rows = compact ? 6 : 9; | |
| 1814 | + const gap = R * 0.2; | |
| 1815 | + const top = cy - R * 1.02; | |
| 1816 | + const pegs: Array<[number, number, number]> = []; | |
| 1817 | + for (let r = 0; r < rows; r++) for (let k = 0; k <= r + 2; k++) pegs.push([cx + (k - (r + 2) / 2) * gap, top + r * gap, R * 0.022]); | |
| 1818 | + let lane = 0; | |
| 1819 | + let path = `M${f(cx)} ${f(top - gap * 0.8)}`; | |
| 1820 | + const lit: Array<[number, number, number]> = []; | |
| 1821 | + for (let r = 0; r < rows; r++) { | |
| 1822 | + lane += s.rnd() > 0.5 ? 0.5 : -0.5; | |
| 1823 | + const x = cx + lane * gap; | |
| 1824 | + path += ` L${f(x)} ${f(top + r * gap + gap * 0.5)}`; | |
| 1825 | + lit.push([x + (s.rnd() > 0.5 ? gap / 2 : -gap / 2), top + r * gap, R * 0.03]); | |
| 1826 | + } | |
| 1827 | + const capR = Math.min(rows - 1, 5); | |
| 1828 | + const capX = cx + lane * gap * (capR / rows); | |
| 1829 | + const capY = top + capR * gap + gap * 0.5; | |
| 1830 | + const bucketY = top + rows * gap + gap * 0.3; | |
| 1831 | + const values = ["25", "6", "2.5", "1.4", ".4", "1.4", "2.5", "6", "25"]; | |
| 1832 | + const bw = gap * 1.15; | |
| 1833 | + const bx0 = cx - (values.length * bw) / 2; | |
| 1834 | + const deepY = bucketY + gap * 1.35; | |
| 1835 | + const deepPegs = scatterDots(s, 12, [cx - R * 0.9, deepY + gap * 0.3, R * 1.8, gap * 1.4], R * 0.02, R * 0.02); | |
| 1836 | + return ( | |
| 1837 | + <g> | |
| 1838 | + <defs> | |
| 1839 | + <Radial id={`${id}-dz1`} color={p.primary} o0={0.35} /> | |
| 1840 | + <Radial id={`${id}-dz2`} color={p.glow} o0={1} mid={[0.4, p.primary, 0.7]} /> | |
| 1841 | + <Linear id={`${id}-dz3`} stops={[[0, p.secondary, 0.9], [0.5, p.primary, 0.25], [1, p.secondary, 0.9]]} dir="h" /> | |
| 1842 | + </defs> | |
| 1843 | + <polygon points={`${f(cx - gap * 1.4)},${f(top - gap)} ${f(cx + gap * 1.4)},${f(top - gap)} ${f(cx + ((rows + 2) / 2) * gap + gap * 0.6)},${f(bucketY)} ${f(cx - ((rows + 2) / 2) * gap - gap * 0.6)},${f(bucketY)}`} fill={`url(#${id}-dz1)`} stroke={p.primary} strokeOpacity={0.35} strokeWidth={1} /> | |
| 1844 | + <path d={dots(pegs)} fill="#cbd5e1" fillOpacity={0.85} /> | |
| 1845 | + <path d={path} fill="none" stroke={p.primary} strokeOpacity={0.8} strokeWidth={1.5} strokeDasharray="3 4" /> | |
| 1846 | + <path d={dots(lit)} fill={p.glow} /> | |
| 1847 | + {!compact ? ( | |
| 1848 | + <> | |
| 1849 | + <rect x={f(cx - gap * 1.3)} y={f(top + gap * 2.5 - 2)} width={f(gap * 1.1)} height={4} rx={2} fill={p.secondary} /> | |
| 1850 | + <Pill x={cx - gap * 1.85} y={top + gap * 2.5} text="×2" color={p.secondary} ink={p.secondary} size={R * 0.055} /> | |
| 1851 | + <rect x={f(cx + gap * 0.9)} y={f(top + gap * 6.5 - 2)} width={f(gap * 1.1)} height={4} rx={2} fill={p.secondary} /> | |
| 1852 | + <Pill x={cx + gap * 2.6} y={top + gap * 6.5} text="×3" color={p.secondary} ink={p.secondary} size={R * 0.055} /> | |
| 1853 | + </> | |
| 1854 | + ) : null} | |
| 1855 | + <circle cx={f(capX)} cy={f(capY)} r={f(gap * 0.5)} fill={`url(#${id}-dz2)`} opacity={0.8} /> | |
| 1856 | + <rect x={f(capX - gap * 0.16)} y={f(capY - gap * 0.3)} width={f(gap * 0.32)} height={f(gap * 0.6)} rx={f(gap * 0.16)} fill="#e0fbff" stroke={p.primary} strokeWidth={1.5} /> | |
| 1857 | + <rect x={f(bx0)} y={f(bucketY)} width={f(values.length * bw)} height={f(gap * 0.9)} fill={`url(#${id}-dz3)`} opacity={0.35} /> | |
| 1858 | + {values.map((v, i) => { | |
| 1859 | + const edge = Math.abs(i - 4) / 4; | |
| 1860 | + return ( | |
| 1861 | + <g key={i}> | |
| 1862 | + <rect x={f(bx0 + i * bw + 1.5)} y={f(bucketY)} width={f(bw - 3)} height={f(gap * 0.9)} rx={3} fill="#050a14" fillOpacity={0.8} stroke={edge > 0.6 ? p.secondary : p.primary} strokeOpacity={0.4 + edge * 0.6} strokeWidth={1} /> | |
| 1863 | + {!compact ? <Small x={bx0 + i * bw + bw / 2} y={bucketY + gap * 0.45} text={v} color={edge > 0.6 ? p.secondary : "#e0fbff"} size={R * (edge > 0.6 ? 0.07 : 0.058)} weight={800} tracking="0" /> : null} | |
| 1864 | + </g> | |
| 1865 | + ); | |
| 1866 | + })} | |
| 1867 | + {!compact ? ( | |
| 1868 | + <> | |
| 1869 | + <rect x={f(cx - R * 1.0)} y={f(deepY)} width={f(R * 2.0)} height={f(gap * 2.2)} rx={4} fill={p.secondary} fillOpacity={0.06} stroke={p.secondary} strokeOpacity={0.6} strokeWidth={1} strokeDasharray="6 5" /> | |
| 1870 | + <path d={deepPegs} fill={p.secondary} fillOpacity={0.55} /> | |
| 1871 | + <Pill x={cx} y={deepY} text="DEEP DROP ×0 → ×10" color={p.secondary} ink={p.secondary} bg={p.bg} size={R * 0.058} /> | |
| 1872 | + <Verb x={cx + R * 0.92} y={cy - R * 0.5} text="DROP" color={p.primary} size={R * 0.078} /> | |
| 1873 | + </> | |
| 1874 | + ) : null} | |
| 1875 | + </g> | |
| 1876 | + ); | |
| 1877 | +} | |
| 1878 | + | |
| 1879 | +/* 32 — GRID//BREAK: 8×8 neon block grid, a wave down one column, detonating cluster, ×2 ×4 ×8 chain */ | |
| 1880 | +function GridBreak(s: Stage) { | |
| 1881 | + const { W, H, cx, cy, R, id, p, compact } = s; | |
| 1882 | + const n = 8; | |
| 1883 | + const c = R * 0.225; | |
| 1884 | + const gx = cx - (n * c) / 2 - R * 0.2; | |
| 1885 | + const gy = cy - (n * c) / 2 - R * 0.05; | |
| 1886 | + const col = 5; | |
| 1887 | + const tints = [p.primary, p.secondary, "#a78bfa", "#f472b6", "#facc15", "#22d3ee"]; | |
| 1888 | + const paths = tints.map(() => ""); | |
| 1889 | + const blown = new Set(["5,3", "5,4", "4,4", "5,5", "6,4", "6,5"]); | |
| 1890 | + for (let r = 0; r < n; r++) { | |
| 1891 | + for (let k = 0; k < n; k++) { | |
| 1892 | + if (blown.has(`${k},${r}`)) continue; | |
| 1893 | + const t = Math.floor(s.rnd() * tints.length); | |
| 1894 | + paths[t] += `M${f(gx + k * c + 2)} ${f(gy + r * c + 2)}h${f(c - 4)}v${f(c - 4)}h${f(-(c - 4))}z `; | |
| 1895 | + } | |
| 1896 | + } | |
| 1897 | + const ex = gx + col * c + c / 2; | |
| 1898 | + const ey = gy + 4 * c + c / 2; | |
| 1899 | + let shards = ""; | |
| 1900 | + for (let i = 0; i < 12; i++) { | |
| 1901 | + const a = s.rnd() * PI * 2; | |
| 1902 | + const d = c * (0.9 + s.rnd() * 1.6); | |
| 1903 | + const k = c * (0.12 + s.rnd() * 0.16); | |
| 1904 | + shards += `M${f(ex + Math.cos(a) * d)} ${f(ey + Math.sin(a) * d)}h${f(k)}v${f(k)}h${f(-k)}z `; | |
| 1905 | + } | |
| 1906 | + let scan = ""; | |
| 1907 | + for (let y = 0; y < H; y += 5) scan += `M0 ${f(y)}H${f(W)} `; | |
| 1908 | + return ( | |
| 1909 | + <g> | |
| 1910 | + <defs> | |
| 1911 | + <Linear id={`${id}-gb1`} stops={[[0, p.secondary, 0], [0.5, p.secondary, 0.55], [1, p.secondary, 0.15]]} /> | |
| 1912 | + <Radial id={`${id}-gb2`} color={p.glow} o0={0.9} /> | |
| 1913 | + </defs> | |
| 1914 | + <path d={scan} stroke="#fff" strokeOpacity={0.035} strokeWidth={1.5} /> | |
| 1915 | + <rect x={f(gx - R * 0.08)} y={f(gy - R * 0.08)} width={f(n * c + R * 0.16)} height={f(n * c + R * 0.16)} rx={f(R * 0.06)} fill="#070b10" stroke={p.primary} strokeOpacity={0.5} strokeWidth={2} /> | |
| 1916 | + {paths.map((d, i) => (d ? <path key={i} d={d} fill={tints[i]} fillOpacity={0.82} /> : null))} | |
| 1917 | + <rect x={f(gx + col * c)} y={f(gy - R * 0.3)} width={f(c)} height={f(n * c + R * 0.3)} fill={`url(#${id}-gb1)`} /> | |
| 1918 | + <line x1={f(ex)} y1={f(gy - R * 0.3)} x2={f(ex)} y2={f(gy + n * c)} stroke="#fff" strokeOpacity={0.85} strokeWidth={1.5} /> | |
| 1919 | + <polygon points={`${f(ex - c * 0.45)},${f(gy + n * c + R * 0.22)} ${f(ex + c * 0.45)},${f(gy + n * c + R * 0.22)} ${f(ex)},${f(gy + n * c + R * 0.06)}`} fill={p.secondary} /> | |
| 1920 | + <circle cx={f(ex)} cy={f(ey)} r={f(c * 1.5)} fill={`url(#${id}-gb2)`} /> | |
| 1921 | + <polygon points={svgStar(ex, ey, c * 1.7, c * 0.7, 8)} fill={p.glow} fillOpacity={0.9} /> | |
| 1922 | + <polygon points={svgStar(ex, ey, c * 0.9, c * 0.35, 8)} fill="#fff" /> | |
| 1923 | + <path d={shards} fill={p.primary} /> | |
| 1924 | + {!compact ? ( | |
| 1925 | + <> | |
| 1926 | + <Pill x={gx + n * c + R * 0.28} y={gy + c * 5.5} text="×2" color={p.primary} ink={p.primary} size={R * 0.07} /> | |
| 1927 | + <Pill x={gx + n * c + R * 0.29} y={gy + c * 3.9} text="×4" color={p.primary} ink={p.glow} size={R * 0.085} /> | |
| 1928 | + <Pill x={gx + n * c + R * 0.3} y={gy + c * 2.0} text="×8" color={p.secondary} ink="#fff" bg="#0c2233" size={R * 0.1} /> | |
| 1929 | + <Small x={gx + n * c + R * 0.3} y={gy + c * 1.0} text="CHAIN" color={p.secondary} size={R * 0.06} tracking="0.24em" /> | |
| 1930 | + <Verb x={ex} y={gy + n * c + R * 0.45} text="FIRE WAVE" color={p.secondary} size={R * 0.072} /> | |
| 1931 | + </> | |
| 1932 | + ) : null} | |
| 1933 | + </g> | |
| 1934 | + ); | |
| 1935 | +} | |
| 1936 | + | |
| 1937 | +/* 33 — THE VAULT: colossal door, five concentric locked layers, digits being revealed at the core */ | |
| 1938 | +function TheVault(s: Stage) { | |
| 1939 | + const { cx, cy, R, id, p, compact } = s; | |
| 1940 | + const vx = cx; | |
| 1941 | + const vy = cy + R * 0.08; | |
| 1942 | + let bolts = ""; | |
| 1943 | + const bl: Array<[number, number, number]> = []; | |
| 1944 | + for (let i = 0; i < 18; i++) { | |
| 1945 | + const a = (i / 18) * PI * 2; | |
| 1946 | + bl.push([vx + Math.cos(a) * R * 1.3, vy + Math.sin(a) * R * 1.3, R * 0.028]); | |
| 1947 | + } | |
| 1948 | + bolts = dots(bl); | |
| 1949 | + let ticks = ""; | |
| 1950 | + for (let i = 0; i < 60; i++) { | |
| 1951 | + const a = (i / 60) * PI * 2; | |
| 1952 | + const len = i % 5 === 0 ? R * 0.1 : R * 0.05; | |
| 1953 | + ticks += `M${f(vx + Math.cos(a) * R * 1.05)} ${f(vy + Math.sin(a) * R * 1.05)}L${f(vx + Math.cos(a) * (R * 1.05 - len))} ${f(vy + Math.sin(a) * (R * 1.05 - len))} `; | |
| 1954 | + } | |
| 1955 | + const gaugeA0 = PI * 0.75; | |
| 1956 | + const gaugeA1 = PI * 2.05; | |
| 1957 | + const layers = ["1.20×", "1.71×", "3.12×", "7.79×", "31.2×"]; | |
| 1958 | + const lx = cx - R * 1.12; | |
| 1959 | + const rungs: Array<[number, number, string]> = layers.map((l, i) => [lx, cy + R * 0.9 - i * R * 0.36, l]); | |
| 1960 | + const digits = ["4", "7", "?"]; | |
| 1961 | + return ( | |
| 1962 | + <g> | |
| 1963 | + <defs> | |
| 1964 | + <Radial id={`${id}-tv1`} color={p.glow} o0={0.95} mid={[0.5, p.primary, 0.45]} /> | |
| 1965 | + <Linear id={`${id}-tv2`} stops={[[0, "#2a2530"], [0.5, "#0f0d13"], [1, "#26212c"]]} dir="d" /> | |
| 1966 | + </defs> | |
| 1967 | + <circle cx={f(vx)} cy={f(vy)} r={f(R * 1.42)} fill={`url(#${id}-tv2)`} stroke={p.primary} strokeOpacity={0.9} strokeWidth={f(R * 0.03)} /> | |
| 1968 | + <circle cx={f(vx)} cy={f(vy)} r={f(R * 1.18)} fill="#100e14" stroke={p.primary} strokeOpacity={0.5} strokeWidth={1.5} /> | |
| 1969 | + <path d={bolts} fill={p.primary} stroke="#5a4a20" strokeWidth={1} /> | |
| 1970 | + <path d={ticks} stroke={p.glow} strokeOpacity={0.6} strokeWidth={1.2} /> | |
| 1971 | + <circle cx={f(vx)} cy={f(vy)} r={f(R * 0.92)} fill="#0b0a0e" stroke={p.primary} strokeOpacity={0.7} strokeWidth={2} /> | |
| 1972 | + <circle cx={f(vx)} cy={f(vy)} r={f(R * 0.82)} fill="none" stroke={p.secondary} strokeOpacity={0.9} strokeWidth={f(R * 0.05)} strokeDasharray={`${f(R * 0.09)} ${f(R * 0.06)}`} /> | |
| 1973 | + <circle cx={f(vx)} cy={f(vy)} r={f(R * 0.82)} fill="none" stroke={p.secondary} strokeOpacity={0.35} strokeWidth={f(R * 0.12)} strokeDasharray={`${f(R * 0.4)} ${f(R * 0.9)}`} /> | |
| 1974 | + <circle cx={f(vx)} cy={f(vy)} r={f(R * 0.7)} fill="#141117" stroke={p.primary} strokeOpacity={0.6} strokeWidth={1.5} /> | |
| 1975 | + <path d={svgArc(vx, vy, R * 0.6, gaugeA0, gaugeA1)} fill="none" stroke="#2b2630" strokeWidth={f(R * 0.09)} strokeLinecap="round" /> | |
| 1976 | + <path d={svgArc(vx, vy, R * 0.6, gaugeA0, gaugeA0 + (gaugeA1 - gaugeA0) * 0.7)} fill="none" stroke={p.primary} strokeWidth={f(R * 0.09)} strokeLinecap="round" /> | |
| 1977 | + <circle cx={f(vx)} cy={f(vy)} r={f(R * 0.48)} fill={`url(#${id}-tv1)`} /> | |
| 1978 | + <circle cx={f(vx)} cy={f(vy)} r={f(R * 0.48)} fill="none" stroke={p.glow} strokeWidth={2} /> | |
| 1979 | + {digits.map((d, i) => ( | |
| 1980 | + <g key={i}> | |
| 1981 | + <rect x={f(vx - R * 0.36 + i * R * 0.25)} y={f(vy - R * 0.16)} width={f(R * 0.22)} height={f(R * 0.32)} rx={f(R * 0.03)} fill="#0b0a0e" stroke={d === "?" ? p.secondary : p.primary} strokeWidth={1.5} /> | |
| 1982 | + <Small x={vx - R * 0.25 + i * R * 0.25} y={vy} text={d} color={d === "?" ? p.secondary : p.glow} size={R * 0.2} weight={800} tracking="0" /> | |
| 1983 | + </g> | |
| 1984 | + ))} | |
| 1985 | + <circle cx={f(vx)} cy={f(vy - R * 1.3)} r={f(R * 0.05)} fill="#ef4444" /> | |
| 1986 | + <circle cx={f(vx)} cy={f(vy - R * 1.3)} r={f(R * 0.11)} fill="#ef4444" fillOpacity={0.3} /> | |
| 1987 | + {!compact ? ( | |
| 1988 | + <> | |
| 1989 | + <path d={`M${f(lx - R * 0.02)} ${f(cy + R * 1.0)}V${f(cy - R * 0.62)}`} stroke={p.primary} strokeOpacity={0.35} strokeWidth={1} /> | |
| 1990 | + <Rungs items={rungs} color={p.primary} hot={p.glow} size={R * 0.065} anchor="start" /> | |
| 1991 | + <Small x={cx + R * 1.02} y={cy - R * 0.68} text="LAYER 3 / 5" color={p.secondary} size={R * 0.062} tracking="0.22em" anchor="end" /> | |
| 1992 | + <Small x={cx + R * 1.02} y={cy - R * 0.54} text="BIOMETRIC RING" color={p.glow} size={R * 0.055} tracking="0.16em" anchor="end" opacity={0.75} /> | |
| 1993 | + <Verb x={cx - R * 0.42} y={cy + R * 1.14} text="SECURE" color={p.primary} size={R * 0.072} /> | |
| 1994 | + <Verb x={cx + R * 0.45} y={cy + R * 1.14} text="OPEN" color={p.secondary} size={R * 0.072} /> | |
| 1995 | + </> | |
| 1996 | + ) : null} | |
| 1997 | + </g> | |
| 1998 | + ); | |
| 1999 | +} | |
| 2000 | + | |
| 2001 | +/* 34 — ORBIT: burning core, concentric orbits with planets/satellites/comets, an impulse beam, Supernova */ | |
| 2002 | +function Orbit(s: Stage) { | |
| 2003 | + const { W, H, cx, cy, R, id, p, compact } = s; | |
| 2004 | + const stars = scatterDots(s, 60, [0, 0, W, H], 0.5, 1.3); | |
| 2005 | + const ox = cx; | |
| 2006 | + const oy = cy + R * 0.05; | |
| 2007 | + const orbits = [0.42, 0.68, 0.94, 1.2]; | |
| 2008 | + const debris: Array<[number, number, number]> = []; | |
| 2009 | + for (let i = 0; i < 16; i++) { | |
| 2010 | + const r = R * orbits[Math.floor(s.rnd() * orbits.length)]; | |
| 2011 | + const a = s.rnd() * PI * 2; | |
| 2012 | + debris.push([ox + Math.cos(a) * r, oy + Math.sin(a) * r, R * 0.018]); | |
| 2013 | + } | |
| 2014 | + const at = (k: number, a: number): [number, number] => [ox + Math.cos(a) * R * orbits[k], oy + Math.sin(a) * R * orbits[k]]; | |
| 2015 | + const [sx, sy] = at(0, -2.2); | |
| 2016 | + const [px, py] = at(1, 0.6); | |
| 2017 | + const [cmx, cmy] = at(2, -0.9); | |
| 2018 | + const [ggx, ggy] = at(3, 2.62); | |
| 2019 | + const [qx, qy] = at(3, -0.62); | |
| 2020 | + const beamA = -1.05; | |
| 2021 | + const beamLen = R * 1.45; | |
| 2022 | + const bx1 = ox + Math.cos(beamA - 0.09) * beamLen; | |
| 2023 | + const by1 = oy + Math.sin(beamA - 0.09) * beamLen; | |
| 2024 | + const bx2 = ox + Math.cos(beamA + 0.09) * beamLen; | |
| 2025 | + const by2 = oy + Math.sin(beamA + 0.09) * beamLen; | |
| 2026 | + return ( | |
| 2027 | + <g> | |
| 2028 | + <defs> | |
| 2029 | + <Radial id={`${id}-ob1`} color={p.secondary} o0={0.8} mid={[0.25, p.secondary, 0.3]} /> | |
| 2030 | + <Radial id={`${id}-ob2`} color="#fff" o0={1} mid={[0.45, p.secondary, 1]} o1={0.9} /> | |
| 2031 | + <Linear id={`${id}-ob3`} stops={[[0, p.secondary, 0.7], [1, p.secondary, 0]]} /> | |
| 2032 | + </defs> | |
| 2033 | + <path d={stars} fill="#fff" fillOpacity={0.7} /> | |
| 2034 | + <circle cx={f(ox)} cy={f(oy)} r={f(R * 0.75)} fill={`url(#${id}-ob1)`} /> | |
| 2035 | + {orbits.map((k, i) => ( | |
| 2036 | + <circle key={i} cx={f(ox)} cy={f(oy)} r={f(R * k)} fill="none" stroke={p.primary} strokeOpacity={i === 3 ? 0.45 : 0.6} strokeWidth={1.2} strokeDasharray={i === 3 ? "4 7" : undefined} /> | |
| 2037 | + ))} | |
| 2038 | + <polygon points={`${f(ox)},${f(oy)} ${f(bx1)},${f(by1)} ${f(bx2)},${f(by2)}`} fill={`url(#${id}-ob3)`} opacity={0.65} /> | |
| 2039 | + <line x1={f(ox)} y1={f(oy)} x2={f(ox + Math.cos(beamA) * beamLen)} y2={f(oy + Math.sin(beamA) * beamLen)} stroke="#fff" strokeOpacity={0.9} strokeWidth={1.5} /> | |
| 2040 | + <circle cx={f(ox + Math.cos(beamA) * R * 0.94)} cy={f(oy + Math.sin(beamA) * R * 0.94)} r={f(R * 0.06)} fill="#fff" /> | |
| 2041 | + <polygon points={svgStar(ox, oy, R * 0.36, R * 0.18, 12)} fill={p.secondary} fillOpacity={0.6} /> | |
| 2042 | + <circle cx={f(ox)} cy={f(oy)} r={f(R * 0.2)} fill={`url(#${id}-ob2)`} /> | |
| 2043 | + <path d={dots(debris)} fill="#e2e8f0" fillOpacity={0.85} /> | |
| 2044 | + <Shape shape="satellite" color="#cbd5e1" accent={p.secondary} x={sx} y={sy} size={R * 0.34} rotate={-20} /> | |
| 2045 | + <Shape shape="planet" color="#60a5fa" accent={p.primary} x={px} y={py} size={R * 0.5} rotate={-15} /> | |
| 2046 | + <Shape shape="comet" color="#fef3c7" accent={p.secondary} x={cmx} y={cmy} size={R * 0.46} /> | |
| 2047 | + <Shape shape="planet" color="#fb923c" accent="#fde68a" x={ggx} y={ggy} size={R * 0.72} rotate={12} /> | |
| 2048 | + {!compact ? ( | |
| 2049 | + <> | |
| 2050 | + <polygon points={svgStar(qx, qy, R * 0.16, R * 0.05, 4)} fill={p.primary} /> | |
| 2051 | + <Small x={sx} y={sy + R * 0.22} text="0.8×" color={p.glow} size={R * 0.055} weight={700} tracking="0.02em" /> | |
| 2052 | + <Small x={px} y={py + R * 0.3} text="2×" color={p.glow} size={R * 0.06} weight={700} tracking="0.02em" /> | |
| 2053 | + <Small x={cmx + R * 0.02} y={cmy - R * 0.26} text="4×" color={p.glow} size={R * 0.06} weight={700} tracking="0.02em" /> | |
| 2054 | + <Small x={ggx} y={ggy + R * 0.4} text="8×" color={p.glow} size={R * 0.065} weight={800} tracking="0.02em" /> | |
| 2055 | + <Small x={qx} y={qy - R * 0.22} text="QUASAR 25×" color={p.primary} size={R * 0.055} weight={800} tracking="0.1em" /> | |
| 2056 | + <Pill x={cx - R * 0.82} y={cy - R * 0.62} text="SUPERNOVA" color={p.secondary} ink={p.secondary} size={R * 0.062} /> | |
| 2057 | + <Verb x={cx + R * 0.7} y={cy + R * 1.08} text="FIRE IMPULSE" color={p.secondary} size={R * 0.072} /> | |
| 2058 | + </> | |
| 2059 | + ) : null} | |
| 2060 | + </g> | |
| 2061 | + ); | |
| 2062 | +} | |
| 2063 | + | |
| 2064 | +/* 35 — ESCAPE 99: tower cross-section with rooms and a stairwell, checkpoint rail 10 · 25 · 50 · 75 · 99 */ | |
| 2065 | +function Escape99(s: Stage) { | |
| 2066 | + const { H, cx, cy, R, id, p, compact } = s; | |
| 2067 | + const tw = R * 1.16; | |
| 2068 | + const tx = cx - R * 0.3; | |
| 2069 | + const top = cy - R * 1.12; | |
| 2070 | + const fh = R * 0.29; | |
| 2071 | + const nFloors = Math.floor((H - top) / fh) + 1; | |
| 2072 | + let floors = ""; | |
| 2073 | + for (let i = 1; i < nFloors; i++) floors += `M${f(tx - tw / 2)} ${f(top + i * fh)}h${f(tw)} `; | |
| 2074 | + let stairs = ""; | |
| 2075 | + for (let i = 0; i < nFloors; i++) { | |
| 2076 | + const y = top + i * fh; | |
| 2077 | + stairs += `M${f(tx - R * 0.1)} ${f(y + fh)} l${f(R * 0.05)} ${f(-fh * 0.25)} h${f(R * 0.05)} l${f(R * 0.05)} ${f(-fh * 0.25)} h${f(R * 0.05)} l${f(R * 0.05)} ${f(-fh * 0.25)} `; | |
| 2078 | + } | |
| 2079 | + const roomX = (side: -1 | 1) => tx + side * (R * 0.1 + (tw / 2 - R * 0.1) / 2); | |
| 2080 | + const fy = (i: number) => top + i * fh + fh / 2; | |
| 2081 | + let crenel = ""; | |
| 2082 | + for (let i = 0; i < 6; i++) crenel += `M${f(tx - tw / 2 + i * (tw / 6) + 2)} ${f(top)}v${f(-R * 0.08)}h${f(tw / 12)}v${f(R * 0.08)}z `; | |
| 2083 | + let spikes = ""; | |
| 2084 | + for (let i = 0; i < 5; i++) spikes += `M${f(roomX(-1) - R * 0.14 + i * R * 0.07)} ${f(fy(4) + fh * 0.4)}l${f(R * 0.035)} ${f(-fh * 0.55)}l${f(R * 0.035)} ${f(fh * 0.55)}z `; | |
| 2085 | + const hx = tx; | |
| 2086 | + const hy = fy(3) + fh * 0.05; | |
| 2087 | + const rx = cx + R * 0.9; | |
| 2088 | + const cps = [ | |
| 2089 | + { n: "10", m: "×1.4", t: 0 }, | |
| 2090 | + { n: "25", m: "×2.9", t: 0.25 }, | |
| 2091 | + { n: "50", m: "×18", t: 0.5 }, | |
| 2092 | + { n: "75", m: "×190", t: 0.75 }, | |
| 2093 | + { n: "99", m: "×3k", t: 1 }, | |
| 2094 | + ]; | |
| 2095 | + const railTop = cy - R * 0.72; | |
| 2096 | + const railBot = cy + R * 1.05; | |
| 2097 | + const leaves = scatterDots(s, 16, [cx - R * 1.25, cy + R * 0.4, R * 0.7, R * 0.9], 1.2, 2.6); | |
| 2098 | + return ( | |
| 2099 | + <g> | |
| 2100 | + <defs> | |
| 2101 | + <Radial id={`${id}-es1`} color={p.primary} o0={0.4} /> | |
| 2102 | + <Radial id={`${id}-es2`} color={p.secondary} o0={0.9} /> | |
| 2103 | + <Linear id={`${id}-es3`} stops={[[0, p.glow, 0.8], [1, p.primary, 0.1]]} /> | |
| 2104 | + </defs> | |
| 2105 | + <ellipse cx={f(tx)} cy={f(top)} rx={f(R * 1.2)} ry={f(R * 0.7)} fill={`url(#${id}-es1)`} /> | |
| 2106 | + <rect x={f(tx - tw / 2)} y={f(top)} width={f(tw)} height={f(H - top)} fill="#0a1411" stroke={p.primary} strokeOpacity={0.75} strokeWidth={2} /> | |
| 2107 | + <path d={crenel} fill="#0a1411" stroke={p.primary} strokeOpacity={0.75} strokeWidth={1.5} /> | |
| 2108 | + <path d={floors} stroke={p.primary} strokeOpacity={0.35} strokeWidth={1} /> | |
| 2109 | + <rect x={f(tx - R * 0.1)} y={f(top)} width={f(R * 0.2)} height={f(H - top)} fill="#06090a" /> | |
| 2110 | + <path d={stairs} fill="none" stroke={p.primary} strokeOpacity={0.6} strokeWidth={1.2} /> | |
| 2111 | + <circle cx={f(tx)} cy={f(top + fh * 0.5)} r={f(R * 0.22)} fill={`url(#${id}-es2)`} /> | |
| 2112 | + <Shape shape="ring" color="#a78bfa" accent="#f5f3ff" x={roomX(1)} y={fy(1)} size={fh * 0.9} /> | |
| 2113 | + <Shape shape="chest" color="#b8741c" accent="#fde68a" x={roomX(-1)} y={fy(2) + fh * 0.05} size={fh * 0.9} /> | |
| 2114 | + <Shape shape="skull" color="#f8fafc" accent="#94a3b8" x={roomX(1)} y={fy(3)} size={fh * 0.8} /> | |
| 2115 | + <path d={spikes} fill={p.secondary} /> | |
| 2116 | + <Shape shape="hex" color={p.primary} accent={p.glow} x={roomX(1)} y={fy(5)} size={fh * 0.85} label="×" /> | |
| 2117 | + <path d={`M${f(roomX(-1))} ${f(fy(6) + fh * 0.35)}V${f(fy(6))} L${f(roomX(-1) - R * 0.14)} ${f(fy(6) - fh * 0.3)} M${f(roomX(-1))} ${f(fy(6))} L${f(roomX(-1) + R * 0.14)} ${f(fy(6) - fh * 0.3)}`} fill="none" stroke={p.secondary} strokeWidth={2.5} strokeLinecap="round" /> | |
| 2118 | + <Shape shape="chest" color="#b8741c" accent="#fde68a" x={roomX(1)} y={fy(7) + fh * 0.05} size={fh * 0.9} /> | |
| 2119 | + <g fill={p.glow} stroke="#06090a" strokeWidth={1}> | |
| 2120 | + <circle cx={f(hx)} cy={f(hy - fh * 0.22)} r={f(fh * 0.13)} /> | |
| 2121 | + <rect x={f(hx - fh * 0.1)} y={f(hy - fh * 0.1)} width={f(fh * 0.2)} height={f(fh * 0.32)} rx={f(fh * 0.05)} /> | |
| 2122 | + <path d={`M${f(hx - fh * 0.06)} ${f(hy + fh * 0.2)}l${f(-fh * 0.1)} ${f(fh * 0.22)} M${f(hx + fh * 0.06)} ${f(hy + fh * 0.2)}l${f(fh * 0.12)} ${f(fh * 0.2)}`} stroke={p.glow} strokeWidth={f(fh * 0.09)} strokeLinecap="round" fill="none" /> | |
| 2123 | + </g> | |
| 2124 | + <path d={leaves} fill={p.primary} fillOpacity={0.45} /> | |
| 2125 | + {!compact ? ( | |
| 2126 | + <> | |
| 2127 | + <line x1={f(rx)} y1={f(railTop)} x2={f(rx)} y2={f(railBot)} stroke={`url(#${id}-es3)`} strokeWidth={3} strokeLinecap="round" /> | |
| 2128 | + {cps.map((cp) => { | |
| 2129 | + const y = railBot - (railBot - railTop) * cp.t; | |
| 2130 | + const summit = cp.n === "99"; | |
| 2131 | + return ( | |
| 2132 | + <g key={cp.n}> | |
| 2133 | + <circle cx={f(rx)} cy={f(y)} r={f(R * (summit ? 0.05 : 0.03))} fill={summit ? p.secondary : p.glow} /> | |
| 2134 | + <Small x={rx - R * 0.09} y={y} text={cp.n} color={summit ? p.secondary : "#fff"} size={R * (summit ? 0.11 : 0.075)} anchor="end" weight={800} tracking="0" /> | |
| 2135 | + <Small x={rx + R * 0.09} y={y} text={cp.m} color={summit ? p.secondary : p.glow} size={R * (summit ? 0.07 : 0.055)} anchor="start" weight={700} tracking="0.04em" opacity={summit ? 1 : 0.75} /> | |
| 2136 | + </g> | |
| 2137 | + ); | |
| 2138 | + })} | |
| 2139 | + <Verb x={cx - R * 0.98} y={cy - R * 0.62} text="CLIMB" color={p.primary} size={R * 0.075} /> | |
| 2140 | + <Verb x={cx - R * 0.9} y={cy - R * 0.3} text="CASH OUT" color={p.secondary} size={R * 0.068} /> | |
| 2141 | + </> | |
| 2142 | + ) : null} | |
| 2143 | + </g> | |
| 2144 | + ); | |
| 2145 | +} | |
| 2146 | + | |
| 1176 | 2147 | /* Fallback for unknown slugs: palette glow + the game's top symbols if known. */ |
| 1177 | 2148 | function Generic(s: Stage) { |
| 1178 | 2149 | const { W, H, cx, cy, R, id, p, compact } = s; |
@@ -1217,6 +2188,23 @@ const SCENES: Record<string, (s: Stage) => React.ReactNode> = { | ||
| 1217 | 2188 | "reel-reactor": ReelReactor, |
| 1218 | 2189 | obsidian: Obsidian, |
| 1219 | 2190 | "spinza-original": SpinzaOriginal, |
| 2191 | + /* Risk Games */ | |
| 2192 | + skyfall: Skyfall, | |
| 2193 | + "deep-dive": DeepDive, | |
| 2194 | + "rocket-run": RocketRun, | |
| 2195 | + "bank-heist": BankHeist, | |
| 2196 | + volcano: Volcano, | |
| 2197 | + "black-hole": BlackHole, | |
| 2198 | + freefall: Freefall, | |
| 2199 | + "core-meltdown": CoreMeltdown, | |
| 2200 | + "storm-chase": StormChase, | |
| 2201 | + "elevator-999": Elevator999, | |
| 2202 | + /* Beyond Slots */ | |
| 2203 | + dropzone: Dropzone, | |
| 2204 | + "grid-break": GridBreak, | |
| 2205 | + "the-vault": TheVault, | |
| 2206 | + orbit: Orbit, | |
| 2207 | + "escape-99": Escape99, | |
| 1220 | 2208 | }; |
| 1221 | 2209 | |
| 1222 | 2210 | /* ------------------------------------------------------------------- title */ |
@@ -1243,6 +2231,12 @@ interface TitleSpec { | ||
| 1243 | 2231 | outline?: string; |
| 1244 | 2232 | /** Scale factor applied to the fitted size (Obsidian whispers). */ |
| 1245 | 2233 | scale?: number; |
| 2234 | + /** Monospace face (control-room / puzzle-grid voices). */ | |
| 2235 | + mono?: boolean; | |
| 2236 | + /** Horizontal glyph stretch: < 1 tall condensed, > 1 extended. */ | |
| 2237 | + stretch?: number; | |
| 2238 | + /** A substring of the name rendered in another colour (GRID//BREAK). */ | |
| 2239 | + accent?: { text: string; fill: string }; | |
| 1246 | 2240 | } |
| 1247 | 2241 | |
| 1248 | 2242 | const TITLES: Record<string, TitleSpec> = { |
@@ -1266,6 +2260,23 @@ const TITLES: Record<string, TitleSpec> = { | ||
| 1266 | 2260 | "reel-reactor": { caps: true, tracking: -0.03, weight: 800, fill: "#a3e635", glow: "#22c55e", eyebrow: "EVERY DEAD SPIN HEATS THE CORE", eyebrowColor: "#facc15" }, |
| 1267 | 2261 | obsidian: { caps: true, tracking: 0.42, weight: 700, fill: "#f5e6c8", align: "center", scale: 0.62 }, |
| 1268 | 2262 | "spinza-original": { caps: true, tracking: -0.02, weight: 800, fill: ["#ffffff", "#c4b5fd"], glow: "#8b5cf6", subline: true, eyebrowColor: "#22d3ee" }, |
| 2263 | + /* Risk Games — each in its own voice */ | |
| 2264 | + skyfall: { caps: true, tracking: 0.2, weight: 700, fill: ["#ffffff", "#7dd3fc"], glow: "#7dd3fc", eyebrow: "ALTITUDE PAYS · RISK GAME", eyebrowColor: "#fbbf24" }, | |
| 2265 | + "deep-dive": { caps: true, tracking: 0.3, weight: 700, fill: ["#dffaff", "#22d3ee"], glow: "#0e7490", eyebrow: "1,000 M = ×2 · 3,000 M = ×5", eyebrowColor: "#34d399" }, | |
| 2266 | + "rocket-run": { caps: true, tracking: -0.05, weight: 800, skew: -9, fill: ["#fff7ed", "#fb923c"], glow: "#fb923c", eyebrow: "MARS OR BUST", eyebrowColor: "#f472b6" }, | |
| 2267 | + "bank-heist": { caps: true, tracking: 0.08, weight: 800, fill: ["#f5e6c8", "#c9a961"], glow: "#ef4444", eyebrow: "THE BAG KEEPS FILLING", eyebrowColor: "#ef4444" }, | |
| 2268 | + volcano: { caps: true, tracking: -0.01, weight: 800, fill: ["#ffd166", "#ff5c3d"], outline: "#2a0c07", glow: "#ff8a5c", eyebrow: "DEEPER CRYSTALS · RISING LAVA", eyebrowColor: "#fbbf24" }, | |
| 2269 | + "black-hole": { caps: true, tracking: 0.34, weight: 700, stretch: 1.14, fill: ["#ffffff", "#a78bfa"], glow: "#f0abfc", eyebrow: "TIME SLOWS · THE MULTIPLIER DOESN'T", eyebrowColor: "#f0abfc" }, | |
| 2270 | + freefall: { caps: true, tracking: -0.06, weight: 800, skew: 14, fill: ["#ffffff", "#60a5fa"], glow: "#60a5fa", eyebrow: "PULL LATE · NOT TOO LATE", eyebrowColor: "#f97316" }, | |
| 2271 | + "core-meltdown": { caps: true, tracking: 0.1, weight: 700, mono: true, fill: "#bef264", glow: "#a3e635", eyebrow: "TEMP RISING · LIMIT 100 %", eyebrowColor: "#facc15" }, | |
| 2272 | + "storm-chase": { caps: true, tracking: -0.03, weight: 800, skew: -6, fill: ["#f8fafc", "#94a3b8"], glow: "#a3e635", eyebrow: "DRIVE INTO THE WALL CLOUD", eyebrowColor: "#a3e635" }, | |
| 2273 | + "elevator-999": { caps: true, tracking: 0.04, weight: 800, stretch: 0.74, fill: ["#f3e2ad", "#c9a961"], glow: "#38bdf8", eyebrow: "EVERY FLOOR IS A DECISION", eyebrowColor: "#38bdf8", split: true }, | |
| 2274 | + /* Beyond Slots */ | |
| 2275 | + dropzone: { caps: true, tracking: 0.22, weight: 800, fill: ["#ffffff", "#22d3ee"], glow: "#f472b6", eyebrow: "RELEASE THE CAPSULE", eyebrowColor: "#f472b6" }, | |
| 2276 | + "grid-break": { caps: true, tracking: 0.02, weight: 700, mono: true, fill: "#bef264", accent: { text: "//", fill: "#38bdf8" }, glow: "#a3e635", eyebrow: "ONE WAVE · INFINITE CHAINS", eyebrowColor: "#38bdf8" }, | |
| 2277 | + "the-vault": { caps: true, tracking: 0.3, weight: 700, fill: ["#fff1c2", "#c9a961"], glow: "#c9a961", eyebrow: "FIVE LAYERS · ONE DECISION AT EACH", eyebrowColor: "#38bdf8" }, | |
| 2278 | + orbit: { caps: true, tracking: 0.5, weight: 700, fill: ["#ffffff", "#f0abfc"], glow: "#facc15", eyebrow: "FIRE THE IMPULSE · RIDE THE CHAOS", eyebrowColor: "#facc15" }, | |
| 2279 | + "escape-99": { caps: true, tracking: -0.02, weight: 800, fill: ["#d1fae5", "#34d399"], outline: "#06090a", glow: "#f97316", eyebrow: "99 FLOORS · CASH OUT ON ANY", eyebrowColor: "#f97316", split: true }, | |
| 1269 | 2280 | }; |
| 1270 | 2281 | |
| 1271 | 2282 | function splitLines(name: string, spec: TitleSpec, v: ArtVariant): string[] { |
@@ -1278,23 +2289,25 @@ function splitLines(name: string, spec: TitleSpec, v: ArtVariant): string[] { | ||
| 1278 | 2289 | return [words.slice(0, mid).join(" "), words.slice(mid).join(" ")]; |
| 1279 | 2290 | } |
| 1280 | 2291 | |
| 1281 | −function estimateWidth(text: string, caps: boolean, tracking: number): number { | |
| 1282 | − const cw = caps ? 0.7 : 0.6; | |
| 1283 | − return text.length * (cw + tracking) - tracking; | |
| 2292 | +function estimateWidth(text: string, caps: boolean, tracking: number, mono = false, stretch = 1): number { | |
| 2293 | + const cw = mono ? 0.64 : caps ? 0.7 : 0.6; | |
| 2294 | + return (text.length * (cw + tracking) - tracking) * stretch; | |
| 1284 | 2295 | } |
| 1285 | 2296 | |
| 1286 | 2297 | function Title({ s, name, showName }: { s: Stage; name: string; showName: boolean }) { |
| 1287 | 2298 | if (!showName) return null; |
| 1288 | 2299 | const { W, H, v, id, p } = s; |
| 1289 | − const spec = TITLES[s.game?.slug ?? ""] ?? { caps: true, tracking: -0.03, weight: 800, glow: p.glow }; | |
| 2300 | + const spec = TITLES[s.slug] ?? { caps: true, tracking: -0.03, weight: 800, glow: p.glow }; | |
| 1290 | 2301 | const caps = spec.caps !== false; |
| 1291 | 2302 | const tracking = spec.tracking ?? -0.03; |
| 1292 | 2303 | const weight = spec.weight ?? 800; |
| 2304 | + const stretch = spec.stretch ?? 1; | |
| 2305 | + const face = spec.mono ? MONO : FONT; | |
| 1293 | 2306 | const rawLines = splitLines(name, spec, v); |
| 1294 | 2307 | const lines = caps ? rawLines.map((l) => l.toUpperCase()) : rawLines; |
| 1295 | 2308 | const cfg = v === "card" ? { pad: 22, maxFont: 54, avail: W - 44, eye: 11 } : v === "hero" ? { pad: 84, maxFont: 168, avail: W * 0.44, eye: 22 } : v === "banner" ? { pad: 56, maxFont: 112, avail: W * 0.42, eye: 18 } : { pad: 14, maxFont: 26, avail: W - 28, eye: 0 }; |
| 1296 | 2309 | const fitLines = spec.subline ? [lines[0]] : lines; |
| 1297 | − const est = Math.max(...fitLines.map((l) => estimateWidth(l, caps, tracking)), 1); | |
| 2310 | + const est = Math.max(...fitLines.map((l) => estimateWidth(l, caps, tracking, spec.mono, stretch)), 1); | |
| 1298 | 2311 | let size = Math.min(cfg.maxFont, cfg.avail / est); |
| 1299 | 2312 | if (spec.scale) size *= spec.scale; |
| 1300 | 2313 | const subSize = size * 0.34; |
@@ -1307,17 +2320,30 @@ function Title({ s, name, showName }: { s: Stage; name: string; showName: boolea | ||
| 1307 | 2320 | const fill = Array.isArray(spec.fill) ? `url(#${id}-title)` : (spec.fill ?? "#fff"); |
| 1308 | 2321 | const eyebrow = spec.eyebrow && cfg.eye > 0 && v !== "tile"; |
| 1309 | 2322 | const eyeSize = v === "hero" ? size * 0.14 : v === "banner" ? size * 0.15 : Math.max(9.5, size * 0.2); |
| 1310 | − const fontStyle: React.CSSProperties = { fontFamily: FONT, letterSpacing: `${tracking}em` }; | |
| 2323 | + const fontStyle: React.CSSProperties = { fontFamily: face, letterSpacing: `${tracking}em` }; | |
| 1311 | 2324 | |
| 1312 | − const render = (props: React.SVGProps<SVGTextElement>, key: string) => | |
| 2325 | + const render = (props: React.SVGProps<SVGTextElement>, key: string, main = false) => | |
| 1313 | 2326 | lines.map((l, i) => { |
| 1314 | 2327 | const isSub = spec.subline && i === 1; |
| 1315 | 2328 | const y = isSub ? baseY + subSize * 1.7 : baseY + i * lineH; |
| 1316 | − const base: React.CSSProperties = isSub ? { fontFamily: FONT, letterSpacing: "0.32em" } : fontStyle; | |
| 1317 | − const transform = spec.skew ? `translate(${f(x)} ${f(y)}) skewX(${spec.skew}) translate(${f(-x)} ${f(-y)})` : undefined; | |
| 2329 | + const base: React.CSSProperties = isSub ? { fontFamily: face, letterSpacing: "0.32em" } : fontStyle; | |
| 2330 | + const ops: string[] = []; | |
| 2331 | + if (spec.skew) ops.push(`skewX(${spec.skew})`); | |
| 2332 | + if (stretch !== 1 && !isSub) ops.push(`scale(${stretch} 1)`); | |
| 2333 | + const transform = ops.length ? `translate(${f(x)} ${f(y)}) ${ops.join(" ")} translate(${f(-x)} ${f(-y)})` : undefined; | |
| 2334 | + const acc = spec.accent; | |
| 2335 | + const k = main && acc && !isSub ? l.indexOf(acc.text) : -1; | |
| 1318 | 2336 | return ( |
| 1319 | 2337 | <text key={`${key}${i}`} x={f(x)} y={f(y)} textAnchor={anchor} fontSize={f(isSub ? subSize : size)} fontWeight={isSub ? 700 : weight} transform={transform} {...props} style={{ ...base, ...props.style }}> |
| 1320 | − {l} | |
| 2338 | + {k >= 0 && acc ? ( | |
| 2339 | + <> | |
| 2340 | + {l.slice(0, k)} | |
| 2341 | + <tspan fill={acc.fill}>{acc.text}</tspan> | |
| 2342 | + {l.slice(k + acc.text.length)} | |
| 2343 | + </> | |
| 2344 | + ) : ( | |
| 2345 | + l | |
| 2346 | + )} | |
| 1321 | 2347 | </text> |
| 1322 | 2348 | ); |
| 1323 | 2349 | }); |
@@ -1330,14 +2356,14 @@ function Title({ s, name, showName }: { s: Stage; name: string; showName: boolea | ||
| 1330 | 2356 | </defs> |
| 1331 | 2357 | ) : null} |
| 1332 | 2358 | {eyebrow ? ( |
| 1333 | − <text x={f(x)} y={f(baseY - size * 1.02)} textAnchor={anchor} fontSize={f(eyeSize)} fontWeight={700} fill={spec.eyebrowColor ?? p.secondary} style={{ fontFamily: FONT, letterSpacing: "0.22em" }}> | |
| 2359 | + <text x={f(x)} y={f(baseY - size * 1.02)} textAnchor={anchor} fontSize={f(eyeSize)} fontWeight={700} fill={spec.eyebrowColor ?? p.secondary} style={{ fontFamily: face, letterSpacing: "0.22em" }}> | |
| 1334 | 2360 | {spec.eyebrow} |
| 1335 | 2361 | </text> |
| 1336 | 2362 | ) : null} |
| 1337 | 2363 | {spec.glow && v !== "tile" ? render({ fill: spec.glow, fillOpacity: 0.5, style: { filter: `blur(${f(size * 0.16)}px)` } }, "g") : null} |
| 1338 | 2364 | {spec.shadow ? render({ fill: spec.shadow, transform: `translate(${f(size * 0.07)} ${f(size * 0.07)})` }, "s") : null} |
| 1339 | 2365 | {spec.outline ? render({ fill: "none", stroke: spec.outline, strokeWidth: f(size * 0.09), strokeLinejoin: "round" }, "o") : null} |
| 1340 | − {render({ fill }, "t")} | |
| 2366 | + {render({ fill }, "t", true)} | |
| 1341 | 2367 | </g> |
| 1342 | 2368 | ); |
| 1343 | 2369 | } |
@@ -1358,6 +2384,7 @@ export function GameArt({ slug, name, palette, variant = "card", className, show | ||
| 1358 | 2384 | W, |
| 1359 | 2385 | H, |
| 1360 | 2386 | v: variant, |
| 2387 | + slug, | |
| 1361 | 2388 | cx, |
| 1362 | 2389 | cy, |
| 1363 | 2390 | R, |
modified
apps/web/src/components/lobby/symbol-svg.tsx
+91 −0
@@ -322,6 +322,97 @@ function Glyph({ style, size }: { style: SymbolStyle; size: number }) { | ||
| 322 | 322 | {style.label ? <Label text={style.label} fontSize={size * 0.3} color="#fff" /> : null} |
| 323 | 323 | </> |
| 324 | 324 | ); |
| 325 | + /* ----------------------------------------------------------- themed set */ | |
| 326 | + case "lock": | |
| 327 | + return ( | |
| 328 | + <> | |
| 329 | + <rect x={f(-r * 0.75)} y={f(-r * 0.1)} width={f(r * 1.5)} height={f(r * 1.1)} rx={f(r * 0.2)} fill={main} /> | |
| 330 | + <path d={arcPath(0, -r * 0.15, r * 0.5, Math.PI, 0)} fill="none" stroke={accent} strokeWidth={sw(0.05)} /> | |
| 331 | + <circle cy={f(r * 0.35)} r={f(r * 0.16)} fill={INK} /> | |
| 332 | + <rect x={f(-r * 0.06)} y={f(r * 0.35)} width={f(r * 0.12)} height={f(r * 0.35)} fill={INK} /> | |
| 333 | + </> | |
| 334 | + ); | |
| 335 | + case "key": | |
| 336 | + return ( | |
| 337 | + <> | |
| 338 | + <circle cx={f(-r * 0.45)} cy={f(-r * 0.35)} r={f(r * 0.42)} fill="none" stroke={main} strokeWidth={sw(0.06)} /> | |
| 339 | + <path d={`M${f(-r * 0.15)} ${f(-r * 0.05)}L${f(r * 0.85)} ${f(r * 0.95)}`} stroke={main} strokeWidth={sw(0.06)} /> | |
| 340 | + <path d={`M${f(r * 0.45)} ${f(r * 0.55)}L${f(r * 0.7)} ${f(r * 0.3)} M${f(r * 0.65)} ${f(r * 0.75)}L${f(r * 0.9)} ${f(r * 0.5)}`} stroke={accent} strokeWidth={sw(0.05)} /> | |
| 341 | + </> | |
| 342 | + ); | |
| 343 | + case "bag": | |
| 344 | + return ( | |
| 345 | + <> | |
| 346 | + <path d={`M${f(-r * 0.75)} ${f(r * 0.9)} C${f(-r * 1.1)} ${f(-r * 0.3)} ${f(-r * 0.3)} ${f(-r * 0.6)} 0 ${f(-r * 0.55)} C${f(r * 0.3)} ${f(-r * 0.6)} ${f(r * 1.1)} ${f(-r * 0.3)} ${f(r * 0.75)} ${f(r * 0.9)}Z`} fill={main} /> | |
| 347 | + <rect x={f(-r * 0.3)} y={f(-r * 0.95)} width={f(r * 0.6)} height={f(r * 0.35)} rx={f(r * 0.1)} fill={accent} /> | |
| 348 | + <line x1={f(-r * 0.35)} y1={f(r * 0.3)} x2={f(r * 0.35)} y2={f(r * 0.3)} stroke={darkenHex(main, 0.3)} strokeWidth={sw(0.03)} /> | |
| 349 | + </> | |
| 350 | + ); | |
| 351 | + case "warning": | |
| 352 | + return ( | |
| 353 | + <> | |
| 354 | + <polygon points={pts([0, -r, r * 0.95, r * 0.75, -r * 0.95, r * 0.75])} fill={main} /> | |
| 355 | + <polygon points={pts([0, -r * 0.6, r * 0.55, r * 0.55, -r * 0.55, r * 0.55])} fill={INK} /> | |
| 356 | + <rect x={f(-r * 0.08)} y={f(-r * 0.3)} width={f(r * 0.16)} height={f(r * 0.5)} fill={main} /> | |
| 357 | + <circle cy={f(r * 0.38)} r={f(r * 0.09)} fill={main} /> | |
| 358 | + </> | |
| 359 | + ); | |
| 360 | + case "gauge": | |
| 361 | + return ( | |
| 362 | + <> | |
| 363 | + <path d={arcPath(0, r * 0.2, r * 0.9, Math.PI, 0)} fill="none" stroke={darkenHex(main, 0.3)} strokeWidth={sw(0.09)} /> | |
| 364 | + <path d={arcPath(0, r * 0.2, r * 0.9, Math.PI, Math.PI * 1.6)} fill="none" stroke={main} strokeWidth={sw(0.09)} /> | |
| 365 | + <line x1={0} y1={f(r * 0.2)} x2={f(r * 0.45)} y2={f(-r * 0.5)} stroke={accent} strokeWidth={sw(0.04)} /> | |
| 366 | + <circle cy={f(r * 0.2)} r={f(r * 0.12)} fill={accent} /> | |
| 367 | + </> | |
| 368 | + ); | |
| 369 | + case "rocket": | |
| 370 | + return ( | |
| 371 | + <> | |
| 372 | + <path d={`M0 ${f(-r)} C${f(r * 0.6)} ${f(-r * 0.4)} ${f(r * 0.5)} ${f(r * 0.4)} ${f(r * 0.35)} ${f(r * 0.6)} L${f(-r * 0.35)} ${f(r * 0.6)} C${f(-r * 0.5)} ${f(r * 0.4)} ${f(-r * 0.6)} ${f(-r * 0.4)} 0 ${f(-r)}Z`} fill={main} /> | |
| 373 | + <polygon points={pts([-r * 0.35, r * 0.2, -r * 0.75, r * 0.8, -r * 0.35, r * 0.6])} fill={accent} /> | |
| 374 | + <polygon points={pts([r * 0.35, r * 0.2, r * 0.75, r * 0.8, r * 0.35, r * 0.6])} fill={accent} /> | |
| 375 | + <circle cy={f(-r * 0.25)} r={f(r * 0.18)} fill={INK} /> | |
| 376 | + <polygon points={pts([-r * 0.2, r * 0.6, r * 0.2, r * 0.6, 0, r])} fill="#ffb347" /> | |
| 377 | + </> | |
| 378 | + ); | |
| 379 | + case "chest": | |
| 380 | + return ( | |
| 381 | + <> | |
| 382 | + <rect x={f(-r * 0.9)} y={f(-r * 0.2)} width={f(r * 1.8)} height={f(r * 1.0)} rx={f(r * 0.1)} fill={main} /> | |
| 383 | + <rect x={f(-r * 0.9)} y={f(-r * 0.75)} width={f(r * 1.8)} height={f(r * 0.6)} rx={f(r * 0.3)} fill={darkenHex(main, 0.2)} /> | |
| 384 | + <rect x={f(-r * 0.9)} y={f(-r * 0.2)} width={f(r * 1.8)} height={f(r * 0.12)} fill={accent} /> | |
| 385 | + <rect x={f(-r * 0.15)} y={f(-r * 0.2)} width={f(r * 0.3)} height={f(r * 0.35)} rx={f(r * 0.05)} fill={accent} /> | |
| 386 | + </> | |
| 387 | + ); | |
| 388 | + case "planet": | |
| 389 | + return ( | |
| 390 | + <> | |
| 391 | + <circle r={f(r * 0.7)} fill={main} /> | |
| 392 | + <ellipse cy={f(r * 0.05)} rx={f(r * 1.05)} ry={f(r * 0.28)} fill="none" stroke={accent} strokeWidth={sw(0.04)} /> | |
| 393 | + <circle cx={f(-r * 0.25)} cy={f(-r * 0.25)} r={f(r * 0.18)} fill="#fff" fillOpacity={0.3} /> | |
| 394 | + <ellipse cx={f(r * 0.15)} cy={f(r * 0.15)} rx={f(r * 0.3)} ry={f(r * 0.12)} fill={darkenHex(main, 0.25)} fillOpacity={0.8} /> | |
| 395 | + </> | |
| 396 | + ); | |
| 397 | + case "comet": | |
| 398 | + return ( | |
| 399 | + <> | |
| 400 | + <polygon points={pts([r * 0.5, -r * 0.5, -r * 1.0, r * 0.15, -r * 0.6, r * 0.6])} fill={accent} fillOpacity={0.5} /> | |
| 401 | + <line x1={f(r * 0.5)} y1={f(-r * 0.5)} x2={f(-r * 0.7)} y2={f(r * 0.7)} stroke={accent} strokeOpacity={0.4} strokeWidth={sw(0.05)} /> | |
| 402 | + <circle cx={f(r * 0.5)} cy={f(-r * 0.5)} r={f(r * 0.4)} fill={main} /> | |
| 403 | + <circle cx={f(r * 0.4)} cy={f(-r * 0.6)} r={f(r * 0.12)} fill="#fff" fillOpacity={0.6} /> | |
| 404 | + </> | |
| 405 | + ); | |
| 406 | + case "satellite": | |
| 407 | + return ( | |
| 408 | + <> | |
| 409 | + <rect x={f(-r * 0.3)} y={f(-r * 0.3)} width={f(r * 0.6)} height={f(r * 0.6)} rx={f(r * 0.1)} fill={main} /> | |
| 410 | + <rect x={f(-r * 1.0)} y={f(-r * 0.18)} width={f(r * 0.6)} height={f(r * 0.36)} fill={accent} /> | |
| 411 | + <rect x={f(r * 0.4)} y={f(-r * 0.18)} width={f(r * 0.6)} height={f(r * 0.36)} fill={accent} /> | |
| 412 | + <path d={[-0.8, -0.6, 0.6, 0.8].map((x) => `M${f(x * r)} ${f(-r * 0.18)}V${f(r * 0.18)}`).join(" ")} stroke={darkenHex(main, 0.4)} strokeWidth={sw(0.012)} /> | |
| 413 | + <path d={arcPath(0, -r * 0.55, r * 0.3, Math.PI, 0)} fill="none" stroke={main} strokeWidth={sw(0.03)} /> | |
| 414 | + </> | |
| 415 | + ); | |
| 325 | 416 | default: |
| 326 | 417 | return <circle r={f(r)} fill={main} />; |
| 327 | 418 | } |
added
games/certifications/dropzone.json
+301 −0
@@ -0,0 +1,301 @@ | ||
| 1 | +{ | |
| 2 | + "game": "dropzone", | |
| 3 | + "name": "Dropzone", | |
| 4 | + "version": "1.0.0", | |
| 5 | + "spins": 5000000, | |
| 6 | + "configuredRtp": 0.96, | |
| 7 | + "observedRtp": 0.961199776, | |
| 8 | + "deviation": 0.0011997759999999857, | |
| 9 | + "hitRate": 0.9514886, | |
| 10 | + "bonusRate": 0, | |
| 11 | + "freeSpinRate": 0, | |
| 12 | + "maxWinMultiplier": 2787.59, | |
| 13 | + "stdDev": 5.956893806457615, | |
| 14 | + "status": "PASS", | |
| 15 | + "checks": [ | |
| 16 | + { | |
| 17 | + "name": "definition", | |
| 18 | + "pass": true, | |
| 19 | + "detail": "rtp 0.96, cap 3000×, payScale 0.5557" | |
| 20 | + }, | |
| 21 | + { | |
| 22 | + "name": "spins", | |
| 23 | + "pass": true, | |
| 24 | + "detail": "5,000,000 rounds" | |
| 25 | + }, | |
| 26 | + { | |
| 27 | + "name": "rtp-deviation", | |
| 28 | + "pass": true, | |
| 29 | + "detail": "0.120% (tolerance ±0.80%)" | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + "name": "rtp-band", | |
| 33 | + "pass": true, | |
| 34 | + "detail": "96.12%" | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + "name": "max-win", | |
| 38 | + "pass": true, | |
| 39 | + "detail": "2787.6× (cap 3000×)" | |
| 40 | + }, | |
| 41 | + { | |
| 42 | + "name": "cap-share", | |
| 43 | + "pass": true, | |
| 44 | + "detail": "0 capped rounds" | |
| 45 | + } | |
| 46 | + ], | |
| 47 | + "certifiedAt": "2026-09-08T03:31:10.758Z", | |
| 48 | + "rules": { | |
| 49 | + "maxDeviation": 0.004, | |
| 50 | + "minSpins": 1000000, | |
| 51 | + "rtpBand": [ | |
| 52 | + 0.93, | |
| 53 | + 0.99 | |
| 54 | + ], | |
| 55 | + "hitRateBand": [ | |
| 56 | + 0.08, | |
| 57 | + 0.6 | |
| 58 | + ], | |
| 59 | + "maxCappedShare": 0.0005 | |
| 60 | + }, | |
| 61 | + "distribution": [ | |
| 62 | + { | |
| 63 | + "label": "0×", | |
| 64 | + "min": 0, | |
| 65 | + "max": 0, | |
| 66 | + "count": 242557, | |
| 67 | + "share": 0.0485114 | |
| 68 | + }, | |
| 69 | + { | |
| 70 | + "label": "0–1×", | |
| 71 | + "min": 0.000001, | |
| 72 | + "max": 1, | |
| 73 | + "count": 3853719, | |
| 74 | + "share": 0.7707438 | |
| 75 | + }, | |
| 76 | + { | |
| 77 | + "label": "1–2×", | |
| 78 | + "min": 1, | |
| 79 | + "max": 2, | |
| 80 | + "count": 474152, | |
| 81 | + "share": 0.0948304 | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + "label": "2–5×", | |
| 85 | + "min": 2, | |
| 86 | + "max": 5, | |
| 87 | + "count": 289973, | |
| 88 | + "share": 0.0579946 | |
| 89 | + }, | |
| 90 | + { | |
| 91 | + "label": "5–10×", | |
| 92 | + "min": 5, | |
| 93 | + "max": 10, | |
| 94 | + "count": 85791, | |
| 95 | + "share": 0.0171582 | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + "label": "10–20×", | |
| 99 | + "min": 10, | |
| 100 | + "max": 20, | |
| 101 | + "count": 32797, | |
| 102 | + "share": 0.0065594 | |
| 103 | + }, | |
| 104 | + { | |
| 105 | + "label": "20–50×", | |
| 106 | + "min": 20, | |
| 107 | + "max": 50, | |
| 108 | + "count": 16217, | |
| 109 | + "share": 0.0032434 | |
| 110 | + }, | |
| 111 | + { | |
| 112 | + "label": "50–100×", | |
| 113 | + "min": 50, | |
| 114 | + "max": 100, | |
| 115 | + "count": 3371, | |
| 116 | + "share": 0.0006742 | |
| 117 | + }, | |
| 118 | + { | |
| 119 | + "label": "100–500×", | |
| 120 | + "min": 100, | |
| 121 | + "max": 500, | |
| 122 | + "count": 1331, | |
| 123 | + "share": 0.0002662 | |
| 124 | + }, | |
| 125 | + { | |
| 126 | + "label": "500×+", | |
| 127 | + "min": 500, | |
| 128 | + "max": null, | |
| 129 | + "count": 92, | |
| 130 | + "share": 0.0000184 | |
| 131 | + } | |
| 132 | + ], | |
| 133 | + "convergence": [ | |
| 134 | + { | |
| 135 | + "spins": 125000, | |
| 136 | + "rtp": 0.9639352 | |
| 137 | + }, | |
| 138 | + { | |
| 139 | + "spins": 250000, | |
| 140 | + "rtp": 0.97022192 | |
| 141 | + }, | |
| 142 | + { | |
| 143 | + "spins": 375000, | |
| 144 | + "rtp": 0.97694008 | |
| 145 | + }, | |
| 146 | + { | |
| 147 | + "spins": 500000, | |
| 148 | + "rtp": 0.97236112 | |
| 149 | + }, | |
| 150 | + { | |
| 151 | + "spins": 625000, | |
| 152 | + "rtp": 0.969624864 | |
| 153 | + }, | |
| 154 | + { | |
| 155 | + "spins": 750000, | |
| 156 | + "rtp": 0.97004356 | |
| 157 | + }, | |
| 158 | + { | |
| 159 | + "spins": 875000, | |
| 160 | + "rtp": 0.9692053714285714 | |
| 161 | + }, | |
| 162 | + { | |
| 163 | + "spins": 1000000, | |
| 164 | + "rtp": 0.96449322 | |
| 165 | + }, | |
| 166 | + { | |
| 167 | + "spins": 1125000, | |
| 168 | + "rtp": 0.9654234666666667 | |
| 169 | + }, | |
| 170 | + { | |
| 171 | + "spins": 1250000, | |
| 172 | + "rtp": 0.96520872 | |
| 173 | + }, | |
| 174 | + { | |
| 175 | + "spins": 1375000, | |
| 176 | + "rtp": 0.9645822327272727 | |
| 177 | + }, | |
| 178 | + { | |
| 179 | + "spins": 1500000, | |
| 180 | + "rtp": 0.9617119933333333 | |
| 181 | + }, | |
| 182 | + { | |
| 183 | + "spins": 1625000, | |
| 184 | + "rtp": 0.9598878892307692 | |
| 185 | + }, | |
| 186 | + { | |
| 187 | + "spins": 1750000, | |
| 188 | + "rtp": 0.9605027028571429 | |
| 189 | + }, | |
| 190 | + { | |
| 191 | + "spins": 1875000, | |
| 192 | + "rtp": 0.9631120373333333 | |
| 193 | + }, | |
| 194 | + { | |
| 195 | + "spins": 2000000, | |
| 196 | + "rtp": 0.961650675 | |
| 197 | + }, | |
| 198 | + { | |
| 199 | + "spins": 2125000, | |
| 200 | + "rtp": 0.9622069317647058 | |
| 201 | + }, | |
| 202 | + { | |
| 203 | + "spins": 2250000, | |
| 204 | + "rtp": 0.9610357333333334 | |
| 205 | + }, | |
| 206 | + { | |
| 207 | + "spins": 2375000, | |
| 208 | + "rtp": 0.9609856042105264 | |
| 209 | + }, | |
| 210 | + { | |
| 211 | + "spins": 2500000, | |
| 212 | + "rtp": 0.961821972 | |
| 213 | + }, | |
| 214 | + { | |
| 215 | + "spins": 2625000, | |
| 216 | + "rtp": 0.9612519161904762 | |
| 217 | + }, | |
| 218 | + { | |
| 219 | + "spins": 2750000, | |
| 220 | + "rtp": 0.9623464254545454 | |
| 221 | + }, | |
| 222 | + { | |
| 223 | + "spins": 2875000, | |
| 224 | + "rtp": 0.9620102573913043 | |
| 225 | + }, | |
| 226 | + { | |
| 227 | + "spins": 3000000, | |
| 228 | + "rtp": 0.96253723 | |
| 229 | + }, | |
| 230 | + { | |
| 231 | + "spins": 3125000, | |
| 232 | + "rtp": 0.9627412608 | |
| 233 | + }, | |
| 234 | + { | |
| 235 | + "spins": 3250000, | |
| 236 | + "rtp": 0.9623729507692308 | |
| 237 | + }, | |
| 238 | + { | |
| 239 | + "spins": 3375000, | |
| 240 | + "rtp": 0.9630034607407407 | |
| 241 | + }, | |
| 242 | + { | |
| 243 | + "spins": 3500000, | |
| 244 | + "rtp": 0.9633927771428571 | |
| 245 | + }, | |
| 246 | + { | |
| 247 | + "spins": 3625000, | |
| 248 | + "rtp": 0.9635726565517241 | |
| 249 | + }, | |
| 250 | + { | |
| 251 | + "spins": 3750000, | |
| 252 | + "rtp": 0.9635082693333333 | |
| 253 | + }, | |
| 254 | + { | |
| 255 | + "spins": 3875000, | |
| 256 | + "rtp": 0.9632522658064516 | |
| 257 | + }, | |
| 258 | + { | |
| 259 | + "spins": 4000000, | |
| 260 | + "rtp": 0.9624044975 | |
| 261 | + }, | |
| 262 | + { | |
| 263 | + "spins": 4125000, | |
| 264 | + "rtp": 0.9625301551515152 | |
| 265 | + }, | |
| 266 | + { | |
| 267 | + "spins": 4250000, | |
| 268 | + "rtp": 0.9622728329411765 | |
| 269 | + }, | |
| 270 | + { | |
| 271 | + "spins": 4375000, | |
| 272 | + "rtp": 0.9620660937142858 | |
| 273 | + }, | |
| 274 | + { | |
| 275 | + "spins": 4500000, | |
| 276 | + "rtp": 0.9626329466666667 | |
| 277 | + }, | |
| 278 | + { | |
| 279 | + "spins": 4625000, | |
| 280 | + "rtp": 0.9622044237837838 | |
| 281 | + }, | |
| 282 | + { | |
| 283 | + "spins": 4750000, | |
| 284 | + "rtp": 0.962079132631579 | |
| 285 | + }, | |
| 286 | + { | |
| 287 | + "spins": 4875000, | |
| 288 | + "rtp": 0.9615967856410257 | |
| 289 | + }, | |
| 290 | + { | |
| 291 | + "spins": 5000000, | |
| 292 | + "rtp": 0.961199776 | |
| 293 | + } | |
| 294 | + ], | |
| 295 | + "featureCounts": { | |
| 296 | + "Gate": 1739382, | |
| 297 | + "Deep Drop": 400671, | |
| 298 | + "Portal": 653799 | |
| 299 | + }, | |
| 300 | + "durationMs": 16998 | |
| 301 | +} | |
added
games/certifications/escape-99.json
+297 −0
@@ -0,0 +1,297 @@ | ||
| 1 | +{ | |
| 2 | + "game": "escape-99", | |
| 3 | + "name": "Escape 99", | |
| 4 | + "version": "1.0.0", | |
| 5 | + "spins": 5000000, | |
| 6 | + "configuredRtp": 0.96, | |
| 7 | + "observedRtp": 0.843717692, | |
| 8 | + "deviation": -0.11628230799999995, | |
| 9 | + "hitRate": 0.177482, | |
| 10 | + "bonusRate": 0, | |
| 11 | + "freeSpinRate": 0, | |
| 12 | + "maxWinMultiplier": 5000, | |
| 13 | + "stdDev": 19.088674673847127, | |
| 14 | + "status": "FAIL", | |
| 15 | + "checks": [ | |
| 16 | + { | |
| 17 | + "name": "definition", | |
| 18 | + "pass": true, | |
| 19 | + "detail": "rtp 0.96, cap 5000×, payScale 1" | |
| 20 | + }, | |
| 21 | + { | |
| 22 | + "name": "spins", | |
| 23 | + "pass": true, | |
| 24 | + "detail": "5,000,000 rounds" | |
| 25 | + }, | |
| 26 | + { | |
| 27 | + "name": "rtp-deviation", | |
| 28 | + "pass": false, | |
| 29 | + "detail": "-11.628% (tolerance ±1.50%)" | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + "name": "rtp-band", | |
| 33 | + "pass": false, | |
| 34 | + "detail": "84.37%" | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + "name": "max-win", | |
| 38 | + "pass": true, | |
| 39 | + "detail": "5000.0× (cap 5000×)" | |
| 40 | + }, | |
| 41 | + { | |
| 42 | + "name": "cap-share", | |
| 43 | + "pass": true, | |
| 44 | + "detail": "0 capped rounds" | |
| 45 | + } | |
| 46 | + ], | |
| 47 | + "certifiedAt": "2026-09-08T03:48:00.592Z", | |
| 48 | + "rules": { | |
| 49 | + "maxDeviation": 0.004, | |
| 50 | + "minSpins": 1000000, | |
| 51 | + "rtpBand": [ | |
| 52 | + 0.93, | |
| 53 | + 0.99 | |
| 54 | + ], | |
| 55 | + "hitRateBand": [ | |
| 56 | + 0.08, | |
| 57 | + 0.6 | |
| 58 | + ], | |
| 59 | + "maxCappedShare": 0.0005 | |
| 60 | + }, | |
| 61 | + "distribution": [ | |
| 62 | + { | |
| 63 | + "label": "0×", | |
| 64 | + "min": 0, | |
| 65 | + "max": 0, | |
| 66 | + "count": 4112590, | |
| 67 | + "share": 0.822518 | |
| 68 | + }, | |
| 69 | + { | |
| 70 | + "label": "0–1×", | |
| 71 | + "min": 0.000001, | |
| 72 | + "max": 1, | |
| 73 | + "count": 48544, | |
| 74 | + "share": 0.0097088 | |
| 75 | + }, | |
| 76 | + { | |
| 77 | + "label": "1–2×", | |
| 78 | + "min": 1, | |
| 79 | + "max": 2, | |
| 80 | + "count": 530219, | |
| 81 | + "share": 0.1060438 | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + "label": "2–5×", | |
| 85 | + "min": 2, | |
| 86 | + "max": 5, | |
| 87 | + "count": 202949, | |
| 88 | + "share": 0.0405898 | |
| 89 | + }, | |
| 90 | + { | |
| 91 | + "label": "5–10×", | |
| 92 | + "min": 5, | |
| 93 | + "max": 10, | |
| 94 | + "count": 57963, | |
| 95 | + "share": 0.0115926 | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + "label": "10–20×", | |
| 99 | + "min": 10, | |
| 100 | + "max": 20, | |
| 101 | + "count": 25310, | |
| 102 | + "share": 0.005062 | |
| 103 | + }, | |
| 104 | + { | |
| 105 | + "label": "20–50×", | |
| 106 | + "min": 20, | |
| 107 | + "max": 50, | |
| 108 | + "count": 14186, | |
| 109 | + "share": 0.0028372 | |
| 110 | + }, | |
| 111 | + { | |
| 112 | + "label": "50–100×", | |
| 113 | + "min": 50, | |
| 114 | + "max": 100, | |
| 115 | + "count": 4449, | |
| 116 | + "share": 0.0008898 | |
| 117 | + }, | |
| 118 | + { | |
| 119 | + "label": "100–500×", | |
| 120 | + "min": 100, | |
| 121 | + "max": 500, | |
| 122 | + "count": 3180, | |
| 123 | + "share": 0.000636 | |
| 124 | + }, | |
| 125 | + { | |
| 126 | + "label": "500×+", | |
| 127 | + "min": 500, | |
| 128 | + "max": null, | |
| 129 | + "count": 610, | |
| 130 | + "share": 0.000122 | |
| 131 | + } | |
| 132 | + ], | |
| 133 | + "convergence": [ | |
| 134 | + { | |
| 135 | + "spins": 125000, | |
| 136 | + "rtp": 0.74817352 | |
| 137 | + }, | |
| 138 | + { | |
| 139 | + "spins": 250000, | |
| 140 | + "rtp": 0.77398808 | |
| 141 | + }, | |
| 142 | + { | |
| 143 | + "spins": 375000, | |
| 144 | + "rtp": 0.7763465333333334 | |
| 145 | + }, | |
| 146 | + { | |
| 147 | + "spins": 500000, | |
| 148 | + "rtp": 0.78804504 | |
| 149 | + }, | |
| 150 | + { | |
| 151 | + "spins": 625000, | |
| 152 | + "rtp": 0.80086168 | |
| 153 | + }, | |
| 154 | + { | |
| 155 | + "spins": 750000, | |
| 156 | + "rtp": 0.8139142933333333 | |
| 157 | + }, | |
| 158 | + { | |
| 159 | + "spins": 875000, | |
| 160 | + "rtp": 0.8288938971428571 | |
| 161 | + }, | |
| 162 | + { | |
| 163 | + "spins": 1000000, | |
| 164 | + "rtp": 0.82749557 | |
| 165 | + }, | |
| 166 | + { | |
| 167 | + "spins": 1125000, | |
| 168 | + "rtp": 0.8316278222222222 | |
| 169 | + }, | |
| 170 | + { | |
| 171 | + "spins": 1250000, | |
| 172 | + "rtp": 0.836861112 | |
| 173 | + }, | |
| 174 | + { | |
| 175 | + "spins": 1375000, | |
| 176 | + "rtp": 0.8343994036363637 | |
| 177 | + }, | |
| 178 | + { | |
| 179 | + "spins": 1500000, | |
| 180 | + "rtp": 0.8424341 | |
| 181 | + }, | |
| 182 | + { | |
| 183 | + "spins": 1625000, | |
| 184 | + "rtp": 0.8511155876923077 | |
| 185 | + }, | |
| 186 | + { | |
| 187 | + "spins": 1750000, | |
| 188 | + "rtp": 0.84591704 | |
| 189 | + }, | |
| 190 | + { | |
| 191 | + "spins": 1875000, | |
| 192 | + "rtp": 0.85105528 | |
| 193 | + }, | |
| 194 | + { | |
| 195 | + "spins": 2000000, | |
| 196 | + "rtp": 0.850496275 | |
| 197 | + }, | |
| 198 | + { | |
| 199 | + "spins": 2125000, | |
| 200 | + "rtp": 0.8494788376470588 | |
| 201 | + }, | |
| 202 | + { | |
| 203 | + "spins": 2250000, | |
| 204 | + "rtp": 0.8470352311111111 | |
| 205 | + }, | |
| 206 | + { | |
| 207 | + "spins": 2375000, | |
| 208 | + "rtp": 0.8486985178947368 | |
| 209 | + }, | |
| 210 | + { | |
| 211 | + "spins": 2500000, | |
| 212 | + "rtp": 0.849853204 | |
| 213 | + }, | |
| 214 | + { | |
| 215 | + "spins": 2625000, | |
| 216 | + "rtp": 0.8491573676190476 | |
| 217 | + }, | |
| 218 | + { | |
| 219 | + "spins": 2750000, | |
| 220 | + "rtp": 0.8480010181818182 | |
| 221 | + }, | |
| 222 | + { | |
| 223 | + "spins": 2875000, | |
| 224 | + "rtp": 0.8488998191304348 | |
| 225 | + }, | |
| 226 | + { | |
| 227 | + "spins": 3000000, | |
| 228 | + "rtp": 0.84491513 | |
| 229 | + }, | |
| 230 | + { | |
| 231 | + "spins": 3125000, | |
| 232 | + "rtp": 0.8439477728 | |
| 233 | + }, | |
| 234 | + { | |
| 235 | + "spins": 3250000, | |
| 236 | + "rtp": 0.8411797446153846 | |
| 237 | + }, | |
| 238 | + { | |
| 239 | + "spins": 3375000, | |
| 240 | + "rtp": 0.8436860622222222 | |
| 241 | + }, | |
| 242 | + { | |
| 243 | + "spins": 3500000, | |
| 244 | + "rtp": 0.84567104 | |
| 245 | + }, | |
| 246 | + { | |
| 247 | + "spins": 3625000, | |
| 248 | + "rtp": 0.8452766537931035 | |
| 249 | + }, | |
| 250 | + { | |
| 251 | + "spins": 3750000, | |
| 252 | + "rtp": 0.8427253173333333 | |
| 253 | + }, | |
| 254 | + { | |
| 255 | + "spins": 3875000, | |
| 256 | + "rtp": 0.8443765806451613 | |
| 257 | + }, | |
| 258 | + { | |
| 259 | + "spins": 4000000, | |
| 260 | + "rtp": 0.8477031125 | |
| 261 | + }, | |
| 262 | + { | |
| 263 | + "spins": 4125000, | |
| 264 | + "rtp": 0.8480632145454545 | |
| 265 | + }, | |
| 266 | + { | |
| 267 | + "spins": 4250000, | |
| 268 | + "rtp": 0.84873276 | |
| 269 | + }, | |
| 270 | + { | |
| 271 | + "spins": 4375000, | |
| 272 | + "rtp": 0.8482617485714286 | |
| 273 | + }, | |
| 274 | + { | |
| 275 | + "spins": 4500000, | |
| 276 | + "rtp": 0.84866652 | |
| 277 | + }, | |
| 278 | + { | |
| 279 | + "spins": 4625000, | |
| 280 | + "rtp": 0.8469645081081081 | |
| 281 | + }, | |
| 282 | + { | |
| 283 | + "spins": 4750000, | |
| 284 | + "rtp": 0.8453844863157894 | |
| 285 | + }, | |
| 286 | + { | |
| 287 | + "spins": 4875000, | |
| 288 | + "rtp": 0.8433274276923077 | |
| 289 | + }, | |
| 290 | + { | |
| 291 | + "spins": 5000000, | |
| 292 | + "rtp": 0.843717692 | |
| 293 | + } | |
| 294 | + ], | |
| 295 | + "featureCounts": {}, | |
| 296 | + "durationMs": 750088 | |
| 297 | +} | |
added
games/certifications/grid-break.json
+302 −0
@@ -0,0 +1,302 @@ | ||
| 1 | +{ | |
| 2 | + "game": "grid-break", | |
| 3 | + "name": "Grid//Break", | |
| 4 | + "version": "1.0.0", | |
| 5 | + "spins": 5000000, | |
| 6 | + "configuredRtp": 0.96, | |
| 7 | + "observedRtp": 0.962304202, | |
| 8 | + "deviation": 0.002304202000000033, | |
| 9 | + "hitRate": 0.5994606, | |
| 10 | + "bonusRate": 0, | |
| 11 | + "freeSpinRate": 0, | |
| 12 | + "maxWinMultiplier": 402.36, | |
| 13 | + "stdDev": 4.096724817716841, | |
| 14 | + "status": "PASS", | |
| 15 | + "checks": [ | |
| 16 | + { | |
| 17 | + "name": "definition", | |
| 18 | + "pass": true, | |
| 19 | + "detail": "rtp 0.96, cap 2500×, payScale 0.1739" | |
| 20 | + }, | |
| 21 | + { | |
| 22 | + "name": "spins", | |
| 23 | + "pass": true, | |
| 24 | + "detail": "5,000,000 rounds" | |
| 25 | + }, | |
| 26 | + { | |
| 27 | + "name": "rtp-deviation", | |
| 28 | + "pass": true, | |
| 29 | + "detail": "0.230% (tolerance ±0.55%)" | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + "name": "rtp-band", | |
| 33 | + "pass": true, | |
| 34 | + "detail": "96.23%" | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + "name": "max-win", | |
| 38 | + "pass": true, | |
| 39 | + "detail": "402.4× (cap 2500×)" | |
| 40 | + }, | |
| 41 | + { | |
| 42 | + "name": "cap-share", | |
| 43 | + "pass": true, | |
| 44 | + "detail": "0 capped rounds" | |
| 45 | + } | |
| 46 | + ], | |
| 47 | + "certifiedAt": "2026-09-08T03:34:43.230Z", | |
| 48 | + "rules": { | |
| 49 | + "maxDeviation": 0.004, | |
| 50 | + "minSpins": 1000000, | |
| 51 | + "rtpBand": [ | |
| 52 | + 0.93, | |
| 53 | + 0.99 | |
| 54 | + ], | |
| 55 | + "hitRateBand": [ | |
| 56 | + 0.08, | |
| 57 | + 0.6 | |
| 58 | + ], | |
| 59 | + "maxCappedShare": 0.0005 | |
| 60 | + }, | |
| 61 | + "distribution": [ | |
| 62 | + { | |
| 63 | + "label": "0×", | |
| 64 | + "min": 0, | |
| 65 | + "max": 0, | |
| 66 | + "count": 2002697, | |
| 67 | + "share": 0.4005394 | |
| 68 | + }, | |
| 69 | + { | |
| 70 | + "label": "0–1×", | |
| 71 | + "min": 0.000001, | |
| 72 | + "max": 1, | |
| 73 | + "count": 2190151, | |
| 74 | + "share": 0.4380302 | |
| 75 | + }, | |
| 76 | + { | |
| 77 | + "label": "1–2×", | |
| 78 | + "min": 1, | |
| 79 | + "max": 2, | |
| 80 | + "count": 371286, | |
| 81 | + "share": 0.0742572 | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + "label": "2–5×", | |
| 85 | + "min": 2, | |
| 86 | + "max": 5, | |
| 87 | + "count": 256437, | |
| 88 | + "share": 0.0512874 | |
| 89 | + }, | |
| 90 | + { | |
| 91 | + "label": "5–10×", | |
| 92 | + "min": 5, | |
| 93 | + "max": 10, | |
| 94 | + "count": 90355, | |
| 95 | + "share": 0.018071 | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + "label": "10–20×", | |
| 99 | + "min": 10, | |
| 100 | + "max": 20, | |
| 101 | + "count": 49392, | |
| 102 | + "share": 0.0098784 | |
| 103 | + }, | |
| 104 | + { | |
| 105 | + "label": "20–50×", | |
| 106 | + "min": 20, | |
| 107 | + "max": 50, | |
| 108 | + "count": 32870, | |
| 109 | + "share": 0.006574 | |
| 110 | + }, | |
| 111 | + { | |
| 112 | + "label": "50–100×", | |
| 113 | + "min": 50, | |
| 114 | + "max": 100, | |
| 115 | + "count": 6144, | |
| 116 | + "share": 0.0012288 | |
| 117 | + }, | |
| 118 | + { | |
| 119 | + "label": "100–500×", | |
| 120 | + "min": 100, | |
| 121 | + "max": 500, | |
| 122 | + "count": 668, | |
| 123 | + "share": 0.0001336 | |
| 124 | + }, | |
| 125 | + { | |
| 126 | + "label": "500×+", | |
| 127 | + "min": 500, | |
| 128 | + "max": null, | |
| 129 | + "count": 0, | |
| 130 | + "share": 0 | |
| 131 | + } | |
| 132 | + ], | |
| 133 | + "convergence": [ | |
| 134 | + { | |
| 135 | + "spins": 125000, | |
| 136 | + "rtp": 0.95604352 | |
| 137 | + }, | |
| 138 | + { | |
| 139 | + "spins": 250000, | |
| 140 | + "rtp": 0.9629272 | |
| 141 | + }, | |
| 142 | + { | |
| 143 | + "spins": 375000, | |
| 144 | + "rtp": 0.96270616 | |
| 145 | + }, | |
| 146 | + { | |
| 147 | + "spins": 500000, | |
| 148 | + "rtp": 0.9636362 | |
| 149 | + }, | |
| 150 | + { | |
| 151 | + "spins": 625000, | |
| 152 | + "rtp": 0.962187536 | |
| 153 | + }, | |
| 154 | + { | |
| 155 | + "spins": 750000, | |
| 156 | + "rtp": 0.9595883333333334 | |
| 157 | + }, | |
| 158 | + { | |
| 159 | + "spins": 875000, | |
| 160 | + "rtp": 0.9608969714285714 | |
| 161 | + }, | |
| 162 | + { | |
| 163 | + "spins": 1000000, | |
| 164 | + "rtp": 0.96129772 | |
| 165 | + }, | |
| 166 | + { | |
| 167 | + "spins": 1125000, | |
| 168 | + "rtp": 0.9626801244444444 | |
| 169 | + }, | |
| 170 | + { | |
| 171 | + "spins": 1250000, | |
| 172 | + "rtp": 0.960792928 | |
| 173 | + }, | |
| 174 | + { | |
| 175 | + "spins": 1375000, | |
| 176 | + "rtp": 0.9619385527272727 | |
| 177 | + }, | |
| 178 | + { | |
| 179 | + "spins": 1500000, | |
| 180 | + "rtp": 0.9628839866666666 | |
| 181 | + }, | |
| 182 | + { | |
| 183 | + "spins": 1625000, | |
| 184 | + "rtp": 0.9627842769230769 | |
| 185 | + }, | |
| 186 | + { | |
| 187 | + "spins": 1750000, | |
| 188 | + "rtp": 0.9619497485714286 | |
| 189 | + }, | |
| 190 | + { | |
| 191 | + "spins": 1875000, | |
| 192 | + "rtp": 0.9629062293333334 | |
| 193 | + }, | |
| 194 | + { | |
| 195 | + "spins": 2000000, | |
| 196 | + "rtp": 0.962967655 | |
| 197 | + }, | |
| 198 | + { | |
| 199 | + "spins": 2125000, | |
| 200 | + "rtp": 0.96312696 | |
| 201 | + }, | |
| 202 | + { | |
| 203 | + "spins": 2250000, | |
| 204 | + "rtp": 0.9632352222222222 | |
| 205 | + }, | |
| 206 | + { | |
| 207 | + "spins": 2375000, | |
| 208 | + "rtp": 0.9636849389473684 | |
| 209 | + }, | |
| 210 | + { | |
| 211 | + "spins": 2500000, | |
| 212 | + "rtp": 0.9633209 | |
| 213 | + }, | |
| 214 | + { | |
| 215 | + "spins": 2625000, | |
| 216 | + "rtp": 0.9640879123809524 | |
| 217 | + }, | |
| 218 | + { | |
| 219 | + "spins": 2750000, | |
| 220 | + "rtp": 0.9641040218181818 | |
| 221 | + }, | |
| 222 | + { | |
| 223 | + "spins": 2875000, | |
| 224 | + "rtp": 0.963744747826087 | |
| 225 | + }, | |
| 226 | + { | |
| 227 | + "spins": 3000000, | |
| 228 | + "rtp": 0.9636835233333333 | |
| 229 | + }, | |
| 230 | + { | |
| 231 | + "spins": 3125000, | |
| 232 | + "rtp": 0.96312744 | |
| 233 | + }, | |
| 234 | + { | |
| 235 | + "spins": 3250000, | |
| 236 | + "rtp": 0.9632511661538462 | |
| 237 | + }, | |
| 238 | + { | |
| 239 | + "spins": 3375000, | |
| 240 | + "rtp": 0.9630610192592592 | |
| 241 | + }, | |
| 242 | + { | |
| 243 | + "spins": 3500000, | |
| 244 | + "rtp": 0.9632510885714286 | |
| 245 | + }, | |
| 246 | + { | |
| 247 | + "spins": 3625000, | |
| 248 | + "rtp": 0.9631946565517241 | |
| 249 | + }, | |
| 250 | + { | |
| 251 | + "spins": 3750000, | |
| 252 | + "rtp": 0.9628689813333333 | |
| 253 | + }, | |
| 254 | + { | |
| 255 | + "spins": 3875000, | |
| 256 | + "rtp": 0.9622925806451613 | |
| 257 | + }, | |
| 258 | + { | |
| 259 | + "spins": 4000000, | |
| 260 | + "rtp": 0.9620684275 | |
| 261 | + }, | |
| 262 | + { | |
| 263 | + "spins": 4125000, | |
| 264 | + "rtp": 0.9618926812121212 | |
| 265 | + }, | |
| 266 | + { | |
| 267 | + "spins": 4250000, | |
| 268 | + "rtp": 0.961978894117647 | |
| 269 | + }, | |
| 270 | + { | |
| 271 | + "spins": 4375000, | |
| 272 | + "rtp": 0.962476928 | |
| 273 | + }, | |
| 274 | + { | |
| 275 | + "spins": 4500000, | |
| 276 | + "rtp": 0.9626553066666667 | |
| 277 | + }, | |
| 278 | + { | |
| 279 | + "spins": 4625000, | |
| 280 | + "rtp": 0.9625427632432433 | |
| 281 | + }, | |
| 282 | + { | |
| 283 | + "spins": 4750000, | |
| 284 | + "rtp": 0.9627719031578947 | |
| 285 | + }, | |
| 286 | + { | |
| 287 | + "spins": 4875000, | |
| 288 | + "rtp": 0.9627279158974359 | |
| 289 | + }, | |
| 290 | + { | |
| 291 | + "spins": 5000000, | |
| 292 | + "rtp": 0.962304202 | |
| 293 | + } | |
| 294 | + ], | |
| 295 | + "featureCounts": { | |
| 296 | + "Chain reaction": 2736491, | |
| 297 | + "×2 block": 1172959, | |
| 298 | + "Bomb": 1164332, | |
| 299 | + "Line clear": 785333 | |
| 300 | + }, | |
| 301 | + "durationMs": 212471 | |
| 302 | +} | |
added
games/certifications/orbit.json
+307 −0
@@ -0,0 +1,307 @@ | ||
| 1 | +{ | |
| 2 | + "game": "orbit", | |
| 3 | + "name": "Orbit", | |
| 4 | + "version": "1.0.0", | |
| 5 | + "spins": 5000000, | |
| 6 | + "configuredRtp": 0.96, | |
| 7 | + "observedRtp": 0.962501212, | |
| 8 | + "deviation": 0.002501212000000086, | |
| 9 | + "hitRate": 0.6607512, | |
| 10 | + "bonusRate": 0, | |
| 11 | + "freeSpinRate": 0, | |
| 12 | + "maxWinMultiplier": 352.98, | |
| 13 | + "stdDev": 3.2575740288460002, | |
| 14 | + "status": "PASS", | |
| 15 | + "checks": [ | |
| 16 | + { | |
| 17 | + "name": "definition", | |
| 18 | + "pass": true, | |
| 19 | + "detail": "rtp 0.96, cap 2000×, payScale 0.4347" | |
| 20 | + }, | |
| 21 | + { | |
| 22 | + "name": "spins", | |
| 23 | + "pass": true, | |
| 24 | + "detail": "5,000,000 rounds" | |
| 25 | + }, | |
| 26 | + { | |
| 27 | + "name": "rtp-deviation", | |
| 28 | + "pass": true, | |
| 29 | + "detail": "0.250% (tolerance ±0.44%)" | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + "name": "rtp-band", | |
| 33 | + "pass": true, | |
| 34 | + "detail": "96.25%" | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + "name": "max-win", | |
| 38 | + "pass": true, | |
| 39 | + "detail": "353.0× (cap 2000×)" | |
| 40 | + }, | |
| 41 | + { | |
| 42 | + "name": "cap-share", | |
| 43 | + "pass": true, | |
| 44 | + "detail": "0 capped rounds" | |
| 45 | + } | |
| 46 | + ], | |
| 47 | + "certifiedAt": "2026-09-08T03:35:30.504Z", | |
| 48 | + "rules": { | |
| 49 | + "maxDeviation": 0.004, | |
| 50 | + "minSpins": 1000000, | |
| 51 | + "rtpBand": [ | |
| 52 | + 0.93, | |
| 53 | + 0.99 | |
| 54 | + ], | |
| 55 | + "hitRateBand": [ | |
| 56 | + 0.08, | |
| 57 | + 0.6 | |
| 58 | + ], | |
| 59 | + "maxCappedShare": 0.0005 | |
| 60 | + }, | |
| 61 | + "distribution": [ | |
| 62 | + { | |
| 63 | + "label": "0×", | |
| 64 | + "min": 0, | |
| 65 | + "max": 0, | |
| 66 | + "count": 1696244, | |
| 67 | + "share": 0.3392488 | |
| 68 | + }, | |
| 69 | + { | |
| 70 | + "label": "0–1×", | |
| 71 | + "min": 0.000001, | |
| 72 | + "max": 1, | |
| 73 | + "count": 2187053, | |
| 74 | + "share": 0.4374106 | |
| 75 | + }, | |
| 76 | + { | |
| 77 | + "label": "1–2×", | |
| 78 | + "min": 1, | |
| 79 | + "max": 2, | |
| 80 | + "count": 544113, | |
| 81 | + "share": 0.1088226 | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + "label": "2–5×", | |
| 85 | + "min": 2, | |
| 86 | + "max": 5, | |
| 87 | + "count": 437113, | |
| 88 | + "share": 0.0874226 | |
| 89 | + }, | |
| 90 | + { | |
| 91 | + "label": "5–10×", | |
| 92 | + "min": 5, | |
| 93 | + "max": 10, | |
| 94 | + "count": 55856, | |
| 95 | + "share": 0.0111712 | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + "label": "10–20×", | |
| 99 | + "min": 10, | |
| 100 | + "max": 20, | |
| 101 | + "count": 62399, | |
| 102 | + "share": 0.0124798 | |
| 103 | + }, | |
| 104 | + { | |
| 105 | + "label": "20–50×", | |
| 106 | + "min": 20, | |
| 107 | + "max": 50, | |
| 108 | + "count": 14664, | |
| 109 | + "share": 0.0029328 | |
| 110 | + }, | |
| 111 | + { | |
| 112 | + "label": "50–100×", | |
| 113 | + "min": 50, | |
| 114 | + "max": 100, | |
| 115 | + "count": 1263, | |
| 116 | + "share": 0.0002526 | |
| 117 | + }, | |
| 118 | + { | |
| 119 | + "label": "100–500×", | |
| 120 | + "min": 100, | |
| 121 | + "max": 500, | |
| 122 | + "count": 1295, | |
| 123 | + "share": 0.000259 | |
| 124 | + }, | |
| 125 | + { | |
| 126 | + "label": "500×+", | |
| 127 | + "min": 500, | |
| 128 | + "max": null, | |
| 129 | + "count": 0, | |
| 130 | + "share": 0 | |
| 131 | + } | |
| 132 | + ], | |
| 133 | + "convergence": [ | |
| 134 | + { | |
| 135 | + "spins": 125000, | |
| 136 | + "rtp": 0.97083088 | |
| 137 | + }, | |
| 138 | + { | |
| 139 | + "spins": 250000, | |
| 140 | + "rtp": 0.96837148 | |
| 141 | + }, | |
| 142 | + { | |
| 143 | + "spins": 375000, | |
| 144 | + "rtp": 0.9636322133333334 | |
| 145 | + }, | |
| 146 | + { | |
| 147 | + "spins": 500000, | |
| 148 | + "rtp": 0.9639165 | |
| 149 | + }, | |
| 150 | + { | |
| 151 | + "spins": 625000, | |
| 152 | + "rtp": 0.967868816 | |
| 153 | + }, | |
| 154 | + { | |
| 155 | + "spins": 750000, | |
| 156 | + "rtp": 0.9689794533333334 | |
| 157 | + }, | |
| 158 | + { | |
| 159 | + "spins": 875000, | |
| 160 | + "rtp": 0.9701950628571429 | |
| 161 | + }, | |
| 162 | + { | |
| 163 | + "spins": 1000000, | |
| 164 | + "rtp": 0.96775131 | |
| 165 | + }, | |
| 166 | + { | |
| 167 | + "spins": 1125000, | |
| 168 | + "rtp": 0.9673317688888889 | |
| 169 | + }, | |
| 170 | + { | |
| 171 | + "spins": 1250000, | |
| 172 | + "rtp": 0.966221696 | |
| 173 | + }, | |
| 174 | + { | |
| 175 | + "spins": 1375000, | |
| 176 | + "rtp": 0.9646939418181818 | |
| 177 | + }, | |
| 178 | + { | |
| 179 | + "spins": 1500000, | |
| 180 | + "rtp": 0.9648843866666666 | |
| 181 | + }, | |
| 182 | + { | |
| 183 | + "spins": 1625000, | |
| 184 | + "rtp": 0.9645462646153846 | |
| 185 | + }, | |
| 186 | + { | |
| 187 | + "spins": 1750000, | |
| 188 | + "rtp": 0.9643484285714286 | |
| 189 | + }, | |
| 190 | + { | |
| 191 | + "spins": 1875000, | |
| 192 | + "rtp": 0.963997552 | |
| 193 | + }, | |
| 194 | + { | |
| 195 | + "spins": 2000000, | |
| 196 | + "rtp": 0.9643276 | |
| 197 | + }, | |
| 198 | + { | |
| 199 | + "spins": 2125000, | |
| 200 | + "rtp": 0.9642172376470588 | |
| 201 | + }, | |
| 202 | + { | |
| 203 | + "spins": 2250000, | |
| 204 | + "rtp": 0.9643788711111111 | |
| 205 | + }, | |
| 206 | + { | |
| 207 | + "spins": 2375000, | |
| 208 | + "rtp": 0.9644876463157894 | |
| 209 | + }, | |
| 210 | + { | |
| 211 | + "spins": 2500000, | |
| 212 | + "rtp": 0.964746868 | |
| 213 | + }, | |
| 214 | + { | |
| 215 | + "spins": 2625000, | |
| 216 | + "rtp": 0.9648717828571428 | |
| 217 | + }, | |
| 218 | + { | |
| 219 | + "spins": 2750000, | |
| 220 | + "rtp": 0.9645632727272727 | |
| 221 | + }, | |
| 222 | + { | |
| 223 | + "spins": 2875000, | |
| 224 | + "rtp": 0.9644537913043478 | |
| 225 | + }, | |
| 226 | + { | |
| 227 | + "spins": 3000000, | |
| 228 | + "rtp": 0.9641617 | |
| 229 | + }, | |
| 230 | + { | |
| 231 | + "spins": 3125000, | |
| 232 | + "rtp": 0.9645400864 | |
| 233 | + }, | |
| 234 | + { | |
| 235 | + "spins": 3250000, | |
| 236 | + "rtp": 0.96440376 | |
| 237 | + }, | |
| 238 | + { | |
| 239 | + "spins": 3375000, | |
| 240 | + "rtp": 0.9638297244444445 | |
| 241 | + }, | |
| 242 | + { | |
| 243 | + "spins": 3500000, | |
| 244 | + "rtp": 0.9638747685714286 | |
| 245 | + }, | |
| 246 | + { | |
| 247 | + "spins": 3625000, | |
| 248 | + "rtp": 0.9640188386206896 | |
| 249 | + }, | |
| 250 | + { | |
| 251 | + "spins": 3750000, | |
| 252 | + "rtp": 0.9640946293333333 | |
| 253 | + }, | |
| 254 | + { | |
| 255 | + "spins": 3875000, | |
| 256 | + "rtp": 0.9640729806451613 | |
| 257 | + }, | |
| 258 | + { | |
| 259 | + "spins": 4000000, | |
| 260 | + "rtp": 0.963503645 | |
| 261 | + }, | |
| 262 | + { | |
| 263 | + "spins": 4125000, | |
| 264 | + "rtp": 0.96363272 | |
| 265 | + }, | |
| 266 | + { | |
| 267 | + "spins": 4250000, | |
| 268 | + "rtp": 0.9635847976470588 | |
| 269 | + }, | |
| 270 | + { | |
| 271 | + "spins": 4375000, | |
| 272 | + "rtp": 0.9635665302857143 | |
| 273 | + }, | |
| 274 | + { | |
| 275 | + "spins": 4500000, | |
| 276 | + "rtp": 0.9631729377777778 | |
| 277 | + }, | |
| 278 | + { | |
| 279 | + "spins": 4625000, | |
| 280 | + "rtp": 0.9633420108108108 | |
| 281 | + }, | |
| 282 | + { | |
| 283 | + "spins": 4750000, | |
| 284 | + "rtp": 0.9630729915789473 | |
| 285 | + }, | |
| 286 | + { | |
| 287 | + "spins": 4875000, | |
| 288 | + "rtp": 0.9627274441025641 | |
| 289 | + }, | |
| 290 | + { | |
| 291 | + "spins": 5000000, | |
| 292 | + "rtp": 0.962501212 | |
| 293 | + } | |
| 294 | + ], | |
| 295 | + "featureCounts": { | |
| 296 | + "Deflection": 2072875, | |
| 297 | + "Satellite": 1189930, | |
| 298 | + "Gas giant": 245728, | |
| 299 | + "Planet": 848906, | |
| 300 | + "New orbit": 512586, | |
| 301 | + "Comet": 468932, | |
| 302 | + "Supernova": 132406, | |
| 303 | + "Debris": 1516107, | |
| 304 | + "Quasar": 49894 | |
| 305 | + }, | |
| 306 | + "durationMs": 7176 | |
| 307 | +} | |
added
games/certifications/the-vault.json
+297 −0
@@ -0,0 +1,297 @@ | ||
| 1 | +{ | |
| 2 | + "game": "the-vault", | |
| 3 | + "name": "The Vault", | |
| 4 | + "version": "1.0.0", | |
| 5 | + "spins": 3000000, | |
| 6 | + "configuredRtp": 0.96, | |
| 7 | + "observedRtp": 0.9606461366666667, | |
| 8 | + "deviation": 0.0006461366666666857, | |
| 9 | + "hitRate": 0.364491, | |
| 10 | + "bonusRate": 0, | |
| 11 | + "freeSpinRate": 0, | |
| 12 | + "maxWinMultiplier": 31.17, | |
| 13 | + "stdDev": 2.784813176146086, | |
| 14 | + "status": "PASS", | |
| 15 | + "checks": [ | |
| 16 | + { | |
| 17 | + "name": "definition", | |
| 18 | + "pass": true, | |
| 19 | + "detail": "rtp 0.96, cap 50×, payScale 1" | |
| 20 | + }, | |
| 21 | + { | |
| 22 | + "name": "spins", | |
| 23 | + "pass": true, | |
| 24 | + "detail": "3,000,000 rounds" | |
| 25 | + }, | |
| 26 | + { | |
| 27 | + "name": "rtp-deviation", | |
| 28 | + "pass": true, | |
| 29 | + "detail": "0.065% (tolerance ±0.48%)" | |
| 30 | + }, | |
| 31 | + { | |
| 32 | + "name": "rtp-band", | |
| 33 | + "pass": true, | |
| 34 | + "detail": "96.06%" | |
| 35 | + }, | |
| 36 | + { | |
| 37 | + "name": "max-win", | |
| 38 | + "pass": true, | |
| 39 | + "detail": "31.2× (cap 50×)" | |
| 40 | + }, | |
| 41 | + { | |
| 42 | + "name": "cap-share", | |
| 43 | + "pass": true, | |
| 44 | + "detail": "0 capped rounds" | |
| 45 | + } | |
| 46 | + ], | |
| 47 | + "certifiedAt": "2026-09-08T03:49:22.126Z", | |
| 48 | + "rules": { | |
| 49 | + "maxDeviation": 0.004, | |
| 50 | + "minSpins": 1000000, | |
| 51 | + "rtpBand": [ | |
| 52 | + 0.93, | |
| 53 | + 0.99 | |
| 54 | + ], | |
| 55 | + "hitRateBand": [ | |
| 56 | + 0.08, | |
| 57 | + 0.6 | |
| 58 | + ], | |
| 59 | + "maxCappedShare": 0.0005 | |
| 60 | + }, | |
| 61 | + "distribution": [ | |
| 62 | + { | |
| 63 | + "label": "0×", | |
| 64 | + "min": 0, | |
| 65 | + "max": 0, | |
| 66 | + "count": 1906527, | |
| 67 | + "share": 0.635509 | |
| 68 | + }, | |
| 69 | + { | |
| 70 | + "label": "0–1×", | |
| 71 | + "min": 0.000001, | |
| 72 | + "max": 1, | |
| 73 | + "count": 0, | |
| 74 | + "share": 0 | |
| 75 | + }, | |
| 76 | + { | |
| 77 | + "label": "1–2×", | |
| 78 | + "min": 1, | |
| 79 | + "max": 2, | |
| 80 | + "count": 817011, | |
| 81 | + "share": 0.272337 | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + "label": "2–5×", | |
| 85 | + "min": 2, | |
| 86 | + "max": 5, | |
| 87 | + "count": 183824, | |
| 88 | + "share": 0.061274666666666665 | |
| 89 | + }, | |
| 90 | + { | |
| 91 | + "label": "5–10×", | |
| 92 | + "min": 5, | |
| 93 | + "max": 10, | |
| 94 | + "count": 74032, | |
| 95 | + "share": 0.024677333333333332 | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + "label": "10–20×", | |
| 99 | + "min": 10, | |
| 100 | + "max": 20, | |
| 101 | + "count": 0, | |
| 102 | + "share": 0 | |
| 103 | + }, | |
| 104 | + { | |
| 105 | + "label": "20–50×", | |
| 106 | + "min": 20, | |
| 107 | + "max": 50, | |
| 108 | + "count": 18606, | |
| 109 | + "share": 0.006202 | |
| 110 | + }, | |
| 111 | + { | |
| 112 | + "label": "50–100×", | |
| 113 | + "min": 50, | |
| 114 | + "max": 100, | |
| 115 | + "count": 0, | |
| 116 | + "share": 0 | |
| 117 | + }, | |
| 118 | + { | |
| 119 | + "label": "100–500×", | |
| 120 | + "min": 100, | |
| 121 | + "max": 500, | |
| 122 | + "count": 0, | |
| 123 | + "share": 0 | |
| 124 | + }, | |
| 125 | + { | |
| 126 | + "label": "500×+", | |
| 127 | + "min": 500, | |
| 128 | + "max": null, | |
| 129 | + "count": 0, | |
| 130 | + "share": 0 | |
| 131 | + } | |
| 132 | + ], | |
| 133 | + "convergence": [ | |
| 134 | + { | |
| 135 | + "spins": 75000, | |
| 136 | + "rtp": 0.9582056 | |
| 137 | + }, | |
| 138 | + { | |
| 139 | + "spins": 150000, | |
| 140 | + "rtp": 0.9543438666666667 | |
| 141 | + }, | |
| 142 | + { | |
| 143 | + "spins": 225000, | |
| 144 | + "rtp": 0.9503724888888889 | |
| 145 | + }, | |
| 146 | + { | |
| 147 | + "spins": 300000, | |
| 148 | + "rtp": 0.9514903 | |
| 149 | + }, | |
| 150 | + { | |
| 151 | + "spins": 375000, | |
| 152 | + "rtp": 0.95467408 | |
| 153 | + }, | |
| 154 | + { | |
| 155 | + "spins": 450000, | |
| 156 | + "rtp": 0.9527761777777778 | |
| 157 | + }, | |
| 158 | + { | |
| 159 | + "spins": 525000, | |
| 160 | + "rtp": 0.954324 | |
| 161 | + }, | |
| 162 | + { | |
| 163 | + "spins": 600000, | |
| 164 | + "rtp": 0.9564638 | |
| 165 | + }, | |
| 166 | + { | |
| 167 | + "spins": 675000, | |
| 168 | + "rtp": 0.9569747851851852 | |
| 169 | + }, | |
| 170 | + { | |
| 171 | + "spins": 750000, | |
| 172 | + "rtp": 0.9583412 | |
| 173 | + }, | |
| 174 | + { | |
| 175 | + "spins": 825000, | |
| 176 | + "rtp": 0.9591383515151515 | |
| 177 | + }, | |
| 178 | + { | |
| 179 | + "spins": 900000, | |
| 180 | + "rtp": 0.9604935111111111 | |
| 181 | + }, | |
| 182 | + { | |
| 183 | + "spins": 975000, | |
| 184 | + "rtp": 0.9588733641025641 | |
| 185 | + }, | |
| 186 | + { | |
| 187 | + "spins": 1050000, | |
| 188 | + "rtp": 0.9592387809523809 | |
| 189 | + }, | |
| 190 | + { | |
| 191 | + "spins": 1125000, | |
| 192 | + "rtp": 0.9586845066666667 | |
| 193 | + }, | |
| 194 | + { | |
| 195 | + "spins": 1200000, | |
| 196 | + "rtp": 0.958702575 | |
| 197 | + }, | |
| 198 | + { | |
| 199 | + "spins": 1275000, | |
| 200 | + "rtp": 0.9590246117647059 | |
| 201 | + }, | |
| 202 | + { | |
| 203 | + "spins": 1350000, | |
| 204 | + "rtp": 0.9600741259259259 | |
| 205 | + }, | |
| 206 | + { | |
| 207 | + "spins": 1425000, | |
| 208 | + "rtp": 0.9609325824561403 | |
| 209 | + }, | |
| 210 | + { | |
| 211 | + "spins": 1500000, | |
| 212 | + "rtp": 0.9617418933333334 | |
| 213 | + }, | |
| 214 | + { | |
| 215 | + "spins": 1575000, | |
| 216 | + "rtp": 0.9620120317460318 | |
| 217 | + }, | |
| 218 | + { | |
| 219 | + "spins": 1650000, | |
| 220 | + "rtp": 0.9620848848484849 | |
| 221 | + }, | |
| 222 | + { | |
| 223 | + "spins": 1725000, | |
| 224 | + "rtp": 0.961910452173913 | |
| 225 | + }, | |
| 226 | + { | |
| 227 | + "spins": 1800000, | |
| 228 | + "rtp": 0.961642 | |
| 229 | + }, | |
| 230 | + { | |
| 231 | + "spins": 1875000, | |
| 232 | + "rtp": 0.961627776 | |
| 233 | + }, | |
| 234 | + { | |
| 235 | + "spins": 1950000, | |
| 236 | + "rtp": 0.9609179692307692 | |
| 237 | + }, | |
| 238 | + { | |
| 239 | + "spins": 2025000, | |
| 240 | + "rtp": 0.9611022666666666 | |
| 241 | + }, | |
| 242 | + { | |
| 243 | + "spins": 2100000, | |
| 244 | + "rtp": 0.9611009952380952 | |
| 245 | + }, | |
| 246 | + { | |
| 247 | + "spins": 2175000, | |
| 248 | + "rtp": 0.9603351724137931 | |
| 249 | + }, | |
| 250 | + { | |
| 251 | + "spins": 2250000, | |
| 252 | + "rtp": 0.9607563688888889 | |
| 253 | + }, | |
| 254 | + { | |
| 255 | + "spins": 2325000, | |
| 256 | + "rtp": 0.9608575483870968 | |
| 257 | + }, | |
| 258 | + { | |
| 259 | + "spins": 2400000, | |
| 260 | + "rtp": 0.9606920166666667 | |
| 261 | + }, | |
| 262 | + { | |
| 263 | + "spins": 2475000, | |
| 264 | + "rtp": 0.9609252646464647 | |
| 265 | + }, | |
| 266 | + { | |
| 267 | + "spins": 2550000, | |
| 268 | + "rtp": 0.9609430862745098 | |
| 269 | + }, | |
| 270 | + { | |
| 271 | + "spins": 2625000, | |
| 272 | + "rtp": 0.9612338628571429 | |
| 273 | + }, | |
| 274 | + { | |
| 275 | + "spins": 2700000, | |
| 276 | + "rtp": 0.9613437925925926 | |
| 277 | + }, | |
| 278 | + { | |
| 279 | + "spins": 2775000, | |
| 280 | + "rtp": 0.9615058558558559 | |
| 281 | + }, | |
| 282 | + { | |
| 283 | + "spins": 2850000, | |
| 284 | + "rtp": 0.9610267473684211 | |
| 285 | + }, | |
| 286 | + { | |
| 287 | + "spins": 2925000, | |
| 288 | + "rtp": 0.9606571794871794 | |
| 289 | + }, | |
| 290 | + { | |
| 291 | + "spins": 3000000, | |
| 292 | + "rtp": 0.9606461366666667 | |
| 293 | + } | |
| 294 | + ], | |
| 295 | + "featureCounts": {}, | |
| 296 | + "durationMs": 23177 | |
| 297 | +} | |
added
games/src/arcade/index.ts
+199 −0
@@ -0,0 +1,199 @@ | ||
| 1 | +import { defineArcadeGame, type ArcadeGameDefinition } from "@spinza/game-core"; | |
| 2 | +import calibration from "../calibration.json"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Spinza Originals — Beyond Slots. Five interactive originals sharing the | |
| 6 | + * arcade engine: two ladder games (analytic RTP) and three instant games | |
| 7 | + * (payScale calibrated by simulation, see calibration.json). | |
| 8 | + */ | |
| 9 | + | |
| 10 | +export const dropzone = defineArcadeGame({ | |
| 11 | + slug: "dropzone", | |
| 12 | + name: "Dropzone", | |
| 13 | + version: "1.0.0", | |
| 14 | + tagline: "Release the capsule. Pray for the edge.", | |
| 15 | + description: | |
| 16 | + "Drop a capsule from the top of a vertical tower. It ricochets through pegs, energy gates and portals down to a row of buckets whose values depend on the risk profile you pick. Some descents trigger Deep Drop: the tower extends and the capsule keeps falling into a far riskier zone where the multipliers stack — or vanish.", | |
| 17 | + theme: "arcade tower drop", | |
| 18 | + mode: "instant", | |
| 19 | + rtp: 0.96, | |
| 20 | + maxMultiplier: 3000, | |
| 21 | + volatility: "high", | |
| 22 | + presentation: { scene: "dropzone", palette: { primary: "#22d3ee", secondary: "#f472b6", glow: "#67e8f9", bg: "#050a14", surface: "#0b1a2e" }, ambience: "arcade", verb: "DROP" }, | |
| 23 | + config: { | |
| 24 | + rows: 12, | |
| 25 | + lanes: 13, | |
| 26 | + buckets: { | |
| 27 | + low: [8, 3, 1.6, 1.2, 1, 0.7, 0.5, 0.7, 1, 1.2, 1.6, 3, 8], | |
| 28 | + medium: [25, 6, 2.5, 1.4, 0.8, 0.4, 0.2, 0.4, 0.8, 1.4, 2.5, 6, 25], | |
| 29 | + high: [120, 18, 4, 1.2, 0.4, 0.1, 0, 0.1, 0.4, 1.2, 4, 18, 120], | |
| 30 | + }, | |
| 31 | + gateChancePerRow: 0.035, | |
| 32 | + gateValues: [1.5, 2, 3], | |
| 33 | + portalChancePerRow: 0.012, | |
| 34 | + deepDropChance: 0.08, | |
| 35 | + deepRows: 8, | |
| 36 | + deepBuckets: [10, 4, 2, 1, 0, 1, 2, 4, 10], | |
| 37 | + }, | |
| 38 | + featureNames: ["3 risk profiles", "Energy gates", "Portals", "Deep Drop"], | |
| 39 | + rules: [ | |
| 40 | + "Choose a risk profile (Low / Medium / High) and a release lane, then press DROP.", | |
| 41 | + "The capsule bounces left or right at every peg row (12 rows) and lands in one of 13 buckets.", | |
| 42 | + "Energy gates multiply the result by ×1.5, ×2 or ×3; portals hop the capsule one to three lanes sideways.", | |
| 43 | + "Bucket values are normalised per release lane, so every lane has the same expected value — edges are just wilder.", | |
| 44 | + "Deep Drop (8% of drops): the tower extends by 8 rows and the capsule falls into a second bucket row that multiplies the result by 0× to 10×.", | |
| 45 | + "Bucket values shown are already scaled to the game's certified RTP. Maximum win: 3,000× the bet.", | |
| 46 | + ], | |
| 47 | +}); | |
| 48 | + | |
| 49 | +export const gridBreak = defineArcadeGame({ | |
| 50 | + slug: "grid-break", | |
| 51 | + name: "Grid//Break", | |
| 52 | + version: "1.0.0", | |
| 53 | + tagline: "One wave. Infinite chains.", | |
| 54 | + description: | |
| 55 | + "An 8×8 grid of energy blocks. Fire a wave down a column: every cluster it touches detonates, blocks fall, fresh ones drop in and new clusters detonate in a chain. Longer chains climb the multiplier ladder. Bombs clear 3×3, line blocks wipe a row, ×2 blocks double the step. It plays like a lightning-fast puzzle game.", | |
| 56 | + theme: "futuristic puzzle grid", | |
| 57 | + mode: "instant", | |
| 58 | + rtp: 0.96, | |
| 59 | + maxMultiplier: 2500, | |
| 60 | + volatility: "medium", | |
| 61 | + presentation: { scene: "gridbreak", palette: { primary: "#a3e635", secondary: "#38bdf8", glow: "#bef264", bg: "#05080c", surface: "#0e1620" }, ambience: "quantum", verb: "FIRE WAVE" }, | |
| 62 | + config: { | |
| 63 | + size: 8, | |
| 64 | + colors: 6, | |
| 65 | + values: [1, 1.5, 2.5, 4, 7, 12], | |
| 66 | + weights: [26, 22, 19, 15, 11, 7], | |
| 67 | + specialChance: { bomb: 0.02, line: 0.012, x2: 0.02 }, | |
| 68 | + chainLadder: [1, 2, 4, 8, 16, 32, 64, 128], | |
| 69 | + minCluster: 4, | |
| 70 | + maxChains: 40, | |
| 71 | + }, | |
| 72 | + featureNames: ["Chain reactions", "Bombs", "Line clears", "×2 blocks"], | |
| 73 | + rules: [ | |
| 74 | + "Pick a column and fire the wave. Clusters of 4+ connected same-colour blocks touching that column detonate.", | |
| 75 | + "Blocks fall, new blocks drop in; any new cluster of 4+ detonates automatically — a chain.", | |
| 76 | + "Each chain step doubles the multiplier: ×1, ×2, ×4, ×8, ×16, ×32, ×64, ×128.", | |
| 77 | + "Bomb blocks clear the 3×3 around them, line blocks clear their whole row, ×2 blocks double the step.", | |
| 78 | + "Block values: the rarer the colour, the higher the value. Maximum win: 2,500× the bet.", | |
| 79 | + ], | |
| 80 | +}); | |
| 81 | + | |
| 82 | +export const theVault = defineArcadeGame({ | |
| 83 | + slug: "the-vault", | |
| 84 | + name: "The Vault", | |
| 85 | + version: "1.0.0", | |
| 86 | + tagline: "Five layers. One decision at each.", | |
| 87 | + description: | |
| 88 | + "Face a colossal vault with five locked layers. Each layer reveals its digits one by one; when a layer opens, secure your multiplier or attempt the next lock. The deeper you go, the more spectacular the vault — and the higher the risk. Open all five in one run to trigger the fictional jackpot.", | |
| 89 | + theme: "monumental vault", | |
| 90 | + mode: "ladder", | |
| 91 | + rtp: 0.96, | |
| 92 | + maxMultiplier: 50, | |
| 93 | + volatility: "high", | |
| 94 | + presentation: { scene: "vault", palette: { primary: "#c9a961", secondary: "#38bdf8", glow: "#e8cf8f", bg: "#08070a", surface: "#16131a" }, ambience: "heist", verb: "OPEN NEXT LOCK", secondaryVerb: "SECURE" }, | |
| 95 | + config: { | |
| 96 | + layers: [0.8, 0.7, 0.55, 0.4, 0.25], | |
| 97 | + digitsPerLayer: 3, | |
| 98 | + layerNames: ["Outer door", "Time lock", "Biometric ring", "Pressure chamber", "The core"], | |
| 99 | + }, | |
| 100 | + featureNames: ["Secure or push", "5 layers", "Fictional jackpot"], | |
| 101 | + rules: [ | |
| 102 | + "Place a bet. The first layer opens automatically — its 3 digits are revealed one by one.", | |
| 103 | + "After each opened layer, SECURE your multiplier or OPEN the next lock.", | |
| 104 | + "Chances the lock yields: 80% → 70% → 55% → 40% → 25%. Multipliers after each layer: 1.20× → 1.71× → 3.12× → 7.79× → 31.2×.", | |
| 105 | + "A failed digit triggers the alarm: the run ends and the bet is lost.", | |
| 106 | + "Opening all five layers in one run pays the fictional jackpot (31.2×). Expected return is 96% whatever your stopping point.", | |
| 107 | + ], | |
| 108 | +}); | |
| 109 | + | |
| 110 | +export const orbit = defineArcadeGame({ | |
| 111 | + slug: "orbit", | |
| 112 | + name: "Orbit", | |
| 113 | + version: "1.0.0", | |
| 114 | + tagline: "Fire the impulse. Ride the chaos.", | |
| 115 | + description: | |
| 116 | + "Planets, satellites and debris circle a burning core. Aim an impulse through the orbits: every object it touches adds value, may deflect the impulse or spawn a brand-new orbit. Some rounds cascade into fifteen objects spinning at once. Supernova collapses everything and turns the survivors into multipliers.", | |
| 117 | + theme: "orbital mechanics", | |
| 118 | + mode: "instant", | |
| 119 | + rtp: 0.96, | |
| 120 | + maxMultiplier: 2000, | |
| 121 | + volatility: "high", | |
| 122 | + presentation: { scene: "orbit", palette: { primary: "#f0abfc", secondary: "#facc15", glow: "#f5d0fe", bg: "#07030f", surface: "#150a26" }, ambience: "space", verb: "FIRE IMPULSE" }, | |
| 123 | + config: { | |
| 124 | + orbits: 3, | |
| 125 | + objectsPerOrbit: [3, 6], | |
| 126 | + beamHalfWidth: 14, | |
| 127 | + objectTypes: [ | |
| 128 | + { id: "debris", label: "Debris", value: 0.3, weight: 34 }, | |
| 129 | + { id: "satellite", label: "Satellite", value: 0.8, weight: 26, deflect: 40 }, | |
| 130 | + { id: "planet", label: "Planet", value: 2, weight: 18, deflect: 20 }, | |
| 131 | + { id: "comet", label: "Comet", value: 4, weight: 10, spawn: true }, | |
| 132 | + { id: "gasgiant", label: "Gas giant", value: 8, weight: 5, deflect: 60 }, | |
| 133 | + { id: "quasar", label: "Quasar", value: 25, weight: 1, spawn: true }, | |
| 134 | + ], | |
| 135 | + supernovaChance: 0.04, | |
| 136 | + maxOrbits: 7, | |
| 137 | + }, | |
| 138 | + featureNames: ["Aim the impulse", "Deflections", "New orbits", "Supernova"], | |
| 139 | + rules: [ | |
| 140 | + "Choose an angle and fire. The impulse travels outward through every orbit.", | |
| 141 | + "An object within the beam is collected: Debris 0.3×, Satellite 0.8×, Planet 2×, Comet 4×, Gas giant 8×, Quasar 25× (values scaled to the certified RTP).", | |
| 142 | + "Satellites, planets and gas giants deflect the impulse; comets and quasars spawn a new orbit (up to 7).", | |
| 143 | + "Supernova (4% of scoring rounds): every remaining object becomes +0.5× on the collected total.", | |
| 144 | + "Maximum win: 2,000× the bet.", | |
| 145 | + ], | |
| 146 | +}); | |
| 147 | + | |
| 148 | +export const escape99 = defineArcadeGame({ | |
| 149 | + slug: "escape-99", | |
| 150 | + name: "Escape 99", | |
| 151 | + version: "1.0.0", | |
| 152 | + tagline: "Ninety-nine floors. Cash out on any of them.", | |
| 153 | + description: | |
| 154 | + "A lightning-fast roguelike. Start on floor 1 of a 99-floor tower and climb room by room: chests, guardians, traps, runes, portals and forks between a safe corridor and a risky one. Cash out on any floor, or push for the summit — floor 99 triggers the final sequence.", | |
| 155 | + theme: "roguelike tower", | |
| 156 | + mode: "ladder", | |
| 157 | + rtp: 0.96, | |
| 158 | + maxMultiplier: 5000, | |
| 159 | + volatility: "extreme", | |
| 160 | + presentation: { scene: "escape", palette: { primary: "#34d399", secondary: "#f97316", glow: "#6ee7b7", bg: "#06090a", surface: "#0f1a16" }, ambience: "jungle", verb: "CLIMB", secondaryVerb: "CASH OUT" }, | |
| 161 | + config: { | |
| 162 | + floors: 99, | |
| 163 | + bands: [ | |
| 164 | + [10, 0.97], | |
| 165 | + [25, 0.95], | |
| 166 | + [50, 0.93], | |
| 167 | + [75, 0.91], | |
| 168 | + [99, 0.89], | |
| 169 | + ], | |
| 170 | + checkpoints: [10, 25, 50, 75, 99], | |
| 171 | + rooms: [ | |
| 172 | + { kind: "chest", label: "Treasure room", weight: 26, detail: "a chest glints in the dark" }, | |
| 173 | + { kind: "enemy", label: "Guardian hall", weight: 22, detail: "something stirs" }, | |
| 174 | + { kind: "trap", label: "Trap corridor", weight: 18, detail: "pressure plates everywhere" }, | |
| 175 | + { kind: "multiplier", label: "Rune chamber", weight: 16, detail: "runes pulse with energy" }, | |
| 176 | + { kind: "room", label: "Quiet stairwell", weight: 18, detail: "nothing but echoes" }, | |
| 177 | + ], | |
| 178 | + forkChance: 0.18, | |
| 179 | + portalChance: 0.06, | |
| 180 | + }, | |
| 181 | + featureNames: ["99 floors", "Forks", "Portals", "Checkpoints", "Final sequence"], | |
| 182 | + rules: [ | |
| 183 | + "Place a bet: you enter floor 1 automatically. Each CLIMB advances one floor (three through a portal).", | |
| 184 | + "Floor safety: 97% up to floor 10, 95% to 25, 93% to 50, 91% to 75, 89% beyond. Each survived floor multiplies by 1 ÷ safety.", | |
| 185 | + "Forks offer a safe corridor (+6% safety, smaller jump) or a risky one (−18%, bigger jump) — equal expected value.", | |
| 186 | + "CASH OUT at any floor after the first. Checkpoints (10, 25, 50, 75, 99) are marked on the tower.", | |
| 187 | + "Floor 99 ends the run with the final sequence. Expected return is 96% for any stopping strategy; maximum win 5,000×.", | |
| 188 | + ], | |
| 189 | +}); | |
| 190 | + | |
| 191 | +type Calibration = Record<string, { payScale: number; version: string }>; | |
| 192 | +const withCalibration = (def: ArcadeGameDefinition): ArcadeGameDefinition => { | |
| 193 | + const c = (calibration as Calibration)[def.slug]; | |
| 194 | + return c && c.version === def.version ? { ...def, payScale: c.payScale } : def; | |
| 195 | +}; | |
| 196 | + | |
| 197 | +export const RAW_ARCADE_GAMES: ArcadeGameDefinition[] = [dropzone, gridBreak, theVault, orbit, escape99]; | |
| 198 | +export const ARCADE_GAMES: ArcadeGameDefinition[] = RAW_ARCADE_GAMES.map(withCalibration); | |
| 199 | +export const ARCADE_BY_SLUG = new Map(ARCADE_GAMES.map((g) => [g.slug, g])); | |
modified
games/src/calibration.json
+21 −0
@@ -138,5 +138,26 @@ | ||
| 138 | 138 | "calibratedAt": "2026-09-08T01:54:22.020Z", |
| 139 | 139 | "spins": 10000000, |
| 140 | 140 | "observedRtp": 0.95927 |
| 141 | + }, | |
| 142 | + "dropzone": { | |
| 143 | + "payScale": 0.5557, | |
| 144 | + "version": "1.0.0", | |
| 145 | + "calibratedAt": "2026-09-08T03:26:19.097Z", | |
| 146 | + "spins": 1500000, | |
| 147 | + "observedRtp": 0.95852 | |
| 148 | + }, | |
| 149 | + "grid-break": { | |
| 150 | + "payScale": 0.1739, | |
| 151 | + "version": "1.0.0", | |
| 152 | + "calibratedAt": "2026-09-08T03:30:44.038Z", | |
| 153 | + "spins": 1500000, | |
| 154 | + "observedRtp": 0.95949 | |
| 155 | + }, | |
| 156 | + "orbit": { | |
| 157 | + "payScale": 0.4347, | |
| 158 | + "version": "1.0.0", | |
| 159 | + "calibratedAt": "2026-09-08T03:30:53.175Z", | |
| 160 | + "spins": 1500000, | |
| 161 | + "observedRtp": 0.96 | |
| 141 | 162 | } |
| 142 | 163 | } |
modified
games/src/client.ts
+1 −0
@@ -5,3 +5,4 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | export { GAMES, GAMES_BY_SLUG, getGame, FEATURED_ORDER, FIRST_GAME_RECOMMENDATIONS } from "./index"; |
| 7 | 7 | export { CRASH_GAMES, CRASH_BY_SLUG } from "./crash"; |
| 8 | +export { ARCADE_GAMES, ARCADE_BY_SLUG } from "./arcade"; | |
modified
games/src/index.ts
+1 −0
@@ -30,3 +30,4 @@ export const FEATURED_ORDER = ["spinza-original", "neon-vault", "cosmic-collapse | ||
| 30 | 30 | export const FIRST_GAME_RECOMMENDATIONS = ["neon-vault", "cosmic-collapse", "spinza-original"]; |
| 31 | 31 | export { RAW } from "./registry"; |
| 32 | 32 | export { CRASH_GAMES, CRASH_BY_SLUG } from "./crash"; |
| 33 | +export { ARCADE_GAMES, ARCADE_BY_SLUG, RAW_ARCADE_GAMES } from "./arcade"; | |
added
packages/database/drizzle/0002_nosy_giant_man.sql
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +CREATE TABLE "arcade_sessions" ( | |
| 2 | + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, | |
| 3 | + "round_id" varchar(32) NOT NULL, | |
| 4 | + "user_id" uuid NOT NULL, | |
| 5 | + "game_id" uuid NOT NULL, | |
| 6 | + "game_slug" varchar(64) NOT NULL, | |
| 7 | + "game_version" varchar(16) NOT NULL, | |
| 8 | + "client_round_id" uuid NOT NULL, | |
| 9 | + "bet" bigint NOT NULL, | |
| 10 | + "state" jsonb NOT NULL, | |
| 11 | + "status" varchar(12) DEFAULT 'running' NOT NULL, | |
| 12 | + "win" bigint DEFAULT 0 NOT NULL, | |
| 13 | + "started_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| 14 | + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, | |
| 15 | + "settled_at" timestamp with time zone | |
| 16 | +); | |
| 17 | +--> statement-breakpoint | |
| 18 | +ALTER TABLE "arcade_sessions" ADD CONSTRAINT "arcade_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint | |
| 19 | +ALTER TABLE "arcade_sessions" ADD CONSTRAINT "arcade_sessions_game_id_games_id_fk" FOREIGN KEY ("game_id") REFERENCES "public"."games"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint | |
| 20 | +CREATE UNIQUE INDEX "arcade_sessions_round_id_idx" ON "arcade_sessions" USING btree ("round_id");--> statement-breakpoint | |
| 21 | +CREATE UNIQUE INDEX "arcade_sessions_user_client_idx" ON "arcade_sessions" USING btree ("user_id","client_round_id");--> statement-breakpoint | |
| 22 | +CREATE INDEX "arcade_sessions_user_status_idx" ON "arcade_sessions" USING btree ("user_id","status"); | |
| \ No newline at end of file | ||
added
packages/database/drizzle/meta/0002_snapshot.json
+2988 −0
@@ -0,0 +1,2988 @@ | ||
| 1 | +{ | |
| 2 | + "id": "2e2fe479-1d38-4656-9964-b1f0fda2ba45", | |
| 3 | + "prevId": "423e47ca-9681-4a28-97a8-d582818cd2d7", | |
| 4 | + "version": "7", | |
| 5 | + "dialect": "postgresql", | |
| 6 | + "tables": { | |
| 7 | + "public.achievements": { | |
| 8 | + "name": "achievements", | |
| 9 | + "schema": "", | |
| 10 | + "columns": { | |
| 11 | + "key": { | |
| 12 | + "name": "key", | |
| 13 | + "type": "varchar(48)", | |
| 14 | + "primaryKey": true, | |
| 15 | + "notNull": true | |
| 16 | + }, | |
| 17 | + "name": { | |
| 18 | + "name": "name", | |
| 19 | + "type": "varchar(80)", | |
| 20 | + "primaryKey": false, | |
| 21 | + "notNull": true | |
| 22 | + }, | |
| 23 | + "description": { | |
| 24 | + "name": "description", | |
| 25 | + "type": "text", | |
| 26 | + "primaryKey": false, | |
| 27 | + "notNull": true | |
| 28 | + }, | |
| 29 | + "category": { | |
| 30 | + "name": "category", | |
| 31 | + "type": "varchar(24)", | |
| 32 | + "primaryKey": false, | |
| 33 | + "notNull": true | |
| 34 | + }, | |
| 35 | + "metric": { | |
| 36 | + "name": "metric", | |
| 37 | + "type": "varchar(32)", | |
| 38 | + "primaryKey": false, | |
| 39 | + "notNull": true | |
| 40 | + }, | |
| 41 | + "target": { | |
| 42 | + "name": "target", | |
| 43 | + "type": "bigint", | |
| 44 | + "primaryKey": false, | |
| 45 | + "notNull": true | |
| 46 | + }, | |
| 47 | + "reward_credits": { | |
| 48 | + "name": "reward_credits", | |
| 49 | + "type": "integer", | |
| 50 | + "primaryKey": false, | |
| 51 | + "notNull": true, | |
| 52 | + "default": 0 | |
| 53 | + }, | |
| 54 | + "reward_xp": { | |
| 55 | + "name": "reward_xp", | |
| 56 | + "type": "integer", | |
| 57 | + "primaryKey": false, | |
| 58 | + "notNull": true, | |
| 59 | + "default": 0 | |
| 60 | + }, | |
| 61 | + "icon": { | |
| 62 | + "name": "icon", | |
| 63 | + "type": "varchar(32)", | |
| 64 | + "primaryKey": false, | |
| 65 | + "notNull": true, | |
| 66 | + "default": "'star'" | |
| 67 | + }, | |
| 68 | + "sort_order": { | |
| 69 | + "name": "sort_order", | |
| 70 | + "type": "integer", | |
| 71 | + "primaryKey": false, | |
| 72 | + "notNull": true, | |
| 73 | + "default": 0 | |
| 74 | + }, | |
| 75 | + "enabled": { | |
| 76 | + "name": "enabled", | |
| 77 | + "type": "boolean", | |
| 78 | + "primaryKey": false, | |
| 79 | + "notNull": true, | |
| 80 | + "default": true | |
| 81 | + } | |
| 82 | + }, | |
| 83 | + "indexes": {}, | |
| 84 | + "foreignKeys": {}, | |
| 85 | + "compositePrimaryKeys": {}, | |
| 86 | + "uniqueConstraints": {}, | |
| 87 | + "policies": {}, | |
| 88 | + "checkConstraints": {}, | |
| 89 | + "isRLSEnabled": false | |
| 90 | + }, | |
| 91 | + "public.admin_sessions": { | |
| 92 | + "name": "admin_sessions", | |
| 93 | + "schema": "", | |
| 94 | + "columns": { | |
| 95 | + "id": { | |
| 96 | + "name": "id", | |
| 97 | + "type": "uuid", | |
| 98 | + "primaryKey": true, | |
| 99 | + "notNull": true, | |
| 100 | + "default": "gen_random_uuid()" | |
| 101 | + }, | |
| 102 | + "admin_id": { | |
| 103 | + "name": "admin_id", | |
| 104 | + "type": "uuid", | |
| 105 | + "primaryKey": false, | |
| 106 | + "notNull": true | |
| 107 | + }, | |
| 108 | + "token_hash": { | |
| 109 | + "name": "token_hash", | |
| 110 | + "type": "text", | |
| 111 | + "primaryKey": false, | |
| 112 | + "notNull": true | |
| 113 | + }, | |
| 114 | + "created_at": { | |
| 115 | + "name": "created_at", | |
| 116 | + "type": "timestamp with time zone", | |
| 117 | + "primaryKey": false, | |
| 118 | + "notNull": true, | |
| 119 | + "default": "now()" | |
| 120 | + }, | |
| 121 | + "expires_at": { | |
| 122 | + "name": "expires_at", | |
| 123 | + "type": "timestamp with time zone", | |
| 124 | + "primaryKey": false, | |
| 125 | + "notNull": true | |
| 126 | + }, | |
| 127 | + "ip": { | |
| 128 | + "name": "ip", | |
| 129 | + "type": "varchar(64)", | |
| 130 | + "primaryKey": false, | |
| 131 | + "notNull": false | |
| 132 | + } | |
| 133 | + }, | |
| 134 | + "indexes": { | |
| 135 | + "admin_sessions_token_idx": { | |
| 136 | + "name": "admin_sessions_token_idx", | |
| 137 | + "columns": [ | |
| 138 | + { | |
| 139 | + "expression": "token_hash", | |
| 140 | + "isExpression": false, | |
| 141 | + "asc": true, | |
| 142 | + "nulls": "last" | |
| 143 | + } | |
| 144 | + ], | |
| 145 | + "isUnique": true, | |
| 146 | + "concurrently": false, | |
| 147 | + "method": "btree", | |
| 148 | + "with": {} | |
| 149 | + } | |
| 150 | + }, | |
| 151 | + "foreignKeys": { | |
| 152 | + "admin_sessions_admin_id_admin_users_id_fk": { | |
| 153 | + "name": "admin_sessions_admin_id_admin_users_id_fk", | |
| 154 | + "tableFrom": "admin_sessions", | |
| 155 | + "tableTo": "admin_users", | |
| 156 | + "columnsFrom": [ | |
| 157 | + "admin_id" | |
| 158 | + ], | |
| 159 | + "columnsTo": [ | |
| 160 | + "id" | |
| 161 | + ], | |
| 162 | + "onDelete": "cascade", | |
| 163 | + "onUpdate": "no action" | |
| 164 | + } | |
| 165 | + }, | |
| 166 | + "compositePrimaryKeys": {}, | |
| 167 | + "uniqueConstraints": {}, | |
| 168 | + "policies": {}, | |
| 169 | + "checkConstraints": {}, | |
| 170 | + "isRLSEnabled": false | |
| 171 | + }, | |
| 172 | + "public.admin_users": { | |
| 173 | + "name": "admin_users", | |
| 174 | + "schema": "", | |
| 175 | + "columns": { | |
| 176 | + "id": { | |
| 177 | + "name": "id", | |
| 178 | + "type": "uuid", | |
| 179 | + "primaryKey": true, | |
| 180 | + "notNull": true, | |
| 181 | + "default": "gen_random_uuid()" | |
| 182 | + }, | |
| 183 | + "username": { | |
| 184 | + "name": "username", | |
| 185 | + "type": "varchar(32)", | |
| 186 | + "primaryKey": false, | |
| 187 | + "notNull": true | |
| 188 | + }, | |
| 189 | + "password_hash": { | |
| 190 | + "name": "password_hash", | |
| 191 | + "type": "text", | |
| 192 | + "primaryKey": false, | |
| 193 | + "notNull": true | |
| 194 | + }, | |
| 195 | + "totp_secret": { | |
| 196 | + "name": "totp_secret", | |
| 197 | + "type": "text", | |
| 198 | + "primaryKey": false, | |
| 199 | + "notNull": true | |
| 200 | + }, | |
| 201 | + "role": { | |
| 202 | + "name": "role", | |
| 203 | + "type": "varchar(16)", | |
| 204 | + "primaryKey": false, | |
| 205 | + "notNull": true, | |
| 206 | + "default": "'admin'" | |
| 207 | + }, | |
| 208 | + "created_at": { | |
| 209 | + "name": "created_at", | |
| 210 | + "type": "timestamp with time zone", | |
| 211 | + "primaryKey": false, | |
| 212 | + "notNull": true, | |
| 213 | + "default": "now()" | |
| 214 | + }, | |
| 215 | + "last_login_at": { | |
| 216 | + "name": "last_login_at", | |
| 217 | + "type": "timestamp with time zone", | |
| 218 | + "primaryKey": false, | |
| 219 | + "notNull": false | |
| 220 | + }, | |
| 221 | + "disabled": { | |
| 222 | + "name": "disabled", | |
| 223 | + "type": "boolean", | |
| 224 | + "primaryKey": false, | |
| 225 | + "notNull": true, | |
| 226 | + "default": false | |
| 227 | + } | |
| 228 | + }, | |
| 229 | + "indexes": { | |
| 230 | + "admin_users_username_idx": { | |
| 231 | + "name": "admin_users_username_idx", | |
| 232 | + "columns": [ | |
| 233 | + { | |
| 234 | + "expression": "username", | |
| 235 | + "isExpression": false, | |
| 236 | + "asc": true, | |
| 237 | + "nulls": "last" | |
| 238 | + } | |
| 239 | + ], | |
| 240 | + "isUnique": true, | |
| 241 | + "concurrently": false, | |
| 242 | + "method": "btree", | |
| 243 | + "with": {} | |
| 244 | + } | |
| 245 | + }, | |
| 246 | + "foreignKeys": {}, | |
| 247 | + "compositePrimaryKeys": {}, | |
| 248 | + "uniqueConstraints": {}, | |
| 249 | + "policies": {}, | |
| 250 | + "checkConstraints": {}, | |
| 251 | + "isRLSEnabled": false | |
| 252 | + }, | |
| 253 | + "public.arcade_sessions": { | |
| 254 | + "name": "arcade_sessions", | |
| 255 | + "schema": "", | |
| 256 | + "columns": { | |
| 257 | + "id": { | |
| 258 | + "name": "id", | |
| 259 | + "type": "uuid", | |
| 260 | + "primaryKey": true, | |
| 261 | + "notNull": true, | |
| 262 | + "default": "gen_random_uuid()" | |
| 263 | + }, | |
| 264 | + "round_id": { | |
| 265 | + "name": "round_id", | |
| 266 | + "type": "varchar(32)", | |
| 267 | + "primaryKey": false, | |
| 268 | + "notNull": true | |
| 269 | + }, | |
| 270 | + "user_id": { | |
| 271 | + "name": "user_id", | |
| 272 | + "type": "uuid", | |
| 273 | + "primaryKey": false, | |
| 274 | + "notNull": true | |
| 275 | + }, | |
| 276 | + "game_id": { | |
| 277 | + "name": "game_id", | |
| 278 | + "type": "uuid", | |
| 279 | + "primaryKey": false, | |
| 280 | + "notNull": true | |
| 281 | + }, | |
| 282 | + "game_slug": { | |
| 283 | + "name": "game_slug", | |
| 284 | + "type": "varchar(64)", | |
| 285 | + "primaryKey": false, | |
| 286 | + "notNull": true | |
| 287 | + }, | |
| 288 | + "game_version": { | |
| 289 | + "name": "game_version", | |
| 290 | + "type": "varchar(16)", | |
| 291 | + "primaryKey": false, | |
| 292 | + "notNull": true | |
| 293 | + }, | |
| 294 | + "client_round_id": { | |
| 295 | + "name": "client_round_id", | |
| 296 | + "type": "uuid", | |
| 297 | + "primaryKey": false, | |
| 298 | + "notNull": true | |
| 299 | + }, | |
| 300 | + "bet": { | |
| 301 | + "name": "bet", | |
| 302 | + "type": "bigint", | |
| 303 | + "primaryKey": false, | |
| 304 | + "notNull": true | |
| 305 | + }, | |
| 306 | + "state": { | |
| 307 | + "name": "state", | |
| 308 | + "type": "jsonb", | |
| 309 | + "primaryKey": false, | |
| 310 | + "notNull": true | |
| 311 | + }, | |
| 312 | + "status": { | |
| 313 | + "name": "status", | |
| 314 | + "type": "varchar(12)", | |
| 315 | + "primaryKey": false, | |
| 316 | + "notNull": true, | |
| 317 | + "default": "'running'" | |
| 318 | + }, | |
| 319 | + "win": { | |
| 320 | + "name": "win", | |
| 321 | + "type": "bigint", | |
| 322 | + "primaryKey": false, | |
| 323 | + "notNull": true, | |
| 324 | + "default": 0 | |
| 325 | + }, | |
| 326 | + "started_at": { | |
| 327 | + "name": "started_at", | |
| 328 | + "type": "timestamp with time zone", | |
| 329 | + "primaryKey": false, | |
| 330 | + "notNull": true, | |
| 331 | + "default": "now()" | |
| 332 | + }, | |
| 333 | + "updated_at": { | |
| 334 | + "name": "updated_at", | |
| 335 | + "type": "timestamp with time zone", | |
| 336 | + "primaryKey": false, | |
| 337 | + "notNull": true, | |
| 338 | + "default": "now()" | |
| 339 | + }, | |
| 340 | + "settled_at": { | |
| 341 | + "name": "settled_at", | |
| 342 | + "type": "timestamp with time zone", | |
| 343 | + "primaryKey": false, | |
| 344 | + "notNull": false | |
| 345 | + } | |
| 346 | + }, | |
| 347 | + "indexes": { | |
| 348 | + "arcade_sessions_round_id_idx": { | |
| 349 | + "name": "arcade_sessions_round_id_idx", | |
| 350 | + "columns": [ | |
| 351 | + { | |
| 352 | + "expression": "round_id", | |
| 353 | + "isExpression": false, | |
| 354 | + "asc": true, | |
| 355 | + "nulls": "last" | |
| 356 | + } | |
| 357 | + ], | |
| 358 | + "isUnique": true, | |
| 359 | + "concurrently": false, | |
| 360 | + "method": "btree", | |
| 361 | + "with": {} | |
| 362 | + }, | |
| 363 | + "arcade_sessions_user_client_idx": { | |
| 364 | + "name": "arcade_sessions_user_client_idx", | |
| 365 | + "columns": [ | |
| 366 | + { | |
| 367 | + "expression": "user_id", | |
| 368 | + "isExpression": false, | |
| 369 | + "asc": true, | |
| 370 | + "nulls": "last" | |
| 371 | + }, | |
| 372 | + { | |
| 373 | + "expression": "client_round_id", | |
| 374 | + "isExpression": false, | |
| 375 | + "asc": true, | |
| 376 | + "nulls": "last" | |
| 377 | + } | |
| 378 | + ], | |
| 379 | + "isUnique": true, | |
| 380 | + "concurrently": false, | |
| 381 | + "method": "btree", | |
| 382 | + "with": {} | |
| 383 | + }, | |
| 384 | + "arcade_sessions_user_status_idx": { | |
| 385 | + "name": "arcade_sessions_user_status_idx", | |
| 386 | + "columns": [ | |
| 387 | + { | |
| 388 | + "expression": "user_id", | |
| 389 | + "isExpression": false, | |
| 390 | + "asc": true, | |
| 391 | + "nulls": "last" | |
| 392 | + }, | |
| 393 | + { | |
| 394 | + "expression": "status", | |
| 395 | + "isExpression": false, | |
| 396 | + "asc": true, | |
| 397 | + "nulls": "last" | |
| 398 | + } | |
| 399 | + ], | |
| 400 | + "isUnique": false, | |
| 401 | + "concurrently": false, | |
| 402 | + "method": "btree", | |
| 403 | + "with": {} | |
| 404 | + } | |
| 405 | + }, | |
| 406 | + "foreignKeys": { | |
| 407 | + "arcade_sessions_user_id_users_id_fk": { | |
| 408 | + "name": "arcade_sessions_user_id_users_id_fk", | |
| 409 | + "tableFrom": "arcade_sessions", | |
| 410 | + "tableTo": "users", | |
| 411 | + "columnsFrom": [ | |
| 412 | + "user_id" | |
| 413 | + ], | |
| 414 | + "columnsTo": [ | |
| 415 | + "id" | |
| 416 | + ], | |
| 417 | + "onDelete": "cascade", | |
| 418 | + "onUpdate": "no action" | |
| 419 | + }, | |
| 420 | + "arcade_sessions_game_id_games_id_fk": { | |
| 421 | + "name": "arcade_sessions_game_id_games_id_fk", | |
| 422 | + "tableFrom": "arcade_sessions", | |
| 423 | + "tableTo": "games", | |
| 424 | + "columnsFrom": [ | |
| 425 | + "game_id" | |
| 426 | + ], | |
| 427 | + "columnsTo": [ | |
| 428 | + "id" | |
| 429 | + ], | |
| 430 | + "onDelete": "no action", | |
| 431 | + "onUpdate": "no action" | |
| 432 | + } | |
| 433 | + }, | |
| 434 | + "compositePrimaryKeys": {}, | |
| 435 | + "uniqueConstraints": {}, | |
| 436 | + "policies": {}, | |
| 437 | + "checkConstraints": {}, | |
| 438 | + "isRLSEnabled": false | |
| 439 | + }, | |
| 440 | + "public.crash_rounds": { | |
| 441 | + "name": "crash_rounds", | |
| 442 | + "schema": "", | |
| 443 | + "columns": { | |
| 444 | + "id": { | |
| 445 | + "name": "id", | |
| 446 | + "type": "uuid", | |
| 447 | + "primaryKey": true, | |
| 448 | + "notNull": true, | |
| 449 | + "default": "gen_random_uuid()" | |
| 450 | + }, | |
| 451 | + "round_id": { | |
| 452 | + "name": "round_id", | |
| 453 | + "type": "varchar(32)", | |
| 454 | + "primaryKey": false, | |
| 455 | + "notNull": true | |
| 456 | + }, | |
| 457 | + "user_id": { | |
| 458 | + "name": "user_id", | |
| 459 | + "type": "uuid", | |
| 460 | + "primaryKey": false, | |
| 461 | + "notNull": true | |
| 462 | + }, | |
| 463 | + "game_id": { | |
| 464 | + "name": "game_id", | |
| 465 | + "type": "uuid", | |
| 466 | + "primaryKey": false, | |
| 467 | + "notNull": true | |
| 468 | + }, | |
| 469 | + "game_slug": { | |
| 470 | + "name": "game_slug", | |
| 471 | + "type": "varchar(64)", | |
| 472 | + "primaryKey": false, | |
| 473 | + "notNull": true | |
| 474 | + }, | |
| 475 | + "game_version": { | |
| 476 | + "name": "game_version", | |
| 477 | + "type": "varchar(16)", | |
| 478 | + "primaryKey": false, | |
| 479 | + "notNull": true | |
| 480 | + }, | |
| 481 | + "client_round_id": { | |
| 482 | + "name": "client_round_id", | |
| 483 | + "type": "uuid", | |
| 484 | + "primaryKey": false, | |
| 485 | + "notNull": true | |
| 486 | + }, | |
| 487 | + "bet": { | |
| 488 | + "name": "bet", | |
| 489 | + "type": "bigint", | |
| 490 | + "primaryKey": false, | |
| 491 | + "notNull": true | |
| 492 | + }, | |
| 493 | + "crash_multiplier": { | |
| 494 | + "name": "crash_multiplier", | |
| 495 | + "type": "numeric(12, 2)", | |
| 496 | + "primaryKey": false, | |
| 497 | + "notNull": true | |
| 498 | + }, | |
| 499 | + "crash_at_ms": { | |
| 500 | + "name": "crash_at_ms", | |
| 501 | + "type": "integer", | |
| 502 | + "primaryKey": false, | |
| 503 | + "notNull": true | |
| 504 | + }, | |
| 505 | + "seed": { | |
| 506 | + "name": "seed", | |
| 507 | + "type": "text", | |
| 508 | + "primaryKey": false, | |
| 509 | + "notNull": true | |
| 510 | + }, | |
| 511 | + "commitment": { | |
| 512 | + "name": "commitment", | |
| 513 | + "type": "varchar(64)", | |
| 514 | + "primaryKey": false, | |
| 515 | + "notNull": true | |
| 516 | + }, | |
| 517 | + "events": { | |
| 518 | + "name": "events", | |
| 519 | + "type": "jsonb", | |
| 520 | + "primaryKey": false, | |
| 521 | + "notNull": true, | |
| 522 | + "default": "'[]'::jsonb" | |
| 523 | + }, | |
| 524 | + "auto_cashout": { | |
| 525 | + "name": "auto_cashout", | |
| 526 | + "type": "numeric(12, 2)", | |
| 527 | + "primaryKey": false, | |
| 528 | + "notNull": false | |
| 529 | + }, | |
| 530 | + "status": { | |
| 531 | + "name": "status", | |
| 532 | + "type": "varchar(12)", | |
| 533 | + "primaryKey": false, | |
| 534 | + "notNull": true, | |
| 535 | + "default": "'running'" | |
| 536 | + }, | |
| 537 | + "cashout_multiplier": { | |
| 538 | + "name": "cashout_multiplier", | |
| 539 | + "type": "numeric(12, 2)", | |
| 540 | + "primaryKey": false, | |
| 541 | + "notNull": false | |
| 542 | + }, | |
| 543 | + "win": { | |
| 544 | + "name": "win", | |
| 545 | + "type": "bigint", | |
| 546 | + "primaryKey": false, | |
| 547 | + "notNull": true, | |
| 548 | + "default": 0 | |
| 549 | + }, | |
| 550 | + "started_at": { | |
| 551 | + "name": "started_at", | |
| 552 | + "type": "timestamp with time zone", | |
| 553 | + "primaryKey": false, | |
| 554 | + "notNull": true, | |
| 555 | + "default": "now()" | |
| 556 | + }, | |
| 557 | + "settled_at": { | |
| 558 | + "name": "settled_at", | |
| 559 | + "type": "timestamp with time zone", | |
| 560 | + "primaryKey": false, | |
| 561 | + "notNull": false | |
| 562 | + } | |
| 563 | + }, | |
| 564 | + "indexes": { | |
| 565 | + "crash_rounds_round_id_idx": { | |
| 566 | + "name": "crash_rounds_round_id_idx", | |
| 567 | + "columns": [ | |
| 568 | + { | |
| 569 | + "expression": "round_id", | |
| 570 | + "isExpression": false, | |
| 571 | + "asc": true, | |
| 572 | + "nulls": "last" | |
| 573 | + } | |
| 574 | + ], | |
| 575 | + "isUnique": true, | |
| 576 | + "concurrently": false, | |
| 577 | + "method": "btree", | |
| 578 | + "with": {} | |
| 579 | + }, | |
| 580 | + "crash_rounds_user_client_idx": { | |
| 581 | + "name": "crash_rounds_user_client_idx", | |
| 582 | + "columns": [ | |
| 583 | + { | |
| 584 | + "expression": "user_id", | |
| 585 | + "isExpression": false, | |
| 586 | + "asc": true, | |
| 587 | + "nulls": "last" | |
| 588 | + }, | |
| 589 | + { | |
| 590 | + "expression": "client_round_id", | |
| 591 | + "isExpression": false, | |
| 592 | + "asc": true, | |
| 593 | + "nulls": "last" | |
| 594 | + } | |
| 595 | + ], | |
| 596 | + "isUnique": true, | |
| 597 | + "concurrently": false, | |
| 598 | + "method": "btree", | |
| 599 | + "with": {} | |
| 600 | + }, | |
| 601 | + "crash_rounds_user_status_idx": { | |
| 602 | + "name": "crash_rounds_user_status_idx", | |
| 603 | + "columns": [ | |
| 604 | + { | |
| 605 | + "expression": "user_id", | |
| 606 | + "isExpression": false, | |
| 607 | + "asc": true, | |
| 608 | + "nulls": "last" | |
| 609 | + }, | |
| 610 | + { | |
| 611 | + "expression": "status", | |
| 612 | + "isExpression": false, | |
| 613 | + "asc": true, | |
| 614 | + "nulls": "last" | |
| 615 | + } | |
| 616 | + ], | |
| 617 | + "isUnique": false, | |
| 618 | + "concurrently": false, | |
| 619 | + "method": "btree", | |
| 620 | + "with": {} | |
| 621 | + } | |
| 622 | + }, | |
| 623 | + "foreignKeys": { | |
| 624 | + "crash_rounds_user_id_users_id_fk": { | |
| 625 | + "name": "crash_rounds_user_id_users_id_fk", | |
| 626 | + "tableFrom": "crash_rounds", | |
| 627 | + "tableTo": "users", | |
| 628 | + "columnsFrom": [ | |
| 629 | + "user_id" | |
| 630 | + ], | |
| 631 | + "columnsTo": [ | |
| 632 | + "id" | |
| 633 | + ], | |
| 634 | + "onDelete": "cascade", | |
| 635 | + "onUpdate": "no action" | |
| 636 | + }, | |
| 637 | + "crash_rounds_game_id_games_id_fk": { | |
| 638 | + "name": "crash_rounds_game_id_games_id_fk", | |
| 639 | + "tableFrom": "crash_rounds", | |
| 640 | + "tableTo": "games", | |
| 641 | + "columnsFrom": [ | |
| 642 | + "game_id" | |
| 643 | + ], | |
| 644 | + "columnsTo": [ | |
| 645 | + "id" | |
| 646 | + ], | |
| 647 | + "onDelete": "no action", | |
| 648 | + "onUpdate": "no action" | |
| 649 | + } | |
| 650 | + }, | |
| 651 | + "compositePrimaryKeys": {}, | |
| 652 | + "uniqueConstraints": {}, | |
| 653 | + "policies": {}, | |
| 654 | + "checkConstraints": {}, | |
| 655 | + "isRLSEnabled": false | |
| 656 | + }, | |
| 657 | + "public.credit_transactions": { | |
| 658 | + "name": "credit_transactions", | |
| 659 | + "schema": "", | |
| 660 | + "columns": { | |
| 661 | + "id": { | |
| 662 | + "name": "id", | |
| 663 | + "type": "uuid", | |
| 664 | + "primaryKey": true, | |
| 665 | + "notNull": true, | |
| 666 | + "default": "gen_random_uuid()" | |
| 667 | + }, | |
| 668 | + "user_id": { | |
| 669 | + "name": "user_id", | |
| 670 | + "type": "uuid", | |
| 671 | + "primaryKey": false, | |
| 672 | + "notNull": true | |
| 673 | + }, | |
| 674 | + "type": { | |
| 675 | + "name": "type", | |
| 676 | + "type": "varchar(24)", | |
| 677 | + "primaryKey": false, | |
| 678 | + "notNull": true | |
| 679 | + }, | |
| 680 | + "amount": { | |
| 681 | + "name": "amount", | |
| 682 | + "type": "bigint", | |
| 683 | + "primaryKey": false, | |
| 684 | + "notNull": true | |
| 685 | + }, | |
| 686 | + "balance_after": { | |
| 687 | + "name": "balance_after", | |
| 688 | + "type": "bigint", | |
| 689 | + "primaryKey": false, | |
| 690 | + "notNull": true | |
| 691 | + }, | |
| 692 | + "reference": { | |
| 693 | + "name": "reference", | |
| 694 | + "type": "text", | |
| 695 | + "primaryKey": false, | |
| 696 | + "notNull": false | |
| 697 | + }, | |
| 698 | + "meta": { | |
| 699 | + "name": "meta", | |
| 700 | + "type": "jsonb", | |
| 701 | + "primaryKey": false, | |
| 702 | + "notNull": false | |
| 703 | + }, | |
| 704 | + "created_at": { | |
| 705 | + "name": "created_at", | |
| 706 | + "type": "timestamp with time zone", | |
| 707 | + "primaryKey": false, | |
| 708 | + "notNull": true, | |
| 709 | + "default": "now()" | |
| 710 | + } | |
| 711 | + }, | |
| 712 | + "indexes": { | |
| 713 | + "ctx_user_created_idx": { | |
| 714 | + "name": "ctx_user_created_idx", | |
| 715 | + "columns": [ | |
| 716 | + { | |
| 717 | + "expression": "user_id", | |
| 718 | + "isExpression": false, | |
| 719 | + "asc": true, | |
| 720 | + "nulls": "last" | |
| 721 | + }, | |
| 722 | + { | |
| 723 | + "expression": "created_at", | |
| 724 | + "isExpression": false, | |
| 725 | + "asc": true, | |
| 726 | + "nulls": "last" | |
| 727 | + } | |
| 728 | + ], | |
| 729 | + "isUnique": false, | |
| 730 | + "concurrently": false, | |
| 731 | + "method": "btree", | |
| 732 | + "with": {} | |
| 733 | + }, | |
| 734 | + "ctx_type_idx": { | |
| 735 | + "name": "ctx_type_idx", | |
| 736 | + "columns": [ | |
| 737 | + { | |
| 738 | + "expression": "type", | |
| 739 | + "isExpression": false, | |
| 740 | + "asc": true, | |
| 741 | + "nulls": "last" | |
| 742 | + } | |
| 743 | + ], | |
| 744 | + "isUnique": false, | |
| 745 | + "concurrently": false, | |
| 746 | + "method": "btree", | |
| 747 | + "with": {} | |
| 748 | + }, | |
| 749 | + "ctx_reference_idx": { | |
| 750 | + "name": "ctx_reference_idx", | |
| 751 | + "columns": [ | |
| 752 | + { | |
| 753 | + "expression": "reference", | |
| 754 | + "isExpression": false, | |
| 755 | + "asc": true, | |
| 756 | + "nulls": "last" | |
| 757 | + } | |
| 758 | + ], | |
| 759 | + "isUnique": false, | |
| 760 | + "concurrently": false, | |
| 761 | + "method": "btree", | |
| 762 | + "with": {} | |
| 763 | + } | |
| 764 | + }, | |
| 765 | + "foreignKeys": { | |
| 766 | + "credit_transactions_user_id_users_id_fk": { | |
| 767 | + "name": "credit_transactions_user_id_users_id_fk", | |
| 768 | + "tableFrom": "credit_transactions", | |
| 769 | + "tableTo": "users", | |
| 770 | + "columnsFrom": [ | |
| 771 | + "user_id" | |
| 772 | + ], | |
| 773 | + "columnsTo": [ | |
| 774 | + "id" | |
| 775 | + ], | |
| 776 | + "onDelete": "cascade", | |
| 777 | + "onUpdate": "no action" | |
| 778 | + } | |
| 779 | + }, | |
| 780 | + "compositePrimaryKeys": {}, | |
| 781 | + "uniqueConstraints": {}, | |
| 782 | + "policies": {}, | |
| 783 | + "checkConstraints": {}, | |
| 784 | + "isRLSEnabled": false | |
| 785 | + }, | |
| 786 | + "public.daily_rewards": { | |
| 787 | + "name": "daily_rewards", | |
| 788 | + "schema": "", | |
| 789 | + "columns": { | |
| 790 | + "user_id": { | |
| 791 | + "name": "user_id", | |
| 792 | + "type": "uuid", | |
| 793 | + "primaryKey": true, | |
| 794 | + "notNull": true | |
| 795 | + }, | |
| 796 | + "streak_day": { | |
| 797 | + "name": "streak_day", | |
| 798 | + "type": "integer", | |
| 799 | + "primaryKey": false, | |
| 800 | + "notNull": true, | |
| 801 | + "default": 0 | |
| 802 | + }, | |
| 803 | + "last_claimed_at": { | |
| 804 | + "name": "last_claimed_at", | |
| 805 | + "type": "timestamp with time zone", | |
| 806 | + "primaryKey": false, | |
| 807 | + "notNull": false | |
| 808 | + }, | |
| 809 | + "next_available_at": { | |
| 810 | + "name": "next_available_at", | |
| 811 | + "type": "timestamp with time zone", | |
| 812 | + "primaryKey": false, | |
| 813 | + "notNull": false | |
| 814 | + }, | |
| 815 | + "total_claimed": { | |
| 816 | + "name": "total_claimed", | |
| 817 | + "type": "bigint", | |
| 818 | + "primaryKey": false, | |
| 819 | + "notNull": true, | |
| 820 | + "default": 0 | |
| 821 | + }, | |
| 822 | + "claims": { | |
| 823 | + "name": "claims", | |
| 824 | + "type": "integer", | |
| 825 | + "primaryKey": false, | |
| 826 | + "notNull": true, | |
| 827 | + "default": 0 | |
| 828 | + } | |
| 829 | + }, | |
| 830 | + "indexes": {}, | |
| 831 | + "foreignKeys": { | |
| 832 | + "daily_rewards_user_id_users_id_fk": { | |
| 833 | + "name": "daily_rewards_user_id_users_id_fk", | |
| 834 | + "tableFrom": "daily_rewards", | |
| 835 | + "tableTo": "users", | |
| 836 | + "columnsFrom": [ | |
| 837 | + "user_id" | |
| 838 | + ], | |
| 839 | + "columnsTo": [ | |
| 840 | + "id" | |
| 841 | + ], | |
| 842 | + "onDelete": "cascade", | |
| 843 | + "onUpdate": "no action" | |
| 844 | + } | |
| 845 | + }, | |
| 846 | + "compositePrimaryKeys": {}, | |
| 847 | + "uniqueConstraints": {}, | |
| 848 | + "policies": {}, | |
| 849 | + "checkConstraints": {}, | |
| 850 | + "isRLSEnabled": false | |
| 851 | + }, | |
| 852 | + "public.favorites": { | |
| 853 | + "name": "favorites", | |
| 854 | + "schema": "", | |
| 855 | + "columns": { | |
| 856 | + "user_id": { | |
| 857 | + "name": "user_id", | |
| 858 | + "type": "uuid", | |
| 859 | + "primaryKey": false, | |
| 860 | + "notNull": true | |
| 861 | + }, | |
| 862 | + "game_id": { | |
| 863 | + "name": "game_id", | |
| 864 | + "type": "uuid", | |
| 865 | + "primaryKey": false, | |
| 866 | + "notNull": true | |
| 867 | + }, | |
| 868 | + "created_at": { | |
| 869 | + "name": "created_at", | |
| 870 | + "type": "timestamp with time zone", | |
| 871 | + "primaryKey": false, | |
| 872 | + "notNull": true, | |
| 873 | + "default": "now()" | |
| 874 | + } | |
| 875 | + }, | |
| 876 | + "indexes": {}, | |
| 877 | + "foreignKeys": { | |
| 878 | + "favorites_user_id_users_id_fk": { | |
| 879 | + "name": "favorites_user_id_users_id_fk", | |
| 880 | + "tableFrom": "favorites", | |
| 881 | + "tableTo": "users", | |
| 882 | + "columnsFrom": [ | |
| 883 | + "user_id" | |
| 884 | + ], | |
| 885 | + "columnsTo": [ | |
| 886 | + "id" | |
| 887 | + ], | |
| 888 | + "onDelete": "cascade", | |
| 889 | + "onUpdate": "no action" | |
| 890 | + }, | |
| 891 | + "favorites_game_id_games_id_fk": { | |
| 892 | + "name": "favorites_game_id_games_id_fk", | |
| 893 | + "tableFrom": "favorites", | |
| 894 | + "tableTo": "games", | |
| 895 | + "columnsFrom": [ | |
| 896 | + "game_id" | |
| 897 | + ], | |
| 898 | + "columnsTo": [ | |
| 899 | + "id" | |
| 900 | + ], | |
| 901 | + "onDelete": "cascade", | |
| 902 | + "onUpdate": "no action" | |
| 903 | + } | |
| 904 | + }, | |
| 905 | + "compositePrimaryKeys": { | |
| 906 | + "favorites_user_id_game_id_pk": { | |
| 907 | + "name": "favorites_user_id_game_id_pk", | |
| 908 | + "columns": [ | |
| 909 | + "user_id", | |
| 910 | + "game_id" | |
| 911 | + ] | |
| 912 | + } | |
| 913 | + }, | |
| 914 | + "uniqueConstraints": {}, | |
| 915 | + "policies": {}, | |
| 916 | + "checkConstraints": {}, | |
| 917 | + "isRLSEnabled": false | |
| 918 | + }, | |
| 919 | + "public.feature_flags": { | |
| 920 | + "name": "feature_flags", | |
| 921 | + "schema": "", | |
| 922 | + "columns": { | |
| 923 | + "key": { | |
| 924 | + "name": "key", | |
| 925 | + "type": "varchar(64)", | |
| 926 | + "primaryKey": true, | |
| 927 | + "notNull": true | |
| 928 | + }, | |
| 929 | + "enabled": { | |
| 930 | + "name": "enabled", | |
| 931 | + "type": "boolean", | |
| 932 | + "primaryKey": false, | |
| 933 | + "notNull": true, | |
| 934 | + "default": true | |
| 935 | + }, | |
| 936 | + "description": { | |
| 937 | + "name": "description", | |
| 938 | + "type": "text", | |
| 939 | + "primaryKey": false, | |
| 940 | + "notNull": false | |
| 941 | + }, | |
| 942 | + "updated_at": { | |
| 943 | + "name": "updated_at", | |
| 944 | + "type": "timestamp with time zone", | |
| 945 | + "primaryKey": false, | |
| 946 | + "notNull": true, | |
| 947 | + "default": "now()" | |
| 948 | + } | |
| 949 | + }, | |
| 950 | + "indexes": {}, | |
| 951 | + "foreignKeys": {}, | |
| 952 | + "compositePrimaryKeys": {}, | |
| 953 | + "uniqueConstraints": {}, | |
| 954 | + "policies": {}, | |
| 955 | + "checkConstraints": {}, | |
| 956 | + "isRLSEnabled": false | |
| 957 | + }, | |
| 958 | + "public.game_rounds": { | |
| 959 | + "name": "game_rounds", | |
| 960 | + "schema": "", | |
| 961 | + "columns": { | |
| 962 | + "id": { | |
| 963 | + "name": "id", | |
| 964 | + "type": "uuid", | |
| 965 | + "primaryKey": true, | |
| 966 | + "notNull": true, | |
| 967 | + "default": "gen_random_uuid()" | |
| 968 | + }, | |
| 969 | + "round_id": { | |
| 970 | + "name": "round_id", | |
| 971 | + "type": "varchar(32)", | |
| 972 | + "primaryKey": false, | |
| 973 | + "notNull": true | |
| 974 | + }, | |
| 975 | + "user_id": { | |
| 976 | + "name": "user_id", | |
| 977 | + "type": "uuid", | |
| 978 | + "primaryKey": false, | |
| 979 | + "notNull": true | |
| 980 | + }, | |
| 981 | + "game_id": { | |
| 982 | + "name": "game_id", | |
| 983 | + "type": "uuid", | |
| 984 | + "primaryKey": false, | |
| 985 | + "notNull": true | |
| 986 | + }, | |
| 987 | + "game_slug": { | |
| 988 | + "name": "game_slug", | |
| 989 | + "type": "varchar(64)", | |
| 990 | + "primaryKey": false, | |
| 991 | + "notNull": true | |
| 992 | + }, | |
| 993 | + "game_version": { | |
| 994 | + "name": "game_version", | |
| 995 | + "type": "varchar(16)", | |
| 996 | + "primaryKey": false, | |
| 997 | + "notNull": true | |
| 998 | + }, | |
| 999 | + "client_round_id": { | |
| 1000 | + "name": "client_round_id", | |
| 1001 | + "type": "uuid", | |
| 1002 | + "primaryKey": false, | |
| 1003 | + "notNull": true | |
| 1004 | + }, | |
| 1005 | + "bet": { | |
| 1006 | + "name": "bet", | |
| 1007 | + "type": "bigint", | |
| 1008 | + "primaryKey": false, | |
| 1009 | + "notNull": true | |
| 1010 | + }, | |
| 1011 | + "win": { | |
| 1012 | + "name": "win", | |
| 1013 | + "type": "bigint", | |
| 1014 | + "primaryKey": false, | |
| 1015 | + "notNull": true | |
| 1016 | + }, | |
| 1017 | + "multiplier": { | |
| 1018 | + "name": "multiplier", | |
| 1019 | + "type": "numeric(12, 4)", | |
| 1020 | + "primaryKey": false, | |
| 1021 | + "notNull": true | |
| 1022 | + }, | |
| 1023 | + "balance_after": { | |
| 1024 | + "name": "balance_after", | |
| 1025 | + "type": "bigint", | |
| 1026 | + "primaryKey": false, | |
| 1027 | + "notNull": true | |
| 1028 | + }, | |
| 1029 | + "result": { | |
| 1030 | + "name": "result", | |
| 1031 | + "type": "jsonb", | |
| 1032 | + "primaryKey": false, | |
| 1033 | + "notNull": true | |
| 1034 | + }, | |
| 1035 | + "features": { | |
| 1036 | + "name": "features", | |
| 1037 | + "type": "text[]", | |
| 1038 | + "primaryKey": false, | |
| 1039 | + "notNull": true, | |
| 1040 | + "default": "'{}'::text[]" | |
| 1041 | + }, | |
| 1042 | + "free_spins": { | |
| 1043 | + "name": "free_spins", | |
| 1044 | + "type": "boolean", | |
| 1045 | + "primaryKey": false, | |
| 1046 | + "notNull": true, | |
| 1047 | + "default": false | |
| 1048 | + }, | |
| 1049 | + "bonus": { | |
| 1050 | + "name": "bonus", | |
| 1051 | + "type": "boolean", | |
| 1052 | + "primaryKey": false, | |
| 1053 | + "notNull": true, | |
| 1054 | + "default": false | |
| 1055 | + }, | |
| 1056 | + "jackpot_tier": { | |
| 1057 | + "name": "jackpot_tier", | |
| 1058 | + "type": "varchar(8)", | |
| 1059 | + "primaryKey": false, | |
| 1060 | + "notNull": false | |
| 1061 | + }, | |
| 1062 | + "rng_reference": { | |
| 1063 | + "name": "rng_reference", | |
| 1064 | + "type": "text", | |
| 1065 | + "primaryKey": false, | |
| 1066 | + "notNull": true | |
| 1067 | + }, | |
| 1068 | + "duration_ms": { | |
| 1069 | + "name": "duration_ms", | |
| 1070 | + "type": "integer", | |
| 1071 | + "primaryKey": false, | |
| 1072 | + "notNull": false | |
| 1073 | + }, | |
| 1074 | + "created_at": { | |
| 1075 | + "name": "created_at", | |
| 1076 | + "type": "timestamp with time zone", | |
| 1077 | + "primaryKey": false, | |
| 1078 | + "notNull": true, | |
| 1079 | + "default": "now()" | |
| 1080 | + } | |
| 1081 | + }, | |
| 1082 | + "indexes": { | |
| 1083 | + "game_rounds_round_id_idx": { | |
| 1084 | + "name": "game_rounds_round_id_idx", | |
| 1085 | + "columns": [ | |
| 1086 | + { | |
| 1087 | + "expression": "round_id", | |
| 1088 | + "isExpression": false, | |
| 1089 | + "asc": true, | |
| 1090 | + "nulls": "last" | |
| 1091 | + } | |
| 1092 | + ], | |
| 1093 | + "isUnique": true, | |
| 1094 | + "concurrently": false, | |
| 1095 | + "method": "btree", | |
| 1096 | + "with": {} | |
| 1097 | + }, | |
| 1098 | + "game_rounds_user_client_idx": { | |
| 1099 | + "name": "game_rounds_user_client_idx", | |
| 1100 | + "columns": [ | |
| 1101 | + { | |
| 1102 | + "expression": "user_id", | |
| 1103 | + "isExpression": false, | |
| 1104 | + "asc": true, | |
| 1105 | + "nulls": "last" | |
| 1106 | + }, | |
| 1107 | + { | |
| 1108 | + "expression": "client_round_id", | |
| 1109 | + "isExpression": false, | |
| 1110 | + "asc": true, | |
| 1111 | + "nulls": "last" | |
| 1112 | + } | |
| 1113 | + ], | |
| 1114 | + "isUnique": true, | |
| 1115 | + "concurrently": false, | |
| 1116 | + "method": "btree", | |
| 1117 | + "with": {} | |
| 1118 | + }, | |
| 1119 | + "game_rounds_user_created_idx": { | |
| 1120 | + "name": "game_rounds_user_created_idx", | |
| 1121 | + "columns": [ | |
| 1122 | + { | |
| 1123 | + "expression": "user_id", | |
| 1124 | + "isExpression": false, | |
| 1125 | + "asc": true, | |
| 1126 | + "nulls": "last" | |
| 1127 | + }, | |
| 1128 | + { | |
| 1129 | + "expression": "created_at", | |
| 1130 | + "isExpression": false, | |
| 1131 | + "asc": true, | |
| 1132 | + "nulls": "last" | |
| 1133 | + } | |
| 1134 | + ], | |
| 1135 | + "isUnique": false, | |
| 1136 | + "concurrently": false, | |
| 1137 | + "method": "btree", | |
| 1138 | + "with": {} | |
| 1139 | + }, | |
| 1140 | + "game_rounds_game_created_idx": { | |
| 1141 | + "name": "game_rounds_game_created_idx", | |
| 1142 | + "columns": [ | |
| 1143 | + { | |
| 1144 | + "expression": "game_id", | |
| 1145 | + "isExpression": false, | |
| 1146 | + "asc": true, | |
| 1147 | + "nulls": "last" | |
| 1148 | + }, | |
| 1149 | + { | |
| 1150 | + "expression": "created_at", | |
| 1151 | + "isExpression": false, | |
| 1152 | + "asc": true, | |
| 1153 | + "nulls": "last" | |
| 1154 | + } | |
| 1155 | + ], | |
| 1156 | + "isUnique": false, | |
| 1157 | + "concurrently": false, | |
| 1158 | + "method": "btree", | |
| 1159 | + "with": {} | |
| 1160 | + }, | |
| 1161 | + "game_rounds_created_idx": { | |
| 1162 | + "name": "game_rounds_created_idx", | |
| 1163 | + "columns": [ | |
| 1164 | + { | |
| 1165 | + "expression": "created_at", | |
| 1166 | + "isExpression": false, | |
| 1167 | + "asc": true, | |
| 1168 | + "nulls": "last" | |
| 1169 | + } | |
| 1170 | + ], | |
| 1171 | + "isUnique": false, | |
| 1172 | + "concurrently": false, | |
| 1173 | + "method": "btree", | |
| 1174 | + "with": {} | |
| 1175 | + }, | |
| 1176 | + "game_rounds_win_idx": { | |
| 1177 | + "name": "game_rounds_win_idx", | |
| 1178 | + "columns": [ | |
| 1179 | + { | |
| 1180 | + "expression": "win", | |
| 1181 | + "isExpression": false, | |
| 1182 | + "asc": true, | |
| 1183 | + "nulls": "last" | |
| 1184 | + } | |
| 1185 | + ], | |
| 1186 | + "isUnique": false, | |
| 1187 | + "concurrently": false, | |
| 1188 | + "method": "btree", | |
| 1189 | + "with": {} | |
| 1190 | + } | |
| 1191 | + }, | |
| 1192 | + "foreignKeys": { | |
| 1193 | + "game_rounds_user_id_users_id_fk": { | |
| 1194 | + "name": "game_rounds_user_id_users_id_fk", | |
| 1195 | + "tableFrom": "game_rounds", | |
| 1196 | + "tableTo": "users", | |
| 1197 | + "columnsFrom": [ | |
| 1198 | + "user_id" | |
| 1199 | + ], | |
| 1200 | + "columnsTo": [ | |
| 1201 | + "id" | |
| 1202 | + ], | |
| 1203 | + "onDelete": "cascade", | |
| 1204 | + "onUpdate": "no action" | |
| 1205 | + }, | |
| 1206 | + "game_rounds_game_id_games_id_fk": { | |
| 1207 | + "name": "game_rounds_game_id_games_id_fk", | |
| 1208 | + "tableFrom": "game_rounds", | |
| 1209 | + "tableTo": "games", | |
| 1210 | + "columnsFrom": [ | |
| 1211 | + "game_id" | |
| 1212 | + ], | |
| 1213 | + "columnsTo": [ | |
| 1214 | + "id" | |
| 1215 | + ], | |
| 1216 | + "onDelete": "no action", | |
| 1217 | + "onUpdate": "no action" | |
| 1218 | + } | |
| 1219 | + }, | |
| 1220 | + "compositePrimaryKeys": {}, | |
| 1221 | + "uniqueConstraints": {}, | |
| 1222 | + "policies": {}, | |
| 1223 | + "checkConstraints": {}, | |
| 1224 | + "isRLSEnabled": false | |
| 1225 | + }, | |
| 1226 | + "public.game_states": { | |
| 1227 | + "name": "game_states", | |
| 1228 | + "schema": "", | |
| 1229 | + "columns": { | |
| 1230 | + "user_id": { | |
| 1231 | + "name": "user_id", | |
| 1232 | + "type": "uuid", | |
| 1233 | + "primaryKey": false, | |
| 1234 | + "notNull": true | |
| 1235 | + }, | |
| 1236 | + "game_id": { | |
| 1237 | + "name": "game_id", | |
| 1238 | + "type": "uuid", | |
| 1239 | + "primaryKey": false, | |
| 1240 | + "notNull": true | |
| 1241 | + }, | |
| 1242 | + "state": { | |
| 1243 | + "name": "state", | |
| 1244 | + "type": "jsonb", | |
| 1245 | + "primaryKey": false, | |
| 1246 | + "notNull": true | |
| 1247 | + }, | |
| 1248 | + "updated_at": { | |
| 1249 | + "name": "updated_at", | |
| 1250 | + "type": "timestamp with time zone", | |
| 1251 | + "primaryKey": false, | |
| 1252 | + "notNull": true, | |
| 1253 | + "default": "now()" | |
| 1254 | + } | |
| 1255 | + }, | |
| 1256 | + "indexes": {}, | |
| 1257 | + "foreignKeys": { | |
| 1258 | + "game_states_user_id_users_id_fk": { | |
| 1259 | + "name": "game_states_user_id_users_id_fk", | |
| 1260 | + "tableFrom": "game_states", | |
| 1261 | + "tableTo": "users", | |
| 1262 | + "columnsFrom": [ | |
| 1263 | + "user_id" | |
| 1264 | + ], | |
| 1265 | + "columnsTo": [ | |
| 1266 | + "id" | |
| 1267 | + ], | |
| 1268 | + "onDelete": "cascade", | |
| 1269 | + "onUpdate": "no action" | |
| 1270 | + }, | |
| 1271 | + "game_states_game_id_games_id_fk": { | |
| 1272 | + "name": "game_states_game_id_games_id_fk", | |
| 1273 | + "tableFrom": "game_states", | |
| 1274 | + "tableTo": "games", | |
| 1275 | + "columnsFrom": [ | |
| 1276 | + "game_id" | |
| 1277 | + ], | |
| 1278 | + "columnsTo": [ | |
| 1279 | + "id" | |
| 1280 | + ], | |
| 1281 | + "onDelete": "cascade", | |
| 1282 | + "onUpdate": "no action" | |
| 1283 | + } | |
| 1284 | + }, | |
| 1285 | + "compositePrimaryKeys": { | |
| 1286 | + "game_states_user_id_game_id_pk": { | |
| 1287 | + "name": "game_states_user_id_game_id_pk", | |
| 1288 | + "columns": [ | |
| 1289 | + "user_id", | |
| 1290 | + "game_id" | |
| 1291 | + ] | |
| 1292 | + } | |
| 1293 | + }, | |
| 1294 | + "uniqueConstraints": {}, | |
| 1295 | + "policies": {}, | |
| 1296 | + "checkConstraints": {}, | |
| 1297 | + "isRLSEnabled": false | |
| 1298 | + }, | |
| 1299 | + "public.game_statistics": { | |
| 1300 | + "name": "game_statistics", | |
| 1301 | + "schema": "", | |
| 1302 | + "columns": { | |
| 1303 | + "game_id": { | |
| 1304 | + "name": "game_id", | |
| 1305 | + "type": "uuid", | |
| 1306 | + "primaryKey": true, | |
| 1307 | + "notNull": true | |
| 1308 | + }, | |
| 1309 | + "launches": { | |
| 1310 | + "name": "launches", | |
| 1311 | + "type": "bigint", | |
| 1312 | + "primaryKey": false, | |
| 1313 | + "notNull": true, | |
| 1314 | + "default": 0 | |
| 1315 | + }, | |
| 1316 | + "spins": { | |
| 1317 | + "name": "spins", | |
| 1318 | + "type": "bigint", | |
| 1319 | + "primaryKey": false, | |
| 1320 | + "notNull": true, | |
| 1321 | + "default": 0 | |
| 1322 | + }, | |
| 1323 | + "wagered": { | |
| 1324 | + "name": "wagered", | |
| 1325 | + "type": "bigint", | |
| 1326 | + "primaryKey": false, | |
| 1327 | + "notNull": true, | |
| 1328 | + "default": 0 | |
| 1329 | + }, | |
| 1330 | + "won": { | |
| 1331 | + "name": "won", | |
| 1332 | + "type": "bigint", | |
| 1333 | + "primaryKey": false, | |
| 1334 | + "notNull": true, | |
| 1335 | + "default": 0 | |
| 1336 | + }, | |
| 1337 | + "wins": { | |
| 1338 | + "name": "wins", | |
| 1339 | + "type": "bigint", | |
| 1340 | + "primaryKey": false, | |
| 1341 | + "notNull": true, | |
| 1342 | + "default": 0 | |
| 1343 | + }, | |
| 1344 | + "bonuses": { | |
| 1345 | + "name": "bonuses", | |
| 1346 | + "type": "bigint", | |
| 1347 | + "primaryKey": false, | |
| 1348 | + "notNull": true, | |
| 1349 | + "default": 0 | |
| 1350 | + }, | |
| 1351 | + "free_spins": { | |
| 1352 | + "name": "free_spins", | |
| 1353 | + "type": "bigint", | |
| 1354 | + "primaryKey": false, | |
| 1355 | + "notNull": true, | |
| 1356 | + "default": 0 | |
| 1357 | + }, | |
| 1358 | + "big_wins": { | |
| 1359 | + "name": "big_wins", | |
| 1360 | + "type": "bigint", | |
| 1361 | + "primaryKey": false, | |
| 1362 | + "notNull": true, | |
| 1363 | + "default": 0 | |
| 1364 | + }, | |
| 1365 | + "max_win": { | |
| 1366 | + "name": "max_win", | |
| 1367 | + "type": "bigint", | |
| 1368 | + "primaryKey": false, | |
| 1369 | + "notNull": true, | |
| 1370 | + "default": 0 | |
| 1371 | + }, | |
| 1372 | + "max_multiplier": { | |
| 1373 | + "name": "max_multiplier", | |
| 1374 | + "type": "numeric(12, 2)", | |
| 1375 | + "primaryKey": false, | |
| 1376 | + "notNull": true, | |
| 1377 | + "default": "'0'" | |
| 1378 | + }, | |
| 1379 | + "favorites": { | |
| 1380 | + "name": "favorites", | |
| 1381 | + "type": "integer", | |
| 1382 | + "primaryKey": false, | |
| 1383 | + "notNull": true, | |
| 1384 | + "default": 0 | |
| 1385 | + }, | |
| 1386 | + "updated_at": { | |
| 1387 | + "name": "updated_at", | |
| 1388 | + "type": "timestamp with time zone", | |
| 1389 | + "primaryKey": false, | |
| 1390 | + "notNull": true, | |
| 1391 | + "default": "now()" | |
| 1392 | + } | |
| 1393 | + }, | |
| 1394 | + "indexes": {}, | |
| 1395 | + "foreignKeys": { | |
| 1396 | + "game_statistics_game_id_games_id_fk": { | |
| 1397 | + "name": "game_statistics_game_id_games_id_fk", | |
| 1398 | + "tableFrom": "game_statistics", | |
| 1399 | + "tableTo": "games", | |
| 1400 | + "columnsFrom": [ | |
| 1401 | + "game_id" | |
| 1402 | + ], | |
| 1403 | + "columnsTo": [ | |
| 1404 | + "id" | |
| 1405 | + ], | |
| 1406 | + "onDelete": "cascade", | |
| 1407 | + "onUpdate": "no action" | |
| 1408 | + } | |
| 1409 | + }, | |
| 1410 | + "compositePrimaryKeys": {}, | |
| 1411 | + "uniqueConstraints": {}, | |
| 1412 | + "policies": {}, | |
| 1413 | + "checkConstraints": {}, | |
| 1414 | + "isRLSEnabled": false | |
| 1415 | + }, | |
| 1416 | + "public.game_versions": { | |
| 1417 | + "name": "game_versions", | |
| 1418 | + "schema": "", | |
| 1419 | + "columns": { | |
| 1420 | + "id": { | |
| 1421 | + "name": "id", | |
| 1422 | + "type": "uuid", | |
| 1423 | + "primaryKey": true, | |
| 1424 | + "notNull": true, | |
| 1425 | + "default": "gen_random_uuid()" | |
| 1426 | + }, | |
| 1427 | + "game_id": { | |
| 1428 | + "name": "game_id", | |
| 1429 | + "type": "uuid", | |
| 1430 | + "primaryKey": false, | |
| 1431 | + "notNull": true | |
| 1432 | + }, | |
| 1433 | + "version": { | |
| 1434 | + "name": "version", | |
| 1435 | + "type": "varchar(16)", | |
| 1436 | + "primaryKey": false, | |
| 1437 | + "notNull": true | |
| 1438 | + }, | |
| 1439 | + "definition": { | |
| 1440 | + "name": "definition", | |
| 1441 | + "type": "jsonb", | |
| 1442 | + "primaryKey": false, | |
| 1443 | + "notNull": true | |
| 1444 | + }, | |
| 1445 | + "definition_hash": { | |
| 1446 | + "name": "definition_hash", | |
| 1447 | + "type": "varchar(64)", | |
| 1448 | + "primaryKey": false, | |
| 1449 | + "notNull": true | |
| 1450 | + }, | |
| 1451 | + "certification": { | |
| 1452 | + "name": "certification", | |
| 1453 | + "type": "jsonb", | |
| 1454 | + "primaryKey": false, | |
| 1455 | + "notNull": false | |
| 1456 | + }, | |
| 1457 | + "status": { | |
| 1458 | + "name": "status", | |
| 1459 | + "type": "varchar(16)", | |
| 1460 | + "primaryKey": false, | |
| 1461 | + "notNull": true, | |
| 1462 | + "default": "'draft'" | |
| 1463 | + }, | |
| 1464 | + "created_at": { | |
| 1465 | + "name": "created_at", | |
| 1466 | + "type": "timestamp with time zone", | |
| 1467 | + "primaryKey": false, | |
| 1468 | + "notNull": true, | |
| 1469 | + "default": "now()" | |
| 1470 | + } | |
| 1471 | + }, | |
| 1472 | + "indexes": { | |
| 1473 | + "game_versions_game_version_idx": { | |
| 1474 | + "name": "game_versions_game_version_idx", | |
| 1475 | + "columns": [ | |
| 1476 | + { | |
| 1477 | + "expression": "game_id", | |
| 1478 | + "isExpression": false, | |
| 1479 | + "asc": true, | |
| 1480 | + "nulls": "last" | |
| 1481 | + }, | |
| 1482 | + { | |
| 1483 | + "expression": "version", | |
| 1484 | + "isExpression": false, | |
| 1485 | + "asc": true, | |
| 1486 | + "nulls": "last" | |
| 1487 | + } | |
| 1488 | + ], | |
| 1489 | + "isUnique": true, | |
| 1490 | + "concurrently": false, | |
| 1491 | + "method": "btree", | |
| 1492 | + "with": {} | |
| 1493 | + } | |
| 1494 | + }, | |
| 1495 | + "foreignKeys": { | |
| 1496 | + "game_versions_game_id_games_id_fk": { | |
| 1497 | + "name": "game_versions_game_id_games_id_fk", | |
| 1498 | + "tableFrom": "game_versions", | |
| 1499 | + "tableTo": "games", | |
| 1500 | + "columnsFrom": [ | |
| 1501 | + "game_id" | |
| 1502 | + ], | |
| 1503 | + "columnsTo": [ | |
| 1504 | + "id" | |
| 1505 | + ], | |
| 1506 | + "onDelete": "cascade", | |
| 1507 | + "onUpdate": "no action" | |
| 1508 | + } | |
| 1509 | + }, | |
| 1510 | + "compositePrimaryKeys": {}, | |
| 1511 | + "uniqueConstraints": {}, | |
| 1512 | + "policies": {}, | |
| 1513 | + "checkConstraints": {}, | |
| 1514 | + "isRLSEnabled": false | |
| 1515 | + }, | |
| 1516 | + "public.games": { | |
| 1517 | + "name": "games", | |
| 1518 | + "schema": "", | |
| 1519 | + "columns": { | |
| 1520 | + "id": { | |
| 1521 | + "name": "id", | |
| 1522 | + "type": "uuid", | |
| 1523 | + "primaryKey": true, | |
| 1524 | + "notNull": true, | |
| 1525 | + "default": "gen_random_uuid()" | |
| 1526 | + }, | |
| 1527 | + "slug": { | |
| 1528 | + "name": "slug", | |
| 1529 | + "type": "varchar(64)", | |
| 1530 | + "primaryKey": false, | |
| 1531 | + "notNull": true | |
| 1532 | + }, | |
| 1533 | + "name": { | |
| 1534 | + "name": "name", | |
| 1535 | + "type": "varchar(80)", | |
| 1536 | + "primaryKey": false, | |
| 1537 | + "notNull": true | |
| 1538 | + }, | |
| 1539 | + "version": { | |
| 1540 | + "name": "version", | |
| 1541 | + "type": "varchar(16)", | |
| 1542 | + "primaryKey": false, | |
| 1543 | + "notNull": true | |
| 1544 | + }, | |
| 1545 | + "lifecycle": { | |
| 1546 | + "name": "lifecycle", | |
| 1547 | + "type": "varchar(16)", | |
| 1548 | + "primaryKey": false, | |
| 1549 | + "notNull": true, | |
| 1550 | + "default": "'draft'" | |
| 1551 | + }, | |
| 1552 | + "sort_order": { | |
| 1553 | + "name": "sort_order", | |
| 1554 | + "type": "integer", | |
| 1555 | + "primaryKey": false, | |
| 1556 | + "notNull": true, | |
| 1557 | + "default": 0 | |
| 1558 | + }, | |
| 1559 | + "is_featured": { | |
| 1560 | + "name": "is_featured", | |
| 1561 | + "type": "boolean", | |
| 1562 | + "primaryKey": false, | |
| 1563 | + "notNull": true, | |
| 1564 | + "default": false | |
| 1565 | + }, | |
| 1566 | + "is_new": { | |
| 1567 | + "name": "is_new", | |
| 1568 | + "type": "boolean", | |
| 1569 | + "primaryKey": false, | |
| 1570 | + "notNull": true, | |
| 1571 | + "default": true | |
| 1572 | + }, | |
| 1573 | + "summary": { | |
| 1574 | + "name": "summary", | |
| 1575 | + "type": "jsonb", | |
| 1576 | + "primaryKey": false, | |
| 1577 | + "notNull": true | |
| 1578 | + }, | |
| 1579 | + "created_at": { | |
| 1580 | + "name": "created_at", | |
| 1581 | + "type": "timestamp with time zone", | |
| 1582 | + "primaryKey": false, | |
| 1583 | + "notNull": true, | |
| 1584 | + "default": "now()" | |
| 1585 | + }, | |
| 1586 | + "published_at": { | |
| 1587 | + "name": "published_at", | |
| 1588 | + "type": "timestamp with time zone", | |
| 1589 | + "primaryKey": false, | |
| 1590 | + "notNull": false | |
| 1591 | + }, | |
| 1592 | + "updated_at": { | |
| 1593 | + "name": "updated_at", | |
| 1594 | + "type": "timestamp with time zone", | |
| 1595 | + "primaryKey": false, | |
| 1596 | + "notNull": true, | |
| 1597 | + "default": "now()" | |
| 1598 | + } | |
| 1599 | + }, | |
| 1600 | + "indexes": { | |
| 1601 | + "games_slug_idx": { | |
| 1602 | + "name": "games_slug_idx", | |
| 1603 | + "columns": [ | |
| 1604 | + { | |
| 1605 | + "expression": "slug", | |
| 1606 | + "isExpression": false, | |
| 1607 | + "asc": true, | |
| 1608 | + "nulls": "last" | |
| 1609 | + } | |
| 1610 | + ], | |
| 1611 | + "isUnique": true, | |
| 1612 | + "concurrently": false, | |
| 1613 | + "method": "btree", | |
| 1614 | + "with": {} | |
| 1615 | + } | |
| 1616 | + }, | |
| 1617 | + "foreignKeys": {}, | |
| 1618 | + "compositePrimaryKeys": {}, | |
| 1619 | + "uniqueConstraints": {}, | |
| 1620 | + "policies": {}, | |
| 1621 | + "checkConstraints": {}, | |
| 1622 | + "isRLSEnabled": false | |
| 1623 | + }, | |
| 1624 | + "public.leaderboards": { | |
| 1625 | + "name": "leaderboards", | |
| 1626 | + "schema": "", | |
| 1627 | + "columns": { | |
| 1628 | + "id": { | |
| 1629 | + "name": "id", | |
| 1630 | + "type": "uuid", | |
| 1631 | + "primaryKey": true, | |
| 1632 | + "notNull": true, | |
| 1633 | + "default": "gen_random_uuid()" | |
| 1634 | + }, | |
| 1635 | + "category": { | |
| 1636 | + "name": "category", | |
| 1637 | + "type": "varchar(32)", | |
| 1638 | + "primaryKey": false, | |
| 1639 | + "notNull": true | |
| 1640 | + }, | |
| 1641 | + "period_key": { | |
| 1642 | + "name": "period_key", | |
| 1643 | + "type": "varchar(16)", | |
| 1644 | + "primaryKey": false, | |
| 1645 | + "notNull": true | |
| 1646 | + }, | |
| 1647 | + "user_id": { | |
| 1648 | + "name": "user_id", | |
| 1649 | + "type": "uuid", | |
| 1650 | + "primaryKey": false, | |
| 1651 | + "notNull": true | |
| 1652 | + }, | |
| 1653 | + "value": { | |
| 1654 | + "name": "value", | |
| 1655 | + "type": "numeric(18, 2)", | |
| 1656 | + "primaryKey": false, | |
| 1657 | + "notNull": true | |
| 1658 | + }, | |
| 1659 | + "game_slug": { | |
| 1660 | + "name": "game_slug", | |
| 1661 | + "type": "varchar(64)", | |
| 1662 | + "primaryKey": false, | |
| 1663 | + "notNull": false | |
| 1664 | + }, | |
| 1665 | + "round_id": { | |
| 1666 | + "name": "round_id", | |
| 1667 | + "type": "varchar(32)", | |
| 1668 | + "primaryKey": false, | |
| 1669 | + "notNull": false | |
| 1670 | + }, | |
| 1671 | + "updated_at": { | |
| 1672 | + "name": "updated_at", | |
| 1673 | + "type": "timestamp with time zone", | |
| 1674 | + "primaryKey": false, | |
| 1675 | + "notNull": true, | |
| 1676 | + "default": "now()" | |
| 1677 | + } | |
| 1678 | + }, | |
| 1679 | + "indexes": { | |
| 1680 | + "leaderboards_unique_idx": { | |
| 1681 | + "name": "leaderboards_unique_idx", | |
| 1682 | + "columns": [ | |
| 1683 | + { | |
| 1684 | + "expression": "category", | |
| 1685 | + "isExpression": false, | |
| 1686 | + "asc": true, | |
| 1687 | + "nulls": "last" | |
| 1688 | + }, | |
| 1689 | + { | |
| 1690 | + "expression": "period_key", | |
| 1691 | + "isExpression": false, | |
| 1692 | + "asc": true, | |
| 1693 | + "nulls": "last" | |
| 1694 | + }, | |
| 1695 | + { | |
| 1696 | + "expression": "user_id", | |
| 1697 | + "isExpression": false, | |
| 1698 | + "asc": true, | |
| 1699 | + "nulls": "last" | |
| 1700 | + } | |
| 1701 | + ], | |
| 1702 | + "isUnique": true, | |
| 1703 | + "concurrently": false, | |
| 1704 | + "method": "btree", | |
| 1705 | + "with": {} | |
| 1706 | + }, | |
| 1707 | + "leaderboards_rank_idx": { | |
| 1708 | + "name": "leaderboards_rank_idx", | |
| 1709 | + "columns": [ | |
| 1710 | + { | |
| 1711 | + "expression": "category", | |
| 1712 | + "isExpression": false, | |
| 1713 | + "asc": true, | |
| 1714 | + "nulls": "last" | |
| 1715 | + }, | |
| 1716 | + { | |
| 1717 | + "expression": "period_key", | |
| 1718 | + "isExpression": false, | |
| 1719 | + "asc": true, | |
| 1720 | + "nulls": "last" | |
| 1721 | + }, | |
| 1722 | + { | |
| 1723 | + "expression": "value", | |
| 1724 | + "isExpression": false, | |
| 1725 | + "asc": true, | |
| 1726 | + "nulls": "last" | |
| 1727 | + } | |
| 1728 | + ], | |
| 1729 | + "isUnique": false, | |
| 1730 | + "concurrently": false, | |
| 1731 | + "method": "btree", | |
| 1732 | + "with": {} | |
| 1733 | + } | |
| 1734 | + }, | |
| 1735 | + "foreignKeys": { | |
| 1736 | + "leaderboards_user_id_users_id_fk": { | |
| 1737 | + "name": "leaderboards_user_id_users_id_fk", | |
| 1738 | + "tableFrom": "leaderboards", | |
| 1739 | + "tableTo": "users", | |
| 1740 | + "columnsFrom": [ | |
| 1741 | + "user_id" | |
| 1742 | + ], | |
| 1743 | + "columnsTo": [ | |
| 1744 | + "id" | |
| 1745 | + ], | |
| 1746 | + "onDelete": "cascade", | |
| 1747 | + "onUpdate": "no action" | |
| 1748 | + } | |
| 1749 | + }, | |
| 1750 | + "compositePrimaryKeys": {}, | |
| 1751 | + "uniqueConstraints": {}, | |
| 1752 | + "policies": {}, | |
| 1753 | + "checkConstraints": {}, | |
| 1754 | + "isRLSEnabled": false | |
| 1755 | + }, | |
| 1756 | + "public.missions": { | |
| 1757 | + "name": "missions", | |
| 1758 | + "schema": "", | |
| 1759 | + "columns": { | |
| 1760 | + "key": { | |
| 1761 | + "name": "key", | |
| 1762 | + "type": "varchar(48)", | |
| 1763 | + "primaryKey": true, | |
| 1764 | + "notNull": true | |
| 1765 | + }, | |
| 1766 | + "name": { | |
| 1767 | + "name": "name", | |
| 1768 | + "type": "varchar(80)", | |
| 1769 | + "primaryKey": false, | |
| 1770 | + "notNull": true | |
| 1771 | + }, | |
| 1772 | + "description": { | |
| 1773 | + "name": "description", | |
| 1774 | + "type": "text", | |
| 1775 | + "primaryKey": false, | |
| 1776 | + "notNull": true | |
| 1777 | + }, | |
| 1778 | + "period": { | |
| 1779 | + "name": "period", | |
| 1780 | + "type": "varchar(8)", | |
| 1781 | + "primaryKey": false, | |
| 1782 | + "notNull": true | |
| 1783 | + }, | |
| 1784 | + "metric": { | |
| 1785 | + "name": "metric", | |
| 1786 | + "type": "varchar(32)", | |
| 1787 | + "primaryKey": false, | |
| 1788 | + "notNull": true | |
| 1789 | + }, | |
| 1790 | + "target": { | |
| 1791 | + "name": "target", | |
| 1792 | + "type": "bigint", | |
| 1793 | + "primaryKey": false, | |
| 1794 | + "notNull": true | |
| 1795 | + }, | |
| 1796 | + "reward_credits": { | |
| 1797 | + "name": "reward_credits", | |
| 1798 | + "type": "integer", | |
| 1799 | + "primaryKey": false, | |
| 1800 | + "notNull": true, | |
| 1801 | + "default": 0 | |
| 1802 | + }, | |
| 1803 | + "reward_xp": { | |
| 1804 | + "name": "reward_xp", | |
| 1805 | + "type": "integer", | |
| 1806 | + "primaryKey": false, | |
| 1807 | + "notNull": true, | |
| 1808 | + "default": 0 | |
| 1809 | + }, | |
| 1810 | + "enabled": { | |
| 1811 | + "name": "enabled", | |
| 1812 | + "type": "boolean", | |
| 1813 | + "primaryKey": false, | |
| 1814 | + "notNull": true, | |
| 1815 | + "default": true | |
| 1816 | + }, | |
| 1817 | + "sort_order": { | |
| 1818 | + "name": "sort_order", | |
| 1819 | + "type": "integer", | |
| 1820 | + "primaryKey": false, | |
| 1821 | + "notNull": true, | |
| 1822 | + "default": 0 | |
| 1823 | + } | |
| 1824 | + }, | |
| 1825 | + "indexes": {}, | |
| 1826 | + "foreignKeys": {}, | |
| 1827 | + "compositePrimaryKeys": {}, | |
| 1828 | + "uniqueConstraints": {}, | |
| 1829 | + "policies": {}, | |
| 1830 | + "checkConstraints": {}, | |
| 1831 | + "isRLSEnabled": false | |
| 1832 | + }, | |
| 1833 | + "public.platform_settings": { | |
| 1834 | + "name": "platform_settings", | |
| 1835 | + "schema": "", | |
| 1836 | + "columns": { | |
| 1837 | + "key": { | |
| 1838 | + "name": "key", | |
| 1839 | + "type": "varchar(64)", | |
| 1840 | + "primaryKey": true, | |
| 1841 | + "notNull": true | |
| 1842 | + }, | |
| 1843 | + "value": { | |
| 1844 | + "name": "value", | |
| 1845 | + "type": "jsonb", | |
| 1846 | + "primaryKey": false, | |
| 1847 | + "notNull": true | |
| 1848 | + }, | |
| 1849 | + "updated_at": { | |
| 1850 | + "name": "updated_at", | |
| 1851 | + "type": "timestamp with time zone", | |
| 1852 | + "primaryKey": false, | |
| 1853 | + "notNull": true, | |
| 1854 | + "default": "now()" | |
| 1855 | + } | |
| 1856 | + }, | |
| 1857 | + "indexes": {}, | |
| 1858 | + "foreignKeys": {}, | |
| 1859 | + "compositePrimaryKeys": {}, | |
| 1860 | + "uniqueConstraints": {}, | |
| 1861 | + "policies": {}, | |
| 1862 | + "checkConstraints": {}, | |
| 1863 | + "isRLSEnabled": false | |
| 1864 | + }, | |
| 1865 | + "public.player_levels": { | |
| 1866 | + "name": "player_levels", | |
| 1867 | + "schema": "", | |
| 1868 | + "columns": { | |
| 1869 | + "level": { | |
| 1870 | + "name": "level", | |
| 1871 | + "type": "integer", | |
| 1872 | + "primaryKey": true, | |
| 1873 | + "notNull": true | |
| 1874 | + }, | |
| 1875 | + "xp_required": { | |
| 1876 | + "name": "xp_required", | |
| 1877 | + "type": "bigint", | |
| 1878 | + "primaryKey": false, | |
| 1879 | + "notNull": true | |
| 1880 | + }, | |
| 1881 | + "xp_cumulative": { | |
| 1882 | + "name": "xp_cumulative", | |
| 1883 | + "type": "bigint", | |
| 1884 | + "primaryKey": false, | |
| 1885 | + "notNull": true | |
| 1886 | + }, | |
| 1887 | + "reward_credits": { | |
| 1888 | + "name": "reward_credits", | |
| 1889 | + "type": "integer", | |
| 1890 | + "primaryKey": false, | |
| 1891 | + "notNull": true | |
| 1892 | + }, | |
| 1893 | + "title": { | |
| 1894 | + "name": "title", | |
| 1895 | + "type": "varchar(48)", | |
| 1896 | + "primaryKey": false, | |
| 1897 | + "notNull": true | |
| 1898 | + } | |
| 1899 | + }, | |
| 1900 | + "indexes": {}, | |
| 1901 | + "foreignKeys": {}, | |
| 1902 | + "compositePrimaryKeys": {}, | |
| 1903 | + "uniqueConstraints": {}, | |
| 1904 | + "policies": {}, | |
| 1905 | + "checkConstraints": {}, | |
| 1906 | + "isRLSEnabled": false | |
| 1907 | + }, | |
| 1908 | + "public.recovery_codes": { | |
| 1909 | + "name": "recovery_codes", | |
| 1910 | + "schema": "", | |
| 1911 | + "columns": { | |
| 1912 | + "user_id": { | |
| 1913 | + "name": "user_id", | |
| 1914 | + "type": "uuid", | |
| 1915 | + "primaryKey": true, | |
| 1916 | + "notNull": true | |
| 1917 | + }, | |
| 1918 | + "code_hash": { | |
| 1919 | + "name": "code_hash", | |
| 1920 | + "type": "text", | |
| 1921 | + "primaryKey": false, | |
| 1922 | + "notNull": true | |
| 1923 | + }, | |
| 1924 | + "created_at": { | |
| 1925 | + "name": "created_at", | |
| 1926 | + "type": "timestamp with time zone", | |
| 1927 | + "primaryKey": false, | |
| 1928 | + "notNull": true, | |
| 1929 | + "default": "now()" | |
| 1930 | + }, | |
| 1931 | + "rotated_at": { | |
| 1932 | + "name": "rotated_at", | |
| 1933 | + "type": "timestamp with time zone", | |
| 1934 | + "primaryKey": false, | |
| 1935 | + "notNull": false | |
| 1936 | + }, | |
| 1937 | + "used_at": { | |
| 1938 | + "name": "used_at", | |
| 1939 | + "type": "timestamp with time zone", | |
| 1940 | + "primaryKey": false, | |
| 1941 | + "notNull": false | |
| 1942 | + }, | |
| 1943 | + "use_count": { | |
| 1944 | + "name": "use_count", | |
| 1945 | + "type": "integer", | |
| 1946 | + "primaryKey": false, | |
| 1947 | + "notNull": true, | |
| 1948 | + "default": 0 | |
| 1949 | + } | |
| 1950 | + }, | |
| 1951 | + "indexes": {}, | |
| 1952 | + "foreignKeys": { | |
| 1953 | + "recovery_codes_user_id_users_id_fk": { | |
| 1954 | + "name": "recovery_codes_user_id_users_id_fk", | |
| 1955 | + "tableFrom": "recovery_codes", | |
| 1956 | + "tableTo": "users", | |
| 1957 | + "columnsFrom": [ | |
| 1958 | + "user_id" | |
| 1959 | + ], | |
| 1960 | + "columnsTo": [ | |
| 1961 | + "id" | |
| 1962 | + ], | |
| 1963 | + "onDelete": "cascade", | |
| 1964 | + "onUpdate": "no action" | |
| 1965 | + } | |
| 1966 | + }, | |
| 1967 | + "compositePrimaryKeys": {}, | |
| 1968 | + "uniqueConstraints": {}, | |
| 1969 | + "policies": {}, | |
| 1970 | + "checkConstraints": {}, | |
| 1971 | + "isRLSEnabled": false | |
| 1972 | + }, | |
| 1973 | + "public.security_events": { | |
| 1974 | + "name": "security_events", | |
| 1975 | + "schema": "", | |
| 1976 | + "columns": { | |
| 1977 | + "id": { | |
| 1978 | + "name": "id", | |
| 1979 | + "type": "uuid", | |
| 1980 | + "primaryKey": true, | |
| 1981 | + "notNull": true, | |
| 1982 | + "default": "gen_random_uuid()" | |
| 1983 | + }, | |
| 1984 | + "user_id": { | |
| 1985 | + "name": "user_id", | |
| 1986 | + "type": "uuid", | |
| 1987 | + "primaryKey": false, | |
| 1988 | + "notNull": false | |
| 1989 | + }, | |
| 1990 | + "admin_id": { | |
| 1991 | + "name": "admin_id", | |
| 1992 | + "type": "uuid", | |
| 1993 | + "primaryKey": false, | |
| 1994 | + "notNull": false | |
| 1995 | + }, | |
| 1996 | + "type": { | |
| 1997 | + "name": "type", | |
| 1998 | + "type": "varchar(48)", | |
| 1999 | + "primaryKey": false, | |
| 2000 | + "notNull": true | |
| 2001 | + }, | |
| 2002 | + "severity": { | |
| 2003 | + "name": "severity", | |
| 2004 | + "type": "varchar(8)", | |
| 2005 | + "primaryKey": false, | |
| 2006 | + "notNull": true, | |
| 2007 | + "default": "'info'" | |
| 2008 | + }, | |
| 2009 | + "ip": { | |
| 2010 | + "name": "ip", | |
| 2011 | + "type": "varchar(64)", | |
| 2012 | + "primaryKey": false, | |
| 2013 | + "notNull": false | |
| 2014 | + }, | |
| 2015 | + "user_agent": { | |
| 2016 | + "name": "user_agent", | |
| 2017 | + "type": "text", | |
| 2018 | + "primaryKey": false, | |
| 2019 | + "notNull": false | |
| 2020 | + }, | |
| 2021 | + "meta": { | |
| 2022 | + "name": "meta", | |
| 2023 | + "type": "jsonb", | |
| 2024 | + "primaryKey": false, | |
| 2025 | + "notNull": false | |
| 2026 | + }, | |
| 2027 | + "created_at": { | |
| 2028 | + "name": "created_at", | |
| 2029 | + "type": "timestamp with time zone", | |
| 2030 | + "primaryKey": false, | |
| 2031 | + "notNull": true, | |
| 2032 | + "default": "now()" | |
| 2033 | + } | |
| 2034 | + }, | |
| 2035 | + "indexes": { | |
| 2036 | + "security_events_created_idx": { | |
| 2037 | + "name": "security_events_created_idx", | |
| 2038 | + "columns": [ | |
| 2039 | + { | |
| 2040 | + "expression": "created_at", | |
| 2041 | + "isExpression": false, | |
| 2042 | + "asc": true, | |
| 2043 | + "nulls": "last" | |
| 2044 | + } | |
| 2045 | + ], | |
| 2046 | + "isUnique": false, | |
| 2047 | + "concurrently": false, | |
| 2048 | + "method": "btree", | |
| 2049 | + "with": {} | |
| 2050 | + }, | |
| 2051 | + "security_events_type_idx": { | |
| 2052 | + "name": "security_events_type_idx", | |
| 2053 | + "columns": [ | |
| 2054 | + { | |
| 2055 | + "expression": "type", | |
| 2056 | + "isExpression": false, | |
| 2057 | + "asc": true, | |
| 2058 | + "nulls": "last" | |
| 2059 | + } | |
| 2060 | + ], | |
| 2061 | + "isUnique": false, | |
| 2062 | + "concurrently": false, | |
| 2063 | + "method": "btree", | |
| 2064 | + "with": {} | |
| 2065 | + }, | |
| 2066 | + "security_events_user_idx": { | |
| 2067 | + "name": "security_events_user_idx", | |
| 2068 | + "columns": [ | |
| 2069 | + { | |
| 2070 | + "expression": "user_id", | |
| 2071 | + "isExpression": false, | |
| 2072 | + "asc": true, | |
| 2073 | + "nulls": "last" | |
| 2074 | + } | |
| 2075 | + ], | |
| 2076 | + "isUnique": false, | |
| 2077 | + "concurrently": false, | |
| 2078 | + "method": "btree", | |
| 2079 | + "with": {} | |
| 2080 | + } | |
| 2081 | + }, | |
| 2082 | + "foreignKeys": { | |
| 2083 | + "security_events_user_id_users_id_fk": { | |
| 2084 | + "name": "security_events_user_id_users_id_fk", | |
| 2085 | + "tableFrom": "security_events", | |
| 2086 | + "tableTo": "users", | |
| 2087 | + "columnsFrom": [ | |
| 2088 | + "user_id" | |
| 2089 | + ], | |
| 2090 | + "columnsTo": [ | |
| 2091 | + "id" | |
| 2092 | + ], | |
| 2093 | + "onDelete": "set null", | |
| 2094 | + "onUpdate": "no action" | |
| 2095 | + } | |
| 2096 | + }, | |
| 2097 | + "compositePrimaryKeys": {}, | |
| 2098 | + "uniqueConstraints": {}, | |
| 2099 | + "policies": {}, | |
| 2100 | + "checkConstraints": {}, | |
| 2101 | + "isRLSEnabled": false | |
| 2102 | + }, | |
| 2103 | + "public.sessions": { | |
| 2104 | + "name": "sessions", | |
| 2105 | + "schema": "", | |
| 2106 | + "columns": { | |
| 2107 | + "id": { | |
| 2108 | + "name": "id", | |
| 2109 | + "type": "uuid", | |
| 2110 | + "primaryKey": true, | |
| 2111 | + "notNull": true, | |
| 2112 | + "default": "gen_random_uuid()" | |
| 2113 | + }, | |
| 2114 | + "user_id": { | |
| 2115 | + "name": "user_id", | |
| 2116 | + "type": "uuid", | |
| 2117 | + "primaryKey": false, | |
| 2118 | + "notNull": true | |
| 2119 | + }, | |
| 2120 | + "token_hash": { | |
| 2121 | + "name": "token_hash", | |
| 2122 | + "type": "text", | |
| 2123 | + "primaryKey": false, | |
| 2124 | + "notNull": true | |
| 2125 | + }, | |
| 2126 | + "created_at": { | |
| 2127 | + "name": "created_at", | |
| 2128 | + "type": "timestamp with time zone", | |
| 2129 | + "primaryKey": false, | |
| 2130 | + "notNull": true, | |
| 2131 | + "default": "now()" | |
| 2132 | + }, | |
| 2133 | + "expires_at": { | |
| 2134 | + "name": "expires_at", | |
| 2135 | + "type": "timestamp with time zone", | |
| 2136 | + "primaryKey": false, | |
| 2137 | + "notNull": true | |
| 2138 | + }, | |
| 2139 | + "last_seen_at": { | |
| 2140 | + "name": "last_seen_at", | |
| 2141 | + "type": "timestamp with time zone", | |
| 2142 | + "primaryKey": false, | |
| 2143 | + "notNull": true, | |
| 2144 | + "default": "now()" | |
| 2145 | + }, | |
| 2146 | + "user_agent": { | |
| 2147 | + "name": "user_agent", | |
| 2148 | + "type": "text", | |
| 2149 | + "primaryKey": false, | |
| 2150 | + "notNull": false | |
| 2151 | + }, | |
| 2152 | + "ip": { | |
| 2153 | + "name": "ip", | |
| 2154 | + "type": "varchar(64)", | |
| 2155 | + "primaryKey": false, | |
| 2156 | + "notNull": false | |
| 2157 | + } | |
| 2158 | + }, | |
| 2159 | + "indexes": { | |
| 2160 | + "sessions_token_hash_idx": { | |
| 2161 | + "name": "sessions_token_hash_idx", | |
| 2162 | + "columns": [ | |
| 2163 | + { | |
| 2164 | + "expression": "token_hash", | |
| 2165 | + "isExpression": false, | |
| 2166 | + "asc": true, | |
| 2167 | + "nulls": "last" | |
| 2168 | + } | |
| 2169 | + ], | |
| 2170 | + "isUnique": true, | |
| 2171 | + "concurrently": false, | |
| 2172 | + "method": "btree", | |
| 2173 | + "with": {} | |
| 2174 | + }, | |
| 2175 | + "sessions_user_idx": { | |
| 2176 | + "name": "sessions_user_idx", | |
| 2177 | + "columns": [ | |
| 2178 | + { | |
| 2179 | + "expression": "user_id", | |
| 2180 | + "isExpression": false, | |
| 2181 | + "asc": true, | |
| 2182 | + "nulls": "last" | |
| 2183 | + } | |
| 2184 | + ], | |
| 2185 | + "isUnique": false, | |
| 2186 | + "concurrently": false, | |
| 2187 | + "method": "btree", | |
| 2188 | + "with": {} | |
| 2189 | + } | |
| 2190 | + }, | |
| 2191 | + "foreignKeys": { | |
| 2192 | + "sessions_user_id_users_id_fk": { | |
| 2193 | + "name": "sessions_user_id_users_id_fk", | |
| 2194 | + "tableFrom": "sessions", | |
| 2195 | + "tableTo": "users", | |
| 2196 | + "columnsFrom": [ | |
| 2197 | + "user_id" | |
| 2198 | + ], | |
| 2199 | + "columnsTo": [ | |
| 2200 | + "id" | |
| 2201 | + ], | |
| 2202 | + "onDelete": "cascade", | |
| 2203 | + "onUpdate": "no action" | |
| 2204 | + } | |
| 2205 | + }, | |
| 2206 | + "compositePrimaryKeys": {}, | |
| 2207 | + "uniqueConstraints": {}, | |
| 2208 | + "policies": {}, | |
| 2209 | + "checkConstraints": {}, | |
| 2210 | + "isRLSEnabled": false | |
| 2211 | + }, | |
| 2212 | + "public.simulation_runs": { | |
| 2213 | + "name": "simulation_runs", | |
| 2214 | + "schema": "", | |
| 2215 | + "columns": { | |
| 2216 | + "id": { | |
| 2217 | + "name": "id", | |
| 2218 | + "type": "uuid", | |
| 2219 | + "primaryKey": true, | |
| 2220 | + "notNull": true, | |
| 2221 | + "default": "gen_random_uuid()" | |
| 2222 | + }, | |
| 2223 | + "game_slug": { | |
| 2224 | + "name": "game_slug", | |
| 2225 | + "type": "varchar(64)", | |
| 2226 | + "primaryKey": false, | |
| 2227 | + "notNull": true | |
| 2228 | + }, | |
| 2229 | + "game_version": { | |
| 2230 | + "name": "game_version", | |
| 2231 | + "type": "varchar(16)", | |
| 2232 | + "primaryKey": false, | |
| 2233 | + "notNull": true | |
| 2234 | + }, | |
| 2235 | + "spins": { | |
| 2236 | + "name": "spins", | |
| 2237 | + "type": "bigint", | |
| 2238 | + "primaryKey": false, | |
| 2239 | + "notNull": true | |
| 2240 | + }, | |
| 2241 | + "status": { | |
| 2242 | + "name": "status", | |
| 2243 | + "type": "varchar(16)", | |
| 2244 | + "primaryKey": false, | |
| 2245 | + "notNull": true, | |
| 2246 | + "default": "'running'" | |
| 2247 | + }, | |
| 2248 | + "progress": { | |
| 2249 | + "name": "progress", | |
| 2250 | + "type": "bigint", | |
| 2251 | + "primaryKey": false, | |
| 2252 | + "notNull": true, | |
| 2253 | + "default": 0 | |
| 2254 | + }, | |
| 2255 | + "result": { | |
| 2256 | + "name": "result", | |
| 2257 | + "type": "jsonb", | |
| 2258 | + "primaryKey": false, | |
| 2259 | + "notNull": false | |
| 2260 | + }, | |
| 2261 | + "error": { | |
| 2262 | + "name": "error", | |
| 2263 | + "type": "text", | |
| 2264 | + "primaryKey": false, | |
| 2265 | + "notNull": false | |
| 2266 | + }, | |
| 2267 | + "requested_by": { | |
| 2268 | + "name": "requested_by", | |
| 2269 | + "type": "uuid", | |
| 2270 | + "primaryKey": false, | |
| 2271 | + "notNull": false | |
| 2272 | + }, | |
| 2273 | + "created_at": { | |
| 2274 | + "name": "created_at", | |
| 2275 | + "type": "timestamp with time zone", | |
| 2276 | + "primaryKey": false, | |
| 2277 | + "notNull": true, | |
| 2278 | + "default": "now()" | |
| 2279 | + }, | |
| 2280 | + "finished_at": { | |
| 2281 | + "name": "finished_at", | |
| 2282 | + "type": "timestamp with time zone", | |
| 2283 | + "primaryKey": false, | |
| 2284 | + "notNull": false | |
| 2285 | + } | |
| 2286 | + }, | |
| 2287 | + "indexes": { | |
| 2288 | + "simulation_runs_game_idx": { | |
| 2289 | + "name": "simulation_runs_game_idx", | |
| 2290 | + "columns": [ | |
| 2291 | + { | |
| 2292 | + "expression": "game_slug", | |
| 2293 | + "isExpression": false, | |
| 2294 | + "asc": true, | |
| 2295 | + "nulls": "last" | |
| 2296 | + }, | |
| 2297 | + { | |
| 2298 | + "expression": "created_at", | |
| 2299 | + "isExpression": false, | |
| 2300 | + "asc": true, | |
| 2301 | + "nulls": "last" | |
| 2302 | + } | |
| 2303 | + ], | |
| 2304 | + "isUnique": false, | |
| 2305 | + "concurrently": false, | |
| 2306 | + "method": "btree", | |
| 2307 | + "with": {} | |
| 2308 | + } | |
| 2309 | + }, | |
| 2310 | + "foreignKeys": {}, | |
| 2311 | + "compositePrimaryKeys": {}, | |
| 2312 | + "uniqueConstraints": {}, | |
| 2313 | + "policies": {}, | |
| 2314 | + "checkConstraints": {}, | |
| 2315 | + "isRLSEnabled": false | |
| 2316 | + }, | |
| 2317 | + "public.user_achievements": { | |
| 2318 | + "name": "user_achievements", | |
| 2319 | + "schema": "", | |
| 2320 | + "columns": { | |
| 2321 | + "user_id": { | |
| 2322 | + "name": "user_id", | |
| 2323 | + "type": "uuid", | |
| 2324 | + "primaryKey": false, | |
| 2325 | + "notNull": true | |
| 2326 | + }, | |
| 2327 | + "achievement_key": { | |
| 2328 | + "name": "achievement_key", | |
| 2329 | + "type": "varchar(48)", | |
| 2330 | + "primaryKey": false, | |
| 2331 | + "notNull": true | |
| 2332 | + }, | |
| 2333 | + "unlocked_at": { | |
| 2334 | + "name": "unlocked_at", | |
| 2335 | + "type": "timestamp with time zone", | |
| 2336 | + "primaryKey": false, | |
| 2337 | + "notNull": true, | |
| 2338 | + "default": "now()" | |
| 2339 | + } | |
| 2340 | + }, | |
| 2341 | + "indexes": {}, | |
| 2342 | + "foreignKeys": { | |
| 2343 | + "user_achievements_user_id_users_id_fk": { | |
| 2344 | + "name": "user_achievements_user_id_users_id_fk", | |
| 2345 | + "tableFrom": "user_achievements", | |
| 2346 | + "tableTo": "users", | |
| 2347 | + "columnsFrom": [ | |
| 2348 | + "user_id" | |
| 2349 | + ], | |
| 2350 | + "columnsTo": [ | |
| 2351 | + "id" | |
| 2352 | + ], | |
| 2353 | + "onDelete": "cascade", | |
| 2354 | + "onUpdate": "no action" | |
| 2355 | + }, | |
| 2356 | + "user_achievements_achievement_key_achievements_key_fk": { | |
| 2357 | + "name": "user_achievements_achievement_key_achievements_key_fk", | |
| 2358 | + "tableFrom": "user_achievements", | |
| 2359 | + "tableTo": "achievements", | |
| 2360 | + "columnsFrom": [ | |
| 2361 | + "achievement_key" | |
| 2362 | + ], | |
| 2363 | + "columnsTo": [ | |
| 2364 | + "key" | |
| 2365 | + ], | |
| 2366 | + "onDelete": "cascade", | |
| 2367 | + "onUpdate": "no action" | |
| 2368 | + } | |
| 2369 | + }, | |
| 2370 | + "compositePrimaryKeys": { | |
| 2371 | + "user_achievements_user_id_achievement_key_pk": { | |
| 2372 | + "name": "user_achievements_user_id_achievement_key_pk", | |
| 2373 | + "columns": [ | |
| 2374 | + "user_id", | |
| 2375 | + "achievement_key" | |
| 2376 | + ] | |
| 2377 | + } | |
| 2378 | + }, | |
| 2379 | + "uniqueConstraints": {}, | |
| 2380 | + "policies": {}, | |
| 2381 | + "checkConstraints": {}, | |
| 2382 | + "isRLSEnabled": false | |
| 2383 | + }, | |
| 2384 | + "public.user_game_stats": { | |
| 2385 | + "name": "user_game_stats", | |
| 2386 | + "schema": "", | |
| 2387 | + "columns": { | |
| 2388 | + "user_id": { | |
| 2389 | + "name": "user_id", | |
| 2390 | + "type": "uuid", | |
| 2391 | + "primaryKey": false, | |
| 2392 | + "notNull": true | |
| 2393 | + }, | |
| 2394 | + "game_id": { | |
| 2395 | + "name": "game_id", | |
| 2396 | + "type": "uuid", | |
| 2397 | + "primaryKey": false, | |
| 2398 | + "notNull": true | |
| 2399 | + }, | |
| 2400 | + "spins": { | |
| 2401 | + "name": "spins", | |
| 2402 | + "type": "bigint", | |
| 2403 | + "primaryKey": false, | |
| 2404 | + "notNull": true, | |
| 2405 | + "default": 0 | |
| 2406 | + }, | |
| 2407 | + "wagered": { | |
| 2408 | + "name": "wagered", | |
| 2409 | + "type": "bigint", | |
| 2410 | + "primaryKey": false, | |
| 2411 | + "notNull": true, | |
| 2412 | + "default": 0 | |
| 2413 | + }, | |
| 2414 | + "won": { | |
| 2415 | + "name": "won", | |
| 2416 | + "type": "bigint", | |
| 2417 | + "primaryKey": false, | |
| 2418 | + "notNull": true, | |
| 2419 | + "default": 0 | |
| 2420 | + }, | |
| 2421 | + "bonuses": { | |
| 2422 | + "name": "bonuses", | |
| 2423 | + "type": "integer", | |
| 2424 | + "primaryKey": false, | |
| 2425 | + "notNull": true, | |
| 2426 | + "default": 0 | |
| 2427 | + }, | |
| 2428 | + "biggest_win": { | |
| 2429 | + "name": "biggest_win", | |
| 2430 | + "type": "bigint", | |
| 2431 | + "primaryKey": false, | |
| 2432 | + "notNull": true, | |
| 2433 | + "default": 0 | |
| 2434 | + }, | |
| 2435 | + "biggest_multiplier": { | |
| 2436 | + "name": "biggest_multiplier", | |
| 2437 | + "type": "numeric(12, 2)", | |
| 2438 | + "primaryKey": false, | |
| 2439 | + "notNull": true, | |
| 2440 | + "default": "'0'" | |
| 2441 | + }, | |
| 2442 | + "last_played_at": { | |
| 2443 | + "name": "last_played_at", | |
| 2444 | + "type": "timestamp with time zone", | |
| 2445 | + "primaryKey": false, | |
| 2446 | + "notNull": true, | |
| 2447 | + "default": "now()" | |
| 2448 | + }, | |
| 2449 | + "first_played_at": { | |
| 2450 | + "name": "first_played_at", | |
| 2451 | + "type": "timestamp with time zone", | |
| 2452 | + "primaryKey": false, | |
| 2453 | + "notNull": true, | |
| 2454 | + "default": "now()" | |
| 2455 | + } | |
| 2456 | + }, | |
| 2457 | + "indexes": { | |
| 2458 | + "ugs_user_last_idx": { | |
| 2459 | + "name": "ugs_user_last_idx", | |
| 2460 | + "columns": [ | |
| 2461 | + { | |
| 2462 | + "expression": "user_id", | |
| 2463 | + "isExpression": false, | |
| 2464 | + "asc": true, | |
| 2465 | + "nulls": "last" | |
| 2466 | + }, | |
| 2467 | + { | |
| 2468 | + "expression": "last_played_at", | |
| 2469 | + "isExpression": false, | |
| 2470 | + "asc": true, | |
| 2471 | + "nulls": "last" | |
| 2472 | + } | |
| 2473 | + ], | |
| 2474 | + "isUnique": false, | |
| 2475 | + "concurrently": false, | |
| 2476 | + "method": "btree", | |
| 2477 | + "with": {} | |
| 2478 | + } | |
| 2479 | + }, | |
| 2480 | + "foreignKeys": { | |
| 2481 | + "user_game_stats_user_id_users_id_fk": { | |
| 2482 | + "name": "user_game_stats_user_id_users_id_fk", | |
| 2483 | + "tableFrom": "user_game_stats", | |
| 2484 | + "tableTo": "users", | |
| 2485 | + "columnsFrom": [ | |
| 2486 | + "user_id" | |
| 2487 | + ], | |
| 2488 | + "columnsTo": [ | |
| 2489 | + "id" | |
| 2490 | + ], | |
| 2491 | + "onDelete": "cascade", | |
| 2492 | + "onUpdate": "no action" | |
| 2493 | + }, | |
| 2494 | + "user_game_stats_game_id_games_id_fk": { | |
| 2495 | + "name": "user_game_stats_game_id_games_id_fk", | |
| 2496 | + "tableFrom": "user_game_stats", | |
| 2497 | + "tableTo": "games", | |
| 2498 | + "columnsFrom": [ | |
| 2499 | + "game_id" | |
| 2500 | + ], | |
| 2501 | + "columnsTo": [ | |
| 2502 | + "id" | |
| 2503 | + ], | |
| 2504 | + "onDelete": "cascade", | |
| 2505 | + "onUpdate": "no action" | |
| 2506 | + } | |
| 2507 | + }, | |
| 2508 | + "compositePrimaryKeys": { | |
| 2509 | + "user_game_stats_user_id_game_id_pk": { | |
| 2510 | + "name": "user_game_stats_user_id_game_id_pk", | |
| 2511 | + "columns": [ | |
| 2512 | + "user_id", | |
| 2513 | + "game_id" | |
| 2514 | + ] | |
| 2515 | + } | |
| 2516 | + }, | |
| 2517 | + "uniqueConstraints": {}, | |
| 2518 | + "policies": {}, | |
| 2519 | + "checkConstraints": {}, | |
| 2520 | + "isRLSEnabled": false | |
| 2521 | + }, | |
| 2522 | + "public.user_missions": { | |
| 2523 | + "name": "user_missions", | |
| 2524 | + "schema": "", | |
| 2525 | + "columns": { | |
| 2526 | + "id": { | |
| 2527 | + "name": "id", | |
| 2528 | + "type": "uuid", | |
| 2529 | + "primaryKey": true, | |
| 2530 | + "notNull": true, | |
| 2531 | + "default": "gen_random_uuid()" | |
| 2532 | + }, | |
| 2533 | + "user_id": { | |
| 2534 | + "name": "user_id", | |
| 2535 | + "type": "uuid", | |
| 2536 | + "primaryKey": false, | |
| 2537 | + "notNull": true | |
| 2538 | + }, | |
| 2539 | + "mission_key": { | |
| 2540 | + "name": "mission_key", | |
| 2541 | + "type": "varchar(48)", | |
| 2542 | + "primaryKey": false, | |
| 2543 | + "notNull": true | |
| 2544 | + }, | |
| 2545 | + "period_key": { | |
| 2546 | + "name": "period_key", | |
| 2547 | + "type": "varchar(16)", | |
| 2548 | + "primaryKey": false, | |
| 2549 | + "notNull": true | |
| 2550 | + }, | |
| 2551 | + "progress": { | |
| 2552 | + "name": "progress", | |
| 2553 | + "type": "bigint", | |
| 2554 | + "primaryKey": false, | |
| 2555 | + "notNull": true, | |
| 2556 | + "default": 0 | |
| 2557 | + }, | |
| 2558 | + "progress_set": { | |
| 2559 | + "name": "progress_set", | |
| 2560 | + "type": "text[]", | |
| 2561 | + "primaryKey": false, | |
| 2562 | + "notNull": true, | |
| 2563 | + "default": "'{}'::text[]" | |
| 2564 | + }, | |
| 2565 | + "completed_at": { | |
| 2566 | + "name": "completed_at", | |
| 2567 | + "type": "timestamp with time zone", | |
| 2568 | + "primaryKey": false, | |
| 2569 | + "notNull": false | |
| 2570 | + }, | |
| 2571 | + "claimed_at": { | |
| 2572 | + "name": "claimed_at", | |
| 2573 | + "type": "timestamp with time zone", | |
| 2574 | + "primaryKey": false, | |
| 2575 | + "notNull": false | |
| 2576 | + }, | |
| 2577 | + "expires_at": { | |
| 2578 | + "name": "expires_at", | |
| 2579 | + "type": "timestamp with time zone", | |
| 2580 | + "primaryKey": false, | |
| 2581 | + "notNull": true | |
| 2582 | + } | |
| 2583 | + }, | |
| 2584 | + "indexes": { | |
| 2585 | + "user_missions_unique_idx": { | |
| 2586 | + "name": "user_missions_unique_idx", | |
| 2587 | + "columns": [ | |
| 2588 | + { | |
| 2589 | + "expression": "user_id", | |
| 2590 | + "isExpression": false, | |
| 2591 | + "asc": true, | |
| 2592 | + "nulls": "last" | |
| 2593 | + }, | |
| 2594 | + { | |
| 2595 | + "expression": "mission_key", | |
| 2596 | + "isExpression": false, | |
| 2597 | + "asc": true, | |
| 2598 | + "nulls": "last" | |
| 2599 | + }, | |
| 2600 | + { | |
| 2601 | + "expression": "period_key", | |
| 2602 | + "isExpression": false, | |
| 2603 | + "asc": true, | |
| 2604 | + "nulls": "last" | |
| 2605 | + } | |
| 2606 | + ], | |
| 2607 | + "isUnique": true, | |
| 2608 | + "concurrently": false, | |
| 2609 | + "method": "btree", | |
| 2610 | + "with": {} | |
| 2611 | + }, | |
| 2612 | + "user_missions_user_idx": { | |
| 2613 | + "name": "user_missions_user_idx", | |
| 2614 | + "columns": [ | |
| 2615 | + { | |
| 2616 | + "expression": "user_id", | |
| 2617 | + "isExpression": false, | |
| 2618 | + "asc": true, | |
| 2619 | + "nulls": "last" | |
| 2620 | + } | |
| 2621 | + ], | |
| 2622 | + "isUnique": false, | |
| 2623 | + "concurrently": false, | |
| 2624 | + "method": "btree", | |
| 2625 | + "with": {} | |
| 2626 | + } | |
| 2627 | + }, | |
| 2628 | + "foreignKeys": { | |
| 2629 | + "user_missions_user_id_users_id_fk": { | |
| 2630 | + "name": "user_missions_user_id_users_id_fk", | |
| 2631 | + "tableFrom": "user_missions", | |
| 2632 | + "tableTo": "users", | |
| 2633 | + "columnsFrom": [ | |
| 2634 | + "user_id" | |
| 2635 | + ], | |
| 2636 | + "columnsTo": [ | |
| 2637 | + "id" | |
| 2638 | + ], | |
| 2639 | + "onDelete": "cascade", | |
| 2640 | + "onUpdate": "no action" | |
| 2641 | + }, | |
| 2642 | + "user_missions_mission_key_missions_key_fk": { | |
| 2643 | + "name": "user_missions_mission_key_missions_key_fk", | |
| 2644 | + "tableFrom": "user_missions", | |
| 2645 | + "tableTo": "missions", | |
| 2646 | + "columnsFrom": [ | |
| 2647 | + "mission_key" | |
| 2648 | + ], | |
| 2649 | + "columnsTo": [ | |
| 2650 | + "key" | |
| 2651 | + ], | |
| 2652 | + "onDelete": "cascade", | |
| 2653 | + "onUpdate": "no action" | |
| 2654 | + } | |
| 2655 | + }, | |
| 2656 | + "compositePrimaryKeys": {}, | |
| 2657 | + "uniqueConstraints": {}, | |
| 2658 | + "policies": {}, | |
| 2659 | + "checkConstraints": {}, | |
| 2660 | + "isRLSEnabled": false | |
| 2661 | + }, | |
| 2662 | + "public.user_settings": { | |
| 2663 | + "name": "user_settings", | |
| 2664 | + "schema": "", | |
| 2665 | + "columns": { | |
| 2666 | + "user_id": { | |
| 2667 | + "name": "user_id", | |
| 2668 | + "type": "uuid", | |
| 2669 | + "primaryKey": true, | |
| 2670 | + "notNull": true | |
| 2671 | + }, | |
| 2672 | + "sound_enabled": { | |
| 2673 | + "name": "sound_enabled", | |
| 2674 | + "type": "boolean", | |
| 2675 | + "primaryKey": false, | |
| 2676 | + "notNull": true, | |
| 2677 | + "default": true | |
| 2678 | + }, | |
| 2679 | + "music_volume": { | |
| 2680 | + "name": "music_volume", | |
| 2681 | + "type": "numeric(3, 2)", | |
| 2682 | + "primaryKey": false, | |
| 2683 | + "notNull": true, | |
| 2684 | + "default": "'0.6'" | |
| 2685 | + }, | |
| 2686 | + "effects_volume": { | |
| 2687 | + "name": "effects_volume", | |
| 2688 | + "type": "numeric(3, 2)", | |
| 2689 | + "primaryKey": false, | |
| 2690 | + "notNull": true, | |
| 2691 | + "default": "'0.8'" | |
| 2692 | + }, | |
| 2693 | + "master_volume": { | |
| 2694 | + "name": "master_volume", | |
| 2695 | + "type": "numeric(3, 2)", | |
| 2696 | + "primaryKey": false, | |
| 2697 | + "notNull": true, | |
| 2698 | + "default": "'0.8'" | |
| 2699 | + }, | |
| 2700 | + "reduce_motion": { | |
| 2701 | + "name": "reduce_motion", | |
| 2702 | + "type": "boolean", | |
| 2703 | + "primaryKey": false, | |
| 2704 | + "notNull": true, | |
| 2705 | + "default": false | |
| 2706 | + }, | |
| 2707 | + "animation_intensity": { | |
| 2708 | + "name": "animation_intensity", | |
| 2709 | + "type": "varchar(8)", | |
| 2710 | + "primaryKey": false, | |
| 2711 | + "notNull": true, | |
| 2712 | + "default": "'high'" | |
| 2713 | + }, | |
| 2714 | + "session_reminder_minutes": { | |
| 2715 | + "name": "session_reminder_minutes", | |
| 2716 | + "type": "integer", | |
| 2717 | + "primaryKey": false, | |
| 2718 | + "notNull": true, | |
| 2719 | + "default": 60 | |
| 2720 | + }, | |
| 2721 | + "break_reminder": { | |
| 2722 | + "name": "break_reminder", | |
| 2723 | + "type": "boolean", | |
| 2724 | + "primaryKey": false, | |
| 2725 | + "notNull": true, | |
| 2726 | + "default": true | |
| 2727 | + }, | |
| 2728 | + "leaderboard_opt_in": { | |
| 2729 | + "name": "leaderboard_opt_in", | |
| 2730 | + "type": "boolean", | |
| 2731 | + "primaryKey": false, | |
| 2732 | + "notNull": true, | |
| 2733 | + "default": true | |
| 2734 | + }, | |
| 2735 | + "updated_at": { | |
| 2736 | + "name": "updated_at", | |
| 2737 | + "type": "timestamp with time zone", | |
| 2738 | + "primaryKey": false, | |
| 2739 | + "notNull": true, | |
| 2740 | + "default": "now()" | |
| 2741 | + } | |
| 2742 | + }, | |
| 2743 | + "indexes": {}, | |
| 2744 | + "foreignKeys": { | |
| 2745 | + "user_settings_user_id_users_id_fk": { | |
| 2746 | + "name": "user_settings_user_id_users_id_fk", | |
| 2747 | + "tableFrom": "user_settings", | |
| 2748 | + "tableTo": "users", | |
| 2749 | + "columnsFrom": [ | |
| 2750 | + "user_id" | |
| 2751 | + ], | |
| 2752 | + "columnsTo": [ | |
| 2753 | + "id" | |
| 2754 | + ], | |
| 2755 | + "onDelete": "cascade", | |
| 2756 | + "onUpdate": "no action" | |
| 2757 | + } | |
| 2758 | + }, | |
| 2759 | + "compositePrimaryKeys": {}, | |
| 2760 | + "uniqueConstraints": {}, | |
| 2761 | + "policies": {}, | |
| 2762 | + "checkConstraints": {}, | |
| 2763 | + "isRLSEnabled": false | |
| 2764 | + }, | |
| 2765 | + "public.users": { | |
| 2766 | + "name": "users", | |
| 2767 | + "schema": "", | |
| 2768 | + "columns": { | |
| 2769 | + "id": { | |
| 2770 | + "name": "id", | |
| 2771 | + "type": "uuid", | |
| 2772 | + "primaryKey": true, | |
| 2773 | + "notNull": true, | |
| 2774 | + "default": "gen_random_uuid()" | |
| 2775 | + }, | |
| 2776 | + "username": { | |
| 2777 | + "name": "username", | |
| 2778 | + "type": "varchar(24)", | |
| 2779 | + "primaryKey": false, | |
| 2780 | + "notNull": true | |
| 2781 | + }, | |
| 2782 | + "username_normalized": { | |
| 2783 | + "name": "username_normalized", | |
| 2784 | + "type": "varchar(24)", | |
| 2785 | + "primaryKey": false, | |
| 2786 | + "notNull": true | |
| 2787 | + }, | |
| 2788 | + "password_hash": { | |
| 2789 | + "name": "password_hash", | |
| 2790 | + "type": "text", | |
| 2791 | + "primaryKey": false, | |
| 2792 | + "notNull": true | |
| 2793 | + }, | |
| 2794 | + "level": { | |
| 2795 | + "name": "level", | |
| 2796 | + "type": "integer", | |
| 2797 | + "primaryKey": false, | |
| 2798 | + "notNull": true, | |
| 2799 | + "default": 1 | |
| 2800 | + }, | |
| 2801 | + "xp": { | |
| 2802 | + "name": "xp", | |
| 2803 | + "type": "bigint", | |
| 2804 | + "primaryKey": false, | |
| 2805 | + "notNull": true, | |
| 2806 | + "default": 0 | |
| 2807 | + }, | |
| 2808 | + "status": { | |
| 2809 | + "name": "status", | |
| 2810 | + "type": "varchar(16)", | |
| 2811 | + "primaryKey": false, | |
| 2812 | + "notNull": true, | |
| 2813 | + "default": "'active'" | |
| 2814 | + }, | |
| 2815 | + "age_confirmed_at": { | |
| 2816 | + "name": "age_confirmed_at", | |
| 2817 | + "type": "timestamp with time zone", | |
| 2818 | + "primaryKey": false, | |
| 2819 | + "notNull": false | |
| 2820 | + }, | |
| 2821 | + "created_at": { | |
| 2822 | + "name": "created_at", | |
| 2823 | + "type": "timestamp with time zone", | |
| 2824 | + "primaryKey": false, | |
| 2825 | + "notNull": true, | |
| 2826 | + "default": "now()" | |
| 2827 | + }, | |
| 2828 | + "last_login_at": { | |
| 2829 | + "name": "last_login_at", | |
| 2830 | + "type": "timestamp with time zone", | |
| 2831 | + "primaryKey": false, | |
| 2832 | + "notNull": false | |
| 2833 | + }, | |
| 2834 | + "last_rescue_at": { | |
| 2835 | + "name": "last_rescue_at", | |
| 2836 | + "type": "timestamp with time zone", | |
| 2837 | + "primaryKey": false, | |
| 2838 | + "notNull": false | |
| 2839 | + }, | |
| 2840 | + "total_spins": { | |
| 2841 | + "name": "total_spins", | |
| 2842 | + "type": "bigint", | |
| 2843 | + "primaryKey": false, | |
| 2844 | + "notNull": true, | |
| 2845 | + "default": 0 | |
| 2846 | + }, | |
| 2847 | + "games_played": { | |
| 2848 | + "name": "games_played", | |
| 2849 | + "type": "integer", | |
| 2850 | + "primaryKey": false, | |
| 2851 | + "notNull": true, | |
| 2852 | + "default": 0 | |
| 2853 | + }, | |
| 2854 | + "biggest_win": { | |
| 2855 | + "name": "biggest_win", | |
| 2856 | + "type": "bigint", | |
| 2857 | + "primaryKey": false, | |
| 2858 | + "notNull": true, | |
| 2859 | + "default": 0 | |
| 2860 | + }, | |
| 2861 | + "biggest_multiplier": { | |
| 2862 | + "name": "biggest_multiplier", | |
| 2863 | + "type": "numeric(12, 2)", | |
| 2864 | + "primaryKey": false, | |
| 2865 | + "notNull": true, | |
| 2866 | + "default": "'0'" | |
| 2867 | + } | |
| 2868 | + }, | |
| 2869 | + "indexes": { | |
| 2870 | + "users_username_normalized_idx": { | |
| 2871 | + "name": "users_username_normalized_idx", | |
| 2872 | + "columns": [ | |
| 2873 | + { | |
| 2874 | + "expression": "username_normalized", | |
| 2875 | + "isExpression": false, | |
| 2876 | + "asc": true, | |
| 2877 | + "nulls": "last" | |
| 2878 | + } | |
| 2879 | + ], | |
| 2880 | + "isUnique": true, | |
| 2881 | + "concurrently": false, | |
| 2882 | + "method": "btree", | |
| 2883 | + "with": {} | |
| 2884 | + }, | |
| 2885 | + "users_username_idx": { | |
| 2886 | + "name": "users_username_idx", | |
| 2887 | + "columns": [ | |
| 2888 | + { | |
| 2889 | + "expression": "username", | |
| 2890 | + "isExpression": false, | |
| 2891 | + "asc": true, | |
| 2892 | + "nulls": "last" | |
| 2893 | + } | |
| 2894 | + ], | |
| 2895 | + "isUnique": true, | |
| 2896 | + "concurrently": false, | |
| 2897 | + "method": "btree", | |
| 2898 | + "with": {} | |
| 2899 | + } | |
| 2900 | + }, | |
| 2901 | + "foreignKeys": {}, | |
| 2902 | + "compositePrimaryKeys": {}, | |
| 2903 | + "uniqueConstraints": {}, | |
| 2904 | + "policies": {}, | |
| 2905 | + "checkConstraints": {}, | |
| 2906 | + "isRLSEnabled": false | |
| 2907 | + }, | |
| 2908 | + "public.wallets": { | |
| 2909 | + "name": "wallets", | |
| 2910 | + "schema": "", | |
| 2911 | + "columns": { | |
| 2912 | + "user_id": { | |
| 2913 | + "name": "user_id", | |
| 2914 | + "type": "uuid", | |
| 2915 | + "primaryKey": true, | |
| 2916 | + "notNull": true | |
| 2917 | + }, | |
| 2918 | + "balance": { | |
| 2919 | + "name": "balance", | |
| 2920 | + "type": "bigint", | |
| 2921 | + "primaryKey": false, | |
| 2922 | + "notNull": true, | |
| 2923 | + "default": 0 | |
| 2924 | + }, | |
| 2925 | + "lifetime_wagered": { | |
| 2926 | + "name": "lifetime_wagered", | |
| 2927 | + "type": "bigint", | |
| 2928 | + "primaryKey": false, | |
| 2929 | + "notNull": true, | |
| 2930 | + "default": 0 | |
| 2931 | + }, | |
| 2932 | + "lifetime_won": { | |
| 2933 | + "name": "lifetime_won", | |
| 2934 | + "type": "bigint", | |
| 2935 | + "primaryKey": false, | |
| 2936 | + "notNull": true, | |
| 2937 | + "default": 0 | |
| 2938 | + }, | |
| 2939 | + "lifetime_granted": { | |
| 2940 | + "name": "lifetime_granted", | |
| 2941 | + "type": "bigint", | |
| 2942 | + "primaryKey": false, | |
| 2943 | + "notNull": true, | |
| 2944 | + "default": 0 | |
| 2945 | + }, | |
| 2946 | + "updated_at": { | |
| 2947 | + "name": "updated_at", | |
| 2948 | + "type": "timestamp with time zone", | |
| 2949 | + "primaryKey": false, | |
| 2950 | + "notNull": true, | |
| 2951 | + "default": "now()" | |
| 2952 | + } | |
| 2953 | + }, | |
| 2954 | + "indexes": {}, | |
| 2955 | + "foreignKeys": { | |
| 2956 | + "wallets_user_id_users_id_fk": { | |
| 2957 | + "name": "wallets_user_id_users_id_fk", | |
| 2958 | + "tableFrom": "wallets", | |
| 2959 | + "tableTo": "users", | |
| 2960 | + "columnsFrom": [ | |
| 2961 | + "user_id" | |
| 2962 | + ], | |
| 2963 | + "columnsTo": [ | |
| 2964 | + "id" | |
| 2965 | + ], | |
| 2966 | + "onDelete": "cascade", | |
| 2967 | + "onUpdate": "no action" | |
| 2968 | + } | |
| 2969 | + }, | |
| 2970 | + "compositePrimaryKeys": {}, | |
| 2971 | + "uniqueConstraints": {}, | |
| 2972 | + "policies": {}, | |
| 2973 | + "checkConstraints": {}, | |
| 2974 | + "isRLSEnabled": false | |
| 2975 | + } | |
| 2976 | + }, | |
| 2977 | + "enums": {}, | |
| 2978 | + "schemas": {}, | |
| 2979 | + "sequences": {}, | |
| 2980 | + "roles": {}, | |
| 2981 | + "policies": {}, | |
| 2982 | + "views": {}, | |
| 2983 | + "_meta": { | |
| 2984 | + "columns": {}, | |
| 2985 | + "schemas": {}, | |
| 2986 | + "tables": {} | |
| 2987 | + } | |
| 2988 | +} | |
| \ No newline at end of file | ||
modified
packages/database/drizzle/meta/_journal.json
+7 −0
@@ -15,6 +15,13 @@ | ||
| 15 | 15 | "when": 1788836033256, |
| 16 | 16 | "tag": "0001_lumpy_hulk", |
| 17 | 17 | "breakpoints": true |
| 18 | + }, | |
| 19 | + { | |
| 20 | + "idx": 2, | |
| 21 | + "version": "7", | |
| 22 | + "when": 1788837686777, | |
| 23 | + "tag": "0002_nosy_giant_man", | |
| 24 | + "breakpoints": true | |
| 18 | 25 | } |
| 19 | 26 | ] |
| 20 | 27 | } |
| \ No newline at end of file | ||
modified
packages/database/src/schema.ts
+26 −0
@@ -206,6 +206,32 @@ export const crashRounds = pgTable( | ||
| 206 | 206 | ], |
| 207 | 207 | ); |
| 208 | 208 | |
| 209 | +/** Ladder-game sessions (The Vault, Escape 99): step-by-step rounds with cash-out. Settled rounds also go to game_rounds. */ | |
| 210 | +export const arcadeSessions = pgTable( | |
| 211 | + "arcade_sessions", | |
| 212 | + { | |
| 213 | + id: uuid().primaryKey().defaultRandom(), | |
| 214 | + roundId: varchar({ length: 32 }).notNull(), | |
| 215 | + userId: uuid().notNull().references(() => users.id, { onDelete: "cascade" }), | |
| 216 | + gameId: uuid().notNull().references(() => games.id), | |
| 217 | + gameSlug: varchar({ length: 64 }).notNull(), | |
| 218 | + gameVersion: varchar({ length: 16 }).notNull(), | |
| 219 | + clientRoundId: uuid().notNull(), | |
| 220 | + bet: bigint({ mode: "number" }).notNull(), | |
| 221 | + state: jsonb().$type<Record<string, unknown>>().notNull(), | |
| 222 | + status: varchar({ length: 12 }).notNull().default("running"), // running | cashed | busted | completed | |
| 223 | + win: bigint({ mode: "number" }).notNull().default(0), | |
| 224 | + startedAt: now(), | |
| 225 | + updatedAt: now(), | |
| 226 | + settledAt: ts(), | |
| 227 | + }, | |
| 228 | + (t) => [ | |
| 229 | + uniqueIndex("arcade_sessions_round_id_idx").on(t.roundId), | |
| 230 | + uniqueIndex("arcade_sessions_user_client_idx").on(t.userId, t.clientRoundId), | |
| 231 | + index("arcade_sessions_user_status_idx").on(t.userId, t.status), | |
| 232 | + ], | |
| 233 | +); | |
| 234 | + | |
| 209 | 235 | export const gameStatistics = pgTable("game_statistics", { |
| 210 | 236 | gameId: uuid().primaryKey().references(() => games.id, { onDelete: "cascade" }), |
| 211 | 237 | launches: bigint({ mode: "number" }).notNull().default(0), |
modified
packages/database/src/sync-games.ts
+46 −2
@@ -2,8 +2,8 @@ import fs from "node:fs"; | ||
| 2 | 2 | import path from "node:path"; |
| 3 | 3 | import { createHash } from "node:crypto"; |
| 4 | 4 | import { createRequire } from "node:module"; |
| 5 | −import { GAMES, FEATURED_ORDER, CRASH_GAMES } from "@spinza/games"; | |
| 6 | −import { validateDefinition, type CertificationReport, type GameDefinition, type CrashGameDefinition } from "@spinza/game-core"; | |
| 5 | +import { GAMES, FEATURED_ORDER, CRASH_GAMES, ARCADE_GAMES } from "@spinza/games"; | |
| 6 | +import { validateDefinition, type CertificationReport, type GameDefinition, type CrashGameDefinition, type ArcadeGameDefinition } from "@spinza/game-core"; | |
| 7 | 7 | import { eq, sql } from "drizzle-orm"; |
| 8 | 8 | import type { Db } from "./index"; |
| 9 | 9 | import { gameStatistics, gameVersions, games } from "./schema"; |
@@ -45,6 +45,28 @@ export function crashSummary(def: CrashGameDefinition) { | ||
| 45 | 45 | }; |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | +export function arcadeSummary(def: ArcadeGameDefinition) { | |
| 49 | + return { | |
| 50 | + kind: "arcade", | |
| 51 | + category: "originals", | |
| 52 | + mode: def.mode, | |
| 53 | + tagline: def.tagline, | |
| 54 | + description: def.description, | |
| 55 | + theme: def.theme, | |
| 56 | + volatility: def.volatility, | |
| 57 | + grid: { reels: 0, rows: 0 }, | |
| 58 | + minBet: def.minBet, | |
| 59 | + maxBet: def.maxBet, | |
| 60 | + maxMultiplier: def.maxMultiplier, | |
| 61 | + features: def.featureNames, | |
| 62 | + tags: def.tags, | |
| 63 | + palette: def.presentation.palette, | |
| 64 | + presentation: { frame: "glass", backdrop: def.presentation.scene, particles: "energy", ambience: def.presentation.ambience, scene: def.presentation.scene, verb: def.presentation.verb }, | |
| 65 | + isJackpot: def.slug === "the-vault", | |
| 66 | + rtp: def.rtp, | |
| 67 | + }; | |
| 68 | +} | |
| 69 | + | |
| 48 | 70 | export function publicSummary(def: GameDefinition) { |
| 49 | 71 | return { |
| 50 | 72 | kind: "slot", |
@@ -168,5 +190,27 @@ export async function syncGames(db: Db, log: (s: string) => void = () => {}): Pr | ||
| 168 | 190 | reports.push({ slug: def.slug, version: def.version, lifecycle, certified }); |
| 169 | 191 | log(` ${certified ? "✓" : "·"} ${def.slug}@${def.version} (crash) → ${lifecycle}`); |
| 170 | 192 | } |
| 193 | + for (const def of ARCADE_GAMES) { | |
| 194 | + order++; | |
| 195 | + const cert = loadCertification(def.slug); | |
| 196 | + const certified = !!cert && cert.status === "PASS" && cert.version === def.version; | |
| 197 | + const existing = await db.query.games.findFirst({ where: eq(games.slug, def.slug) }); | |
| 198 | + let lifecycle = certified ? "published" : "simulation"; | |
| 199 | + if (existing?.lifecycle === "disabled") lifecycle = "disabled"; | |
| 200 | + const summary = { ...arcadeSummary(def), certification: cert ? { status: cert.status, spins: cert.spins, observedRtp: cert.observedRtp, hitRate: cert.hitRate, certifiedAt: cert.certifiedAt } : null }; | |
| 201 | + const isNew = existing ? existing.isNew && Date.now() - existing.createdAt.getTime() < 14 * 86400_000 : true; | |
| 202 | + const [row] = await db | |
| 203 | + .insert(games) | |
| 204 | + .values({ slug: def.slug, name: def.name, version: def.version, lifecycle, sortOrder: order, isFeatured: false, isNew, summary, publishedAt: certified ? new Date() : null }) | |
| 205 | + .onConflictDoUpdate({ target: games.slug, set: { name: def.name, version: def.version, lifecycle, sortOrder: order, isNew, summary, publishedAt: certified ? sql`coalesce(${games.publishedAt}, now())` : games.publishedAt, updatedAt: new Date() } }) | |
| 206 | + .returning(); | |
| 207 | + await db | |
| 208 | + .insert(gameVersions) | |
| 209 | + .values({ gameId: row.id, version: def.version, definition: def as unknown as Record<string, unknown>, definitionHash: createHash("sha256").update(JSON.stringify(def)).digest("hex"), certification: (cert as unknown as Record<string, unknown>) ?? null, status: certified ? "published" : "simulation" }) | |
| 210 | + .onConflictDoUpdate({ target: [gameVersions.gameId, gameVersions.version], set: { definition: def as unknown as Record<string, unknown>, certification: (cert as unknown as Record<string, unknown>) ?? null, status: certified ? "published" : "simulation" } }); | |
| 211 | + await db.insert(gameStatistics).values({ gameId: row.id }).onConflictDoNothing(); | |
| 212 | + reports.push({ slug: def.slug, version: def.version, lifecycle, certified }); | |
| 213 | + log(` ${certified ? "✓" : "·"} ${def.slug}@${def.version} (arcade) → ${lifecycle}`); | |
| 214 | + } | |
| 171 | 215 | return reports; |
| 172 | 216 | } |
added
packages/game-core/src/arcade/dropzone.ts
+143 −0
@@ -0,0 +1,143 @@ | ||
| 1 | +import type { Rng } from "../rng"; | |
| 2 | +import type { ArcadeGameDefinition, ArcadeOutcome } from "./types"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * DROPZONE — a capsule falls through a tower of pegs (binomial random walk), | |
| 6 | + * passing gates (×2 boosts) and portals (lane swaps), into a row of buckets. | |
| 7 | + * Deep Drop: the tower extends and the capsule keeps falling into a riskier | |
| 8 | + * second bucket row whose values multiply the first (0× included). | |
| 9 | + * The player picks a risk profile (bucket table) and a start lane. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +export interface DropzoneConfig { | |
| 13 | + rows: number; | |
| 14 | + lanes: number; // = rows + 1 buckets | |
| 15 | + /** Bucket multipliers per risk profile, symmetric, length = lanes. */ | |
| 16 | + buckets: Record<"low" | "medium" | "high", number[]>; | |
| 17 | + gateChancePerRow: number; | |
| 18 | + gateValues: number[]; | |
| 19 | + portalChancePerRow: number; | |
| 20 | + deepDropChance: number; | |
| 21 | + deepRows: number; | |
| 22 | + deepBuckets: number[]; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export interface DropzoneInput { | |
| 26 | + risk: "low" | "medium" | "high"; | |
| 27 | + /** Start lane 0..lanes-1 (the capsule is released above this bucket column). */ | |
| 28 | + lane: number; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export interface DropStep { | |
| 32 | + row: number; | |
| 33 | + /** Horizontal position after this row (in half-lane units, 0 = far left). */ | |
| 34 | + x: number; | |
| 35 | + event?: { type: "gate"; value: number } | { type: "portal"; to: number }; | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** Exact end-bucket distribution for a start lane (binomial walk in half-lane units with edge clamping). */ | |
| 39 | +export function bucketDistribution(cfg: DropzoneConfig, startLane: number): number[] { | |
| 40 | + const width = cfg.lanes * 2; | |
| 41 | + let dist = new Array(width).fill(0); | |
| 42 | + dist[startLane * 2 + 1] = 1; | |
| 43 | + for (let row = 0; row < cfg.rows; row++) { | |
| 44 | + const next = new Array(width).fill(0); | |
| 45 | + for (let x = 0; x < width; x++) { | |
| 46 | + if (!dist[x]) continue; | |
| 47 | + const l = Math.max(0, x - 1); | |
| 48 | + const r = Math.min(width - 1, x + 1); | |
| 49 | + next[l] += dist[x] / 2; | |
| 50 | + next[r] += dist[x] / 2; | |
| 51 | + } | |
| 52 | + dist = next; | |
| 53 | + } | |
| 54 | + const buckets = new Array(cfg.lanes).fill(0); | |
| 55 | + for (let x = 0; x < width; x++) buckets[Math.min(cfg.lanes - 1, Math.floor(x / 2))] += dist[x]; | |
| 56 | + return buckets; | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** Per-lane normalisation so every start lane has the same expected base value (no "edge lane" exploit). */ | |
| 60 | +export function laneNormalizer(cfg: DropzoneConfig, risk: DropzoneInput["risk"], startLane: number): number { | |
| 61 | + const table = cfg.buckets[risk]; | |
| 62 | + const center = Math.floor(cfg.lanes / 2); | |
| 63 | + const ev = (lane: number) => bucketDistribution(cfg, lane).reduce((a, p, k) => a + p * table[k], 0); | |
| 64 | + return ev(center) / ev(startLane); | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** Bucket values as the player will see them for a given lane/risk (already normalised and RTP-scaled). */ | |
| 68 | +export function displayedBuckets(def: ArcadeGameDefinition, risk: DropzoneInput["risk"], startLane: number): number[] { | |
| 69 | + const cfg = def.config as unknown as DropzoneConfig; | |
| 70 | + const norm = laneNormalizer(cfg, risk, startLane); | |
| 71 | + return cfg.buckets[risk].map((v) => Math.round(v * norm * def.payScale * 100) / 100); | |
| 72 | +} | |
| 73 | + | |
| 74 | +export function resolveDropzone(def: ArcadeGameDefinition, bet: number, rng: Rng, inputRaw: Partial<DropzoneInput>): ArcadeOutcome { | |
| 75 | + const cfg = def.config as unknown as DropzoneConfig; | |
| 76 | + const risk: DropzoneInput["risk"] = inputRaw.risk === "low" || inputRaw.risk === "high" ? inputRaw.risk : "medium"; | |
| 77 | + const lanes = cfg.lanes; | |
| 78 | + const startLane = Math.max(0, Math.min(lanes - 1, Math.round(inputRaw.lane ?? Math.floor(lanes / 2)))); | |
| 79 | + const norm = laneNormalizer(cfg, risk, startLane); | |
| 80 | + const table = cfg.buckets[risk].map((v) => v * norm); | |
| 81 | + const features: string[] = []; | |
| 82 | + const steps: DropStep[] = []; | |
| 83 | + // Position in "half-lane" units: bucket k spans [2k, 2k+2). Start centred above the chosen lane. | |
| 84 | + let x = startLane * 2 + 1; | |
| 85 | + let gateMult = 1; | |
| 86 | + for (let row = 0; row < cfg.rows; row++) { | |
| 87 | + x += rng.chance(0.5) ? 1 : -1; | |
| 88 | + x = Math.max(0, Math.min(lanes * 2 - 1, x)); | |
| 89 | + const step: DropStep = { row, x }; | |
| 90 | + if (rng.chance(cfg.gateChancePerRow)) { | |
| 91 | + const v = cfg.gateValues[rng.int(cfg.gateValues.length)]; | |
| 92 | + gateMult *= v; | |
| 93 | + step.event = { type: "gate", value: v }; | |
| 94 | + features.push("Gate"); | |
| 95 | + } else if (rng.chance(cfg.portalChancePerRow)) { | |
| 96 | + const hop = (1 + rng.int(3)) * 2 * (rng.chance(0.5) ? 1 : -1); | |
| 97 | + const to = Math.max(0, Math.min(lanes * 2 - 1, x + hop)); | |
| 98 | + step.event = { type: "portal", to }; | |
| 99 | + x = to; | |
| 100 | + features.push("Portal"); | |
| 101 | + } | |
| 102 | + steps.push(step); | |
| 103 | + } | |
| 104 | + const bucket = Math.min(lanes - 1, Math.floor(x / 2)); | |
| 105 | + const base = table[bucket] * gateMult; | |
| 106 | + let deep: { steps: DropStep[]; bucket: number; value: number } | null = null; | |
| 107 | + let total = base; | |
| 108 | + if (rng.chance(cfg.deepDropChance)) { | |
| 109 | + features.push("Deep Drop"); | |
| 110 | + const dsteps: DropStep[] = []; | |
| 111 | + let dx = bucket * 2 + 1; | |
| 112 | + for (let row = 0; row < cfg.deepRows; row++) { | |
| 113 | + dx += rng.chance(0.5) ? 1 : -1; | |
| 114 | + dx = Math.max(0, Math.min(cfg.deepBuckets.length * 2 - 1, dx)); | |
| 115 | + dsteps.push({ row, x: dx }); | |
| 116 | + } | |
| 117 | + const db = Math.min(cfg.deepBuckets.length - 1, Math.floor(dx / 2)); | |
| 118 | + deep = { steps: dsteps, bucket: db, value: cfg.deepBuckets[db] }; | |
| 119 | + total = base * cfg.deepBuckets[db]; | |
| 120 | + } | |
| 121 | + const scaled = total * def.payScale; | |
| 122 | + const capped = scaled > def.maxMultiplier; | |
| 123 | + const multiplier = Math.min(def.maxMultiplier, scaled); | |
| 124 | + const totalWin = Math.round(bet * multiplier); | |
| 125 | + return { | |
| 126 | + game: def.slug, | |
| 127 | + version: def.version, | |
| 128 | + bet, | |
| 129 | + totalWin, | |
| 130 | + multiplier: bet ? totalWin / bet : 0, | |
| 131 | + capped, | |
| 132 | + features: [...new Set(features)], | |
| 133 | + steps, | |
| 134 | + summary: { risk, startLane, bucket, bucketValue: Math.round(table[bucket] * def.payScale * 100) / 100, gateMult, deep, table: table.map((v) => Math.round(v * def.payScale * 100) / 100) }, | |
| 135 | + }; | |
| 136 | +} | |
| 137 | + | |
| 138 | +/** Random-input driver for simulation. */ | |
| 139 | +export function randomDropzoneInput(def: ArcadeGameDefinition, rng: Rng): DropzoneInput { | |
| 140 | + const cfg = def.config as unknown as DropzoneConfig; | |
| 141 | + const risks: DropzoneInput["risk"][] = ["low", "medium", "high"]; | |
| 142 | + return { risk: risks[rng.int(3)], lane: rng.int(cfg.lanes) }; | |
| 143 | +} | |
added
packages/game-core/src/arcade/gridbreak.ts
+166 −0
@@ -0,0 +1,166 @@ | ||
| 1 | +import type { Rng } from "../rng"; | |
| 2 | +import type { ArcadeGameDefinition, ArcadeOutcome } from "./types"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * GRID//BREAK — an 8×8 grid of energy blocks. The player fires a wave down a | |
| 6 | + * column; every cluster (≥3 connected blocks of one colour) the wave touches | |
| 7 | + * detonates, blocks fall, new ones drop in, and any new cluster detonates in a | |
| 8 | + * chain. Bombs clear 3×3, line blocks clear a row, ×2 blocks double the step. | |
| 9 | + */ | |
| 10 | + | |
| 11 | +export interface GridbreakConfig { | |
| 12 | + size: number; | |
| 13 | + colors: number; | |
| 14 | + /** Value per block by colour index, in bet units (× total bet / size²·k). */ | |
| 15 | + values: number[]; | |
| 16 | + weights: number[]; | |
| 17 | + specialChance: { bomb: number; line: number; x2: number }; | |
| 18 | + chainLadder: number[]; | |
| 19 | + minCluster: number; | |
| 20 | + maxChains: number; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export type Cell = { c: number; s?: "bomb" | "line" | "x2" }; | |
| 24 | + | |
| 25 | +export interface GridStep { | |
| 26 | + grid: Cell[][]; // [col][row], row 0 = top | |
| 27 | + destroyed: [number, number][]; | |
| 28 | + specials: { type: string; at: [number, number] }[]; | |
| 29 | + chain: number; | |
| 30 | + multiplier: number; | |
| 31 | + stepValue: number; | |
| 32 | + win: number; | |
| 33 | +} | |
| 34 | + | |
| 35 | +function drawCell(cfg: GridbreakConfig, rng: Rng): Cell { | |
| 36 | + const c = rng.weighted(cfg.weights); | |
| 37 | + const roll = rng.float(); | |
| 38 | + if (roll < cfg.specialChance.bomb) return { c, s: "bomb" }; | |
| 39 | + if (roll < cfg.specialChance.bomb + cfg.specialChance.line) return { c, s: "line" }; | |
| 40 | + if (roll < cfg.specialChance.bomb + cfg.specialChance.line + cfg.specialChance.x2) return { c, s: "x2" }; | |
| 41 | + return { c }; | |
| 42 | +} | |
| 43 | + | |
| 44 | +function clusters(grid: Cell[][], size: number, minCluster: number): [number, number][][] { | |
| 45 | + const seen = new Set<string>(); | |
| 46 | + const out: [number, number][][] = []; | |
| 47 | + for (let x = 0; x < size; x++) | |
| 48 | + for (let y = 0; y < size; y++) { | |
| 49 | + const k = `${x}:${y}`; | |
| 50 | + if (seen.has(k)) continue; | |
| 51 | + const color = grid[x][y].c; | |
| 52 | + const stack: [number, number][] = [[x, y]]; | |
| 53 | + const group: [number, number][] = []; | |
| 54 | + seen.add(k); | |
| 55 | + while (stack.length) { | |
| 56 | + const [cx, cy] = stack.pop()!; | |
| 57 | + group.push([cx, cy]); | |
| 58 | + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { | |
| 59 | + const nx = cx + dx; | |
| 60 | + const ny = cy + dy; | |
| 61 | + if (nx < 0 || ny < 0 || nx >= size || ny >= size) continue; | |
| 62 | + const nk = `${nx}:${ny}`; | |
| 63 | + if (seen.has(nk) || grid[nx][ny].c !== color) continue; | |
| 64 | + seen.add(nk); | |
| 65 | + stack.push([nx, ny]); | |
| 66 | + } | |
| 67 | + } | |
| 68 | + if (group.length >= minCluster) out.push(group); | |
| 69 | + } | |
| 70 | + return out; | |
| 71 | +} | |
| 72 | + | |
| 73 | +export function resolveGridbreak(def: ArcadeGameDefinition, bet: number, rng: Rng, inputRaw: { column?: number }): ArcadeOutcome { | |
| 74 | + const cfg = def.config as unknown as GridbreakConfig; | |
| 75 | + const size = cfg.size; | |
| 76 | + const column = Math.max(0, Math.min(size - 1, Math.round(inputRaw.column ?? Math.floor(size / 2)))); | |
| 77 | + const grid: Cell[][] = Array.from({ length: size }, () => Array.from({ length: size }, () => drawCell(cfg, rng))); | |
| 78 | + const steps: GridStep[] = []; | |
| 79 | + const features: string[] = []; | |
| 80 | + let chain = 0; | |
| 81 | + let totalUnits = 0; | |
| 82 | + const unit = 1 / (size * size); // one block of value 1 ≈ 1/64 of the bet | |
| 83 | + let firstWave = true; | |
| 84 | + while (chain < cfg.maxChains) { | |
| 85 | + const all = clusters(grid, size, cfg.minCluster); | |
| 86 | + // The first wave only detonates clusters touching the fired column; chains detonate everything. | |
| 87 | + const hit = firstWave ? all.filter((g) => g.some(([x]) => x === column)) : all; | |
| 88 | + if (hit.length === 0) break; | |
| 89 | + const destroyed = new Set<string>(); | |
| 90 | + const specials: GridStep["specials"] = []; | |
| 91 | + let stepMult = 1; | |
| 92 | + const push = (x: number, y: number) => { | |
| 93 | + if (x >= 0 && y >= 0 && x < size && y < size) destroyed.add(`${x}:${y}`); | |
| 94 | + }; | |
| 95 | + for (const g of hit) for (const [x, y] of g) push(x, y); | |
| 96 | + // Specials inside destroyed blocks trigger. | |
| 97 | + for (const k of [...destroyed]) { | |
| 98 | + const [x, y] = k.split(":").map(Number); | |
| 99 | + const cell = grid[x][y]; | |
| 100 | + if (cell.s === "bomb") { | |
| 101 | + specials.push({ type: "bomb", at: [x, y] }); | |
| 102 | + for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) push(x + dx, y + dy); | |
| 103 | + features.push("Bomb"); | |
| 104 | + } else if (cell.s === "line") { | |
| 105 | + specials.push({ type: "line", at: [x, y] }); | |
| 106 | + for (let xx = 0; xx < size; xx++) push(xx, y); | |
| 107 | + features.push("Line clear"); | |
| 108 | + } else if (cell.s === "x2") { | |
| 109 | + specials.push({ type: "x2", at: [x, y] }); | |
| 110 | + stepMult *= 2; | |
| 111 | + features.push("×2 block"); | |
| 112 | + } | |
| 113 | + } | |
| 114 | + const chainMult = cfg.chainLadder[Math.min(chain, cfg.chainLadder.length - 1)]; | |
| 115 | + let value = 0; | |
| 116 | + const destroyedList: [number, number][] = []; | |
| 117 | + for (const k of destroyed) { | |
| 118 | + const [x, y] = k.split(":").map(Number); | |
| 119 | + value += cfg.values[grid[x][y].c] * unit; | |
| 120 | + destroyedList.push([x, y]); | |
| 121 | + } | |
| 122 | + const stepUnits = value * stepMult * chainMult; | |
| 123 | + totalUnits += stepUnits; | |
| 124 | + steps.push({ | |
| 125 | + grid: grid.map((c) => c.map((cell) => ({ ...cell }))), | |
| 126 | + destroyed: destroyedList, | |
| 127 | + specials, | |
| 128 | + chain, | |
| 129 | + multiplier: stepMult * chainMult, | |
| 130 | + stepValue: Math.round(stepUnits * def.payScale * 10000) / 10000, | |
| 131 | + win: Math.round(bet * stepUnits * def.payScale), | |
| 132 | + }); | |
| 133 | + // Gravity + refill. | |
| 134 | + for (let x = 0; x < size; x++) { | |
| 135 | + const kept: Cell[] = []; | |
| 136 | + for (let y = 0; y < size; y++) if (!destroyed.has(`${x}:${y}`)) kept.push(grid[x][y]); | |
| 137 | + const fresh: Cell[] = []; | |
| 138 | + while (fresh.length + kept.length < size) fresh.push(drawCell(cfg, rng)); | |
| 139 | + grid[x] = [...fresh, ...kept]; | |
| 140 | + } | |
| 141 | + chain++; | |
| 142 | + firstWave = false; | |
| 143 | + if (chain > 1) features.push("Chain reaction"); | |
| 144 | + } | |
| 145 | + // Final grid for presentation. | |
| 146 | + steps.push({ grid: grid.map((c) => c.map((cell) => ({ ...cell }))), destroyed: [], specials: [], chain, multiplier: 1, stepValue: 0, win: 0 }); | |
| 147 | + const scaled = totalUnits * def.payScale; | |
| 148 | + const capped = scaled > def.maxMultiplier; | |
| 149 | + const multiplier = Math.min(def.maxMultiplier, scaled); | |
| 150 | + const totalWin = Math.round(bet * multiplier); | |
| 151 | + return { | |
| 152 | + game: def.slug, | |
| 153 | + version: def.version, | |
| 154 | + bet, | |
| 155 | + totalWin, | |
| 156 | + multiplier: bet ? totalWin / bet : 0, | |
| 157 | + capped, | |
| 158 | + features: [...new Set(features)], | |
| 159 | + steps, | |
| 160 | + summary: { column, chains: chain, blocksDestroyed: steps.reduce((a, s) => a + s.destroyed.length, 0) }, | |
| 161 | + }; | |
| 162 | +} | |
| 163 | + | |
| 164 | +export function randomGridbreakInput(def: ArcadeGameDefinition, rng: Rng): { column: number } { | |
| 165 | + return { column: rng.int((def.config as unknown as GridbreakConfig).size) }; | |
| 166 | +} | |
added
packages/game-core/src/arcade/index.ts
+185 −0
@@ -0,0 +1,185 @@ | ||
| 1 | +import type { Rng } from "../rng"; | |
| 2 | +import { CryptoRng } from "../rng"; | |
| 3 | +import { DEFAULT_CERTIFICATION_RULES, type CertificationReport, type CertificationRules } from "../validation"; | |
| 4 | +import type { ArcadeGameDefinition, ArcadeOutcome } from "./types"; | |
| 5 | +import { resolveDropzone, randomDropzoneInput } from "./dropzone"; | |
| 6 | +import { resolveGridbreak, randomGridbreakInput } from "./gridbreak"; | |
| 7 | +import { resolveOrbit, randomOrbitInput } from "./orbit"; | |
| 8 | +import { simulateLadderRound } from "./ladder"; | |
| 9 | +import type { CrashSimulationResult } from "../crash"; | |
| 10 | + | |
| 11 | +export * from "./types"; | |
| 12 | +export * from "./ladder"; | |
| 13 | +export * from "./dropzone"; | |
| 14 | +export * from "./gridbreak"; | |
| 15 | +export * from "./orbit"; | |
| 16 | + | |
| 17 | +/* ------------------------------------------------------------ dispatch */ | |
| 18 | + | |
| 19 | +export function resolveInstant(def: ArcadeGameDefinition, bet: number, input: Record<string, unknown>, rng: Rng = new CryptoRng()): ArcadeOutcome { | |
| 20 | + switch (def.presentation.scene) { | |
| 21 | + case "dropzone": | |
| 22 | + return resolveDropzone(def, bet, rng, input as never); | |
| 23 | + case "gridbreak": | |
| 24 | + return resolveGridbreak(def, bet, rng, input as never); | |
| 25 | + case "orbit": | |
| 26 | + return resolveOrbit(def, bet, rng, input as never); | |
| 27 | + default: | |
| 28 | + throw new Error(`${def.slug} is not an instant game`); | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +export function randomInput(def: ArcadeGameDefinition, rng: Rng): Record<string, unknown> { | |
| 33 | + switch (def.presentation.scene) { | |
| 34 | + case "dropzone": | |
| 35 | + return randomDropzoneInput(def, rng) as unknown as Record<string, unknown>; | |
| 36 | + case "gridbreak": | |
| 37 | + return randomGridbreakInput(def, rng); | |
| 38 | + case "orbit": | |
| 39 | + return randomOrbitInput(def, rng); | |
| 40 | + default: | |
| 41 | + return {}; | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +/* ---------------------------------------------------------- simulation */ | |
| 46 | + | |
| 47 | +export type ArcadeSimulationResult = CrashSimulationResult; | |
| 48 | + | |
| 49 | +export function simulateArcade(def: ArcadeGameDefinition, opts: { spins: number; bet?: number; rng?: Rng; payScale?: number }): ArcadeSimulationResult { | |
| 50 | + const rng = opts.rng ?? new CryptoRng(); | |
| 51 | + const bet = opts.bet ?? 100; | |
| 52 | + const d = opts.payScale !== undefined ? { ...def, payScale: opts.payScale } : def; | |
| 53 | + const n = opts.spins; | |
| 54 | + const started = Date.now(); | |
| 55 | + let returned = 0; | |
| 56 | + let hits = 0; | |
| 57 | + let sum = 0; | |
| 58 | + let sumSq = 0; | |
| 59 | + let maxWin = 0; | |
| 60 | + let capped = 0; | |
| 61 | + const buckets = [ | |
| 62 | + { label: "0×", min: 0, max: 0 }, | |
| 63 | + { label: "0–1×", min: 0.000001, max: 1 }, | |
| 64 | + { label: "1–2×", min: 1, max: 2 }, | |
| 65 | + { label: "2–5×", min: 2, max: 5 }, | |
| 66 | + { label: "5–10×", min: 5, max: 10 }, | |
| 67 | + { label: "10–20×", min: 10, max: 20 }, | |
| 68 | + { label: "20–50×", min: 20, max: 50 }, | |
| 69 | + { label: "50–100×", min: 50, max: 100 }, | |
| 70 | + { label: "100–500×", min: 100, max: 500 }, | |
| 71 | + { label: "500×+", min: 500, max: Infinity }, | |
| 72 | + ]; | |
| 73 | + const counts = new Array(buckets.length).fill(0); | |
| 74 | + const feature: Record<string, number> = {}; | |
| 75 | + const convergence: { spins: number; rtp: number }[] = []; | |
| 76 | + const every = Math.max(1, Math.floor(n / 40)); | |
| 77 | + const sample: number[] = []; | |
| 78 | + for (let i = 0; i < n; i++) { | |
| 79 | + let win: number; | |
| 80 | + if (d.mode === "ladder") win = simulateLadderRound(d, bet, rng); | |
| 81 | + else { | |
| 82 | + const out = resolveInstant(d, bet, randomInput(d, rng), rng); | |
| 83 | + win = out.totalWin; | |
| 84 | + if (out.capped) capped++; | |
| 85 | + for (const f of out.features) feature[f] = (feature[f] ?? 0) + 1; | |
| 86 | + } | |
| 87 | + returned += win; | |
| 88 | + if (win > 0) hits++; | |
| 89 | + const m = win / bet; | |
| 90 | + sum += m; | |
| 91 | + sumSq += m * m; | |
| 92 | + if (win > maxWin) maxWin = win; | |
| 93 | + for (let b = 0; b < buckets.length; b++) if ((buckets[b].max === 0 && m === 0) || (buckets[b].max !== 0 && m >= buckets[b].min && m < buckets[b].max)) { | |
| 94 | + counts[b]++; | |
| 95 | + break; | |
| 96 | + } | |
| 97 | + if (sample.length < 100_000) sample.push(m); | |
| 98 | + if ((i + 1) % every === 0 || i === n - 1) convergence.push({ spins: i + 1, rtp: returned / ((i + 1) * bet) }); | |
| 99 | + } | |
| 100 | + const mean = sum / n; | |
| 101 | + const variance = Math.max(0, sumSq / n - mean * mean); | |
| 102 | + sample.sort((a, b) => a - b); | |
| 103 | + const observedRtp = returned / (n * bet); | |
| 104 | + return { | |
| 105 | + game: d.slug, | |
| 106 | + version: d.version, | |
| 107 | + spins: n, | |
| 108 | + bet, | |
| 109 | + configuredRtp: d.rtp, | |
| 110 | + observedRtp, | |
| 111 | + deviation: observedRtp - d.rtp, | |
| 112 | + hitRate: hits / n, | |
| 113 | + bonusRate: 0, | |
| 114 | + freeSpinRate: 0, | |
| 115 | + jackpotRate: 0, | |
| 116 | + wagered: n * bet, | |
| 117 | + returned, | |
| 118 | + averageWin: returned / n, | |
| 119 | + medianWin: (sample[Math.floor(sample.length / 2)] ?? 0) * bet, | |
| 120 | + maxWin, | |
| 121 | + maxWinMultiplier: maxWin / bet, | |
| 122 | + stdDev: Math.sqrt(variance), | |
| 123 | + distribution: buckets.map((b, i) => ({ ...b, count: counts[i], share: counts[i] / n })), | |
| 124 | + convergence, | |
| 125 | + featureCounts: feature, | |
| 126 | + cappedRounds: capped, | |
| 127 | + durationMs: Date.now() - started, | |
| 128 | + }; | |
| 129 | +} | |
| 130 | + | |
| 131 | +/** Find the payScale bringing an instant game to its target RTP (ladders are analytic). */ | |
| 132 | +export function calibrateArcade(def: ArcadeGameDefinition, spins = 400_000, iterations = 4, log?: (s: string) => void): { payScale: number; result: ArcadeSimulationResult } { | |
| 133 | + let payScale = 1; | |
| 134 | + let result = simulateArcade(def, { spins: Math.max(100_000, Math.floor(spins / 4)), payScale }); | |
| 135 | + log?.(` iter 0: payScale=${payScale.toFixed(4)} rtp=${(result.observedRtp * 100).toFixed(2)}% hit=${(result.hitRate * 100).toFixed(1)}%`); | |
| 136 | + for (let i = 1; i <= iterations; i++) { | |
| 137 | + const se = result.stdDev / Math.sqrt(result.spins); | |
| 138 | + if (i > 1 && Math.abs(result.deviation) < Math.max(0.001, se / 2)) break; | |
| 139 | + payScale = Math.min(10, Math.max(0.01, payScale * (1 + (def.rtp / result.observedRtp - 1) * 0.9))); | |
| 140 | + result = simulateArcade(def, { spins, payScale }); | |
| 141 | + log?.(` iter ${i}: payScale=${payScale.toFixed(4)} rtp=${(result.observedRtp * 100).toFixed(2)}% hit=${(result.hitRate * 100).toFixed(1)}%`); | |
| 142 | + } | |
| 143 | + return { payScale: Number(payScale.toFixed(4)), result }; | |
| 144 | +} | |
| 145 | + | |
| 146 | +export function certifyArcade(def: ArcadeGameDefinition, sim: ArcadeSimulationResult, rules: CertificationRules = DEFAULT_CERTIFICATION_RULES): CertificationReport { | |
| 147 | + const se = sim.stdDev / Math.sqrt(sim.spins); | |
| 148 | + const tolerance = Math.min(0.015, Math.max(rules.maxDeviation, 3 * se)); | |
| 149 | + const checks = [ | |
| 150 | + { name: "definition", pass: def.rtp >= 0.94 && def.rtp <= 0.98 && def.maxMultiplier >= 10 && def.payScale > 0, detail: `rtp ${def.rtp}, cap ${def.maxMultiplier}×, payScale ${def.payScale}` }, | |
| 151 | + { name: "spins", pass: sim.spins >= rules.minSpins, detail: `${sim.spins.toLocaleString("en-US")} rounds` }, | |
| 152 | + { name: "rtp-deviation", pass: Math.abs(sim.deviation) <= tolerance, detail: `${(sim.deviation * 100).toFixed(3)}% (tolerance ±${(tolerance * 100).toFixed(2)}%)` }, | |
| 153 | + { name: "rtp-band", pass: sim.observedRtp >= rules.rtpBand[0] && sim.observedRtp <= rules.rtpBand[1], detail: `${(sim.observedRtp * 100).toFixed(2)}%` }, | |
| 154 | + { name: "max-win", pass: sim.maxWinMultiplier <= def.maxMultiplier + 1e-9, detail: `${sim.maxWinMultiplier.toFixed(1)}× (cap ${def.maxMultiplier}×)` }, | |
| 155 | + { name: "cap-share", pass: sim.cappedRounds / sim.spins <= rules.maxCappedShare, detail: `${sim.cappedRounds} capped rounds` }, | |
| 156 | + ]; | |
| 157 | + return { | |
| 158 | + game: def.slug, | |
| 159 | + name: def.name, | |
| 160 | + version: def.version, | |
| 161 | + spins: sim.spins, | |
| 162 | + configuredRtp: def.rtp, | |
| 163 | + observedRtp: sim.observedRtp, | |
| 164 | + deviation: sim.deviation, | |
| 165 | + hitRate: sim.hitRate, | |
| 166 | + bonusRate: 0, | |
| 167 | + freeSpinRate: 0, | |
| 168 | + maxWinMultiplier: sim.maxWinMultiplier, | |
| 169 | + stdDev: sim.stdDev, | |
| 170 | + status: checks.every((c) => c.pass) ? "PASS" : "FAIL", | |
| 171 | + checks, | |
| 172 | + certifiedAt: new Date().toISOString(), | |
| 173 | + rules, | |
| 174 | + distribution: sim.distribution, | |
| 175 | + convergence: sim.convergence, | |
| 176 | + featureCounts: sim.featureCounts, | |
| 177 | + durationMs: sim.durationMs, | |
| 178 | + }; | |
| 179 | +} | |
| 180 | + | |
| 181 | +type ArcadeInput = Omit<ArcadeGameDefinition, "kind" | "featureNames" | "tags" | "minBet" | "maxBet" | "payScale"> & Partial<Pick<ArcadeGameDefinition, "featureNames" | "tags" | "minBet" | "maxBet" | "payScale">>; | |
| 182 | + | |
| 183 | +export function defineArcadeGame(input: ArcadeInput): ArcadeGameDefinition { | |
| 184 | + return { kind: "arcade", minBet: 10, maxBet: 1000, payScale: 1, tags: ["original", "beyond-slots"], featureNames: [], ...input }; | |
| 185 | +} | |
added
packages/game-core/src/arcade/ladder.ts
+272 −0
@@ -0,0 +1,272 @@ | ||
| 1 | +import type { Rng } from "../rng"; | |
| 2 | +import type { ArcadeGameDefinition, LadderAction, LadderEvent, LadderOffer, LadderState } from "./types"; | |
| 3 | + | |
| 4 | +/* ---------------------------------------------------------------- helpers */ | |
| 5 | + | |
| 6 | +const r2 = (x: number) => Math.floor(x * 100) / 100; | |
| 7 | + | |
| 8 | +function fail(msg: string): never { | |
| 9 | + throw new Error(msg); | |
| 10 | +} | |
| 11 | + | |
| 12 | +/** Next multiplier for a step with survival `p` from multiplier `m` (EV-neutral relative to the round RTP). */ | |
| 13 | +export function stepMultiplier(m: number, p: number): number { | |
| 14 | + return m / p; | |
| 15 | +} | |
| 16 | + | |
| 17 | +/* ------------------------------------------------------------- THE VAULT */ | |
| 18 | + | |
| 19 | +export interface VaultConfig { | |
| 20 | + /** Survival per layer (5 layers). */ | |
| 21 | + layers: number[]; | |
| 22 | + /** Digits revealed per layer (sub-steps, cosmetic — the layer's survival is split evenly). */ | |
| 23 | + digitsPerLayer: number; | |
| 24 | + layerNames: string[]; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function vaultStart(def: ArcadeGameDefinition, bet: number, rng: Rng): LadderState { | |
| 28 | + const cfg = def.config as unknown as VaultConfig; | |
| 29 | + const state: LadderState = { | |
| 30 | + game: def.slug, | |
| 31 | + version: def.version, | |
| 32 | + bet, | |
| 33 | + stage: 0, | |
| 34 | + current: def.rtp, // the round RTP is applied once, up front: securing after step k returns rtp·Π(1/p_i) | |
| 35 | + status: "running", | |
| 36 | + win: 0, | |
| 37 | + offers: [], | |
| 38 | + log: [{ stage: 0, kind: "start", label: "The vault hums. Five layers between you and the core.", multiplierAfter: 0, outcome: "info" }], | |
| 39 | + extra: { digits: [] as number[], layersOpen: 0 }, | |
| 40 | + }; | |
| 41 | + // Mandatory first layer. | |
| 42 | + return vaultAdvance(def, state, rng, { type: "continue" }, true); | |
| 43 | +} | |
| 44 | + | |
| 45 | +function vaultOffers(def: ArcadeGameDefinition, state: LadderState): LadderOffer[] { | |
| 46 | + const cfg = def.config as unknown as VaultConfig; | |
| 47 | + if (state.stage >= cfg.layers.length) return []; | |
| 48 | + const p = cfg.layers[state.stage]; | |
| 49 | + return [ | |
| 50 | + { | |
| 51 | + id: "open", | |
| 52 | + label: `Open layer ${state.stage + 1} — ${cfg.layerNames[state.stage]}`, | |
| 53 | + description: `${Math.round(p * 100)}% chance the lock yields`, | |
| 54 | + survival: p, | |
| 55 | + next: r2(stepMultiplier(state.current, p)), | |
| 56 | + advance: 1, | |
| 57 | + kind: "layer", | |
| 58 | + }, | |
| 59 | + ]; | |
| 60 | +} | |
| 61 | + | |
| 62 | +export function vaultAdvance(def: ArcadeGameDefinition, s: LadderState, rng: Rng, action: LadderAction, first = false): LadderState { | |
| 63 | + const cfg = def.config as unknown as VaultConfig; | |
| 64 | + const state: LadderState = structuredClone(s); | |
| 65 | + if (state.status !== "running") return state; | |
| 66 | + if (action.type === "cashout") { | |
| 67 | + if (first || state.stage === 0) fail("cannot secure before the first layer"); | |
| 68 | + state.status = "cashed"; | |
| 69 | + state.win = Math.round(state.bet * state.current); | |
| 70 | + state.log.push({ stage: state.stage, kind: "secure", label: `Secured ${r2(state.current)}×`, multiplierAfter: r2(state.current), outcome: "cash" }); | |
| 71 | + state.offers = []; | |
| 72 | + return state; | |
| 73 | + } | |
| 74 | + const p = cfg.layers[state.stage]; | |
| 75 | + if (p === undefined) fail("vault already open"); | |
| 76 | + // Reveal digits one by one; the layer's survival is split evenly across its digits. | |
| 77 | + const perDigit = Math.pow(p, 1 / cfg.digitsPerLayer); | |
| 78 | + const digits: number[] = []; | |
| 79 | + let bust = false; | |
| 80 | + for (let i = 0; i < cfg.digitsPerLayer; i++) { | |
| 81 | + if (!rng.chance(perDigit)) { | |
| 82 | + bust = true; | |
| 83 | + break; | |
| 84 | + } | |
| 85 | + digits.push(rng.int(10)); | |
| 86 | + } | |
| 87 | + const layerName = cfg.layerNames[state.stage]; | |
| 88 | + if (bust) { | |
| 89 | + state.status = "busted"; | |
| 90 | + state.win = 0; | |
| 91 | + state.log.push({ stage: state.stage + 1, kind: "alarm", label: `Alarm — ${layerName} sealed shut`, detail: `${digits.length}/${cfg.digitsPerLayer} digits matched`, multiplierAfter: 0, outcome: "bust", data: { digits } }); | |
| 92 | + state.offers = []; | |
| 93 | + state.current = 0; | |
| 94 | + return state; | |
| 95 | + } | |
| 96 | + state.current = stepMultiplier(state.current, p); | |
| 97 | + state.stage += 1; | |
| 98 | + (state.extra.digits as number[]).push(...digits); | |
| 99 | + state.extra.layersOpen = state.stage; | |
| 100 | + state.log.push({ stage: state.stage, kind: "layer", label: `${layerName} opened`, detail: digits.join(" "), multiplierAfter: r2(state.current), outcome: "ok", data: { digits } }); | |
| 101 | + if (state.stage >= cfg.layers.length) { | |
| 102 | + state.status = "completed"; | |
| 103 | + state.current = Math.min(state.current, def.maxMultiplier); | |
| 104 | + state.win = Math.round(state.bet * state.current); | |
| 105 | + state.log.push({ stage: state.stage, kind: "jackpot", label: "The core is open — fictional jackpot", multiplierAfter: r2(state.current), outcome: "cash" }); | |
| 106 | + state.offers = []; | |
| 107 | + return state; | |
| 108 | + } | |
| 109 | + state.offers = vaultOffers(def, state); | |
| 110 | + return state; | |
| 111 | +} | |
| 112 | + | |
| 113 | +/* ------------------------------------------------------------- ESCAPE 99 */ | |
| 114 | + | |
| 115 | +export interface EscapeConfig { | |
| 116 | + floors: number; | |
| 117 | + /** Survival by floor band: [uptoFloor, p][] */ | |
| 118 | + bands: [number, number][]; | |
| 119 | + checkpoints: number[]; | |
| 120 | + rooms: { kind: string; label: string; weight: number; detail: string }[]; | |
| 121 | + /** Chance a floor offers two paths (safe vs risky). */ | |
| 122 | + forkChance: number; | |
| 123 | + /** Chance a floor is a portal that jumps 3 floors in one step. */ | |
| 124 | + portalChance: number; | |
| 125 | +} | |
| 126 | + | |
| 127 | +function floorSurvival(cfg: EscapeConfig, floor: number): number { | |
| 128 | + for (const [upto, p] of cfg.bands) if (floor <= upto) return p; | |
| 129 | + return cfg.bands[cfg.bands.length - 1][1]; | |
| 130 | +} | |
| 131 | + | |
| 132 | +export function escapeStart(def: ArcadeGameDefinition, bet: number, rng: Rng): LadderState { | |
| 133 | + const state: LadderState = { | |
| 134 | + game: def.slug, | |
| 135 | + version: def.version, | |
| 136 | + bet, | |
| 137 | + stage: 0, | |
| 138 | + current: def.rtp, | |
| 139 | + status: "running", | |
| 140 | + win: 0, | |
| 141 | + offers: [], | |
| 142 | + log: [{ stage: 0, kind: "start", label: "Floor 1 of 99. The tower wakes up.", multiplierAfter: 0, outcome: "info" }], | |
| 143 | + extra: { checkpointsHit: [] as number[] }, | |
| 144 | + }; | |
| 145 | + return escapeAdvance(def, state, rng, { type: "continue" }, true); | |
| 146 | +} | |
| 147 | + | |
| 148 | +function escapeOffers(def: ArcadeGameDefinition, state: LadderState, rng: Rng): LadderOffer[] { | |
| 149 | + const cfg = def.config as unknown as EscapeConfig; | |
| 150 | + const floor = state.stage + 1; | |
| 151 | + if (floor > cfg.floors) return []; | |
| 152 | + const p = floorSurvival(cfg, floor); | |
| 153 | + const room = cfg.rooms[rng.weighted(cfg.rooms.map((r) => r.weight))]; | |
| 154 | + if (rng.chance(cfg.forkChance) && floor < cfg.floors - 2) { | |
| 155 | + const safe = Math.min(0.98, p + 0.06); | |
| 156 | + const risky = Math.max(0.5, p - 0.18); | |
| 157 | + return [ | |
| 158 | + { id: "safe", label: `Left corridor — ${room.label}`, description: `${Math.round(safe * 100)}% safe`, survival: safe, next: r2(stepMultiplier(state.current, safe)), advance: 1, kind: "fork-safe" }, | |
| 159 | + { id: "risky", label: `Right corridor — ${room.label}`, description: `${Math.round(risky * 100)}% safe, bigger jump`, survival: risky, next: r2(stepMultiplier(state.current, risky)), advance: 1, kind: "fork-risky" }, | |
| 160 | + ]; | |
| 161 | + } | |
| 162 | + if (rng.chance(cfg.portalChance) && floor <= cfg.floors - 3) { | |
| 163 | + const p3 = floorSurvival(cfg, floor) * floorSurvival(cfg, floor + 1) * floorSurvival(cfg, floor + 2); | |
| 164 | + return [{ id: "portal", label: "Portal — skip three floors", description: `${Math.round(p3 * 100)}% to arrive intact`, survival: p3, next: r2(stepMultiplier(state.current, p3)), advance: 3, kind: "portal" }]; | |
| 165 | + } | |
| 166 | + return [{ id: room.kind, label: `Floor ${floor} — ${room.label}`, description: `${Math.round(p * 100)}% safe · ${room.detail}`, survival: p, next: r2(stepMultiplier(state.current, p)), advance: 1, kind: room.kind }]; | |
| 167 | +} | |
| 168 | + | |
| 169 | +export function escapeAdvance(def: ArcadeGameDefinition, s: LadderState, rng: Rng, action: LadderAction, first = false): LadderState { | |
| 170 | + const cfg = def.config as unknown as EscapeConfig; | |
| 171 | + const state: LadderState = structuredClone(s); | |
| 172 | + if (state.status !== "running") return state; | |
| 173 | + if (action.type === "cashout") { | |
| 174 | + if (first || state.stage === 0) fail("cannot cash out before the first floor"); | |
| 175 | + state.status = "cashed"; | |
| 176 | + state.win = Math.round(state.bet * state.current); | |
| 177 | + state.log.push({ stage: state.stage, kind: "exit", label: `Exited at floor ${state.stage} with ${r2(state.current)}×`, multiplierAfter: r2(state.current), outcome: "cash" }); | |
| 178 | + state.offers = []; | |
| 179 | + return state; | |
| 180 | + } | |
| 181 | + if (state.offers.length === 0) state.offers = first ? [{ id: "start", label: "Floor 1 — Lobby", description: "", survival: floorSurvival(cfg, 1), next: r2(stepMultiplier(state.current, floorSurvival(cfg, 1))), advance: 1, kind: "room" }] : escapeOffers(def, state, rng); | |
| 182 | + const offer = state.offers.find((o) => o.id === (action.offerId ?? state.offers[0].id)) ?? state.offers[0]; | |
| 183 | + const survived = rng.chance(offer.survival); | |
| 184 | + const floorReached = state.stage + offer.advance; | |
| 185 | + if (!survived) { | |
| 186 | + state.status = "busted"; | |
| 187 | + state.current = 0; | |
| 188 | + state.win = 0; | |
| 189 | + state.log.push({ stage: floorReached, kind: offer.kind, label: bustLabel(offer.kind), detail: offer.label, multiplierAfter: 0, outcome: "bust" }); | |
| 190 | + state.offers = []; | |
| 191 | + return state; | |
| 192 | + } | |
| 193 | + state.current = Math.min(def.maxMultiplier, stepMultiplier(state.current, offer.survival)); | |
| 194 | + state.stage = floorReached; | |
| 195 | + const cp = cfg.checkpoints.includes(state.stage); | |
| 196 | + if (cp) (state.extra.checkpointsHit as number[]).push(state.stage); | |
| 197 | + state.log.push({ stage: state.stage, kind: offer.kind, label: offer.label, detail: cp ? "Checkpoint reached" : undefined, multiplierAfter: r2(state.current), outcome: "ok", data: { checkpoint: cp } }); | |
| 198 | + if (state.stage >= cfg.floors) { | |
| 199 | + state.status = "completed"; | |
| 200 | + state.win = Math.round(state.bet * state.current); | |
| 201 | + state.log.push({ stage: state.stage, kind: "summit", label: "Floor 99 — the tower opens to the sky", multiplierAfter: r2(state.current), outcome: "cash" }); | |
| 202 | + state.offers = []; | |
| 203 | + return state; | |
| 204 | + } | |
| 205 | + state.offers = escapeOffers(def, state, rng); | |
| 206 | + return state; | |
| 207 | +} | |
| 208 | + | |
| 209 | +function bustLabel(kind: string): string { | |
| 210 | + return ( | |
| 211 | + { | |
| 212 | + enemy: "Caught by the guardian", | |
| 213 | + trap: "Trap triggered", | |
| 214 | + chest: "The chest was a mimic", | |
| 215 | + portal: "Lost in the portal", | |
| 216 | + "fork-safe": "The corridor collapsed", | |
| 217 | + "fork-risky": "The corridor collapsed", | |
| 218 | + multiplier: "The rune backfired", | |
| 219 | + room: "The door locked behind you", | |
| 220 | + }[kind] ?? "Run over" | |
| 221 | + ); | |
| 222 | +} | |
| 223 | + | |
| 224 | +/* ---------------------------------------------------------------- driver */ | |
| 225 | + | |
| 226 | +export function ladderStart(def: ArcadeGameDefinition, bet: number, rng: Rng): LadderState { | |
| 227 | + if (def.slug === "the-vault") return vaultStart(def, bet, rng); | |
| 228 | + if (def.slug === "escape-99") return escapeStart(def, bet, rng); | |
| 229 | + fail(`unknown ladder game ${def.slug}`); | |
| 230 | +} | |
| 231 | + | |
| 232 | +export function ladderAdvance(def: ArcadeGameDefinition, state: LadderState, rng: Rng, action: LadderAction): LadderState { | |
| 233 | + if (def.slug === "the-vault") return vaultAdvance(def, state, rng, action); | |
| 234 | + if (def.slug === "escape-99") return escapeAdvance(def, state, rng, action); | |
| 235 | + fail(`unknown ladder game ${def.slug}`); | |
| 236 | +} | |
| 237 | + | |
| 238 | +/** Public projection: never leaks anything beyond what the player may see. */ | |
| 239 | +export function ladderView(state: LadderState) { | |
| 240 | + return { | |
| 241 | + game: state.game, | |
| 242 | + version: state.version, | |
| 243 | + bet: state.bet, | |
| 244 | + stage: state.stage, | |
| 245 | + current: r2(state.current), | |
| 246 | + status: state.status, | |
| 247 | + win: state.win, | |
| 248 | + offers: state.offers, | |
| 249 | + log: state.log, | |
| 250 | + extra: state.extra, | |
| 251 | + canCashout: state.status === "running" && state.stage > 0, | |
| 252 | + }; | |
| 253 | +} | |
| 254 | + | |
| 255 | +/* ------------------------------------------------------------- simulation */ | |
| 256 | + | |
| 257 | +/** Mixed strategy population: each simulated player stops at a random target stage (or never). */ | |
| 258 | +export function simulateLadderRound(def: ArcadeGameDefinition, bet: number, rng: Rng): number { | |
| 259 | + let state = ladderStart(def, bet, rng); | |
| 260 | + const maxStage = def.slug === "the-vault" ? 5 : 99; | |
| 261 | + const target = 1 + rng.int(maxStage); // stop after `target` stages | |
| 262 | + const preferRisky = rng.chance(0.5); | |
| 263 | + while (state.status === "running") { | |
| 264 | + if (state.stage >= target) { | |
| 265 | + state = ladderAdvance(def, state, rng, { type: "cashout" }); | |
| 266 | + break; | |
| 267 | + } | |
| 268 | + const offer = state.offers.length > 1 ? (preferRisky ? state.offers[1] : state.offers[0]) : state.offers[0]; | |
| 269 | + state = ladderAdvance(def, state, rng, { type: "continue", offerId: offer?.id }); | |
| 270 | + } | |
| 271 | + return state.win; | |
| 272 | +} | |
added
packages/game-core/src/arcade/orbit.ts
+121 −0
@@ -0,0 +1,121 @@ | ||
| 1 | +import type { Rng } from "../rng"; | |
| 2 | +import type { ArcadeGameDefinition, ArcadeOutcome } from "./types"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * ORBIT — objects circle a core on concentric orbits. The player fires an | |
| 6 | + * impulse at an angle; on each orbit the impulse can hit an object (angular | |
| 7 | + * proximity), collecting its value, deflecting, or spawning a new orbit. | |
| 8 | + * Supernova collapses every orbit and turns the remaining objects into | |
| 9 | + * multipliers of the collected total. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +export interface OrbitConfig { | |
| 13 | + orbits: number; | |
| 14 | + objectsPerOrbit: [number, number]; | |
| 15 | + /** Angular half-width of the impulse in degrees (hit tolerance). */ | |
| 16 | + beamHalfWidth: number; | |
| 17 | + objectTypes: { id: string; label: string; value: number; weight: number; deflect?: number; spawn?: boolean }[]; | |
| 18 | + supernovaChance: number; | |
| 19 | + maxOrbits: number; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export interface OrbitObject { | |
| 23 | + id: string; | |
| 24 | + type: string; | |
| 25 | + angle: number; | |
| 26 | + value: number; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export interface OrbitStep { | |
| 30 | + orbit: number; | |
| 31 | + angle: number; | |
| 32 | + hit: OrbitObject | null; | |
| 33 | + deflectedTo?: number; | |
| 34 | + spawned?: boolean; | |
| 35 | + collected: number; | |
| 36 | +} | |
| 37 | + | |
| 38 | +function angleDiff(a: number, b: number): number { | |
| 39 | + const d = Math.abs(((a - b) % 360 + 540) % 360 - 180); | |
| 40 | + return d; | |
| 41 | +} | |
| 42 | + | |
| 43 | +export function resolveOrbit(def: ArcadeGameDefinition, bet: number, rng: Rng, inputRaw: { angle?: number }): ArcadeOutcome { | |
| 44 | + const cfg = def.config as unknown as OrbitConfig; | |
| 45 | + let angle = ((inputRaw.angle ?? rng.int(360)) % 360 + 360) % 360; | |
| 46 | + const orbits: OrbitObject[][] = []; | |
| 47 | + let seq = 0; | |
| 48 | + const makeOrbit = (): OrbitObject[] => { | |
| 49 | + const n = cfg.objectsPerOrbit[0] + rng.int(cfg.objectsPerOrbit[1] - cfg.objectsPerOrbit[0] + 1); | |
| 50 | + const objs: OrbitObject[] = []; | |
| 51 | + for (let i = 0; i < n; i++) { | |
| 52 | + const t = cfg.objectTypes[rng.weighted(cfg.objectTypes.map((o) => o.weight))]; | |
| 53 | + objs.push({ id: `o${seq++}`, type: t.id, angle: rng.float() * 360, value: t.value }); | |
| 54 | + } | |
| 55 | + return objs; | |
| 56 | + }; | |
| 57 | + for (let i = 0; i < cfg.orbits; i++) orbits.push(makeOrbit()); | |
| 58 | + const steps: OrbitStep[] = []; | |
| 59 | + const features: string[] = []; | |
| 60 | + let collected = 0; | |
| 61 | + let i = 0; | |
| 62 | + while (i < orbits.length && i < cfg.maxOrbits) { | |
| 63 | + const objs = orbits[i]; | |
| 64 | + let hit: OrbitObject | null = null; | |
| 65 | + let best = Infinity; | |
| 66 | + for (const o of objs) { | |
| 67 | + const d = angleDiff(o.angle, angle); | |
| 68 | + if (d <= cfg.beamHalfWidth && d < best) { | |
| 69 | + best = d; | |
| 70 | + hit = o; | |
| 71 | + } | |
| 72 | + } | |
| 73 | + const step: OrbitStep = { orbit: i, angle, hit, collected }; | |
| 74 | + if (hit) { | |
| 75 | + const t = cfg.objectTypes.find((o) => o.id === hit!.type)!; | |
| 76 | + collected += hit.value; | |
| 77 | + step.collected = collected; | |
| 78 | + if (t.deflect) { | |
| 79 | + angle = (angle + (rng.float() - 0.5) * 2 * t.deflect + 360) % 360; | |
| 80 | + step.deflectedTo = angle; | |
| 81 | + features.push("Deflection"); | |
| 82 | + } | |
| 83 | + if (t.spawn && orbits.length < cfg.maxOrbits) { | |
| 84 | + orbits.push(makeOrbit()); | |
| 85 | + step.spawned = true; | |
| 86 | + features.push("New orbit"); | |
| 87 | + } | |
| 88 | + features.push(t.label); | |
| 89 | + } | |
| 90 | + steps.push(step); | |
| 91 | + i++; | |
| 92 | + } | |
| 93 | + let supernova: { remaining: number; multiplier: number } | null = null; | |
| 94 | + const hitIds = new Set(steps.filter((s) => s.hit).map((s) => s.hit!.id)); | |
| 95 | + if (collected > 0 && rng.chance(cfg.supernovaChance)) { | |
| 96 | + const remaining = orbits.flat().filter((o) => !hitIds.has(o.id)).length; | |
| 97 | + const mult = 1 + remaining * 0.5; | |
| 98 | + supernova = { remaining, multiplier: mult }; | |
| 99 | + collected *= mult; | |
| 100 | + features.push("Supernova"); | |
| 101 | + } | |
| 102 | + const scaled = collected * def.payScale; | |
| 103 | + const capped = scaled > def.maxMultiplier; | |
| 104 | + const multiplier = Math.min(def.maxMultiplier, scaled); | |
| 105 | + const totalWin = Math.round(bet * multiplier); | |
| 106 | + return { | |
| 107 | + game: def.slug, | |
| 108 | + version: def.version, | |
| 109 | + bet, | |
| 110 | + totalWin, | |
| 111 | + multiplier: bet ? totalWin / bet : 0, | |
| 112 | + capped, | |
| 113 | + features: [...new Set(features)], | |
| 114 | + steps, | |
| 115 | + summary: { angle: inputRaw.angle ?? null, orbits: orbits.map((o) => o.map((x) => ({ ...x, value: Math.round(x.value * def.payScale * 100) / 100 }))), supernova, hits: hitIds.size }, | |
| 116 | + }; | |
| 117 | +} | |
| 118 | + | |
| 119 | +export function randomOrbitInput(_def: ArcadeGameDefinition, rng: Rng): { angle: number } { | |
| 120 | + return { angle: rng.int(360) }; | |
| 121 | +} | |
added
packages/game-core/src/arcade/types.ts
+106 −0
@@ -0,0 +1,106 @@ | ||
| 1 | +/** | |
| 2 | + * Spinza Originals — "Beyond Slots" arcade engine. | |
| 3 | + * | |
| 4 | + * Two families share the transport: | |
| 5 | + * - instant games (Dropzone, Grid//Break, Orbit): one input → one fully | |
| 6 | + * resolved outcome the client animates; | |
| 7 | + * - ladder games (The Vault, Escape 99): a session advances step by step; the | |
| 8 | + * player may secure the current multiplier or push on. Ladder multipliers | |
| 9 | + * follow m_k = rtp · Π(1/p_i) so the expected return is `rtp` for ANY | |
| 10 | + * stopping strategy, exactly like the crash games. | |
| 11 | + * | |
| 12 | + * All randomness comes from the server `Rng`; the browser never resolves. | |
| 13 | + */ | |
| 14 | + | |
| 15 | +export type ArcadeMode = "instant" | "ladder"; | |
| 16 | + | |
| 17 | +export interface ArcadePresentation { | |
| 18 | + scene: "dropzone" | "gridbreak" | "vault" | "orbit" | "escape"; | |
| 19 | + palette: { primary: string; secondary: string; glow: string; bg: string; surface: string }; | |
| 20 | + ambience: string; | |
| 21 | + /** Main action label(s). */ | |
| 22 | + verb: string; | |
| 23 | + secondaryVerb?: string; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export interface ArcadeGameDefinition { | |
| 27 | + kind: "arcade"; | |
| 28 | + slug: string; | |
| 29 | + name: string; | |
| 30 | + version: string; | |
| 31 | + tagline: string; | |
| 32 | + description: string; | |
| 33 | + theme: string; | |
| 34 | + tags: string[]; | |
| 35 | + mode: ArcadeMode; | |
| 36 | + rtp: number; | |
| 37 | + minBet: number; | |
| 38 | + maxBet: number; | |
| 39 | + maxMultiplier: number; | |
| 40 | + volatility: "low" | "medium" | "high" | "extreme"; | |
| 41 | + /** RTP tuning factor for instant games (set by calibration). Ladders are analytic and ignore it. */ | |
| 42 | + payScale: number; | |
| 43 | + presentation: ArcadePresentation; | |
| 44 | + rules: string[]; | |
| 45 | + featureNames: string[]; | |
| 46 | + /** Game-specific configuration (typed per game module). */ | |
| 47 | + config: Record<string, unknown>; | |
| 48 | +} | |
| 49 | + | |
| 50 | +/** Fully resolved instant outcome; `steps` is game-specific presentation data. */ | |
| 51 | +export interface ArcadeOutcome { | |
| 52 | + game: string; | |
| 53 | + version: string; | |
| 54 | + bet: number; | |
| 55 | + totalWin: number; | |
| 56 | + multiplier: number; | |
| 57 | + capped: boolean; | |
| 58 | + features: string[]; | |
| 59 | + steps: unknown[]; | |
| 60 | + summary: Record<string, unknown>; | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** Ladder session state persisted between actions. */ | |
| 64 | +export interface LadderState { | |
| 65 | + game: string; | |
| 66 | + version: string; | |
| 67 | + bet: number; | |
| 68 | + /** Stage index reached (0 = start, before the mandatory first step). */ | |
| 69 | + stage: number; | |
| 70 | + /** Multiplier the player can secure right now. */ | |
| 71 | + current: number; | |
| 72 | + status: "running" | "cashed" | "busted" | "completed"; | |
| 73 | + win: number; | |
| 74 | + /** Options offered for the next step (one or two paths). */ | |
| 75 | + offers: LadderOffer[]; | |
| 76 | + /** Presentation log of everything that happened. */ | |
| 77 | + log: LadderEvent[]; | |
| 78 | + /** Game-specific extra state (e.g. digits revealed). */ | |
| 79 | + extra: Record<string, unknown>; | |
| 80 | +} | |
| 81 | + | |
| 82 | +export interface LadderOffer { | |
| 83 | + id: string; | |
| 84 | + label: string; | |
| 85 | + description: string; | |
| 86 | + /** Survival probability of this step (shown to the player as risk %). */ | |
| 87 | + survival: number; | |
| 88 | + /** Multiplier reached if the step succeeds. */ | |
| 89 | + next: number; | |
| 90 | + /** Number of stages this step advances (portals skip). */ | |
| 91 | + advance: number; | |
| 92 | + kind: string; | |
| 93 | +} | |
| 94 | + | |
| 95 | +export interface LadderEvent { | |
| 96 | + stage: number; | |
| 97 | + kind: string; | |
| 98 | + label: string; | |
| 99 | + detail?: string; | |
| 100 | + multiplierAfter: number; | |
| 101 | + outcome: "ok" | "bust" | "cash" | "info"; | |
| 102 | + /** Presentation payload (e.g. revealed digits, room type). */ | |
| 103 | + data?: Record<string, unknown>; | |
| 104 | +} | |
| 105 | + | |
| 106 | +export type LadderAction = { type: "continue"; offerId?: string } | { type: "cashout" }; | |
modified
packages/game-core/src/client.ts
+5 −0
@@ -10,3 +10,8 @@ export { waysCount } from "./define"; | ||
| 10 | 10 | export { LINES_10, LINES_20, LINES_25 } from "./evaluate"; |
| 11 | 11 | export type { CrashGameDefinition, CrashEvent, CrashCurve, CrashScene, CrashMilestone } from "./crash/curve"; |
| 12 | 12 | export { multiplierAt, effectiveTime, timeForMultiplier, floorAt } from "./crash/curve"; |
| 13 | +export type { ArcadeGameDefinition, ArcadeOutcome, ArcadePresentation, LadderState, LadderOffer, LadderEvent, LadderAction } from "./arcade/types"; | |
| 14 | +export type { DropStep, DropzoneConfig, DropzoneInput } from "./arcade/dropzone"; | |
| 15 | +export type { GridStep, GridbreakConfig, Cell as GridCell } from "./arcade/gridbreak"; | |
| 16 | +export type { OrbitStep, OrbitObject, OrbitConfig } from "./arcade/orbit"; | |
| 17 | +export type { VaultConfig, EscapeConfig } from "./arcade/ladder"; | |
modified
packages/game-core/src/index.ts
+1 −0
@@ -9,3 +9,4 @@ export * from "./simulation"; | ||
| 9 | 9 | export * from "./validation"; |
| 10 | 10 | export * from "./define"; |
| 11 | 11 | export * from "./crash"; |
| 12 | +export * from "./arcade"; | |
| 12 | 13 | |