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 | | Path | Package | Role | | 7 | | Path | Package | Role | | ||
| 8 | |---|---|---| | 8 | |---|---|---| | ||
| 9 | | `packages/shared` | `@spinza/shared` | Economy constants (10,000 SC start, bet levels, daily rewards, XP curve), zod schemas, formatting (`formatSC` — never a currency sign), API DTO types. | | 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). | | 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 | −| `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). | | 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 | | `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 | | `packages/database` | `@spinza/database` | Drizzle schema (25 tables, snake_case casing), migrations in `drizzle/`, `seed.ts` (games sync, achievements, missions, levels, flags, settings), `sync-games.ts`. | | ||
| 13 | | `apps/api` | `@spinza/api` | Fastify on 127.0.0.1:8231. Auth (argon2id, opaque 256-bit sessions hashed, recovery codes), atomic spin (`services/spin.ts`), wallet ledger (`services/wallet.ts`), progression, rewards, leaderboards, admin (TOTP), health. | | 14 | | `apps/api` | `@spinza/api` | Fastify on 127.0.0.1:8231. Auth (argon2id, opaque 256-bit sessions hashed, recovery codes), atomic spin (`services/spin.ts`), wallet ledger (`services/wallet.ts`), progression, rewards, leaderboards, admin (TOTP), health. | | ||
| 14 | | `apps/simulator` | `@spinza/simulator` | CLI `pnpm sim <validate|run|calibrate|certify>` with worker threads; also used by the admin simulator. | | 15 | | `apps/simulator` | `@spinza/simulator` | CLI `pnpm sim <validate|run|calibrate|certify>` with worker threads; also used by the admin simulator. | | ||
| 15 | | `apps/web` | `@spinza/web` | Next 16 (port 8230). `/api/*` is rewritten to the Fastify service. Game screen = `src/components/game/*` (PixiJS renderer, procedural symbols, WebAudio sound). Admin console under `/admin`. | | 16 | | `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 | ## Non-negotiable rules | 29 | ## Non-negotiable rules | ||
| 18 | 30 | ||||
| 19 | - Outcomes are decided **only** in `apps/api` via `runSpin`. The browser animates `result.steps`; it never computes wins. | 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 | pnpm install | 41 | pnpm install | ||
| 30 | createdb spinza && pnpm db:migrate && pnpm db:seed # local Postgres 17 + Redis required | 42 | createdb spinza && pnpm db:migrate && pnpm db:seed # local Postgres 17 + Redis required | ||
| 31 | pnpm dev:api # 8231 pnpm dev:web # 8230 | 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 | pnpm --filter @spinza/game-core test # engine unit tests | 45 | pnpm --filter @spinza/game-core test # engine unit tests | ||
| 34 | pnpm --filter @spinza/api test # integration tests (needs local DB/Redis) | 46 | pnpm --filter @spinza/api test # integration tests (needs local DB/Redis) | ||
| 35 | pnpm admin:create <username> [password] # prints TOTP secret once | 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 | import { healthRoutes } from "./routes/health"; | 11 | import { healthRoutes } from "./routes/health"; | ||
| 12 | import { adminRoutes } from "./routes/admin"; | 12 | import { adminRoutes } from "./routes/admin"; | ||
| 13 | import { crashRoutes } from "./routes/crash"; | 13 | import { crashRoutes } from "./routes/crash"; | ||
| 14 | +import { arcadeRoutes } from "./routes/arcade"; | ||||
| 14 | import { refreshSettings } from "./lib/settings"; | 15 | import { refreshSettings } from "./lib/settings"; | ||
| 15 | 16 | ||||
| 16 | const REDACT = ["req.headers.cookie", "req.headers.authorization", "*.password", "*.newPassword", "*.currentPassword", "*.recoveryCode", "*.totp", "*.passwordHash", "*.tokenHash"]; | 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 | await app.register(gameRoutes); | 72 | await app.register(gameRoutes); | ||
| 72 | await app.register(rewardRoutes); | 73 | await app.register(rewardRoutes); | ||
| 73 | await app.register(crashRoutes); | 74 | await app.register(crashRoutes); | ||
| 75 | + await app.register(arcadeRoutes); | ||||
| 74 | await app.register(adminRoutes); | 76 | await app.register(adminRoutes); | ||
| 75 | 77 | ||||
| 76 | return app; | 78 | return app; | ||
modified
apps/api/src/routes/admin.ts
+6 −5
@@ -1,7 +1,7 @@ | |||||
| 1 | import type { FastifyInstance } from "fastify"; | 1 | import type { FastifyInstance } from "fastify"; | ||
| 2 | import os from "node:os"; | 2 | import os from "node:os"; | ||
| 3 | import { achievements, adminUsers, and, count, creditTransactions, db, desc, eq, gameRounds, gameStatistics, games, gte, ilike, missions, securityEvents, sessions, simulationRuns, sql, users, wallets, gameVersions, or } from "@spinza/database"; | 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 | import { certify, DEFAULT_CERTIFICATION_RULES, validateDefinition } from "@spinza/game-core"; | 5 | import { certify, DEFAULT_CERTIFICATION_RULES, validateDefinition } from "@spinza/game-core"; | ||
| 6 | import { simulateParallel } from "@spinza/simulator"; | 6 | import { simulateParallel } from "@spinza/simulator"; | ||
| 7 | import { GAME_LIFECYCLE } from "@spinza/shared"; | 7 | import { GAME_LIFECYCLE } from "@spinza/shared"; | ||
@@ -153,13 +153,14 @@ export async function adminRoutes(app: FastifyInstance) { | |||||
| 153 | return { | 153 | return { | ||
| 154 | games: rows.map(({ game, stats }) => { | 154 | games: rows.map(({ game, stats }) => { | ||
| 155 | const def = getGame(game.slug); | 155 | const def = getGame(game.slug); | ||
| 156 | + const other = CRASH_BY_SLUG.get(game.slug) ?? ARCADE_BY_SLUG.get(game.slug); | ||||
| 156 | return { | 157 | return { | ||
| 157 | ...game, | 158 | ...game, | ||
| 158 | enabled: flags[`game.${game.slug}.enabled`] ?? true, | 159 | enabled: flags[`game.${game.slug}.enabled`] ?? true, | ||
| 159 | − rtp: def?.rtp ?? null, | 160 | + rtp: def?.rtp ?? other?.rtp ?? null, | ||
| 160 | − payScale: def?.payScale ?? null, | 161 | + payScale: def?.payScale ?? (other && "payScale" in other ? other.payScale : null), | ||
| 161 | stats: stats ? { ...stats, effectiveRtp: Number(stats.wagered) ? Number(stats.won) / Number(stats.wagered) : null } : null, | 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 | 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 | const daily = await db.execute(sql`select date_trunc('day', created_at) as day, count(*)::int as spins, sum(bet)::bigint as wagered, sum(win)::bigint as won, count(distinct user_id)::int as players | ||
| 176 | from game_rounds where game_id = ${g.id} and created_at >= now() - interval '30 days' group by 1 order by 1`); | 177 | from game_rounds where game_id = ${g.id} and created_at >= now() - interval '30 days' group by 1 order by 1`); | ||
| 177 | const runs = await db.select().from(simulationRuns).where(eq(simulationRuns.gameSlug, slug)).orderBy(desc(simulationRuns.createdAt)).limit(10); | 178 | 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 | app.post("/api/admin/games/:slug/lifecycle", async (req) => { | 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 | import type { FastifyInstance } from "fastify"; | 1 | import type { FastifyInstance } from "fastify"; | ||
| 2 | import { and, db, desc, eq, favorites, gameStatistics, games, sql, userGameStats } from "@spinza/database"; | 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 | import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared"; | 4 | import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared"; | ||
| 5 | import { requireUser } from "../plugins/auth"; | 5 | import { requireUser } from "../plugins/auth"; | ||
| 6 | import { errors } from "../lib/errors"; | 6 | import { errors } from "../lib/errors"; | ||
@@ -90,6 +90,22 @@ export async function gameRoutes(app: FastifyInstance) { | |||||
| 90 | }; | 90 | }; | ||
| 91 | return { game: info, definition: crashDef }; | 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 | const def = getGame(slug); | 109 | const def = getGame(slug); | ||
| 94 | if (!def) throw errors.notFound("Game not found"); | 110 | if (!def) throw errors.notFound("Game not found"); | ||
| 95 | const s = g.summary as Record<string, unknown>; | 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 | import { syncGames } from "@spinza/database/sync-games"; | 4 | import { syncGames } from "@spinza/database/sync-games"; | ||
| 5 | import { verifyCommitment } from "@spinza/game-core"; | 5 | import { verifyCommitment } from "@spinza/game-core"; | ||
| 6 | import { buildApp } from "../src/app"; | 6 | import { buildApp } from "../src/app"; | ||
| 7 | −import { closeRedis } from "../src/lib/redis"; | 7 | +import { closeRedis, redis } from "../src/lib/redis"; | ||
| 8 | import type { FastifyInstance } from "fastify"; | 8 | import type { FastifyInstance } from "fastify"; | ||
| 9 | 9 | ||||
| 10 | let app: FastifyInstance; | 10 | let app: FastifyInstance; | ||
@@ -18,6 +18,9 @@ beforeAll(async () => { | |||||
| 18 | process.env.NODE_ENV = "test"; | 18 | process.env.NODE_ENV = "test"; | ||
| 19 | app = await buildApp(); | 19 | app = await buildApp(); | ||
| 20 | await app.ready(); | 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 | await syncGames(db); | 24 | await syncGames(db); | ||
| 22 | await db.execute(sql`update games set lifecycle = 'published' where slug in ('skyfall','elevator-999')`); | 25 | await db.execute(sql`update games set lifecycle = 'published' where slug in ('skyfall','elevator-999')`); | ||
| 23 | const res = await app.inject({ method: "POST", url: "/api/auth/register", headers: { origin }, payload: { username, password: "password123", confirmPassword: "password123", ageConfirmed: true } }); | 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 | if (settled.status === "cashed") { | 57 | if (settled.status === "cashed") { | ||
| 55 | expect(settled.cashoutMultiplier).toBe(1); | 58 | expect(settled.cashoutMultiplier).toBe(1); | ||
| 56 | expect(settled.win).toBe(100); | 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 | const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId)); | 62 | const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId)); | ||
| 60 | expect(rounds).toHaveLength(1); | 63 | expect(rounds).toHaveLength(1); | ||
modified
apps/api/test/wallet.test.ts
+4 −1
@@ -7,7 +7,7 @@ import { randomUUID } from "node:crypto"; | |||||
| 7 | import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database"; | 7 | import { closeDb, creditTransactions, db, eq, gameRounds, sql, users, wallets } from "@spinza/database"; | ||
| 8 | import { syncGames } from "@spinza/database/sync-games"; | 8 | import { syncGames } from "@spinza/database/sync-games"; | ||
| 9 | import { buildApp } from "../src/app"; | 9 | import { buildApp } from "../src/app"; | ||
| 10 | −import { closeRedis } from "../src/lib/redis"; | 10 | +import { closeRedis, redis } from "../src/lib/redis"; | ||
| 11 | import type { FastifyInstance } from "fastify"; | 11 | import type { FastifyInstance } from "fastify"; | ||
| 12 | 12 | ||||
| 13 | let app: FastifyInstance; | 13 | let app: FastifyInstance; | ||
@@ -20,6 +20,9 @@ beforeAll(async () => { | |||||
| 20 | process.env.NODE_ENV = "test"; | 20 | process.env.NODE_ENV = "test"; | ||
| 21 | app = await buildApp(); | 21 | app = await buildApp(); | ||
| 22 | await app.ready(); | 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 | await syncGames(db); | 26 | await syncGames(db); | ||
| 24 | // Force-publish one game for tests regardless of certification. | 27 | // Force-publish one game for tests regardless of certification. | ||
| 25 | await db.execute(sql`update games set lifecycle = 'published' where slug = 'neon-vault'`); | 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 | import fs from "node:fs"; | 10 | import fs from "node:fs"; | ||
| 11 | import path from "node:path"; | 11 | import path from "node:path"; | ||
| 12 | import { fileURLToPath } from "node:url"; | 12 | import { fileURLToPath } from "node:url"; | ||
| 13 | −import { certify, certifyCrash, formatCertification, simulateCrash, validateDefinition, DEFAULT_CERTIFICATION_RULES } from "@spinza/game-core"; | 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 } from "@spinza/games"; | 14 | +import { RAW_GAMES, GAMES, CRASH_GAMES, CRASH_BY_SLUG, ARCADE_GAMES, ARCADE_BY_SLUG, RAW_ARCADE_GAMES } from "@spinza/games"; | ||
| 15 | import { calibrate, simulateParallel } from "./index"; | 15 | import { calibrate, simulateParallel } from "./index"; | ||
| 16 | 16 | ||||
| 17 | const here = path.dirname(fileURLToPath(import.meta.url)); | 17 | const here = path.dirname(fileURLToPath(import.meta.url)); | ||
@@ -79,7 +79,21 @@ async function main() { | |||||
| 79 | 79 | ||||
| 80 | if (cmd === "calibrate") { | 80 | if (cmd === "calibrate") { | ||
| 81 | const cal = readCalibration(); | 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 | const g = RAW_GAMES.find((x) => x.slug === slug)!; | 97 | const g = RAW_GAMES.find((x) => x.slug === slug)!; | ||
| 84 | console.log(`\nCalibrating ${g.name} (${slug}@${g.version}) target ${(g.rtp * 100).toFixed(2)}%`); | 98 | console.log(`\nCalibrating ${g.name} (${slug}@${g.version}) target ${(g.rtp * 100).toFixed(2)}%`); | ||
| 85 | const { payScale, result } = await calibrate(slug, { spins: num("spins", 400_000), iterations: num("iterations", 4), threads, log: console.log }); | 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 | fs.mkdirSync(certDir, { recursive: true }); | 108 | fs.mkdirSync(certDir, { recursive: true }); | ||
| 95 | const spins = num("spins", 1_000_000); | 109 | const spins = num("spins", 1_000_000); | ||
| 96 | let failed = 0; | 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 | for (const slug of list) { | 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 | const crashDef = CRASH_BY_SLUG.get(slug); | 123 | const crashDef = CRASH_BY_SLUG.get(slug); | ||
| 100 | if (crashDef) { | 124 | if (crashDef) { | ||
| 101 | console.log(`\nCertifying ${crashDef.name} (${slug}@${crashDef.version}, crash) — ${spins.toLocaleString("en-US")} rounds`); | 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 | import { notFound } from "next/navigation"; | 2 | import { notFound } from "next/navigation"; | ||
| 3 | import { apiServer } from "@/lib/api-server"; | 3 | import { apiServer } from "@/lib/api-server"; | ||
| 4 | import type { GameInfo } from "@spinza/shared"; | 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 | import type { ClientDefinition } from "@/components/game/types"; | 6 | import type { ClientDefinition } from "@/components/game/types"; | ||
| 7 | import { GameClient } from "@/components/game/game-client"; | 7 | import { GameClient } from "@/components/game/game-client"; | ||
| 8 | import { CrashClient } from "@/components/crash/crash-client"; | 8 | import { CrashClient } from "@/components/crash/crash-client"; | ||
| 9 | +import { ArcadeClient } from "@/components/arcade/arcade-client"; | ||||
| 9 | 10 | ||||
| 10 | type Params = { params: Promise<{ slug: string }> }; | 11 | type Params = { params: Promise<{ slug: string }> }; | ||
| 11 | 12 | ||||
@@ -18,8 +19,9 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> { | |||||
| 18 | 19 | ||||
| 19 | export default async function GamePage({ params }: Params) { | 20 | export default async function GamePage({ params }: Params) { | ||
| 20 | const { slug } = await params; | 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 | if (!data) notFound(); | 23 | if (!data) notFound(); | ||
| 23 | if (data.game.kind === "crash") return <CrashClient game={data.game} definition={data.definition as CrashGameDefinition} />; | 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 | return <GameClient game={data.game} definition={data.definition as ClientDefinition} />; | 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