SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%

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>
Simon-Pierre Boucher committed 16 days ago (Sep 8, 2026) parent e589373

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