Beyond Slots: arcade engine (instant + ladder), 5 originals with APIs, 5 game UIs, 15 new posters, admin/docs updates
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
43 changed files +11,415 −41
modified
CLAUDE.md
+15 −3
@@ -7,13 +7,25 @@ Spinza (www.spinza.dev) is a premium **fictional** social casino. Virtual credit | ||
| 7 | 7 | | Path | Package | Role | |
| 8 | 8 | |---|---|---| |
| 9 | 9 | | `packages/shared` | `@spinza/shared` | Economy constants (10,000 SC start, bet levels, daily rewards, XP curve), zod schemas, formatting (`formatSC` — never a currency sign), API DTO types. | |
| 10 | −| `packages/game-core` | `@spinza/game-core` | **Server-only game engine**: `CryptoRng` (crypto.randomBytes, rejection sampling — `Math.random` is banned for outcomes), reels, ways/lines evaluation, wild/scatter/cascade/hold-respin/pick-bonus/meter/heat/mystery/quantum/dynamic-grid features, `runSpin()`, `simulate()`, `certify()`, `validateDefinition()`, `defineGame()`. `./client` export = types only (safe for the browser). | | |
| 11 | −| `games/` | `@spinza/games` | 20 original game definitions (`src/<slug>/index.ts`), `registry.ts`, `calibration.json` (payScale per slug+version, written by the simulator), `certifications/<slug>.json` (PASS/FAIL reports). | | |
| 10 | +| `packages/game-core` | `@spinza/game-core` | **Also**: `src/crash/` (crash engine: `P(crash ≥ x) = rtp/x`, SHA-256 commit/reveal, curves exp/power/steps, pacing events, `multiplierAt` shared with the browser via `crash/curve.ts`) and `src/arcade/` (instant resolvers + ladder engine `m_k = rtp·Π(1/p_i)` — strategy-independent RTP). | | |
| 11 | +| | | **Server-only game engine**: `CryptoRng` (crypto.randomBytes, rejection sampling — `Math.random` is banned for outcomes), reels, ways/lines evaluation, wild/scatter/cascade/hold-respin/pick-bonus/meter/heat/mystery/quantum/dynamic-grid features, `runSpin()`, `simulate()`, `certify()`, `validateDefinition()`, `defineGame()`. `./client` export = types only (safe for the browser). | | |
| 12 | +| `games/` | `@spinza/games` | 20 slot definitions (`src/<slug>/index.ts`, `registry.ts`), 10 **Risk Games** (`src/crash/index.ts`, cash-out/crash engine), 5 **Spinza Originals — Beyond Slots** (`src/arcade/index.ts`: Dropzone, Grid//Break, Orbit = instant; The Vault, Escape 99 = ladder), `calibration.json` (payScale per slug+version), `certifications/<slug>.json` (PASS/FAIL reports for all 35 games). | | |
| 12 | 13 | | `packages/database` | `@spinza/database` | Drizzle schema (25 tables, snake_case casing), migrations in `drizzle/`, `seed.ts` (games sync, achievements, missions, levels, flags, settings), `sync-games.ts`. | |
| 13 | 14 | | `apps/api` | `@spinza/api` | Fastify on 127.0.0.1:8231. Auth (argon2id, opaque 256-bit sessions hashed, recovery codes), atomic spin (`services/spin.ts`), wallet ledger (`services/wallet.ts`), progression, rewards, leaderboards, admin (TOTP), health. | |
| 14 | 15 | | `apps/simulator` | `@spinza/simulator` | CLI `pnpm sim <validate|run|calibrate|certify>` with worker threads; also used by the admin simulator. | |
| 15 | 16 | | `apps/web` | `@spinza/web` | Next 16 (port 8230). `/api/*` is rewritten to the Fastify service. Game screen = `src/components/game/*` (PixiJS renderer, procedural symbols, WebAudio sound). Admin console under `/admin`. | |
| 16 | 17 | |
| 18 | +## Game families & API | |
| 19 | + | |
| 20 | +| Family | Kind | Play API | Client | | |
| 21 | +|---|---|---|---| | |
| 22 | +| Slots (20) | `slot` | `POST /api/games/:slug/spin` | `components/game/*` (PixiJS renderer) | | |
| 23 | +| Risk Games (10) | `crash` | `POST /api/crash/:slug/start` → poll `GET /api/crash/:slug/rounds/:id` → `POST /api/crash/:slug/cashout` (auto cash-out settles server-side; `crash_rounds` table; lazy settlement) | `components/crash/*` (2D canvas scenes) | | |
| 24 | +| Beyond Slots instant (3) | `arcade` | `POST /api/arcade/:slug/play {bet, clientRoundId, input}` | `components/arcade/{dropzone,gridbreak,orbit}.tsx` | | |
| 25 | +| Beyond Slots ladder (2) | `arcade` | `POST /api/arcade/:slug/start` → `POST /api/arcade/:slug/act {roundId, action}` (`arcade_sessions`) | `components/arcade/{vault,escape}.tsx` | | |
| 26 | + | |
| 27 | +Every settled round of any family goes through `apps/api/src/services/settle.ts` (`settleRound`: game_rounds row, stats, XP, achievements, missions, leaderboards). Lobby categories come from `summary.kind`/`category` written by `sync-games.ts`. | |
| 28 | + | |
| 17 | 29 | ## Non-negotiable rules |
| 18 | 30 | |
| 19 | 31 | - Outcomes are decided **only** in `apps/api` via `runSpin`. The browser animates `result.steps`; it never computes wins. |
@@ -29,7 +41,7 @@ Spinza (www.spinza.dev) is a premium **fictional** social casino. Virtual credit | ||
| 29 | 41 | pnpm install |
| 30 | 42 | createdb spinza && pnpm db:migrate && pnpm db:seed # local Postgres 17 + Redis required |
| 31 | 43 | pnpm dev:api # 8231 pnpm dev:web # 8230 |
| 32 | −pnpm sim validate | pnpm sim run <slug> --spins 1000000 | pnpm sim calibrate all --spins 10000000 --threads 26 | pnpm sim certify all --spins 10000000 | |
| 44 | +pnpm sim validate | pnpm sim run <slug> --spins 1000000 | pnpm sim calibrate all|arcade --spins 10000000 --threads 26 | pnpm sim certify all|crash|arcade --spins 10000000 | |
| 33 | 45 | pnpm --filter @spinza/game-core test # engine unit tests |
| 34 | 46 | pnpm --filter @spinza/api test # integration tests (needs local DB/Redis) |
| 35 | 47 | pnpm admin:create <username> [password] # prints TOTP secret once |
modified
apps/api/src/app.ts
+2 −0
@@ -11,6 +11,7 @@ import { rewardRoutes } from "./routes/rewards"; | ||
| 11 | 11 | import { healthRoutes } from "./routes/health"; |
| 12 | 12 | import { adminRoutes } from "./routes/admin"; |
| 13 | 13 | import { crashRoutes } from "./routes/crash"; |
| 14 | +import { arcadeRoutes } from "./routes/arcade"; | |
| 14 | 15 | import { refreshSettings } from "./lib/settings"; |
| 15 | 16 | |
| 16 | 17 | const REDACT = ["req.headers.cookie", "req.headers.authorization", "*.password", "*.newPassword", "*.currentPassword", "*.recoveryCode", "*.totp", "*.passwordHash", "*.tokenHash"]; |
@@ -71,6 +72,7 @@ export async function buildApp(): Promise<FastifyInstance> { | ||
| 71 | 72 | await app.register(gameRoutes); |
| 72 | 73 | await app.register(rewardRoutes); |
| 73 | 74 | await app.register(crashRoutes); |
| 75 | + await app.register(arcadeRoutes); | |
| 74 | 76 | await app.register(adminRoutes); |
| 75 | 77 | |
| 76 | 78 | return app; |
modified
apps/api/src/routes/admin.ts
+6 −5
@@ -1,7 +1,7 @@ | ||
| 1 | 1 | import type { FastifyInstance } from "fastify"; |
| 2 | 2 | import os from "node:os"; |
| 3 | 3 | import { achievements, adminUsers, and, count, creditTransactions, db, desc, eq, gameRounds, gameStatistics, games, gte, ilike, missions, securityEvents, sessions, simulationRuns, sql, users, wallets, gameVersions, or } from "@spinza/database"; |
| 4 | −import { GAMES, getGame } from "@spinza/games"; | |
| 4 | +import { GAMES, getGame, CRASH_BY_SLUG, ARCADE_BY_SLUG } from "@spinza/games"; | |
| 5 | 5 | import { certify, DEFAULT_CERTIFICATION_RULES, validateDefinition } from "@spinza/game-core"; |
| 6 | 6 | import { simulateParallel } from "@spinza/simulator"; |
| 7 | 7 | import { GAME_LIFECYCLE } from "@spinza/shared"; |
@@ -153,13 +153,14 @@ export async function adminRoutes(app: FastifyInstance) { | ||
| 153 | 153 | return { |
| 154 | 154 | games: rows.map(({ game, stats }) => { |
| 155 | 155 | const def = getGame(game.slug); |
| 156 | + const other = CRASH_BY_SLUG.get(game.slug) ?? ARCADE_BY_SLUG.get(game.slug); | |
| 156 | 157 | return { |
| 157 | 158 | ...game, |
| 158 | 159 | enabled: flags[`game.${game.slug}.enabled`] ?? true, |
| 159 | − rtp: def?.rtp ?? null, | |
| 160 | − payScale: def?.payScale ?? null, | |
| 160 | + rtp: def?.rtp ?? other?.rtp ?? null, | |
| 161 | + payScale: def?.payScale ?? (other && "payScale" in other ? other.payScale : null), | |
| 161 | 162 | stats: stats ? { ...stats, effectiveRtp: Number(stats.wagered) ? Number(stats.won) / Number(stats.wagered) : null } : null, |
| 162 | − validation: def ? validateDefinition(def) : [{ level: "error", message: "definition missing from library" }], | |
| 163 | + validation: def ? validateDefinition(def) : other ? [] : [{ level: "error", message: "definition missing from library" }], | |
| 163 | 164 | }; |
| 164 | 165 | }), |
| 165 | 166 | }; |
@@ -175,7 +176,7 @@ export async function adminRoutes(app: FastifyInstance) { | ||
| 175 | 176 | const daily = await db.execute(sql`select date_trunc('day', created_at) as day, count(*)::int as spins, sum(bet)::bigint as wagered, sum(win)::bigint as won, count(distinct user_id)::int as players |
| 176 | 177 | from game_rounds where game_id = ${g.id} and created_at >= now() - interval '30 days' group by 1 order by 1`); |
| 177 | 178 | const runs = await db.select().from(simulationRuns).where(eq(simulationRuns.gameSlug, slug)).orderBy(desc(simulationRuns.createdAt)).limit(10); |
| 178 | − return { game: g, definition: getGame(slug) ?? null, versions, stats, daily: daily.rows, simulationRuns: runs }; | |
| 179 | + return { game: g, definition: getGame(slug) ?? CRASH_BY_SLUG.get(slug) ?? ARCADE_BY_SLUG.get(slug) ?? null, versions, stats, daily: daily.rows, simulationRuns: runs }; | |
| 179 | 180 | }); |
| 180 | 181 | |
| 181 | 182 | app.post("/api/admin/games/:slug/lifecycle", async (req) => { |
added
apps/api/src/routes/arcade.ts
+218 −0
@@ -0,0 +1,218 @@ | ||
| 1 | +import type { FastifyInstance } from "fastify"; | |
| 2 | +import { and, arcadeSessions, db, desc, eq, games, sql } from "@spinza/database"; | |
| 3 | +import { CryptoRng, ladderAdvance, ladderStart, ladderView, resolveInstant, type ArcadeGameDefinition, type LadderAction, type LadderState } from "@spinza/game-core"; | |
| 4 | +import { ARCADE_BY_SLUG } from "@spinza/games"; | |
| 5 | +import { BET_LEVELS, classifyWin } from "@spinza/shared"; | |
| 6 | +import { z } from "zod"; | |
| 7 | +import { errors } from "../lib/errors"; | |
| 8 | +import { newRoundId } from "../lib/crypto"; | |
| 9 | +import { flag, maintenance } from "../lib/settings"; | |
| 10 | +import { rateLimit, redis } from "../lib/redis"; | |
| 11 | +import { requireUser } from "../plugins/auth"; | |
| 12 | +import { applyCredit, lockWallet, saveWallet } from "../services/wallet"; | |
| 13 | +import { settleRound } from "../services/settle"; | |
| 14 | +import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg"; | |
| 15 | + | |
| 16 | +type SessionRow = typeof arcadeSessions.$inferSelect; | |
| 17 | + | |
| 18 | +function guard(slug: string): { def: ArcadeGameDefinition } { | |
| 19 | + const def = ARCADE_BY_SLUG.get(slug); | |
| 20 | + if (!def) throw errors.notFound("Unknown game"); | |
| 21 | + const m = maintenance(); | |
| 22 | + if (m.enabled) throw errors.maintenance(m.message); | |
| 23 | + if (!flag(`game.${slug}.enabled`)) throw errors.unavailable("This game is temporarily unavailable."); | |
| 24 | + return { def }; | |
| 25 | +} | |
| 26 | + | |
| 27 | +function checkBet(def: ArcadeGameDefinition, bet: number) { | |
| 28 | + if (!(BET_LEVELS as readonly number[]).includes(bet) || bet < def.minBet || bet > def.maxBet) throw errors.badRequest("Invalid bet for this game."); | |
| 29 | +} | |
| 30 | + | |
| 31 | +export async function arcadeRoutes(app: FastifyInstance) { | |
| 32 | + /* ------------------------------------------------------------ instant */ | |
| 33 | + app.post("/api/arcade/:slug/play", async (req) => { | |
| 34 | + const user = requireUser(req); | |
| 35 | + const { slug } = req.params as { slug: string }; | |
| 36 | + const { def } = guard(slug); | |
| 37 | + if (def.mode !== "instant") throw errors.badRequest("This game is played step by step — use /start."); | |
| 38 | + const retry = await rateLimit(`arcade:${user.id}`, 180, 60); | |
| 39 | + if (retry) throw errors.rateLimited(retry); | |
| 40 | + const body = z.object({ bet: z.number().int(), clientRoundId: z.string().uuid(), input: z.record(z.string(), z.unknown()).default({}) }).parse(req.body); | |
| 41 | + checkBet(def, body.bet); | |
| 42 | + const game = await db.query.games.findFirst({ where: eq(games.slug, slug) }); | |
| 43 | + if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published."); | |
| 44 | + | |
| 45 | + const existing = await db.execute(sql`select round_id, win, multiplier, balance_after, result from game_rounds where user_id = ${user.id} and client_round_id = ${body.clientRoundId}`); | |
| 46 | + if (existing.rows.length) { | |
| 47 | + const r = existing.rows[0] as { round_id: string; win: number; multiplier: string; balance_after: number; result: unknown }; | |
| 48 | + return { roundId: r.round_id, outcome: r.result, win: Number(r.win), multiplier: Number(r.multiplier), balance: Number(r.balance_after), winClass: classifyWin(Number(r.multiplier)), replayed: true }; | |
| 49 | + } | |
| 50 | + const started = Date.now(); | |
| 51 | + const roundId = newRoundId(); | |
| 52 | + const rng = new CryptoRng(); | |
| 53 | + try { | |
| 54 | + const res = await db.transaction(async (tx) => { | |
| 55 | + const wallet = await lockWallet(tx, user.id); | |
| 56 | + if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet); | |
| 57 | + const outcome = resolveInstant(def, body.bet, body.input, rng); | |
| 58 | + await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug }); | |
| 59 | + if (outcome.totalWin > 0) await applyCredit(tx, wallet, "WIN", outcome.totalWin, roundId, { game: slug, multiplier: outcome.multiplier }); | |
| 60 | + const settled = await settleRound(tx, wallet, { | |
| 61 | + roundId, | |
| 62 | + gameId: game.id, | |
| 63 | + gameSlug: slug, | |
| 64 | + gameVersion: def.version, | |
| 65 | + clientRoundId: body.clientRoundId, | |
| 66 | + bet: body.bet, | |
| 67 | + win: outcome.totalWin, | |
| 68 | + multiplier: outcome.multiplier, | |
| 69 | + result: { kind: "arcade", ...outcome } as unknown as Record<string, unknown>, | |
| 70 | + features: outcome.features, | |
| 71 | + freeSpins: false, | |
| 72 | + bonus: outcome.features.some((f) => f === "Deep Drop" || f === "Supernova" || f === "Chain reaction"), | |
| 73 | + jackpotTier: null, | |
| 74 | + rngReference: rng.reference(), | |
| 75 | + durationMs: Date.now() - started, | |
| 76 | + }); | |
| 77 | + await saveWallet(tx, wallet); | |
| 78 | + return { outcome, balance: wallet.balance, settled }; | |
| 79 | + }); | |
| 80 | + redis().zadd("live:players", Date.now(), user.id).catch(() => {}); | |
| 81 | + return { roundId, outcome: res.outcome, win: res.outcome.totalWin, multiplier: res.outcome.multiplier, balance: res.balance, winClass: res.settled.winClass, xp: res.settled.xp, unlocked: res.settled.unlocked }; | |
| 82 | + } catch (e) { | |
| 83 | + if (pgCode(e) === PG_UNIQUE_VIOLATION) { | |
| 84 | + const again = await db.execute(sql`select round_id, win, multiplier, balance_after, result from game_rounds where user_id = ${user.id} and client_round_id = ${body.clientRoundId}`); | |
| 85 | + if (again.rows.length) { | |
| 86 | + const r = again.rows[0] as { round_id: string; win: number; multiplier: string; balance_after: number; result: unknown }; | |
| 87 | + return { roundId: r.round_id, outcome: r.result, win: Number(r.win), multiplier: Number(r.multiplier), balance: Number(r.balance_after), winClass: classifyWin(Number(r.multiplier)), replayed: true }; | |
| 88 | + } | |
| 89 | + } | |
| 90 | + throw e; | |
| 91 | + } | |
| 92 | + }); | |
| 93 | + | |
| 94 | + /* ------------------------------------------------------------- ladder */ | |
| 95 | + app.get("/api/arcade/:slug/current", async (req) => { | |
| 96 | + const user = requireUser(req); | |
| 97 | + const { slug } = req.params as { slug: string }; | |
| 98 | + const row = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.gameSlug, slug), eq(arcadeSessions.status, "running")), orderBy: desc(arcadeSessions.startedAt) }); | |
| 99 | + return { session: row ? { roundId: row.roundId, ...ladderView(row.state as unknown as LadderState) } : null }; | |
| 100 | + }); | |
| 101 | + | |
| 102 | + app.post("/api/arcade/:slug/start", async (req) => { | |
| 103 | + const user = requireUser(req); | |
| 104 | + const { slug } = req.params as { slug: string }; | |
| 105 | + const { def } = guard(slug); | |
| 106 | + if (def.mode !== "ladder") throw errors.badRequest("This game resolves in one shot — use /play."); | |
| 107 | + const retry = await rateLimit(`arcade:${user.id}`, 180, 60); | |
| 108 | + if (retry) throw errors.rateLimited(retry); | |
| 109 | + const body = z.object({ bet: z.number().int(), clientRoundId: z.string().uuid() }).parse(req.body); | |
| 110 | + checkBet(def, body.bet); | |
| 111 | + const game = await db.query.games.findFirst({ where: eq(games.slug, slug) }); | |
| 112 | + if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published."); | |
| 113 | + | |
| 114 | + const existing = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.clientRoundId, body.clientRoundId)) }); | |
| 115 | + if (existing) return { session: { roundId: existing.roundId, ...ladderView(existing.state as unknown as LadderState) }, balance: null, replayed: true }; | |
| 116 | + const running = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.status, "running")) }); | |
| 117 | + if (running) throw errors.conflict("ROUND_IN_PROGRESS", `You have an unfinished ${running.gameSlug} run — finish or cash it out first.`); | |
| 118 | + | |
| 119 | + const roundId = newRoundId(); | |
| 120 | + const rng = new CryptoRng(); | |
| 121 | + try { | |
| 122 | + const result = await db.transaction(async (tx) => { | |
| 123 | + const wallet = await lockWallet(tx, user.id); | |
| 124 | + if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet); | |
| 125 | + await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug }); | |
| 126 | + const state = ladderStart(def, body.bet, rng); | |
| 127 | + let row: SessionRow; | |
| 128 | + [row] = await tx | |
| 129 | + .insert(arcadeSessions) | |
| 130 | + .values({ roundId, userId: user.id, gameId: game.id, gameSlug: slug, gameVersion: def.version, clientRoundId: body.clientRoundId, bet: body.bet, state: state as unknown as Record<string, unknown>, status: state.status }) | |
| 131 | + .returning(); | |
| 132 | + let settled: Awaited<ReturnType<typeof settleRound>> | null = null; | |
| 133 | + if (state.status !== "running") { | |
| 134 | + // Busted (or completed) on the mandatory first step. | |
| 135 | + settled = await finalize(tx, wallet, row, state, def, rng.reference()); | |
| 136 | + [row] = await tx.update(arcadeSessions).set({ status: state.status, win: state.win, settledAt: new Date(), updatedAt: new Date() }).where(eq(arcadeSessions.id, row.id)).returning(); | |
| 137 | + } | |
| 138 | + await saveWallet(tx, wallet); | |
| 139 | + return { row, state, balance: wallet.balance, settled }; | |
| 140 | + }); | |
| 141 | + redis().zadd("live:players", Date.now(), user.id).catch(() => {}); | |
| 142 | + return { session: { roundId, ...ladderView(result.state) }, balance: result.balance, progression: result.settled ? { xp: result.settled.xp, unlocked: result.settled.unlocked } : null }; | |
| 143 | + } catch (e) { | |
| 144 | + if (pgCode(e) === PG_UNIQUE_VIOLATION) { | |
| 145 | + const again = await db.query.arcadeSessions.findFirst({ where: and(eq(arcadeSessions.userId, user.id), eq(arcadeSessions.clientRoundId, body.clientRoundId)) }); | |
| 146 | + if (again) return { session: { roundId: again.roundId, ...ladderView(again.state as unknown as LadderState) }, balance: null, replayed: true }; | |
| 147 | + } | |
| 148 | + throw e; | |
| 149 | + } | |
| 150 | + }); | |
| 151 | + | |
| 152 | + app.post("/api/arcade/:slug/act", async (req) => { | |
| 153 | + const user = requireUser(req); | |
| 154 | + const { slug } = req.params as { slug: string }; | |
| 155 | + const { def } = guard(slug); | |
| 156 | + const body = z.object({ roundId: z.string(), action: z.object({ type: z.enum(["continue", "cashout"]), offerId: z.string().optional() }) }).parse(req.body); | |
| 157 | + const rng = new CryptoRng(); | |
| 158 | + const res = await db.transaction(async (tx) => { | |
| 159 | + const lock = await tx.execute(sql`select id from arcade_sessions where round_id = ${body.roundId} and user_id = ${user.id} for update`); | |
| 160 | + if (!lock.rows.length) throw errors.notFound("Run not found"); | |
| 161 | + const row = (await tx.query.arcadeSessions.findFirst({ where: eq(arcadeSessions.roundId, body.roundId) }))!; | |
| 162 | + const state = row.state as unknown as LadderState; | |
| 163 | + if (row.status !== "running") { | |
| 164 | + return { row, state, balance: null as number | null, settled: null as Awaited<ReturnType<typeof settleRound>> | null }; | |
| 165 | + } | |
| 166 | + let next: LadderState; | |
| 167 | + try { | |
| 168 | + next = ladderAdvance(def, state, rng, body.action as LadderAction); | |
| 169 | + } catch (e) { | |
| 170 | + throw errors.badRequest((e as Error).message); | |
| 171 | + } | |
| 172 | + let settled: Awaited<ReturnType<typeof settleRound>> | null = null; | |
| 173 | + let balance: number | null = null; | |
| 174 | + if (next.status !== "running") { | |
| 175 | + const wallet = await lockWallet(tx, user.id); | |
| 176 | + settled = await finalize(tx, wallet, row, next, def, rng.reference()); | |
| 177 | + await saveWallet(tx, wallet); | |
| 178 | + balance = wallet.balance; | |
| 179 | + } | |
| 180 | + const [updated] = await tx | |
| 181 | + .update(arcadeSessions) | |
| 182 | + .set({ state: next as unknown as Record<string, unknown>, status: next.status, win: next.win, updatedAt: new Date(), settledAt: next.status !== "running" ? new Date() : null }) | |
| 183 | + .where(eq(arcadeSessions.id, row.id)) | |
| 184 | + .returning(); | |
| 185 | + return { row: updated, state: next, balance, settled }; | |
| 186 | + }); | |
| 187 | + return { session: { roundId: res.row.roundId, ...ladderView(res.state) }, balance: res.balance, winClass: res.state.status === "running" ? null : classifyWin(res.state.win / res.state.bet), progression: res.settled ? { xp: res.settled.xp, unlocked: res.settled.unlocked } : null }; | |
| 188 | + }); | |
| 189 | + | |
| 190 | + app.get("/api/arcade/:slug/history", async (req) => { | |
| 191 | + const { slug } = req.params as { slug: string }; | |
| 192 | + if (!ARCADE_BY_SLUG.has(slug)) throw errors.notFound(); | |
| 193 | + const rows = await db.execute(sql`select multiplier, win, bet, created_at, result->'summary' as summary, result->>'status' as status, (result->>'stage')::int as stage from game_rounds where game_slug = ${slug} order by created_at desc limit 20`); | |
| 194 | + return { history: rows.rows }; | |
| 195 | + }); | |
| 196 | +} | |
| 197 | + | |
| 198 | +/** Credit the win (if any) and write the shared round bookkeeping for a finished ladder run. */ | |
| 199 | +async function finalize(tx: Parameters<typeof settleRound>[0], wallet: Parameters<typeof settleRound>[1], row: SessionRow, state: LadderState, def: ArcadeGameDefinition, rngReference: string) { | |
| 200 | + if (state.win > 0) await applyCredit(tx, wallet, "WIN", state.win, row.roundId, { game: def.slug, multiplier: state.win / row.bet }); | |
| 201 | + return settleRound(tx, wallet, { | |
| 202 | + roundId: row.roundId, | |
| 203 | + gameId: row.gameId, | |
| 204 | + gameSlug: def.slug, | |
| 205 | + gameVersion: def.version, | |
| 206 | + clientRoundId: row.clientRoundId, | |
| 207 | + bet: row.bet, | |
| 208 | + win: state.win, | |
| 209 | + multiplier: row.bet ? state.win / row.bet : 0, | |
| 210 | + result: { kind: "arcade", mode: "ladder", status: state.status, stage: state.stage, current: state.current, log: state.log, extra: state.extra } as unknown as Record<string, unknown>, | |
| 211 | + features: [state.status === "completed" ? (def.slug === "the-vault" ? "Jackpot" : "Summit") : state.status === "cashed" ? "Cash out" : "Bust", ...new Set(state.log.map((l) => l.kind))], | |
| 212 | + freeSpins: false, | |
| 213 | + bonus: state.status === "completed", | |
| 214 | + jackpotTier: state.status === "completed" && def.slug === "the-vault" ? "grand" : null, | |
| 215 | + rngReference, | |
| 216 | + durationMs: Date.now() - row.startedAt.getTime(), | |
| 217 | + }); | |
| 218 | +} | |
modified
apps/api/src/routes/games.ts
+17 −1
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { FastifyInstance } from "fastify"; |
| 2 | 2 | import { and, db, desc, eq, favorites, gameStatistics, games, sql, userGameStats } from "@spinza/database"; |
| 3 | −import { getGame, FIRST_GAME_RECOMMENDATIONS, CRASH_BY_SLUG } from "@spinza/games"; | |
| 3 | +import { getGame, FIRST_GAME_RECOMMENDATIONS, CRASH_BY_SLUG, ARCADE_BY_SLUG } from "@spinza/games"; | |
| 4 | 4 | import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared"; |
| 5 | 5 | import { requireUser } from "../plugins/auth"; |
| 6 | 6 | import { errors } from "../lib/errors"; |
@@ -90,6 +90,22 @@ export async function gameRoutes(app: FastifyInstance) { | ||
| 90 | 90 | }; |
| 91 | 91 | return { game: info, definition: crashDef }; |
| 92 | 92 | } |
| 93 | + const arcadeDef = ARCADE_BY_SLUG.get(slug); | |
| 94 | + if (arcadeDef) { | |
| 95 | + const s = g.summary as Record<string, unknown>; | |
| 96 | + let favorite = false; | |
| 97 | + if (req.user) favorite = !!(await db.query.favorites.findFirst({ where: and(eq(favorites.userId, req.user.id), eq(favorites.gameId, g.id)) })); | |
| 98 | + const info: GameInfo = { | |
| 99 | + ...toCard(g, { favorite }), | |
| 100 | + rtp: arcadeDef.rtp, | |
| 101 | + hitFrequency: ((s.certification as { hitRate?: number } | null)?.hitRate) ?? null, | |
| 102 | + description: arcadeDef.description, | |
| 103 | + rules: arcadeDef.rules, | |
| 104 | + paytable: [], | |
| 105 | + certification: (s.certification as GameInfo["certification"] | null) ?? null, | |
| 106 | + }; | |
| 107 | + return { game: info, definition: arcadeDef }; | |
| 108 | + } | |
| 93 | 109 | const def = getGame(slug); |
| 94 | 110 | if (!def) throw errors.notFound("Game not found"); |
| 95 | 111 | const s = g.summary as Record<string, unknown>; |
added
apps/api/test/arcade.test.ts
+113 −0
@@ -0,0 +1,113 @@ | ||
| 1 | +import { afterAll, beforeAll, describe, expect, it } from "vitest"; | |
| 2 | +import { randomUUID } from "node:crypto"; | |
| 3 | +import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database"; | |
| 4 | +import { syncGames } from "@spinza/database/sync-games"; | |
| 5 | +import { buildApp } from "../src/app"; | |
| 6 | +import { closeRedis, redis } from "../src/lib/redis"; | |
| 7 | +import type { FastifyInstance } from "fastify"; | |
| 8 | + | |
| 9 | +let app: FastifyInstance; | |
| 10 | +let cookie = ""; | |
| 11 | +let userId = ""; | |
| 12 | +const username = `a_${randomUUID().slice(0, 8)}`; | |
| 13 | +const origin = "http://localhost:8230"; | |
| 14 | +const headers = () => ({ origin, cookie: `spinza_session=${cookie}` }); | |
| 15 | + | |
| 16 | +beforeAll(async () => { | |
| 17 | + process.env.NODE_ENV = "test"; | |
| 18 | + app = await buildApp(); | |
| 19 | + await app.ready(); | |
| 20 | + const keys = await redis().keys("rl:*"); | |
| 21 | + if (keys.length) await redis().del(...keys); | |
| 22 | + await syncGames(db); | |
| 23 | + await db.execute(sql`update games set lifecycle = 'published' where slug in ('dropzone','grid-break','orbit','the-vault','escape-99')`); | |
| 24 | + const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } }); | |
| 25 | + cookie = res.cookies.find((c) => c.name === "spinza_session")!.value; | |
| 26 | + userId = res.json().user.id; | |
| 27 | +}); | |
| 28 | + | |
| 29 | +afterAll(async () => { | |
| 30 | + if (userId) await db.delete(users).where(eq(users.id, userId)); | |
| 31 | + await app.close(); | |
| 32 | + await closeDb(); | |
| 33 | + await closeRedis(); | |
| 34 | +}); | |
| 35 | + | |
| 36 | +describe("arcade instant games", () => { | |
| 37 | + it("dropzone resolves a drop and is idempotent", async () => { | |
| 38 | + const id = randomUUID(); | |
| 39 | + const a = await app.inject({ method: "POST", url: "/api/arcade/dropzone/play", headers: headers(), payload: { bet: 100, clientRoundId: id, input: { risk: "high", lane: 0 } } }); | |
| 40 | + expect(a.statusCode).toBe(200); | |
| 41 | + const body = a.json(); | |
| 42 | + expect(body.outcome.steps).toHaveLength(12); | |
| 43 | + expect(body.outcome.summary.startLane).toBe(0); | |
| 44 | + expect(body.outcome.summary.table).toHaveLength(13); | |
| 45 | + const b = await app.inject({ method: "POST", url: "/api/arcade/dropzone/play", headers: headers(), payload: { bet: 100, clientRoundId: id, input: { risk: "high", lane: 0 } } }); | |
| 46 | + expect(b.json().roundId).toBe(body.roundId); | |
| 47 | + expect(b.json().replayed).toBe(true); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + it("grid-break and orbit resolve with valid inputs and reject bad bets", async () => { | |
| 51 | + const g = await app.inject({ method: "POST", url: "/api/arcade/grid-break/play", headers: headers(), payload: { bet: 20, clientRoundId: randomUUID(), input: { column: 3 } } }); | |
| 52 | + expect(g.statusCode).toBe(200); | |
| 53 | + expect(g.json().outcome.steps.length).toBeGreaterThanOrEqual(1); | |
| 54 | + const o = await app.inject({ method: "POST", url: "/api/arcade/orbit/play", headers: headers(), payload: { bet: 20, clientRoundId: randomUUID(), input: { angle: 90 } } }); | |
| 55 | + expect(o.statusCode).toBe(200); | |
| 56 | + expect(o.json().outcome.summary.orbits.length).toBeGreaterThanOrEqual(3); | |
| 57 | + const bad = await app.inject({ method: "POST", url: "/api/arcade/orbit/play", headers: headers(), payload: { bet: 33, clientRoundId: randomUUID(), input: {} } }); | |
| 58 | + expect(bad.statusCode).toBe(400); | |
| 59 | + }); | |
| 60 | +}); | |
| 61 | + | |
| 62 | +describe("arcade ladder games", () => { | |
| 63 | + it("the vault: start resolves the first layer, then secure or bust; ledger stays consistent", async () => { | |
| 64 | + const start = await app.inject({ method: "POST", url: "/api/arcade/the-vault/start", headers: headers(), payload: { bet: 100, clientRoundId: randomUUID() } }); | |
| 65 | + expect(start.statusCode).toBe(200); | |
| 66 | + let s = start.json().session; | |
| 67 | + expect(["running", "busted"]).toContain(s.status); | |
| 68 | + if (s.status === "running") { | |
| 69 | + expect(s.stage).toBe(1); | |
| 70 | + expect(s.current).toBeCloseTo(1.2, 2); | |
| 71 | + expect(s.offers).toHaveLength(1); | |
| 72 | + const act = await app.inject({ method: "POST", url: "/api/arcade/the-vault/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "cashout" } } }); | |
| 73 | + expect(act.statusCode).toBe(200); | |
| 74 | + s = act.json().session; | |
| 75 | + expect(s.status).toBe("cashed"); | |
| 76 | + expect(s.win).toBe(120); | |
| 77 | + expect(act.json().balance).toBeGreaterThan(0); | |
| 78 | + // Acting again on a settled run is a no-op. | |
| 79 | + const again = await app.inject({ method: "POST", url: "/api/arcade/the-vault/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "continue" } } }); | |
| 80 | + expect(again.json().session.status).toBe("cashed"); | |
| 81 | + } | |
| 82 | + const [{ total }] = await db.select({ total: sql<number>`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, userId)); | |
| 83 | + const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, userId) }); | |
| 84 | + expect(Number(total)).toBe(w!.balance); | |
| 85 | + const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId)); | |
| 86 | + expect(rounds.length).toBeGreaterThanOrEqual(4); | |
| 87 | + }); | |
| 88 | + | |
| 89 | + it("escape 99: only one run at a time; climbing advances floors", async () => { | |
| 90 | + const start = await app.inject({ method: "POST", url: "/api/arcade/escape-99/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID() } }); | |
| 91 | + expect(start.statusCode).toBe(200); | |
| 92 | + let s = start.json().session; | |
| 93 | + if (s.status === "running") { | |
| 94 | + const dup = await app.inject({ method: "POST", url: "/api/arcade/escape-99/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID() } }); | |
| 95 | + expect(dup.statusCode).toBe(409); | |
| 96 | + const cur = await app.inject({ method: "GET", url: "/api/arcade/escape-99/current", headers: headers() }); | |
| 97 | + expect(cur.json().session.roundId).toBe(s.roundId); | |
| 98 | + for (let i = 0; i < 5 && s.status === "running"; i++) { | |
| 99 | + const offer = s.offers[0]; | |
| 100 | + const act = await app.inject({ method: "POST", url: "/api/arcade/escape-99/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "continue", offerId: offer.id } } }); | |
| 101 | + expect(act.statusCode).toBe(200); | |
| 102 | + const next = act.json().session; | |
| 103 | + if (next.status === "running") expect(next.stage).toBe(s.stage + offer.advance); | |
| 104 | + s = next; | |
| 105 | + } | |
| 106 | + if (s.status === "running") { | |
| 107 | + const out = await app.inject({ method: "POST", url: "/api/arcade/escape-99/act", headers: headers(), payload: { roundId: s.roundId, action: { type: "cashout" } } }); | |
| 108 | + expect(out.json().session.status).toBe("cashed"); | |
| 109 | + expect(out.json().session.win).toBeGreaterThan(0); | |
| 110 | + } | |
| 111 | + } | |
| 112 | + }); | |
| 113 | +}); | |
modified
apps/api/test/crash.test.ts
+5 −2
@@ -4,7 +4,7 @@ import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } | ||
| 4 | 4 | import { syncGames } from "@spinza/database/sync-games"; |
| 5 | 5 | import { verifyCommitment } from "@spinza/game-core"; |
| 6 | 6 | import { buildApp } from "../src/app"; |
| 7 | −import { closeRedis } from "../src/lib/redis"; | |
| 7 | +import { closeRedis, redis } from "../src/lib/redis"; | |
| 8 | 8 | import type { FastifyInstance } from "fastify"; |
| 9 | 9 | |
| 10 | 10 | let app: FastifyInstance; |
@@ -18,6 +18,9 @@ beforeAll(async () => { | ||
| 18 | 18 | process.env.NODE_ENV = "test"; |
| 19 | 19 | app = await buildApp(); |
| 20 | 20 | await app.ready(); |
| 21 | + // Tests register several accounts per run: reset the per-IP rate limits first. | |
| 22 | + const keys = await redis().keys("rl:*"); | |
| 23 | + if (keys.length) await redis().del(...keys); | |
| 21 | 24 | await syncGames(db); |
| 22 | 25 | await db.execute(sql`update games set lifecycle = 'published' where slug in ('skyfall','elevator-999')`); |
| 23 | 26 | const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } }); |
@@ -54,7 +57,7 @@ describe("crash games", () => { | ||
| 54 | 57 | if (settled.status === "cashed") { |
| 55 | 58 | expect(settled.cashoutMultiplier).toBe(1); |
| 56 | 59 | expect(settled.win).toBe(100); |
| 57 | − expect(out.json().balance).toBe(10000); | |
| 60 | + expect(out.json().balance).toBeGreaterThanOrEqual(10000); // bet returned (+ possible first-round achievement reward) | |
| 58 | 61 | } |
| 59 | 62 | const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId)); |
| 60 | 63 | expect(rounds).toHaveLength(1); |
modified
apps/api/test/wallet.test.ts
+4 −1
@@ -7,7 +7,7 @@ import { randomUUID } from "node:crypto"; | ||
| 7 | 7 | import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database"; |
| 8 | 8 | import { syncGames } from "@spinza/database/sync-games"; |
| 9 | 9 | import { buildApp } from "../src/app"; |
| 10 | −import { closeRedis } from "../src/lib/redis"; | |
| 10 | +import { closeRedis, redis } from "../src/lib/redis"; | |
| 11 | 11 | import type { FastifyInstance } from "fastify"; |
| 12 | 12 | |
| 13 | 13 | let app: FastifyInstance; |
@@ -20,6 +20,9 @@ beforeAll(async () => { | ||
| 20 | 20 | process.env.NODE_ENV = "test"; |
| 21 | 21 | app = await buildApp(); |
| 22 | 22 | await app.ready(); |
| 23 | + // Tests register several accounts per run: reset the per-IP rate limits first. | |
| 24 | + const keys = await redis().keys("rl:*"); | |
| 25 | + if (keys.length) await redis().del(...keys); | |
| 23 | 26 | await syncGames(db); |
| 24 | 27 | // Force-publish one game for tests regardless of certification. |
| 25 | 28 | await db.execute(sql`update games set lifecycle = 'published' where slug = 'neon-vault'`); |
added
apps/api/vitest.config.ts
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +import { defineConfig } from "vitest/config"; | |
| 2 | + | |
| 3 | +// Integration tests share one Postgres database: run files sequentially so game | |
| 4 | +// library syncs and force-publish statements do not race each other. | |
| 5 | +export default defineConfig({ test: { fileParallelism: false, testTimeout: 20_000 } }); | |
modified
apps/simulator/src/cli.ts
+28 −4
@@ -10,8 +10,8 @@ | ||
| 10 | 10 | import fs from "node:fs"; |
| 11 | 11 | import path from "node:path"; |
| 12 | 12 | import { fileURLToPath } from "node:url"; |
| 13 | −import { certify, certifyCrash, formatCertification, simulateCrash, validateDefinition, DEFAULT_CERTIFICATION_RULES } from "@spinza/game-core"; | |
| 14 | −import { RAW_GAMES, GAMES, CRASH_GAMES, CRASH_BY_SLUG } from "@spinza/games"; | |
| 13 | +import { certify, certifyCrash, certifyArcade, calibrateArcade, formatCertification, simulateCrash, simulateArcade, validateDefinition, DEFAULT_CERTIFICATION_RULES } from "@spinza/game-core"; | |
| 14 | +import { RAW_GAMES, GAMES, CRASH_GAMES, CRASH_BY_SLUG, ARCADE_GAMES, ARCADE_BY_SLUG, RAW_ARCADE_GAMES } from "@spinza/games"; | |
| 15 | 15 | import { calibrate, simulateParallel } from "./index"; |
| 16 | 16 | |
| 17 | 17 | const here = path.dirname(fileURLToPath(import.meta.url)); |
@@ -79,7 +79,21 @@ async function main() { | ||
| 79 | 79 | |
| 80 | 80 | if (cmd === "calibrate") { |
| 81 | 81 | const cal = readCalibration(); |
| 82 | − for (const slug of targets(sel)) { | |
| 82 | + const list = sel === "arcade" ? RAW_ARCADE_GAMES.filter((g) => g.mode === "instant").map((g) => g.slug) : targets(sel); | |
| 83 | + for (const slug of list) { | |
| 84 | + const arcade = RAW_ARCADE_GAMES.find((x) => x.slug === slug); | |
| 85 | + if (arcade) { | |
| 86 | + if (arcade.mode !== "instant") { | |
| 87 | + console.log(`\n${arcade.name} is a ladder game — RTP is analytic, no calibration needed.`); | |
| 88 | + continue; | |
| 89 | + } | |
| 90 | + console.log(`\nCalibrating ${arcade.name} (${slug}@${arcade.version}, arcade) target ${(arcade.rtp * 100).toFixed(2)}%`); | |
| 91 | + const { payScale, result } = calibrateArcade(arcade, num("spins", 400_000), num("iterations", 4), console.log); | |
| 92 | + cal[slug] = { payScale, version: arcade.version, calibratedAt: new Date().toISOString(), spins: result.spins, observedRtp: Number(result.observedRtp.toFixed(5)) }; | |
| 93 | + fs.writeFileSync(calibrationPath, JSON.stringify(cal, null, 2) + "\n"); | |
| 94 | + console.log(` → payScale ${payScale} written (rtp ${(result.observedRtp * 100).toFixed(2)}%, hit ${(result.hitRate * 100).toFixed(1)}%, maxWin ${result.maxWinMultiplier.toFixed(0)}×)`); | |
| 95 | + continue; | |
| 96 | + } | |
| 83 | 97 | const g = RAW_GAMES.find((x) => x.slug === slug)!; |
| 84 | 98 | console.log(`\nCalibrating ${g.name} (${slug}@${g.version}) target ${(g.rtp * 100).toFixed(2)}%`); |
| 85 | 99 | const { payScale, result } = await calibrate(slug, { spins: num("spins", 400_000), iterations: num("iterations", 4), threads, log: console.log }); |
@@ -94,8 +108,18 @@ async function main() { | ||
| 94 | 108 | fs.mkdirSync(certDir, { recursive: true }); |
| 95 | 109 | const spins = num("spins", 1_000_000); |
| 96 | 110 | let failed = 0; |
| 97 | − const list = sel === "crash" ? CRASH_GAMES.map((g) => g.slug) : targets(sel); | |
| 111 | + const list = sel === "crash" ? CRASH_GAMES.map((g) => g.slug) : sel === "arcade" ? ARCADE_GAMES.map((g) => g.slug) : targets(sel); | |
| 98 | 112 | for (const slug of list) { |
| 113 | + const arcadeDef = ARCADE_BY_SLUG.get(slug); | |
| 114 | + if (arcadeDef) { | |
| 115 | + console.log(`\nCertifying ${arcadeDef.name} (${slug}@${arcadeDef.version}, arcade ${arcadeDef.mode}) payScale=${arcadeDef.payScale} — ${spins.toLocaleString("en-US")} rounds`); | |
| 116 | + const res = simulateArcade(arcadeDef, { spins }); | |
| 117 | + const report = certifyArcade(arcadeDef, res, { ...DEFAULT_CERTIFICATION_RULES, minSpins: Math.min(DEFAULT_CERTIFICATION_RULES.minSpins, spins) }); | |
| 118 | + fs.writeFileSync(path.join(certDir, `${slug}.json`), JSON.stringify(report, null, 2) + "\n"); | |
| 119 | + console.log(formatCertification(report)); | |
| 120 | + if (report.status === "FAIL") failed++; | |
| 121 | + continue; | |
| 122 | + } | |
| 99 | 123 | const crashDef = CRASH_BY_SLUG.get(slug); |
| 100 | 124 | if (crashDef) { |
| 101 | 125 | console.log(`\nCertifying ${crashDef.name} (${slug}@${crashDef.version}, crash) — ${spins.toLocaleString("en-US")} rounds`); |
modified
apps/web/src/app/games/[slug]/page.tsx
+4 −2
@@ -2,10 +2,11 @@ import type { Metadata } from "next"; | ||
| 2 | 2 | import { notFound } from "next/navigation"; |
| 3 | 3 | import { apiServer } from "@/lib/api-server"; |
| 4 | 4 | import type { GameInfo } from "@spinza/shared"; |
| 5 | −import type { CrashGameDefinition } from "@spinza/game-core/client"; | |
| 5 | +import type { ArcadeGameDefinition, CrashGameDefinition } from "@spinza/game-core/client"; | |
| 6 | 6 | import type { ClientDefinition } from "@/components/game/types"; |
| 7 | 7 | import { GameClient } from "@/components/game/game-client"; |
| 8 | 8 | import { CrashClient } from "@/components/crash/crash-client"; |
| 9 | +import { ArcadeClient } from "@/components/arcade/arcade-client"; | |
| 9 | 10 | |
| 10 | 11 | type Params = { params: Promise<{ slug: string }> }; |
| 11 | 12 | |
@@ -18,8 +19,9 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> { | ||
| 18 | 19 | |
| 19 | 20 | export default async function GamePage({ params }: Params) { |
| 20 | 21 | const { slug } = await params; |
| 21 | − const data = await apiServer<{ game: GameInfo; definition: ClientDefinition | CrashGameDefinition }>(`/api/games/${slug}`); | |
| 22 | + const data = await apiServer<{ game: GameInfo; definition: ClientDefinition | CrashGameDefinition | ArcadeGameDefinition }>(`/api/games/${slug}`); | |
| 22 | 23 | if (!data) notFound(); |
| 23 | 24 | if (data.game.kind === "crash") return <CrashClient game={data.game} definition={data.definition as CrashGameDefinition} />; |
| 25 | + if (data.game.kind === "arcade") return <ArcadeClient game={data.game} definition={data.definition as ArcadeGameDefinition} />; | |
| 24 | 26 | return <GameClient game={data.game} definition={data.definition as ClientDefinition} />; |
| 25 | 27 | } |
added
apps/web/src/components/arcade/arcade-client.tsx
+31 −0
@@ -0,0 +1,31 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Dispatcher for the Beyond Slots originals: picks the scene component from | |
| 5 | + * `definition.presentation.scene` and hosts it in the shared ArcadeShell. | |
| 6 | + * (Minimal harness — the integrator owns the final version.) | |
| 7 | + */ | |
| 8 | +import type { ComponentType } from "react"; | |
| 9 | +import type { GameInfo } from "@spinza/shared"; | |
| 10 | +import type { ArcadeGameDefinition } from "@spinza/game-core/client"; | |
| 11 | +import { ArcadeShell } from "./arcade-shell"; | |
| 12 | +import type { ArcadeGameProps } from "./contract"; | |
| 13 | +import { VaultGame } from "./vault"; | |
| 14 | +import { EscapeGame } from "./escape"; | |
| 15 | +import { DropzoneGame } from "./dropzone"; | |
| 16 | +import { GridbreakGame } from "./gridbreak"; | |
| 17 | +import { OrbitGame } from "./orbit"; | |
| 18 | + | |
| 19 | +const SCENES: Partial<Record<ArcadeGameDefinition["presentation"]["scene"], ComponentType<ArcadeGameProps>>> = { | |
| 20 | + vault: VaultGame, | |
| 21 | + escape: EscapeGame, | |
| 22 | + dropzone: DropzoneGame, | |
| 23 | + gridbreak: GridbreakGame, | |
| 24 | + orbit: OrbitGame, | |
| 25 | +}; | |
| 26 | + | |
| 27 | +export function ArcadeClient({ game, definition }: { game: GameInfo; definition: ArcadeGameDefinition }) { | |
| 28 | + const Game = SCENES[definition.presentation.scene]; | |
| 29 | + if (!Game) return null; | |
| 30 | + return <ArcadeShell game={game} definition={definition} Game={Game} />; | |
| 31 | +} | |
added
apps/web/src/components/arcade/arcade-shell.tsx
+200 −0
@@ -0,0 +1,200 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react"; | |
| 4 | +import Link from "next/link"; | |
| 5 | +import { useRouter } from "next/navigation"; | |
| 6 | +import { AnimatePresence, motion } from "framer-motion"; | |
| 7 | +import { ArrowLeft, Heart, Info, Minus, Plus, Volume2, VolumeX } from "lucide-react"; | |
| 8 | +import { BET_LEVELS, classifyWin, formatMultiplier, formatSC, WIN_CLASSES, type GameInfo } from "@spinza/shared"; | |
| 9 | +import type { ArcadeGameDefinition } from "@spinza/game-core/client"; | |
| 10 | +import { api } from "@/lib/api"; | |
| 11 | +import { useSession } from "@/lib/store"; | |
| 12 | +import { cn } from "@/lib/utils"; | |
| 13 | +import { Credits, Sheet, Tabs } from "@/components/ui"; | |
| 14 | +import { getSound } from "@/components/game/sound"; | |
| 15 | +import type { ArcadeGameProps } from "./contract"; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * Shared chrome for the Beyond Slots originals: top bar, bet controls, | |
| 19 | + * balance, win banner, info sheet. The game component fills the middle. | |
| 20 | + */ | |
| 21 | +export function ArcadeShell({ game, definition, Game }: { game: GameInfo; definition: ArcadeGameDefinition; Game: ComponentType<ArcadeGameProps> }) { | |
| 22 | + const router = useRouter(); | |
| 23 | + const { status, wallet, settings } = useSession(); | |
| 24 | + const [bet, setBet] = useState(100); | |
| 25 | + const [busy, setBusy] = useState(false); | |
| 26 | + const [last, setLast] = useState<{ win: number; multiplier: number } | null>(null); | |
| 27 | + const [banner, setBanner] = useState<{ label: string; win: number; multiplier: number } | null>(null); | |
| 28 | + const [infoSheet, setInfoSheet] = useState(false); | |
| 29 | + const [favorite, setFavorite] = useState(!!game.favorite); | |
| 30 | + const soundOn = settings?.soundEnabled ?? true; | |
| 31 | + const palette = definition.presentation.palette; | |
| 32 | + const bets = useMemo(() => (BET_LEVELS as readonly number[]).filter((b) => b >= definition.minBet && b <= definition.maxBet), [definition]); | |
| 33 | + const balance = wallet?.balance ?? 0; | |
| 34 | + const betIndex = bets.indexOf(bet); | |
| 35 | + | |
| 36 | + useEffect(() => { | |
| 37 | + if (status === "guest") router.replace(`/login?next=/games/${game.slug}`); | |
| 38 | + }, [status, router, game.slug]); | |
| 39 | + | |
| 40 | + useEffect(() => { | |
| 41 | + const s = getSound(); | |
| 42 | + s.setLevels({ enabled: soundOn, master: settings?.masterVolume ?? 0.8, music: settings?.musicVolume ?? 0.6, effects: settings?.effectsVolume ?? 0.8 }); | |
| 43 | + s.setAmbience(definition.presentation.ambience); | |
| 44 | + api(`/api/games/${game.slug}/launch`, { method: "POST" }).catch(() => {}); | |
| 45 | + return () => s.destroy(); | |
| 46 | + }, [soundOn, settings?.masterVolume, settings?.musicVolume, settings?.effectsVolume, definition.presentation.ambience, game.slug]); | |
| 47 | + | |
| 48 | + const sound = useCallback<ArcadeGameProps["sound"]>((name) => { | |
| 49 | + const s = getSound(); | |
| 50 | + s.unlock(); | |
| 51 | + switch (name) { | |
| 52 | + case "click": | |
| 53 | + s.click(); | |
| 54 | + break; | |
| 55 | + case "tick": | |
| 56 | + s.tick(); | |
| 57 | + break; | |
| 58 | + case "win": | |
| 59 | + s.win(2); | |
| 60 | + break; | |
| 61 | + case "bigWin": | |
| 62 | + s.bigWin(); | |
| 63 | + break; | |
| 64 | + case "lose": | |
| 65 | + s.error(); | |
| 66 | + break; | |
| 67 | + case "bonus": | |
| 68 | + s.bonus(); | |
| 69 | + break; | |
| 70 | + } | |
| 71 | + }, []); | |
| 72 | + | |
| 73 | + const onResult = useCallback<ArcadeGameProps["onResult"]>((r) => { | |
| 74 | + setLast(r); | |
| 75 | + const cls = classifyWin(r.multiplier); | |
| 76 | + if (cls === "big" || cls === "mega" || cls === "epic" || cls === "legendary") { | |
| 77 | + setBanner({ label: WIN_CLASSES.find((c) => c.id === cls)?.label ?? "WIN", win: r.win, multiplier: r.multiplier }); | |
| 78 | + setTimeout(() => setBanner(null), 2600); | |
| 79 | + } | |
| 80 | + }, []); | |
| 81 | + | |
| 82 | + const toggleFavorite = async () => { | |
| 83 | + setFavorite((f) => !f); | |
| 84 | + try { | |
| 85 | + const r = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" }); | |
| 86 | + setFavorite(r.favorite); | |
| 87 | + } catch { | |
| 88 | + setFavorite((f) => !f); | |
| 89 | + } | |
| 90 | + }; | |
| 91 | + | |
| 92 | + if (status === "guest") return null; | |
| 93 | + | |
| 94 | + return ( | |
| 95 | + <div className="fixed inset-0 flex flex-col" style={{ background: `radial-gradient(120% 80% at 50% 0%, ${palette.surface} 0%, ${palette.bg} 60%, #050608 100%)` }}> | |
| 96 | + <div className="flex items-center justify-between gap-2 px-3 py-2" style={{ paddingTop: "calc(var(--safe-top) + 8px)" }}> | |
| 97 | + <div className="flex items-center gap-2"> | |
| 98 | + <Link href="/" className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Back to lobby"> | |
| 99 | + <ArrowLeft className="h-5 w-5" /> | |
| 100 | + </Link> | |
| 101 | + <div className="leading-tight"> | |
| 102 | + <div className="text-[15px] font-semibold tracking-tight">{game.name}</div> | |
| 103 | + <div className="text-[11px] text-fg-3">Spinza Original · Beyond Slots</div> | |
| 104 | + </div> | |
| 105 | + </div> | |
| 106 | + <div className="flex items-center gap-1"> | |
| 107 | + <button onClick={toggleFavorite} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Favourite"> | |
| 108 | + <Heart className={cn("h-5 w-5", favorite && "fill-danger text-danger")} /> | |
| 109 | + </button> | |
| 110 | + <button onClick={() => setInfoSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Game info"> | |
| 111 | + <Info className="h-5 w-5" /> | |
| 112 | + </button> | |
| 113 | + <button onClick={() => useSession.getState().setSettings({ soundEnabled: !soundOn })} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Sound"> | |
| 114 | + {soundOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />} | |
| 115 | + </button> | |
| 116 | + </div> | |
| 117 | + </div> | |
| 118 | + | |
| 119 | + <div className="relative flex-1 min-h-0"> | |
| 120 | + <Game game={game} definition={definition} bet={bet} onBusy={setBusy} onResult={onResult} sound={sound} reduceMotion={settings?.reduceMotion ?? false} /> | |
| 121 | + <AnimatePresence> | |
| 122 | + {banner ? ( | |
| 123 | + <motion.div key={banner.win} initial={{ opacity: 0, scale: 0.7 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} className="pointer-events-none absolute inset-0 grid place-items-center bg-black/40" style={{ zIndex: 5 }}> | |
| 124 | + <div className="text-center"> | |
| 125 | + <div className="text-[clamp(34px,9vw,72px)] font-extrabold uppercase tracking-tight shimmer-text">{banner.label}</div> | |
| 126 | + <div className="mt-2 text-3xl font-bold tabular text-credit">{formatSC(banner.win)}</div> | |
| 127 | + <div className="text-sm text-fg-2">{formatMultiplier(banner.multiplier)} the bet</div> | |
| 128 | + </div> | |
| 129 | + </motion.div> | |
| 130 | + ) : null} | |
| 131 | + </AnimatePresence> | |
| 132 | + </div> | |
| 133 | + | |
| 134 | + <div className="glass border-x-0 border-b-0 px-3 pt-3" style={{ paddingBottom: "calc(var(--safe-bottom) + 12px)", borderTop: `1px solid ${palette.primary}55` }}> | |
| 135 | + <div className="mx-auto flex max-w-3xl items-center justify-between gap-3"> | |
| 136 | + <div> | |
| 137 | + <div className="eyebrow">Balance</div> | |
| 138 | + <Credits amount={balance} size="md" /> | |
| 139 | + </div> | |
| 140 | + <div className="flex items-center gap-1"> | |
| 141 | + <button disabled={busy || betIndex <= 0} onClick={() => setBet(bets[betIndex - 1])} className="tap grid h-10 w-10 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Decrease bet"> | |
| 142 | + <Minus className="h-4 w-4" /> | |
| 143 | + </button> | |
| 144 | + <div className="flex h-10 min-w-[92px] flex-col items-center justify-center rounded-md surface-2 px-2"> | |
| 145 | + <span className="text-[10px] uppercase tracking-wider text-fg-3">Bet</span> | |
| 146 | + <span className="text-sm font-bold tabular text-fg">{formatSC(bet)}</span> | |
| 147 | + </div> | |
| 148 | + <button disabled={busy || betIndex >= bets.length - 1} onClick={() => setBet(bets[betIndex + 1])} className="tap grid h-10 w-10 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Increase bet"> | |
| 149 | + <Plus className="h-4 w-4" /> | |
| 150 | + </button> | |
| 151 | + </div> | |
| 152 | + <div className="text-right"> | |
| 153 | + <div className="eyebrow">Last win</div> | |
| 154 | + <div className={cn("text-base font-semibold tabular", last && last.win > 0 ? "text-credit" : "text-fg-4")}>{last && last.win > 0 ? `${formatSC(last.win)} · ${formatMultiplier(last.multiplier)}` : "—"}</div> | |
| 155 | + </div> | |
| 156 | + </div> | |
| 157 | + </div> | |
| 158 | + | |
| 159 | + <InfoSheet open={infoSheet} onClose={() => setInfoSheet(false)} game={game} definition={definition} /> | |
| 160 | + </div> | |
| 161 | + ); | |
| 162 | +} | |
| 163 | + | |
| 164 | +function InfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: ArcadeGameDefinition }) { | |
| 165 | + const [tab, setTab] = useState<"rules" | "about">("rules"); | |
| 166 | + return ( | |
| 167 | + <Sheet open={open} onClose={onClose} title={game.name} side="right"> | |
| 168 | + <Tabs value={tab} onChange={setTab} items={[{ value: "rules", label: "How to play" }, { value: "about", label: "About" }]} className="mb-4" /> | |
| 169 | + {tab === "rules" ? ( | |
| 170 | + <ul className="space-y-3 text-sm text-fg-2"> | |
| 171 | + {game.rules.map((r, i) => ( | |
| 172 | + <li key={i} className="flex gap-3"> | |
| 173 | + <span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-surface-2 text-[11px] font-bold text-fg-3">{i + 1}</span> | |
| 174 | + <span>{r}</span> | |
| 175 | + </li> | |
| 176 | + ))} | |
| 177 | + </ul> | |
| 178 | + ) : ( | |
| 179 | + <div className="space-y-4 text-sm text-fg-2"> | |
| 180 | + <p>{game.description}</p> | |
| 181 | + <dl className="grid grid-cols-2 gap-3"> | |
| 182 | + {[ | |
| 183 | + ["Volatility", game.volatility], | |
| 184 | + ["RTP", `${(game.rtp * 100).toFixed(2)}%`], | |
| 185 | + ["Max win", formatMultiplier(definition.maxMultiplier)], | |
| 186 | + ["Mode", definition.mode === "ladder" ? "Step by step" : "Instant"], | |
| 187 | + ].map(([k, v]) => ( | |
| 188 | + <div key={k} className="surface rounded-md p-3"> | |
| 189 | + <dt className="eyebrow">{k}</dt> | |
| 190 | + <dd className="mt-1 text-sm font-semibold capitalize text-fg">{v}</dd> | |
| 191 | + </div> | |
| 192 | + ))} | |
| 193 | + </dl> | |
| 194 | + {game.certification ? <p className="text-[12px] text-fg-3">Certified on {game.certification.spins.toLocaleString("en-US")} simulated rounds · observed RTP {(game.certification.observedRtp * 100).toFixed(2)}%.</p> : null} | |
| 195 | + <p className="text-[12px] text-fg-3">Spinza Credits are fictional and have no cash value.</p> | |
| 196 | + </div> | |
| 197 | + )} | |
| 198 | + </Sheet> | |
| 199 | + ); | |
| 200 | +} | |
added
apps/web/src/components/arcade/contract.ts
+175 −0
@@ -0,0 +1,175 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Shared contract for the five "Beyond Slots" arcade games. Each game module | |
| 5 | + * exports a React component with the `ArcadeGameProps` signature and uses the | |
| 6 | + * helpers below to talk to the API. Outcomes are always resolved server-side. | |
| 7 | + */ | |
| 8 | +import { useCallback, useEffect, useRef, useState } from "react"; | |
| 9 | +import type { ArcadeGameDefinition, ArcadeOutcome, LadderState } from "@spinza/game-core/client"; | |
| 10 | +import type { GameInfo } from "@spinza/shared"; | |
| 11 | +import { api, ApiClientError } from "@/lib/api"; | |
| 12 | +import { toast, useSession } from "@/lib/store"; | |
| 13 | + | |
| 14 | +export interface ArcadeGameProps { | |
| 15 | + game: GameInfo; | |
| 16 | + definition: ArcadeGameDefinition; | |
| 17 | + /** Current bet chosen in the shared shell. */ | |
| 18 | + bet: number; | |
| 19 | + /** Tell the shell whether the game is mid-round (locks bet controls). */ | |
| 20 | + onBusy: (busy: boolean) => void; | |
| 21 | + /** Report the last result to the shell (win banner + counters). */ | |
| 22 | + onResult: (r: { win: number; multiplier: number }) => void; | |
| 23 | + /** Play a UI sound: "click" | "win" | "bigWin" | "lose" | "tick" | "bonus". */ | |
| 24 | + sound: (name: "click" | "win" | "bigWin" | "lose" | "tick" | "bonus") => void; | |
| 25 | + reduceMotion: boolean; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export interface Progression { | |
| 29 | + xp: { gained: number; total: number; level: number; leveledUp: boolean; levelReward: number }; | |
| 30 | + unlocked: { achievements: string[]; missions: string[] }; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface InstantResponse<T extends ArcadeOutcome = ArcadeOutcome> { | |
| 34 | + roundId: string; | |
| 35 | + outcome: T; | |
| 36 | + win: number; | |
| 37 | + multiplier: number; | |
| 38 | + balance: number; | |
| 39 | + winClass: string; | |
| 40 | + xp?: Progression["xp"]; | |
| 41 | + unlocked?: Progression["unlocked"]; | |
| 42 | + replayed?: boolean; | |
| 43 | +} | |
| 44 | + | |
| 45 | +export type LadderView = Omit<LadderState, "game" | "version"> & { roundId: string; game: string; version: string; canCashout: boolean }; | |
| 46 | + | |
| 47 | +export interface LadderResponse { | |
| 48 | + session: LadderView; | |
| 49 | + balance: number | null; | |
| 50 | + winClass?: string | null; | |
| 51 | + progression: Progression | null; | |
| 52 | + replayed?: boolean; | |
| 53 | +} | |
| 54 | + | |
| 55 | +function applyProgression(p: Progression | null | undefined) { | |
| 56 | + if (!p) return; | |
| 57 | + const s = useSession.getState(); | |
| 58 | + s.setUserXp(p.xp.total, p.xp.level); | |
| 59 | + if (p.xp.leveledUp) toast({ title: `Level ${p.xp.level} reached`, description: `+${p.xp.levelReward.toLocaleString("en-US")} SC level reward`, tone: "credit" }); | |
| 60 | + for (const a of p.unlocked.achievements) toast({ title: "Achievement unlocked", description: a.replace(/-/g, " "), tone: "success" }); | |
| 61 | + for (const m of p.unlocked.missions) toast({ title: "Mission complete", description: m.replace(/-/g, " "), tone: "success" }); | |
| 62 | +} | |
| 63 | + | |
| 64 | +export function describeArcadeError(e: unknown): string { | |
| 65 | + if (e instanceof ApiClientError) return e.message; | |
| 66 | + return "Connection lost. Try again."; | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** Instant games: one call → resolved outcome. Handles optimistic balance and progression toasts. */ | |
| 70 | +export function useInstantPlay<T extends ArcadeOutcome>(slug: string) { | |
| 71 | + const [busy, setBusy] = useState(false); | |
| 72 | + const [error, setError] = useState<string | null>(null); | |
| 73 | + const setBalance = useSession((s) => s.setBalance); | |
| 74 | + const play = useCallback( | |
| 75 | + async (bet: number, input: Record<string, unknown>): Promise<InstantResponse<T> | null> => { | |
| 76 | + if (busy) return null; | |
| 77 | + const balance = useSession.getState().wallet?.balance ?? 0; | |
| 78 | + if (balance < bet) { | |
| 79 | + setError("Not enough Spinza Credits for this bet."); | |
| 80 | + return null; | |
| 81 | + } | |
| 82 | + setBusy(true); | |
| 83 | + setError(null); | |
| 84 | + setBalance(balance - bet); | |
| 85 | + try { | |
| 86 | + const res = await api<InstantResponse<T>>(`/api/arcade/${slug}/play`, { json: { bet, clientRoundId: crypto.randomUUID(), input } }); | |
| 87 | + setBalance(res.balance); | |
| 88 | + applyProgression(res.xp && res.unlocked ? { xp: res.xp, unlocked: res.unlocked } : null); | |
| 89 | + return res; | |
| 90 | + } catch (e) { | |
| 91 | + setBalance(balance); | |
| 92 | + setError(describeArcadeError(e)); | |
| 93 | + return null; | |
| 94 | + } finally { | |
| 95 | + setBusy(false); | |
| 96 | + } | |
| 97 | + }, | |
| 98 | + [busy, slug, setBalance], | |
| 99 | + ); | |
| 100 | + return { play, busy, error, clearError: () => setError(null) }; | |
| 101 | +} | |
| 102 | + | |
| 103 | +/** Ladder games: start → act(continue|cashout) until the session ends. Resumes a running session on mount. */ | |
| 104 | +export function useLadder(slug: string) { | |
| 105 | + const [session, setSession] = useState<LadderView | null>(null); | |
| 106 | + const [busy, setBusy] = useState(false); | |
| 107 | + const [error, setError] = useState<string | null>(null); | |
| 108 | + const setBalance = useSession((s) => s.setBalance); | |
| 109 | + const mounted = useRef(false); | |
| 110 | + | |
| 111 | + useEffect(() => { | |
| 112 | + if (mounted.current) return; | |
| 113 | + mounted.current = true; | |
| 114 | + api<{ session: LadderView | null }>(`/api/arcade/${slug}/current`) | |
| 115 | + .then((r) => { | |
| 116 | + if (r.session) setSession(r.session); | |
| 117 | + }) | |
| 118 | + .catch(() => {}); | |
| 119 | + }, [slug]); | |
| 120 | + | |
| 121 | + const start = useCallback( | |
| 122 | + async (bet: number): Promise<LadderResponse | null> => { | |
| 123 | + if (busy) return null; | |
| 124 | + const balance = useSession.getState().wallet?.balance ?? 0; | |
| 125 | + if (balance < bet) { | |
| 126 | + setError("Not enough Spinza Credits for this bet."); | |
| 127 | + return null; | |
| 128 | + } | |
| 129 | + setBusy(true); | |
| 130 | + setError(null); | |
| 131 | + try { | |
| 132 | + const res = await api<LadderResponse>(`/api/arcade/${slug}/start`, { json: { bet, clientRoundId: crypto.randomUUID() } }); | |
| 133 | + if (res.balance !== null) setBalance(res.balance); | |
| 134 | + else setBalance(balance - bet); | |
| 135 | + setSession(res.session); | |
| 136 | + applyProgression(res.progression); | |
| 137 | + return res; | |
| 138 | + } catch (e) { | |
| 139 | + if (e instanceof ApiClientError && e.code === "ROUND_IN_PROGRESS") { | |
| 140 | + const cur = await api<{ session: LadderView | null }>(`/api/arcade/${slug}/current`).catch(() => null); | |
| 141 | + if (cur?.session) setSession(cur.session); | |
| 142 | + } | |
| 143 | + setError(describeArcadeError(e)); | |
| 144 | + return null; | |
| 145 | + } finally { | |
| 146 | + setBusy(false); | |
| 147 | + } | |
| 148 | + }, | |
| 149 | + [busy, slug, setBalance], | |
| 150 | + ); | |
| 151 | + | |
| 152 | + const act = useCallback( | |
| 153 | + async (action: { type: "continue"; offerId?: string } | { type: "cashout" }): Promise<LadderResponse | null> => { | |
| 154 | + if (!session || busy) return null; | |
| 155 | + setBusy(true); | |
| 156 | + setError(null); | |
| 157 | + try { | |
| 158 | + const res = await api<LadderResponse>(`/api/arcade/${slug}/act`, { json: { roundId: session.roundId, action } }); | |
| 159 | + setSession(res.session); | |
| 160 | + if (res.balance !== null) setBalance(res.balance); | |
| 161 | + applyProgression(res.progression); | |
| 162 | + return res; | |
| 163 | + } catch (e) { | |
| 164 | + setError(describeArcadeError(e)); | |
| 165 | + return null; | |
| 166 | + } finally { | |
| 167 | + setBusy(false); | |
| 168 | + } | |
| 169 | + }, | |
| 170 | + [session, busy, slug, setBalance], | |
| 171 | + ); | |
| 172 | + | |
| 173 | + const reset = useCallback(() => setSession(null), []); | |
| 174 | + return { session, start, act, reset, busy, error, clearError: () => setError(null) }; | |
| 175 | +} | |
added
apps/web/src/components/arcade/dropzone.tsx
+903 −0
@@ -0,0 +1,903 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * DROPZONE — browser side. The server resolves the whole descent; this file | |
| 5 | + * only animates `outcome.steps` (half-lane x per row, gates, portals), the | |
| 6 | + * landing bucket and the optional Deep Drop extension on a 2D canvas. | |
| 7 | + */ | |
| 8 | +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 9 | +import { AnimatePresence, motion } from "framer-motion"; | |
| 10 | +import { ChevronLeft, ChevronRight } from "lucide-react"; | |
| 11 | +import { formatMultiplier, formatSC } from "@spinza/shared"; | |
| 12 | +import type { ArcadeGameDefinition, ArcadeOutcome, DropStep, DropzoneConfig } from "@spinza/game-core/client"; | |
| 13 | +import { cn } from "@/lib/utils"; | |
| 14 | +import { useInstantPlay, type ArcadeGameProps } from "./contract"; | |
| 15 | + | |
| 16 | +type Risk = "low" | "medium" | "high"; | |
| 17 | + | |
| 18 | +type DropSummary = { | |
| 19 | + risk: Risk; | |
| 20 | + startLane: number; | |
| 21 | + bucket: number; | |
| 22 | + bucketValue: number; | |
| 23 | + gateMult: number; | |
| 24 | + deep: { steps: DropStep[]; bucket: number; value: number } | null; | |
| 25 | + table: number[]; | |
| 26 | +}; | |
| 27 | + | |
| 28 | +interface DropOutcome extends ArcadeOutcome { | |
| 29 | + steps: DropStep[]; | |
| 30 | + summary: DropSummary; | |
| 31 | +} | |
| 32 | + | |
| 33 | +/* ------------------------------------------------------------------ math */ | |
| 34 | +/* Pure replica of the server's displayed bucket values (see game-core/arcade/dropzone.ts). */ | |
| 35 | + | |
| 36 | +function bucketDistribution(cfg: DropzoneConfig, startLane: number): number[] { | |
| 37 | + const width = cfg.lanes * 2; | |
| 38 | + let dist: number[] = new Array<number>(width).fill(0); | |
| 39 | + dist[startLane * 2 + 1] = 1; | |
| 40 | + for (let row = 0; row < cfg.rows; row++) { | |
| 41 | + const next: number[] = new Array<number>(width).fill(0); | |
| 42 | + for (let x = 0; x < width; x++) { | |
| 43 | + if (!dist[x]) continue; | |
| 44 | + next[Math.max(0, x - 1)] += dist[x] / 2; | |
| 45 | + next[Math.min(width - 1, x + 1)] += dist[x] / 2; | |
| 46 | + } | |
| 47 | + dist = next; | |
| 48 | + } | |
| 49 | + const buckets: number[] = new Array<number>(cfg.lanes).fill(0); | |
| 50 | + for (let x = 0; x < width; x++) buckets[Math.min(cfg.lanes - 1, Math.floor(x / 2))] += dist[x]; | |
| 51 | + return buckets; | |
| 52 | +} | |
| 53 | + | |
| 54 | +function laneNormalizer(cfg: DropzoneConfig, risk: Risk, startLane: number): number { | |
| 55 | + const table = cfg.buckets[risk]; | |
| 56 | + const center = Math.floor(cfg.lanes / 2); | |
| 57 | + const ev = (lane: number) => bucketDistribution(cfg, lane).reduce((a, p, k) => a + p * table[k], 0); | |
| 58 | + return ev(center) / ev(startLane); | |
| 59 | +} | |
| 60 | + | |
| 61 | +function displayedBuckets(def: ArcadeGameDefinition, cfg: DropzoneConfig, risk: Risk, startLane: number): number[] { | |
| 62 | + const norm = laneNormalizer(cfg, risk, startLane); | |
| 63 | + return cfg.buckets[risk].map((v) => Math.round(v * norm * def.payScale * 100) / 100); | |
| 64 | +} | |
| 65 | + | |
| 66 | +/* --------------------------------------------------------------- helpers */ | |
| 67 | + | |
| 68 | +const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)); | |
| 69 | +const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3); | |
| 70 | +const easeInQuad = (t: number) => t * t; | |
| 71 | +const easeOutBack = (t: number) => 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2); | |
| 72 | +const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v)); | |
| 73 | +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; | |
| 74 | + | |
| 75 | +function tween(ms: number, fn: (t: number) => void, alive: () => boolean): Promise<void> { | |
| 76 | + return new Promise((resolve) => { | |
| 77 | + const start = performance.now(); | |
| 78 | + const frame = (now: number) => { | |
| 79 | + if (!alive()) return resolve(); | |
| 80 | + const t = Math.min(1, (now - start) / Math.max(1, ms)); | |
| 81 | + fn(t); | |
| 82 | + if (t < 1) requestAnimationFrame(frame); | |
| 83 | + else resolve(); | |
| 84 | + }; | |
| 85 | + requestAnimationFrame(frame); | |
| 86 | + }); | |
| 87 | +} | |
| 88 | + | |
| 89 | +function rgba(hex: string, a: number): string { | |
| 90 | + const h = hex.replace("#", ""); | |
| 91 | + const n = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16); | |
| 92 | + return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; | |
| 93 | +} | |
| 94 | + | |
| 95 | +function mixHex(a: string, b: string, t: number): string { | |
| 96 | + const pa = parseInt(a.replace("#", ""), 16); | |
| 97 | + const pb = parseInt(b.replace("#", ""), 16); | |
| 98 | + const ch = (s: number) => Math.round(lerp((pa >> s) & 255, (pb >> s) & 255, t)); | |
| 99 | + return `rgb(${ch(16)},${ch(8)},${ch(0)})`; | |
| 100 | +} | |
| 101 | + | |
| 102 | +/* ----------------------------------------------------------------- scene */ | |
| 103 | + | |
| 104 | +interface Particle { | |
| 105 | + x: number; | |
| 106 | + y: number; | |
| 107 | + vx: number; | |
| 108 | + vy: number; | |
| 109 | + life: number; | |
| 110 | + max: number; | |
| 111 | + color: string; | |
| 112 | + size: number; | |
| 113 | +} | |
| 114 | + | |
| 115 | +interface Capsule { | |
| 116 | + x: number; // half-lane units | |
| 117 | + y: number; // row units (0 = release line, r+1 = after row r) | |
| 118 | + deep: boolean; | |
| 119 | + visible: boolean; | |
| 120 | + alpha: number; | |
| 121 | + squash: number; // 1 = round | |
| 122 | + glow: number; | |
| 123 | +} | |
| 124 | + | |
| 125 | +interface Scene { | |
| 126 | + t: number; | |
| 127 | + lane: number; | |
| 128 | + risk: Risk; | |
| 129 | + values: number[]; | |
| 130 | + phase: "idle" | "dropping"; | |
| 131 | + camY: number; // in row units | |
| 132 | + capsule: Capsule; | |
| 133 | + trail: { x: number; y: number; deep: boolean; life: number }[]; | |
| 134 | + gates: { x: number; y: number; value: number; at: number }[]; | |
| 135 | + portals: { x: number; to: number; y: number; at: number }[]; | |
| 136 | + landed: number | null; | |
| 137 | + landedAt: number; | |
| 138 | + deepReveal: number; | |
| 139 | + deepLanded: number | null; | |
| 140 | + deepLandedAt: number; | |
| 141 | + particles: Particle[]; | |
| 142 | + flash: { text: string; at: number; color: string } | null; | |
| 143 | + reduceMotion: boolean; | |
| 144 | +} | |
| 145 | + | |
| 146 | +const MARKER_UNITS = 1.2; | |
| 147 | +const BUCKET_UNITS = 1.5; | |
| 148 | +const DEEP_GAP_UNITS = 0.9; | |
| 149 | + | |
| 150 | +interface Layout { | |
| 151 | + w: number; | |
| 152 | + h: number; | |
| 153 | + pad: number; | |
| 154 | + hl: number; | |
| 155 | + rowH: number; | |
| 156 | + left: number; | |
| 157 | + deepLeft: number; | |
| 158 | + deepStart: number; // row units where the deep release line sits | |
| 159 | + totalDeepUnits: number; | |
| 160 | +} | |
| 161 | + | |
| 162 | +function layoutFor(w: number, h: number, cfg: DropzoneConfig): Layout { | |
| 163 | + const mainUnits = MARKER_UNITS + cfg.rows + BUCKET_UNITS + 0.5; | |
| 164 | + let hl = (Math.min(w, 620) - 20) / (cfg.lanes * 2); | |
| 165 | + let rowH = Math.min((h - 20) / mainUnits, hl * 2.1); | |
| 166 | + if (rowH < hl * 1.05) { | |
| 167 | + // very short viewport: shrink horizontally to keep pegs readable | |
| 168 | + hl = rowH / 1.05; | |
| 169 | + rowH = hl * 1.05; | |
| 170 | + } | |
| 171 | + // Centre the tower vertically when the viewport is taller than needed (tall phones). | |
| 172 | + const pad = Math.max(10, (h - mainUnits * rowH) / 2); | |
| 173 | + const left = (w - hl * cfg.lanes * 2) / 2; | |
| 174 | + const deepLeft = left + ((cfg.lanes * 2 - cfg.deepBuckets.length * 2) / 2) * hl; | |
| 175 | + const deepStart = cfg.rows + BUCKET_UNITS + DEEP_GAP_UNITS; | |
| 176 | + return { w, h, pad, hl, rowH, left, deepLeft, deepStart, totalDeepUnits: deepStart + cfg.deepRows + BUCKET_UNITS }; | |
| 177 | +} | |
| 178 | + | |
| 179 | +function toPx(L: Layout, s: Scene, x: number, y: number, deep: boolean): { px: number; py: number } { | |
| 180 | + const px = (deep ? L.deepLeft : L.left) + (x + 0.5) * L.hl; | |
| 181 | + const py = L.pad + (MARKER_UNITS + (deep ? L.deepStart + y : y) - s.camY) * L.rowH; | |
| 182 | + return { px, py }; | |
| 183 | +} | |
| 184 | + | |
| 185 | +function heat(v: number, max: number): number { | |
| 186 | + if (max <= 0) return 0; | |
| 187 | + return clamp(Math.log1p(v) / Math.log1p(max), 0, 1); | |
| 188 | +} | |
| 189 | + | |
| 190 | +/* --------------------------------------------------------------- drawing */ | |
| 191 | + | |
| 192 | +interface Palette { | |
| 193 | + primary: string; | |
| 194 | + secondary: string; | |
| 195 | + glow: string; | |
| 196 | + bg: string; | |
| 197 | + surface: string; | |
| 198 | +} | |
| 199 | + | |
| 200 | +function drawScene(ctx: CanvasRenderingContext2D, L: Layout, s: Scene, cfg: DropzoneConfig, palette: Palette) { | |
| 201 | + const { w, h, hl, rowH } = L; | |
| 202 | + ctx.clearRect(0, 0, w, h); | |
| 203 | + const font = (px: number, weight = 700) => `${weight} ${px}px Geist, "Geist Fallback", system-ui, -apple-system, sans-serif`; | |
| 204 | + | |
| 205 | + // Tower glass backdrop | |
| 206 | + const towerX = L.left - hl * 0.6; | |
| 207 | + const towerW = hl * cfg.lanes * 2 + hl * 1.2; | |
| 208 | + const towerTop = L.pad + (MARKER_UNITS - 0.6 - s.camY) * rowH; | |
| 209 | + const towerBottom = L.pad + (MARKER_UNITS + cfg.rows + BUCKET_UNITS + 0.15 - s.camY) * rowH; | |
| 210 | + const bg = ctx.createLinearGradient(0, towerTop, 0, towerBottom); | |
| 211 | + bg.addColorStop(0, "rgba(255,255,255,0.035)"); | |
| 212 | + bg.addColorStop(1, "rgba(255,255,255,0.012)"); | |
| 213 | + ctx.fillStyle = bg; | |
| 214 | + ctx.beginPath(); | |
| 215 | + ctx.roundRect(towerX, towerTop, towerW, towerBottom - towerTop, 18); | |
| 216 | + ctx.fill(); | |
| 217 | + ctx.strokeStyle = rgba(palette.primary, 0.18); | |
| 218 | + ctx.lineWidth = 1; | |
| 219 | + ctx.stroke(); | |
| 220 | + | |
| 221 | + // Side rails | |
| 222 | + ctx.strokeStyle = rgba(palette.primary, 0.35); | |
| 223 | + ctx.lineWidth = 2; | |
| 224 | + ctx.beginPath(); | |
| 225 | + ctx.moveTo(towerX, towerTop + 14); | |
| 226 | + ctx.lineTo(towerX, towerBottom - 14); | |
| 227 | + ctx.moveTo(towerX + towerW, towerTop + 14); | |
| 228 | + ctx.lineTo(towerX + towerW, towerBottom - 14); | |
| 229 | + ctx.stroke(); | |
| 230 | + | |
| 231 | + // Pegs (main) | |
| 232 | + const pegR = Math.max(2, hl * 0.17); | |
| 233 | + for (let r = 0; r < cfg.rows; r++) { | |
| 234 | + const parity = r % 2 === 0 ? 1 : 0; | |
| 235 | + const y = L.pad + (MARKER_UNITS + r + 0.5 - s.camY) * rowH; | |
| 236 | + if (y < -10 || y > h + 10) continue; | |
| 237 | + for (let x = parity; x < cfg.lanes * 2; x += 2) { | |
| 238 | + const px = L.left + (x + 0.5) * hl; | |
| 239 | + ctx.beginPath(); | |
| 240 | + ctx.arc(px, y, pegR, 0, Math.PI * 2); | |
| 241 | + ctx.fillStyle = "rgba(255,255,255,0.55)"; | |
| 242 | + ctx.fill(); | |
| 243 | + ctx.beginPath(); | |
| 244 | + ctx.arc(px, y, pegR * 2.2, 0, Math.PI * 2); | |
| 245 | + ctx.fillStyle = rgba(palette.primary, 0.08); | |
| 246 | + ctx.fill(); | |
| 247 | + } | |
| 248 | + } | |
| 249 | + | |
| 250 | + // Buckets (main) | |
| 251 | + const maxV = Math.max(...s.values, 1); | |
| 252 | + const bTop = L.pad + (MARKER_UNITS + cfg.rows + 0.15 - s.camY) * rowH; | |
| 253 | + const bH = (BUCKET_UNITS - 0.3) * rowH; | |
| 254 | + for (let k = 0; k < cfg.lanes; k++) { | |
| 255 | + const x0 = L.left + k * 2 * hl + 1.5; | |
| 256 | + const bw = 2 * hl - 3; | |
| 257 | + const v = s.values[k] ?? 0; | |
| 258 | + const t = heat(v, maxV); | |
| 259 | + const base = v >= 50 ? "#ffd66b" : mixHex(palette.primary, palette.secondary, t); | |
| 260 | + const lit = s.landed === k; | |
| 261 | + const pulse = lit ? 0.5 + 0.5 * Math.sin((s.t - s.landedAt) * 8) : 0; | |
| 262 | + ctx.beginPath(); | |
| 263 | + ctx.roundRect(x0, bTop, bw, bH, Math.min(6, hl * 0.35)); | |
| 264 | + ctx.fillStyle = lit ? rgba(base.startsWith("#") ? base : palette.glow, 0.55 + 0.35 * pulse) : rgba("#ffffff", 0.05 + t * 0.06); | |
| 265 | + ctx.fill(); | |
| 266 | + ctx.strokeStyle = lit ? "#ffffff" : base.startsWith("#") ? rgba(base, 0.35 + t * 0.4) : base; | |
| 267 | + ctx.lineWidth = lit ? 2 : 1; | |
| 268 | + ctx.stroke(); | |
| 269 | + // Value | |
| 270 | + const label = v > 0 && v < 0.01 ? "<0.01×" : formatMultiplier(v); | |
| 271 | + const size = Math.max(8, Math.min(13, hl * 0.72)); | |
| 272 | + ctx.font = font(size, 800); | |
| 273 | + ctx.textAlign = "center"; | |
| 274 | + ctx.textBaseline = "middle"; | |
| 275 | + ctx.fillStyle = lit ? "#0b1020" : base.startsWith("#") ? base : mixHex(palette.primary, palette.secondary, t); | |
| 276 | + ctx.fillText(label, x0 + bw / 2, bTop + bH / 2, bw - 2); | |
| 277 | + } | |
| 278 | + | |
| 279 | + // Deep section | |
| 280 | + if (s.deepReveal > 0) { | |
| 281 | + ctx.save(); | |
| 282 | + ctx.globalAlpha = s.deepReveal; | |
| 283 | + const dTop = L.pad + (MARKER_UNITS + L.deepStart - 0.6 - s.camY) * rowH; | |
| 284 | + const dBottom = L.pad + (MARKER_UNITS + L.totalDeepUnits + 0.15 - s.camY) * rowH; | |
| 285 | + const dX = L.deepLeft - hl * 0.6; | |
| 286 | + const dW = hl * cfg.deepBuckets.length * 2 + hl * 1.2; | |
| 287 | + const dg = ctx.createLinearGradient(0, dTop, 0, dBottom); | |
| 288 | + dg.addColorStop(0, rgba(palette.secondary, 0.12)); | |
| 289 | + dg.addColorStop(1, rgba(palette.secondary, 0.03)); | |
| 290 | + ctx.fillStyle = dg; | |
| 291 | + ctx.beginPath(); | |
| 292 | + ctx.roundRect(dX, dTop, dW, dBottom - dTop, 18); | |
| 293 | + ctx.fill(); | |
| 294 | + ctx.strokeStyle = rgba(palette.secondary, 0.4); | |
| 295 | + ctx.lineWidth = 1.5; | |
| 296 | + ctx.stroke(); | |
| 297 | + // Funnel between main buckets and deep tower | |
| 298 | + ctx.strokeStyle = rgba(palette.secondary, 0.5); | |
| 299 | + ctx.setLineDash([4, 6]); | |
| 300 | + ctx.beginPath(); | |
| 301 | + ctx.moveTo(towerX + 8, towerBottom); | |
| 302 | + ctx.lineTo(dX + 8, dTop); | |
| 303 | + ctx.moveTo(towerX + towerW - 8, towerBottom); | |
| 304 | + ctx.lineTo(dX + dW - 8, dTop); | |
| 305 | + ctx.stroke(); | |
| 306 | + ctx.setLineDash([]); | |
| 307 | + ctx.font = font(11, 800); | |
| 308 | + ctx.textAlign = "center"; | |
| 309 | + ctx.textBaseline = "middle"; | |
| 310 | + ctx.fillStyle = palette.secondary; | |
| 311 | + ctx.fillText("DEEP DROP", w / 2, dTop + 14); | |
| 312 | + for (let r = 0; r < cfg.deepRows; r++) { | |
| 313 | + const parity = r % 2 === 0 ? 1 : 0; | |
| 314 | + const y = L.pad + (MARKER_UNITS + L.deepStart + r + 0.5 - s.camY) * rowH; | |
| 315 | + if (y < -10 || y > h + 10) continue; | |
| 316 | + for (let x = parity; x < cfg.deepBuckets.length * 2; x += 2) { | |
| 317 | + const px = L.deepLeft + (x + 0.5) * hl; | |
| 318 | + ctx.beginPath(); | |
| 319 | + ctx.arc(px, y, pegR, 0, Math.PI * 2); | |
| 320 | + ctx.fillStyle = rgba(palette.secondary, 0.8); | |
| 321 | + ctx.fill(); | |
| 322 | + } | |
| 323 | + } | |
| 324 | + const dbTop = L.pad + (MARKER_UNITS + L.deepStart + cfg.deepRows + 0.15 - s.camY) * rowH; | |
| 325 | + for (let k = 0; k < cfg.deepBuckets.length; k++) { | |
| 326 | + const x0 = L.deepLeft + k * 2 * hl + 1.5; | |
| 327 | + const bw = 2 * hl - 3; | |
| 328 | + const v = cfg.deepBuckets[k]; | |
| 329 | + const lit = s.deepLanded === k; | |
| 330 | + const pulse = lit ? 0.5 + 0.5 * Math.sin((s.t - s.deepLandedAt) * 8) : 0; | |
| 331 | + const col = v === 0 ? "#ff5c7a" : v >= 10 ? "#ffd66b" : palette.secondary; | |
| 332 | + ctx.beginPath(); | |
| 333 | + ctx.roundRect(x0, dbTop, bw, bH, Math.min(6, hl * 0.35)); | |
| 334 | + ctx.fillStyle = lit ? rgba(col, 0.55 + 0.35 * pulse) : rgba(col, 0.1); | |
| 335 | + ctx.fill(); | |
| 336 | + ctx.strokeStyle = lit ? "#ffffff" : rgba(col, 0.5); | |
| 337 | + ctx.lineWidth = lit ? 2 : 1; | |
| 338 | + ctx.stroke(); | |
| 339 | + ctx.font = font(Math.max(9, Math.min(13, hl * 0.78)), 800); | |
| 340 | + ctx.fillStyle = lit ? "#0b1020" : col; | |
| 341 | + ctx.fillText(`×${v}`, x0 + bw / 2, dbTop + bH / 2, bw - 2); | |
| 342 | + } | |
| 343 | + ctx.restore(); | |
| 344 | + } | |
| 345 | + | |
| 346 | + // Gate rings | |
| 347 | + for (const g of s.gates) { | |
| 348 | + const age = s.t - g.at; | |
| 349 | + const { px, py } = toPx(L, s, g.x, g.y, false); | |
| 350 | + const k = Math.max(0, 1 - age / 1.6); | |
| 351 | + ctx.save(); | |
| 352 | + ctx.globalAlpha = 0.35 + 0.65 * k; | |
| 353 | + ctx.strokeStyle = palette.secondary; | |
| 354 | + ctx.lineWidth = 2 + 2 * k; | |
| 355 | + ctx.shadowColor = palette.secondary; | |
| 356 | + ctx.shadowBlur = 16 * k; | |
| 357 | + ctx.beginPath(); | |
| 358 | + ctx.ellipse(px, py, hl * (1.05 + 0.4 * Math.min(1, age * 3)), hl * 0.42, 0, 0, Math.PI * 2); | |
| 359 | + ctx.stroke(); | |
| 360 | + ctx.shadowBlur = 0; | |
| 361 | + ctx.font = font(Math.max(11, hl * 0.95), 900); | |
| 362 | + ctx.textAlign = "center"; | |
| 363 | + ctx.textBaseline = "middle"; | |
| 364 | + ctx.fillStyle = "#ffffff"; | |
| 365 | + const rise = Math.min(1, age * 1.4); | |
| 366 | + ctx.fillText(`×${g.value}`, px, py - hl * 1.1 - rise * hl * 0.8); | |
| 367 | + ctx.restore(); | |
| 368 | + } | |
| 369 | + | |
| 370 | + // Portals | |
| 371 | + for (const p of s.portals) { | |
| 372 | + const age = s.t - p.at; | |
| 373 | + const k = Math.max(0, 1 - age / 1.8); | |
| 374 | + const a = toPx(L, s, p.x, p.y, false); | |
| 375 | + const b = toPx(L, s, p.to, p.y, false); | |
| 376 | + ctx.save(); | |
| 377 | + ctx.globalAlpha = 0.25 + 0.75 * k; | |
| 378 | + ctx.strokeStyle = "#a78bfa"; | |
| 379 | + ctx.lineWidth = 1.5; | |
| 380 | + ctx.setLineDash([3, 4]); | |
| 381 | + ctx.beginPath(); | |
| 382 | + ctx.moveTo(a.px, a.py); | |
| 383 | + ctx.lineTo(b.px, b.py); | |
| 384 | + ctx.stroke(); | |
| 385 | + ctx.setLineDash([]); | |
| 386 | + for (const q of [a, b]) { | |
| 387 | + ctx.beginPath(); | |
| 388 | + ctx.ellipse(q.px, q.py, hl * 0.75, hl * 0.35, 0, 0, Math.PI * 2); | |
| 389 | + ctx.strokeStyle = "#c4b5fd"; | |
| 390 | + ctx.lineWidth = 2; | |
| 391 | + ctx.shadowColor = "#a78bfa"; | |
| 392 | + ctx.shadowBlur = 12 * k; | |
| 393 | + ctx.stroke(); | |
| 394 | + } | |
| 395 | + ctx.restore(); | |
| 396 | + } | |
| 397 | + | |
| 398 | + // Trail | |
| 399 | + for (const tr of s.trail) { | |
| 400 | + const { px, py } = toPx(L, s, tr.x, tr.y, tr.deep); | |
| 401 | + ctx.beginPath(); | |
| 402 | + ctx.arc(px, py, hl * 0.32 * tr.life, 0, Math.PI * 2); | |
| 403 | + ctx.fillStyle = rgba(palette.glow, 0.25 * tr.life); | |
| 404 | + ctx.fill(); | |
| 405 | + } | |
| 406 | + | |
| 407 | + // Release marker (idle) | |
| 408 | + if (s.phase === "idle") { | |
| 409 | + const { px, py } = toPx(L, s, s.lane * 2 + 1, -0.75, false); | |
| 410 | + const bob = Math.sin(s.t * 2.2) * 2; | |
| 411 | + ctx.save(); | |
| 412 | + ctx.strokeStyle = rgba(palette.primary, 0.35); | |
| 413 | + ctx.setLineDash([2, 6]); | |
| 414 | + ctx.beginPath(); | |
| 415 | + ctx.moveTo(px, py + hl * 0.6); | |
| 416 | + ctx.lineTo(px, L.pad + (MARKER_UNITS + cfg.rows - s.camY) * rowH); | |
| 417 | + ctx.stroke(); | |
| 418 | + ctx.setLineDash([]); | |
| 419 | + ctx.fillStyle = rgba(palette.primary, 0.9); | |
| 420 | + ctx.beginPath(); | |
| 421 | + ctx.moveTo(px - hl * 0.7, py - hl * 0.35 + bob); | |
| 422 | + ctx.lineTo(px + hl * 0.7, py - hl * 0.35 + bob); | |
| 423 | + ctx.lineTo(px, py + hl * 0.45 + bob); | |
| 424 | + ctx.closePath(); | |
| 425 | + ctx.fill(); | |
| 426 | + ctx.restore(); | |
| 427 | + } | |
| 428 | + | |
| 429 | + // Capsule | |
| 430 | + if (s.capsule.visible && s.capsule.alpha > 0) { | |
| 431 | + const c = s.capsule; | |
| 432 | + const { px, py } = toPx(L, s, c.x, c.y, c.deep); | |
| 433 | + const r = hl * 0.42; | |
| 434 | + ctx.save(); | |
| 435 | + ctx.globalAlpha = c.alpha; | |
| 436 | + ctx.translate(px, py); | |
| 437 | + ctx.scale(1 / Math.sqrt(c.squash), Math.sqrt(c.squash)); | |
| 438 | + ctx.shadowColor = palette.glow; | |
| 439 | + ctx.shadowBlur = 14 + 22 * c.glow; | |
| 440 | + const grad = ctx.createRadialGradient(-r * 0.3, -r * 0.35, r * 0.1, 0, 0, r); | |
| 441 | + grad.addColorStop(0, "#ffffff"); | |
| 442 | + grad.addColorStop(0.35, palette.glow); | |
| 443 | + grad.addColorStop(1, palette.primary); | |
| 444 | + ctx.fillStyle = grad; | |
| 445 | + ctx.beginPath(); | |
| 446 | + ctx.arc(0, 0, r, 0, Math.PI * 2); | |
| 447 | + ctx.fill(); | |
| 448 | + ctx.shadowBlur = 0; | |
| 449 | + ctx.strokeStyle = "rgba(255,255,255,0.75)"; | |
| 450 | + ctx.lineWidth = 1.2; | |
| 451 | + ctx.stroke(); | |
| 452 | + ctx.restore(); | |
| 453 | + } | |
| 454 | + | |
| 455 | + // Particles | |
| 456 | + for (const p of s.particles) { | |
| 457 | + const k = p.life / p.max; | |
| 458 | + ctx.globalAlpha = k; | |
| 459 | + ctx.fillStyle = p.color; | |
| 460 | + ctx.beginPath(); | |
| 461 | + ctx.arc(p.x, p.y, p.size * (0.4 + 0.6 * k), 0, Math.PI * 2); | |
| 462 | + ctx.fill(); | |
| 463 | + } | |
| 464 | + ctx.globalAlpha = 1; | |
| 465 | + | |
| 466 | + // Centre flash text (deep multiplier etc.) | |
| 467 | + if (s.flash) { | |
| 468 | + const age = s.t - s.flash.at; | |
| 469 | + if (age < 1.6) { | |
| 470 | + const k = age < 0.25 ? easeOutBack(age / 0.25) : 1; | |
| 471 | + const fade = age > 1.1 ? 1 - (age - 1.1) / 0.5 : 1; | |
| 472 | + ctx.save(); | |
| 473 | + ctx.globalAlpha = Math.max(0, fade); | |
| 474 | + ctx.translate(w / 2, h * 0.42); | |
| 475 | + ctx.scale(k, k); | |
| 476 | + ctx.font = font(Math.min(54, w * 0.13), 900); | |
| 477 | + ctx.textAlign = "center"; | |
| 478 | + ctx.textBaseline = "middle"; | |
| 479 | + ctx.shadowColor = s.flash.color; | |
| 480 | + ctx.shadowBlur = 30; | |
| 481 | + ctx.fillStyle = s.flash.color; | |
| 482 | + ctx.fillText(s.flash.text, 0, 0); | |
| 483 | + ctx.restore(); | |
| 484 | + } | |
| 485 | + } | |
| 486 | +} | |
| 487 | + | |
| 488 | +/* ------------------------------------------------------------- component */ | |
| 489 | + | |
| 490 | +const RISKS: { id: Risk; label: string }[] = [ | |
| 491 | + { id: "low", label: "Low" }, | |
| 492 | + { id: "medium", label: "Medium" }, | |
| 493 | + { id: "high", label: "High" }, | |
| 494 | +]; | |
| 495 | + | |
| 496 | +export function DropzoneGame({ definition, bet, onBusy, onResult, sound, reduceMotion }: ArcadeGameProps) { | |
| 497 | + const cfg = definition.config as unknown as DropzoneConfig; | |
| 498 | + const palette = definition.presentation.palette; | |
| 499 | + const { play, error, clearError } = useInstantPlay<DropOutcome>(definition.slug); | |
| 500 | + const [risk, setRisk] = useState<Risk>("medium"); | |
| 501 | + const [lane, setLane] = useState(Math.floor(cfg.lanes / 2)); | |
| 502 | + const [phase, setPhase] = useState<"idle" | "dropping">("idle"); | |
| 503 | + const [live, setLive] = useState<string | null>(null); | |
| 504 | + const [result, setResult] = useState<{ win: number; multiplier: number; parts: string } | null>(null); | |
| 505 | + const canvasRef = useRef<HTMLCanvasElement>(null); | |
| 506 | + const sceneRef = useRef<Scene | null>(null); | |
| 507 | + const aliveRef = useRef(true); | |
| 508 | + const phaseRef = useRef<"idle" | "dropping">("idle"); | |
| 509 | + const values = useMemo(() => displayedBuckets(definition, cfg, risk, lane), [definition, cfg, risk, lane]); | |
| 510 | + | |
| 511 | + const getScene = useCallback((): Scene => { | |
| 512 | + if (!sceneRef.current) { | |
| 513 | + const mid = Math.floor(cfg.lanes / 2); | |
| 514 | + sceneRef.current = { | |
| 515 | + t: 0, | |
| 516 | + lane: mid, | |
| 517 | + risk: "medium", | |
| 518 | + values: displayedBuckets(definition, cfg, "medium", mid), | |
| 519 | + phase: "idle", | |
| 520 | + camY: 0, | |
| 521 | + capsule: { x: mid * 2 + 1, y: 0, deep: false, visible: false, alpha: 1, squash: 1, glow: 0 }, | |
| 522 | + trail: [], | |
| 523 | + gates: [], | |
| 524 | + portals: [], | |
| 525 | + landed: null, | |
| 526 | + landedAt: 0, | |
| 527 | + deepReveal: 0, | |
| 528 | + deepLanded: null, | |
| 529 | + deepLandedAt: 0, | |
| 530 | + particles: [], | |
| 531 | + flash: null, | |
| 532 | + reduceMotion: false, | |
| 533 | + }; | |
| 534 | + } | |
| 535 | + return sceneRef.current; | |
| 536 | + }, [definition, cfg]); | |
| 537 | + | |
| 538 | + /* Render loop */ | |
| 539 | + useEffect(() => { | |
| 540 | + aliveRef.current = true; | |
| 541 | + const canvas = canvasRef.current; | |
| 542 | + if (!canvas) return; | |
| 543 | + const ctx = canvas.getContext("2d"); | |
| 544 | + if (!ctx) return; | |
| 545 | + const s = getScene(); | |
| 546 | + let raf = 0; | |
| 547 | + let last = performance.now(); | |
| 548 | + const loop = (now: number) => { | |
| 549 | + const dt = Math.min(0.05, (now - last) / 1000); | |
| 550 | + last = now; | |
| 551 | + s.t += dt; | |
| 552 | + const dpr = Math.min(2, window.devicePixelRatio || 1); | |
| 553 | + const rect = canvas.getBoundingClientRect(); | |
| 554 | + const W = Math.max(1, Math.round(rect.width * dpr)); | |
| 555 | + const H = Math.max(1, Math.round(rect.height * dpr)); | |
| 556 | + if (canvas.width !== W || canvas.height !== H) { | |
| 557 | + canvas.width = W; | |
| 558 | + canvas.height = H; | |
| 559 | + } | |
| 560 | + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| 561 | + const L = layoutFor(rect.width, rect.height, cfg); | |
| 562 | + // particles | |
| 563 | + for (let i = s.particles.length - 1; i >= 0; i--) { | |
| 564 | + const p = s.particles[i]; | |
| 565 | + p.life -= dt; | |
| 566 | + p.x += p.vx * dt; | |
| 567 | + p.y += p.vy * dt; | |
| 568 | + p.vy += 420 * dt; | |
| 569 | + p.vx *= 0.98; | |
| 570 | + if (p.life <= 0) s.particles.splice(i, 1); | |
| 571 | + } | |
| 572 | + for (let i = s.trail.length - 1; i >= 0; i--) { | |
| 573 | + s.trail[i].life -= dt * 3.2; | |
| 574 | + if (s.trail[i].life <= 0) s.trail.splice(i, 1); | |
| 575 | + } | |
| 576 | + if (s.phase === "dropping" && s.capsule.visible && !s.reduceMotion) s.trail.push({ x: s.capsule.x, y: s.capsule.y, deep: s.capsule.deep, life: 1 }); | |
| 577 | + s.capsule.glow = Math.max(0, s.capsule.glow - dt * 1.5); | |
| 578 | + drawScene(ctx, L, s, cfg, palette); | |
| 579 | + raf = requestAnimationFrame(loop); | |
| 580 | + }; | |
| 581 | + raf = requestAnimationFrame(loop); | |
| 582 | + return () => { | |
| 583 | + aliveRef.current = false; | |
| 584 | + cancelAnimationFrame(raf); | |
| 585 | + }; | |
| 586 | + }, [cfg, palette, getScene]); | |
| 587 | + | |
| 588 | + /* Sync UI state into the scene */ | |
| 589 | + useEffect(() => { | |
| 590 | + const s = getScene(); | |
| 591 | + s.lane = lane; | |
| 592 | + s.risk = risk; | |
| 593 | + s.values = values; | |
| 594 | + s.reduceMotion = reduceMotion; | |
| 595 | + if (s.phase === "idle") { | |
| 596 | + s.capsule.x = lane * 2 + 1; | |
| 597 | + } | |
| 598 | + }, [lane, risk, values, reduceMotion, getScene]); | |
| 599 | + | |
| 600 | + const laneFromPointer = useCallback( | |
| 601 | + (e: React.PointerEvent<HTMLCanvasElement>) => { | |
| 602 | + const rect = e.currentTarget.getBoundingClientRect(); | |
| 603 | + const L = layoutFor(rect.width, rect.height, cfg); | |
| 604 | + const x = e.clientX - rect.left; | |
| 605 | + return clamp(Math.floor((x - L.left) / (L.hl * 2)), 0, cfg.lanes - 1); | |
| 606 | + }, | |
| 607 | + [cfg], | |
| 608 | + ); | |
| 609 | + | |
| 610 | + const onPointer = useCallback( | |
| 611 | + (e: React.PointerEvent<HTMLCanvasElement>) => { | |
| 612 | + if (phaseRef.current !== "idle") return; | |
| 613 | + if (e.type === "pointermove" && e.buttons === 0) return; | |
| 614 | + const l = laneFromPointer(e); | |
| 615 | + setLane((prev) => { | |
| 616 | + if (prev !== l) sound("tick"); | |
| 617 | + return l; | |
| 618 | + }); | |
| 619 | + }, | |
| 620 | + [laneFromPointer, sound], | |
| 621 | + ); | |
| 622 | + | |
| 623 | + const spawnBurst = useCallback((s: Scene, L: Layout, x: number, y: number, deep: boolean, n: number, color: string, speed: number) => { | |
| 624 | + if (s.reduceMotion) return; | |
| 625 | + const { px, py } = toPx(L, s, x, y, deep); | |
| 626 | + for (let i = 0; i < n; i++) { | |
| 627 | + const a = Math.random() * Math.PI * 2; | |
| 628 | + const v = speed * (0.4 + Math.random() * 0.8); | |
| 629 | + s.particles.push({ x: px, y: py, vx: Math.cos(a) * v, vy: Math.sin(a) * v - speed * 0.4, life: 0.35 + Math.random() * 0.4, max: 0.75, color, size: 1.5 + Math.random() * 2.2 }); | |
| 630 | + } | |
| 631 | + }, []); | |
| 632 | + | |
| 633 | + const animate = useCallback( | |
| 634 | + async (outcome: DropOutcome) => { | |
| 635 | + const s = getScene(); | |
| 636 | + const canvas = canvasRef.current; | |
| 637 | + const alive = () => aliveRef.current; | |
| 638 | + const rm = s.reduceMotion; | |
| 639 | + const rect = canvas?.getBoundingClientRect() ?? { width: 390, height: 600 }; | |
| 640 | + const L = () => layoutFor(rect.width, rect.height, cfg); | |
| 641 | + const rowMs = rm ? 45 : 150; | |
| 642 | + | |
| 643 | + // reset | |
| 644 | + s.phase = "dropping"; | |
| 645 | + s.gates = []; | |
| 646 | + s.portals = []; | |
| 647 | + s.landed = null; | |
| 648 | + s.deepLanded = null; | |
| 649 | + s.deepReveal = 0; | |
| 650 | + s.flash = null; | |
| 651 | + s.particles = []; | |
| 652 | + s.trail = []; | |
| 653 | + s.values = outcome.summary.table; | |
| 654 | + if (s.camY > 0) await tween(rm ? 60 : 320, (t) => (s.camY = lerp(s.camY, 0, easeOutCubic(t))), alive); | |
| 655 | + s.camY = 0; | |
| 656 | + const cap = s.capsule; | |
| 657 | + cap.deep = false; | |
| 658 | + cap.x = outcome.summary.startLane * 2 + 1; | |
| 659 | + cap.y = -0.75; | |
| 660 | + cap.alpha = 1; | |
| 661 | + cap.squash = 1; | |
| 662 | + cap.visible = true; | |
| 663 | + cap.glow = 1; | |
| 664 | + await tween(rm ? 60 : 220, (t) => (cap.y = lerp(-0.75, 0, easeInQuad(t))), alive); | |
| 665 | + | |
| 666 | + let gateMult = 1; | |
| 667 | + const runSteps = async (steps: DropStep[], deep: boolean) => { | |
| 668 | + for (let i = 0; i < steps.length; i++) { | |
| 669 | + if (!alive()) return; | |
| 670 | + const step = steps[i]; | |
| 671 | + const fromX = cap.x; | |
| 672 | + const toX = step.x; | |
| 673 | + const r = step.row; | |
| 674 | + await tween(rowMs, (t) => { | |
| 675 | + if (t < 0.5) { | |
| 676 | + cap.y = lerp(r, r + 0.5, easeInQuad(t / 0.5)); | |
| 677 | + cap.squash = 1; | |
| 678 | + } else { | |
| 679 | + const k = (t - 0.5) / 0.5; | |
| 680 | + cap.x = lerp(fromX, toX, easeOutCubic(k)); | |
| 681 | + cap.y = lerp(r + 0.5, r + 1, k); | |
| 682 | + cap.squash = 1 + 0.45 * Math.sin(k * Math.PI) * (k < 0.5 ? 1 : 0.4); | |
| 683 | + } | |
| 684 | + }, alive); | |
| 685 | + spawnBurst(s, L(), fromX, r + 0.5, deep, 3, "#ffffff", 90); | |
| 686 | + if (i % 2 === 0) sound("tick"); | |
| 687 | + if (!deep && step.event?.type === "gate") { | |
| 688 | + gateMult *= step.event.value; | |
| 689 | + s.gates.push({ x: toX, y: r + 1, value: step.event.value, at: s.t }); | |
| 690 | + cap.glow = 1; | |
| 691 | + spawnBurst(s, L(), toX, r + 1, false, 14, palette.secondary, 160); | |
| 692 | + sound("bonus"); | |
| 693 | + setLive(`Gate ×${step.event.value} · total ×${gateMult}`); | |
| 694 | + await wait(rm ? 80 : 260); | |
| 695 | + } else if (!deep && step.event?.type === "portal") { | |
| 696 | + const to = step.event.to; | |
| 697 | + s.portals.push({ x: toX, to, y: r + 1, at: s.t }); | |
| 698 | + sound("bonus"); | |
| 699 | + setLive(`Portal → lane ${Math.floor(to / 2) + 1}`); | |
| 700 | + await tween(rm ? 40 : 160, (t) => (cap.alpha = 1 - t), alive); | |
| 701 | + spawnBurst(s, L(), toX, r + 1, false, 10, "#a78bfa", 120); | |
| 702 | + cap.x = to; | |
| 703 | + spawnBurst(s, L(), to, r + 1, false, 10, "#a78bfa", 120); | |
| 704 | + await tween(rm ? 40 : 160, (t) => (cap.alpha = t), alive); | |
| 705 | + cap.alpha = 1; | |
| 706 | + } | |
| 707 | + } | |
| 708 | + }; | |
| 709 | + | |
| 710 | + await runSteps(outcome.steps, false); | |
| 711 | + if (!alive()) return; | |
| 712 | + | |
| 713 | + // Landing in the main bucket | |
| 714 | + const bucket = outcome.summary.bucket; | |
| 715 | + const fromX = cap.x; | |
| 716 | + await tween(rm ? 60 : 260, (t) => { | |
| 717 | + cap.x = lerp(fromX, bucket * 2 + 1, easeOutCubic(t)); | |
| 718 | + cap.y = lerp(cfg.rows, cfg.rows + 0.85, easeOutBack(t)); | |
| 719 | + cap.squash = 1 + 0.3 * Math.sin(t * Math.PI); | |
| 720 | + }, alive); | |
| 721 | + s.landed = bucket; | |
| 722 | + s.landedAt = s.t; | |
| 723 | + cap.glow = 1; | |
| 724 | + spawnBurst(s, L(), bucket * 2 + 1, cfg.rows + 0.85, false, 18, palette.glow, 180); | |
| 725 | + const bucketValue = outcome.summary.bucketValue; | |
| 726 | + setLive(gateMult > 1 ? `${formatMultiplier(bucketValue)} bucket × gates ×${gateMult}` : `${formatMultiplier(bucketValue)} bucket`); | |
| 727 | + sound(bucketValue * gateMult >= 1 ? "win" : "tick"); | |
| 728 | + | |
| 729 | + // Deep Drop | |
| 730 | + const deep = outcome.summary.deep; | |
| 731 | + if (deep) { | |
| 732 | + await wait(rm ? 80 : 420); | |
| 733 | + sound("bonus"); | |
| 734 | + s.flash = { text: "DEEP DROP", at: s.t, color: palette.secondary }; | |
| 735 | + const Ld = L(); | |
| 736 | + const visibleUnits = (Ld.h - Ld.pad * 2) / Ld.rowH - MARKER_UNITS; | |
| 737 | + const targetCam = Math.max(0, Ld.totalDeepUnits + 0.4 - visibleUnits); | |
| 738 | + const deepStartX = clamp(bucket * 2 + 1, 0, cfg.deepBuckets.length * 2 - 1); | |
| 739 | + await tween(rm ? 120 : 800, (t) => { | |
| 740 | + const k = easeOutCubic(t); | |
| 741 | + s.deepReveal = Math.min(1, t * 1.6); | |
| 742 | + s.camY = targetCam * k; | |
| 743 | + }, alive); | |
| 744 | + // capsule falls through the bucket floor into the deep release line | |
| 745 | + await tween(rm ? 60 : 360, (t) => { | |
| 746 | + cap.alpha = t < 0.5 ? 1 - t * 2 : (t - 0.5) * 2; | |
| 747 | + if (t >= 0.5 && !cap.deep) { | |
| 748 | + cap.deep = true; | |
| 749 | + cap.x = deepStartX; | |
| 750 | + cap.y = -0.6; | |
| 751 | + } | |
| 752 | + if (t >= 0.5) cap.y = lerp(-0.6, 0, (t - 0.5) * 2); | |
| 753 | + }, alive); | |
| 754 | + cap.alpha = 1; | |
| 755 | + cap.deep = true; | |
| 756 | + cap.x = deepStartX; | |
| 757 | + cap.y = 0; | |
| 758 | + await runSteps(deep.steps, true); | |
| 759 | + if (!alive()) return; | |
| 760 | + const fx = cap.x; | |
| 761 | + await tween(rm ? 60 : 260, (t) => { | |
| 762 | + cap.x = lerp(fx, deep.bucket * 2 + 1, easeOutCubic(t)); | |
| 763 | + cap.y = lerp(cfg.deepRows, cfg.deepRows + 0.85, easeOutBack(t)); | |
| 764 | + cap.squash = 1 + 0.3 * Math.sin(t * Math.PI); | |
| 765 | + }, alive); | |
| 766 | + s.deepLanded = deep.bucket; | |
| 767 | + s.deepLandedAt = s.t; | |
| 768 | + const col = deep.value === 0 ? "#ff5c7a" : deep.value >= 10 ? "#ffd66b" : palette.secondary; | |
| 769 | + s.flash = { text: `×${deep.value}`, at: s.t, color: col }; | |
| 770 | + spawnBurst(s, L(), deep.bucket * 2 + 1, cfg.deepRows + 0.85, true, 24, col, 220); | |
| 771 | + setLive(deep.value === 0 ? "Deep bucket ×0 — vanished" : `Deep bucket ×${deep.value}`); | |
| 772 | + await wait(rm ? 100 : 500); | |
| 773 | + } | |
| 774 | + | |
| 775 | + if (!alive()) return; | |
| 776 | + const parts = gateMult > 1 || deep ? [`${formatMultiplier(bucketValue)} bucket`, gateMult > 1 ? `×${gateMult} gates` : null, deep ? `×${deep.value} deep` : null].filter(Boolean).join(" · ") : `Bucket ${bucket + 1}`; | |
| 777 | + setResult({ win: outcome.totalWin, multiplier: outcome.multiplier, parts }); | |
| 778 | + if (outcome.totalWin <= 0) sound("lose"); | |
| 779 | + else if (outcome.multiplier >= 15) sound("bigWin"); | |
| 780 | + else sound("win"); | |
| 781 | + onResult({ win: outcome.totalWin, multiplier: outcome.multiplier }); | |
| 782 | + s.phase = "idle"; | |
| 783 | + }, | |
| 784 | + [cfg, palette, getScene, sound, onResult, spawnBurst], | |
| 785 | + ); | |
| 786 | + | |
| 787 | + const drop = useCallback(async () => { | |
| 788 | + if (phaseRef.current !== "idle") return; | |
| 789 | + phaseRef.current = "dropping"; | |
| 790 | + setPhase("dropping"); | |
| 791 | + setResult(null); | |
| 792 | + setLive(null); | |
| 793 | + onBusy(true); | |
| 794 | + sound("click"); | |
| 795 | + const res = await play(bet, { risk, lane }); | |
| 796 | + if (res && aliveRef.current) { | |
| 797 | + await animate(res.outcome); | |
| 798 | + } | |
| 799 | + phaseRef.current = "idle"; | |
| 800 | + if (aliveRef.current) { | |
| 801 | + setPhase("idle"); | |
| 802 | + const s = sceneRef.current; | |
| 803 | + if (s) s.phase = "idle"; | |
| 804 | + } | |
| 805 | + onBusy(false); | |
| 806 | + }, [play, bet, risk, lane, animate, onBusy, sound]); | |
| 807 | + | |
| 808 | + const dropping = phase === "dropping"; | |
| 809 | + const maxValue = Math.max(...values); | |
| 810 | + | |
| 811 | + return ( | |
| 812 | + <div className="absolute inset-0 flex flex-col"> | |
| 813 | + {/* Risk selector */} | |
| 814 | + <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-3 px-3 pt-2"> | |
| 815 | + <div role="radiogroup" aria-label="Risk profile" className="flex h-11 flex-1 max-w-[330px] rounded-md p-1 surface-2"> | |
| 816 | + {RISKS.map((r) => { | |
| 817 | + const active = risk === r.id; | |
| 818 | + return ( | |
| 819 | + <button | |
| 820 | + key={r.id} | |
| 821 | + role="radio" | |
| 822 | + aria-checked={active} | |
| 823 | + disabled={dropping} | |
| 824 | + onClick={() => { | |
| 825 | + if (risk !== r.id) sound("click"); | |
| 826 | + setRisk(r.id); | |
| 827 | + }} | |
| 828 | + className={cn("flex-1 rounded-[8px] text-[13px] font-semibold transition-all focus-ring disabled:opacity-60", active ? "text-[#061018]" : "text-fg-2 hover:text-fg")} | |
| 829 | + style={active ? { background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary})`, boxShadow: `0 6px 20px -8px ${palette.primary}` } : undefined} | |
| 830 | + > | |
| 831 | + {r.label} | |
| 832 | + </button> | |
| 833 | + ); | |
| 834 | + })} | |
| 835 | + </div> | |
| 836 | + <div className="text-right leading-tight"> | |
| 837 | + <div className="eyebrow">Top bucket</div> | |
| 838 | + <div className="text-sm font-bold tabular" style={{ color: palette.glow }}> | |
| 839 | + {formatMultiplier(maxValue)} | |
| 840 | + </div> | |
| 841 | + </div> | |
| 842 | + </div> | |
| 843 | + | |
| 844 | + {/* Tower */} | |
| 845 | + <div className="relative min-h-0 flex-1"> | |
| 846 | + <canvas | |
| 847 | + ref={canvasRef} | |
| 848 | + className={cn("absolute inset-0 h-full w-full touch-none", dropping ? "cursor-default" : "cursor-pointer")} | |
| 849 | + onPointerDown={onPointer} | |
| 850 | + onPointerMove={onPointer} | |
| 851 | + aria-label="Drop tower. Tap or drag to choose the release lane." | |
| 852 | + role="img" | |
| 853 | + /> | |
| 854 | + <AnimatePresence> | |
| 855 | + {live && dropping ? ( | |
| 856 | + <motion.div key={live} initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute left-1/2 top-2 -translate-x-1/2 whitespace-nowrap rounded-full px-3 py-1 text-[12px] font-bold uppercase tracking-wider" style={{ background: rgba(palette.secondary, 0.18), color: palette.secondary }}> | |
| 857 | + {live} | |
| 858 | + </motion.div> | |
| 859 | + ) : null} | |
| 860 | + {result && !dropping ? ( | |
| 861 | + <motion.div key="result" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="pointer-events-none absolute left-1/2 top-2 flex -translate-x-1/2 flex-col items-center glass rounded-lg px-4 py-1.5 text-center leading-tight"> | |
| 862 | + <span className={cn("whitespace-nowrap text-sm font-bold tabular", result.win > 0 ? "text-credit" : "text-fg-3")}>{result.win > 0 ? `+${formatSC(result.win)} · ${formatMultiplier(result.multiplier)}` : "No win this drop"}</span> | |
| 863 | + <span className="whitespace-nowrap text-[11px] text-fg-3">{result.parts}</span> | |
| 864 | + </motion.div> | |
| 865 | + ) : null} | |
| 866 | + {error ? ( | |
| 867 | + <motion.div key="err" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute inset-x-4 bottom-3 mx-auto max-w-sm glass rounded-md p-3 text-center text-sm"> | |
| 868 | + <div className="text-fg-2">{error}</div> | |
| 869 | + <button onClick={clearError} className="mt-2 rounded-sm px-3 py-1.5 text-[13px] font-semibold surface-2 focus-ring"> | |
| 870 | + Dismiss | |
| 871 | + </button> | |
| 872 | + </motion.div> | |
| 873 | + ) : null} | |
| 874 | + </AnimatePresence> | |
| 875 | + </div> | |
| 876 | + | |
| 877 | + {/* Lane + action */} | |
| 878 | + <div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-3 pb-3 pt-2"> | |
| 879 | + <div className="flex items-center gap-1"> | |
| 880 | + <button disabled={dropping || lane <= 0} onClick={() => { sound("tick"); setLane((l) => Math.max(0, l - 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Lane left"> | |
| 881 | + <ChevronLeft className="h-4 w-4" /> | |
| 882 | + </button> | |
| 883 | + <div className="flex h-11 min-w-[72px] flex-col items-center justify-center rounded-md surface-2 px-2"> | |
| 884 | + <span className="text-[10px] uppercase tracking-wider text-fg-3">Lane</span> | |
| 885 | + <span className="text-sm font-bold tabular">{lane + 1} / {cfg.lanes}</span> | |
| 886 | + </div> | |
| 887 | + <button disabled={dropping || lane >= cfg.lanes - 1} onClick={() => { sound("tick"); setLane((l) => Math.min(cfg.lanes - 1, l + 1)); }} className="tap grid h-11 w-11 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Lane right"> | |
| 888 | + <ChevronRight className="h-4 w-4" /> | |
| 889 | + </button> | |
| 890 | + </div> | |
| 891 | + <button | |
| 892 | + onClick={() => void drop()} | |
| 893 | + disabled={dropping} | |
| 894 | + className="tap relative h-14 flex-1 rounded-lg text-base font-extrabold uppercase tracking-[0.18em] text-[#061018] transition-transform active:scale-[0.98] disabled:opacity-70 focus-ring" | |
| 895 | + style={{ background: `linear-gradient(180deg, ${palette.glow}, ${palette.primary} 55%, ${mixHex(palette.primary, "#000000", 0.25)})`, boxShadow: `0 0 0 4px ${rgba(palette.primary, 0.18)}, 0 18px 50px -14px ${palette.primary}` }} | |
| 896 | + aria-label={definition.presentation.verb} | |
| 897 | + > | |
| 898 | + {dropping ? "DROPPING…" : `${definition.presentation.verb} · ${formatSC(bet)}`} | |
| 899 | + </button> | |
| 900 | + </div> | |
| 901 | + </div> | |
| 902 | + ); | |
| 903 | +} | |
added
apps/web/src/components/arcade/escape.tsx
+538 −0
Large diff (538 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/arcade/gridbreak.tsx
+796 −0
Large diff (796 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/arcade/orbit.tsx
+917 −0
Large diff (917 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/arcade/vault.tsx
+485 −0
Large diff (485 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/lobby/game-art.tsx
+1048 −21
Large diff (1,167 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/lobby/symbol-svg.tsx
+91 −0
Large diff (97 lines) is not loaded by default to keep the page fast.Load diff
added
games/certifications/dropzone.json
+301 −0
Large diff (301 lines) is not loaded by default to keep the page fast.Load diff
added
games/certifications/escape-99.json
+297 −0
Large diff (297 lines) is not loaded by default to keep the page fast.Load diff
added
games/certifications/grid-break.json
+302 −0
Large diff (302 lines) is not loaded by default to keep the page fast.Load diff
added
games/certifications/orbit.json
+307 −0
Large diff (307 lines) is not loaded by default to keep the page fast.Load diff
added
games/certifications/the-vault.json
+297 −0
Large diff (297 lines) is not loaded by default to keep the page fast.Load diff
added
games/src/arcade/index.ts
+199 −0
Large diff (199 lines) is not loaded by default to keep the page fast.Load diff
modified
games/src/calibration.json
+21 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
modified
games/src/client.ts
+1 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
modified
games/src/index.ts
+1 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added
packages/database/drizzle/0002_nosy_giant_man.sql
+22 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
packages/database/drizzle/meta/0002_snapshot.json
+2988 −0
Large diff (2,989 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/database/drizzle/meta/_journal.json
+7 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/database/src/schema.ts
+26 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/database/src/sync-games.ts
+46 −2
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added
packages/game-core/src/arcade/dropzone.ts
+143 −0
Large diff (143 lines) is not loaded by default to keep the page fast.Load diff
added
packages/game-core/src/arcade/gridbreak.ts
+166 −0
Large diff (166 lines) is not loaded by default to keep the page fast.Load diff
added
packages/game-core/src/arcade/index.ts
+185 −0
Large diff (185 lines) is not loaded by default to keep the page fast.Load diff
added
packages/game-core/src/arcade/ladder.ts
+272 −0
Large diff (272 lines) is not loaded by default to keep the page fast.Load diff
added
packages/game-core/src/arcade/orbit.ts
+121 −0
Large diff (121 lines) is not loaded by default to keep the page fast.Load diff
added
packages/game-core/src/arcade/types.ts
+106 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/game-core/src/client.ts
+5 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
packages/game-core/src/index.ts
+1 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff