Risk Games: 10 provably-fair cash-out games (crash engine, API with auto cash-out + lazy settlement, canvas scenes, lobby category), shared settleRound
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
12 changed files +1,935 −137
modified
apps/api/src/app.ts
+2 −0
@@ -10,6 +10,7 @@ import { gameRoutes } from "./routes/games"; | ||
| 10 | 10 | import { rewardRoutes } from "./routes/rewards"; |
| 11 | 11 | import { healthRoutes } from "./routes/health"; |
| 12 | 12 | import { adminRoutes } from "./routes/admin"; |
| 13 | +import { crashRoutes } from "./routes/crash"; | |
| 13 | 14 | import { refreshSettings } from "./lib/settings"; |
| 14 | 15 | |
| 15 | 16 | const REDACT = ["req.headers.cookie", "req.headers.authorization", "*.password", "*.newPassword", "*.currentPassword", "*.recoveryCode", "*.totp", "*.passwordHash", "*.tokenHash"]; |
@@ -69,6 +70,7 @@ export async function buildApp(): Promise<FastifyInstance> { | ||
| 69 | 70 | await app.register(userRoutes); |
| 70 | 71 | await app.register(gameRoutes); |
| 71 | 72 | await app.register(rewardRoutes); |
| 73 | + await app.register(crashRoutes); | |
| 72 | 74 | await app.register(adminRoutes); |
| 73 | 75 | |
| 74 | 76 | return app; |
added
apps/api/src/routes/crash.ts
+235 −0
@@ -0,0 +1,235 @@ | ||
| 1 | +import type { FastifyInstance } from "fastify"; | |
| 2 | +import { and, crashRounds, db, desc, eq, gameRounds, games, sql } from "@spinza/database"; | |
| 3 | +import { multiplierAt, setupCrashRound, type CrashEvent, type CrashGameDefinition } from "@spinza/game-core"; | |
| 4 | +import { CRASH_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 CrashRow = typeof crashRounds.$inferSelect; | |
| 17 | + | |
| 18 | +/** Public view of a round (never leaks seed/crash while running). */ | |
| 19 | +function view(def: CrashGameDefinition, r: CrashRow, serverNow: number) { | |
| 20 | + const running = r.status === "running"; | |
| 21 | + return { | |
| 22 | + roundId: r.roundId, | |
| 23 | + game: r.gameSlug, | |
| 24 | + version: r.gameVersion, | |
| 25 | + bet: r.bet, | |
| 26 | + status: r.status, | |
| 27 | + startedAt: r.startedAt.toISOString(), | |
| 28 | + serverNow, | |
| 29 | + elapsedMs: serverNow - r.startedAt.getTime(), | |
| 30 | + commitment: r.commitment, | |
| 31 | + events: r.events as CrashEvent[], | |
| 32 | + autoCashout: r.autoCashout ? Number(r.autoCashout) : null, | |
| 33 | + curve: def.curve, | |
| 34 | + maxMultiplier: def.maxMultiplier, | |
| 35 | + crashMultiplier: running ? null : Number(r.crashMultiplier), | |
| 36 | + seed: running ? null : r.seed, | |
| 37 | + cashoutMultiplier: r.cashoutMultiplier ? Number(r.cashoutMultiplier) : null, | |
| 38 | + win: running ? null : r.win, | |
| 39 | + }; | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** Settle a running round that has crashed or reached its auto cash-out. Returns the updated row. */ | |
| 43 | +async function autoSettle(def: CrashGameDefinition, row: CrashRow, now: number): Promise<CrashRow> { | |
| 44 | + if (row.status !== "running") return row; | |
| 45 | + const elapsed = now - row.startedAt.getTime(); | |
| 46 | + const crash = Number(row.crashMultiplier); | |
| 47 | + const auto = row.autoCashout ? Number(row.autoCashout) : null; | |
| 48 | + const current = multiplierAt(def, row.events as CrashEvent[], elapsed); | |
| 49 | + // Auto cash-out fires the instant the curve reaches the target, provided the crash is strictly later. | |
| 50 | + if (auto && current >= auto && crash > auto) return settle(def, row, auto, "cashed"); | |
| 51 | + if (elapsed >= row.crashAtMs) return settle(def, row, null, "crashed"); | |
| 52 | + return row; | |
| 53 | +} | |
| 54 | + | |
| 55 | +async function settle(def: CrashGameDefinition, row: CrashRow, cashout: number | null, status: "cashed" | "crashed"): Promise<CrashRow> { | |
| 56 | + const win = cashout ? Math.round(row.bet * cashout) : 0; | |
| 57 | + const multiplier = cashout ?? 0; | |
| 58 | + try { | |
| 59 | + return await db.transaction(async (tx) => { | |
| 60 | + // Re-check under lock (concurrent cashout vs poll). | |
| 61 | + const fresh = await tx.execute(sql`select status from crash_rounds where id = ${row.id} for update`); | |
| 62 | + if ((fresh.rows[0] as { status: string } | undefined)?.status !== "running") { | |
| 63 | + const again = await tx.query.crashRounds.findFirst({ where: eq(crashRounds.id, row.id) }); | |
| 64 | + return again ?? row; | |
| 65 | + } | |
| 66 | + const wallet = await lockWallet(tx, row.userId); | |
| 67 | + if (win > 0) await applyCredit(tx, wallet, "WIN", win, row.roundId, { game: row.gameSlug, multiplier }); | |
| 68 | + const settled = await settleRound(tx, wallet, { | |
| 69 | + roundId: row.roundId, | |
| 70 | + gameId: row.gameId, | |
| 71 | + gameSlug: row.gameSlug, | |
| 72 | + gameVersion: row.gameVersion, | |
| 73 | + clientRoundId: row.clientRoundId, | |
| 74 | + bet: row.bet, | |
| 75 | + win, | |
| 76 | + multiplier, | |
| 77 | + result: { kind: "crash", crashMultiplier: Number(row.crashMultiplier), cashoutMultiplier: cashout, status, events: row.events, commitment: row.commitment, seed: row.seed, autoCashout: row.autoCashout ? Number(row.autoCashout) : null }, | |
| 78 | + features: [status === "cashed" ? "Cash out" : "Crash", ...((row.events as CrashEvent[]).map((e) => e.label))], | |
| 79 | + freeSpins: false, | |
| 80 | + bonus: false, | |
| 81 | + jackpotTier: null, | |
| 82 | + rngReference: `crash:${row.commitment.slice(0, 16)}`, | |
| 83 | + durationMs: Date.now() - row.startedAt.getTime(), | |
| 84 | + }); | |
| 85 | + await saveWallet(tx, wallet); | |
| 86 | + const [updated] = await tx | |
| 87 | + .update(crashRounds) | |
| 88 | + .set({ status, cashoutMultiplier: cashout ? cashout.toFixed(2) : null, win, settledAt: new Date() }) | |
| 89 | + .where(eq(crashRounds.id, row.id)) | |
| 90 | + .returning(); | |
| 91 | + // Attach progression to the row for the response (not persisted here). | |
| 92 | + (updated as CrashRow & { progression?: typeof settled }).progression = settled; | |
| 93 | + redis().zadd("live:players", Date.now(), row.userId).catch(() => {}); | |
| 94 | + return updated; | |
| 95 | + }); | |
| 96 | + } catch (e) { | |
| 97 | + if (pgCode(e) === PG_UNIQUE_VIOLATION) { | |
| 98 | + const again = await db.query.crashRounds.findFirst({ where: eq(crashRounds.id, row.id) }); | |
| 99 | + if (again) return again; | |
| 100 | + } | |
| 101 | + throw e; | |
| 102 | + } | |
| 103 | +} | |
| 104 | + | |
| 105 | +export async function crashRoutes(app: FastifyInstance) { | |
| 106 | + /** Recent crash points for the strip at the top of the game (public). */ | |
| 107 | + app.get("/api/crash/:slug/history", async (req) => { | |
| 108 | + const { slug } = req.params as { slug: string }; | |
| 109 | + if (!CRASH_BY_SLUG.has(slug)) throw errors.notFound(); | |
| 110 | + const rows = await db.execute(sql`select (result->>'crashMultiplier')::numeric as crash, created_at from game_rounds where game_slug = ${slug} order by created_at desc limit 24`); | |
| 111 | + return { history: (rows.rows as { crash: string; created_at: Date }[]).map((r) => ({ crash: Number(r.crash), at: r.created_at })) }; | |
| 112 | + }); | |
| 113 | + | |
| 114 | + /** Player's own running round for this game (resume after reload). */ | |
| 115 | + app.get("/api/crash/:slug/current", async (req) => { | |
| 116 | + const user = requireUser(req); | |
| 117 | + const { slug } = req.params as { slug: string }; | |
| 118 | + const def = CRASH_BY_SLUG.get(slug); | |
| 119 | + if (!def) throw errors.notFound(); | |
| 120 | + let row = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.gameSlug, slug), eq(crashRounds.status, "running")), orderBy: desc(crashRounds.startedAt) }); | |
| 121 | + if (!row) return { round: null }; | |
| 122 | + row = await autoSettle(def, row, Date.now()); | |
| 123 | + return { round: view(def, row, Date.now()) }; | |
| 124 | + }); | |
| 125 | + | |
| 126 | + app.post("/api/crash/:slug/start", async (req) => { | |
| 127 | + const user = requireUser(req); | |
| 128 | + const { slug } = req.params as { slug: string }; | |
| 129 | + const def = CRASH_BY_SLUG.get(slug); | |
| 130 | + if (!def) throw errors.notFound("Unknown game"); | |
| 131 | + const m = maintenance(); | |
| 132 | + if (m.enabled) throw errors.maintenance(m.message); | |
| 133 | + if (!flag(`game.${slug}.enabled`)) throw errors.unavailable("This game is temporarily unavailable."); | |
| 134 | + const retry = await rateLimit(`crash:${user.id}`, 120, 60); | |
| 135 | + if (retry) throw errors.rateLimited(retry); | |
| 136 | + const body = z | |
| 137 | + .object({ bet: z.number().int(), clientRoundId: z.string().uuid(), autoCashout: z.number().min(1.01).max(def.maxMultiplier).optional().nullable() }) | |
| 138 | + .parse(req.body); | |
| 139 | + if (!(BET_LEVELS as readonly number[]).includes(body.bet) || body.bet < def.minBet || body.bet > def.maxBet) throw errors.badRequest("Invalid bet for this game."); | |
| 140 | + const game = await db.query.games.findFirst({ where: eq(games.slug, slug) }); | |
| 141 | + if (!game || game.lifecycle !== "published") throw errors.unavailable("This game is not published."); | |
| 142 | + | |
| 143 | + // Idempotency. | |
| 144 | + const existing = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.clientRoundId, body.clientRoundId)) }); | |
| 145 | + if (existing) return { round: view(def, await autoSettle(def, existing, Date.now()), Date.now()), balance: null, replayed: true }; | |
| 146 | + | |
| 147 | + // Settle any abandoned running round first (its crash time has passed or it is still live → keep it live). | |
| 148 | + const running = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.status, "running")) }); | |
| 149 | + if (running) { | |
| 150 | + const r = await autoSettle(CRASH_BY_SLUG.get(running.gameSlug) ?? def, running, Date.now()); | |
| 151 | + if (r.status === "running") throw errors.conflict("ROUND_IN_PROGRESS", "You already have a round in progress."); | |
| 152 | + } | |
| 153 | + | |
| 154 | + const setup = setupCrashRound(def); | |
| 155 | + const roundId = newRoundId(); | |
| 156 | + try { | |
| 157 | + const result = await db.transaction(async (tx) => { | |
| 158 | + const wallet = await lockWallet(tx, user.id); | |
| 159 | + if (wallet.balance < body.bet) throw errors.insufficient(wallet.balance, body.bet); | |
| 160 | + await applyCredit(tx, wallet, "BET", -body.bet, roundId, { game: slug }); | |
| 161 | + await saveWallet(tx, wallet); | |
| 162 | + const [row] = await tx | |
| 163 | + .insert(crashRounds) | |
| 164 | + .values({ | |
| 165 | + roundId, | |
| 166 | + userId: user.id, | |
| 167 | + gameId: game.id, | |
| 168 | + gameSlug: slug, | |
| 169 | + gameVersion: def.version, | |
| 170 | + clientRoundId: body.clientRoundId, | |
| 171 | + bet: body.bet, | |
| 172 | + crashMultiplier: setup.crashMultiplier.toFixed(2), | |
| 173 | + crashAtMs: Math.max(0, Math.round(setup.crashAtMs)), | |
| 174 | + seed: setup.seed, | |
| 175 | + commitment: setup.commitment, | |
| 176 | + events: setup.events, | |
| 177 | + autoCashout: body.autoCashout ? body.autoCashout.toFixed(2) : null, | |
| 178 | + }) | |
| 179 | + .returning(); | |
| 180 | + return { row, balance: wallet.balance }; | |
| 181 | + }); | |
| 182 | + redis().zadd("live:players", Date.now(), user.id).catch(() => {}); | |
| 183 | + return { round: view(def, result.row, Date.now()), balance: result.balance }; | |
| 184 | + } catch (e) { | |
| 185 | + if (pgCode(e) === PG_UNIQUE_VIOLATION) { | |
| 186 | + const again = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.userId, user.id), eq(crashRounds.clientRoundId, body.clientRoundId)) }); | |
| 187 | + if (again) return { round: view(def, again, Date.now()), balance: null, replayed: true }; | |
| 188 | + } | |
| 189 | + throw e; | |
| 190 | + } | |
| 191 | + }); | |
| 192 | + | |
| 193 | + app.post("/api/crash/:slug/cashout", async (req) => { | |
| 194 | + const user = requireUser(req); | |
| 195 | + const { slug } = req.params as { slug: string }; | |
| 196 | + const def = CRASH_BY_SLUG.get(slug); | |
| 197 | + if (!def) throw errors.notFound(); | |
| 198 | + const body = z.object({ roundId: z.string(), claimedMultiplier: z.number().min(1).optional() }).parse(req.body); | |
| 199 | + const now = Date.now(); | |
| 200 | + let row = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.roundId, body.roundId), eq(crashRounds.userId, user.id)) }); | |
| 201 | + if (!row) throw errors.notFound("Round not found"); | |
| 202 | + row = await autoSettle(def, row, now); | |
| 203 | + if (row.status === "running") { | |
| 204 | + const elapsed = now - row.startedAt.getTime(); | |
| 205 | + const server = multiplierAt(def, row.events as CrashEvent[], elapsed); | |
| 206 | + // Honour the player's displayed multiplier when it is not ahead of the server clock (network latency works in the player's favour). | |
| 207 | + const claimed = body.claimedMultiplier ? Math.floor(body.claimedMultiplier * 100) / 100 : server; | |
| 208 | + const cashout = Math.max(1, Math.min(server, claimed)); | |
| 209 | + const crash = Number(row.crashMultiplier); | |
| 210 | + row = cashout < crash && elapsed < row.crashAtMs ? await settle(def, row, cashout, "cashed") : await settle(def, row, null, "crashed"); | |
| 211 | + } | |
| 212 | + const prog = (row as CrashRow & { progression?: { xp: unknown; unlocked: unknown } }).progression; | |
| 213 | + const [bal] = await db.execute(sql`select balance from wallets where user_id = ${user.id}`).then((r) => r.rows as { balance: number }[]); | |
| 214 | + return { round: view(def, row, Date.now()), balance: Number(bal.balance), winClass: classifyWin(row.cashoutMultiplier ? Number(row.cashoutMultiplier) : 0), progression: prog ?? null }; | |
| 215 | + }); | |
| 216 | + | |
| 217 | + app.get("/api/crash/:slug/rounds/:roundId", async (req) => { | |
| 218 | + const user = requireUser(req); | |
| 219 | + const { slug, roundId } = req.params as { slug: string; roundId: string }; | |
| 220 | + const def = CRASH_BY_SLUG.get(slug); | |
| 221 | + if (!def) throw errors.notFound(); | |
| 222 | + let row = await db.query.crashRounds.findFirst({ where: and(eq(crashRounds.roundId, roundId), eq(crashRounds.userId, user.id)) }); | |
| 223 | + if (!row) throw errors.notFound("Round not found"); | |
| 224 | + row = await autoSettle(def, row, Date.now()); | |
| 225 | + const prog = (row as CrashRow & { progression?: unknown }).progression; | |
| 226 | + let balance: number | null = null; | |
| 227 | + if (row.status !== "running") { | |
| 228 | + const [bal] = await db.execute(sql`select balance from wallets where user_id = ${user.id}`).then((r) => r.rows as { balance: number }[]); | |
| 229 | + balance = Number(bal.balance); | |
| 230 | + } | |
| 231 | + return { round: view(def, row, Date.now()), balance, progression: prog ?? null }; | |
| 232 | + }); | |
| 233 | + | |
| 234 | + void gameRounds; | |
| 235 | +} | |
modified
apps/api/src/routes/games.ts
+21 −2
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { FastifyInstance } from "fastify"; |
| 2 | 2 | import { and, db, desc, eq, favorites, gameStatistics, games, sql, userGameStats } from "@spinza/database"; |
| 3 | −import { getGame, FIRST_GAME_RECOMMENDATIONS } from "@spinza/games"; | |
| 3 | +import { getGame, FIRST_GAME_RECOMMENDATIONS, CRASH_BY_SLUG } from "@spinza/games"; | |
| 4 | 4 | import { spinSchema, type GameCard, type GameInfo } from "@spinza/shared"; |
| 5 | 5 | import { requireUser } from "../plugins/auth"; |
| 6 | 6 | import { errors } from "../lib/errors"; |
@@ -32,6 +32,8 @@ function toCard(g: GameRow, extra: { popularity?: number; favorite?: boolean; la | ||
| 32 | 32 | ? { frame: (s.presentation as GameCard["presentation"])!.frame, backdrop: (s.presentation as GameCard["presentation"])!.backdrop, particles: (s.presentation as GameCard["presentation"])!.particles, ambience: (s.presentation as GameCard["presentation"])!.ambience } |
| 33 | 33 | : undefined, |
| 34 | 34 | lifecycle: g.lifecycle as GameCard["lifecycle"], |
| 35 | + kind: ((s.kind as string) ?? "slot") as GameCard["kind"], | |
| 36 | + category: ((s.category as string) ?? "slots") as GameCard["category"], | |
| 35 | 37 | isNew: g.isNew, |
| 36 | 38 | isFeatured: g.isFeatured, |
| 37 | 39 | isJackpot: !!s.isJackpot, |
@@ -71,8 +73,25 @@ export async function gameRoutes(app: FastifyInstance) { | ||
| 71 | 73 | app.get("/api/games/:slug", async (req) => { |
| 72 | 74 | const { slug } = req.params as { slug: string }; |
| 73 | 75 | const g = await db.query.games.findFirst({ where: eq(games.slug, slug) }); |
| 76 | + if (!g || g.lifecycle !== "published" || !flag(`game.${slug}.enabled`)) throw errors.notFound("Game not found"); | |
| 77 | + const crashDef = CRASH_BY_SLUG.get(slug); | |
| 78 | + if (crashDef) { | |
| 79 | + const s = g.summary as Record<string, unknown>; | |
| 80 | + let favorite = false; | |
| 81 | + if (req.user) favorite = !!(await db.query.favorites.findFirst({ where: and(eq(favorites.userId, req.user.id), eq(favorites.gameId, g.id)) })); | |
| 82 | + const info: GameInfo = { | |
| 83 | + ...toCard(g, { favorite }), | |
| 84 | + rtp: crashDef.rtp, | |
| 85 | + hitFrequency: ((s.certification as { hitRate?: number } | null)?.hitRate) ?? null, | |
| 86 | + description: crashDef.description, | |
| 87 | + rules: crashDef.rules, | |
| 88 | + paytable: [], | |
| 89 | + certification: (s.certification as GameInfo["certification"] | null) ?? null, | |
| 90 | + }; | |
| 91 | + return { game: info, definition: crashDef }; | |
| 92 | + } | |
| 74 | 93 | const def = getGame(slug); |
| 75 | − if (!g || !def || g.lifecycle !== "published" || !flag(`game.${slug}.enabled`)) throw errors.notFound("Game not found"); | |
| 94 | + if (!def) throw errors.notFound("Game not found"); | |
| 76 | 95 | const s = g.summary as Record<string, unknown>; |
| 77 | 96 | const cert = (s.certification as GameInfo["certification"] | null) ?? null; |
| 78 | 97 | let favorite = false; |
added
apps/api/src/services/settle.ts
+161 −0
@@ -0,0 +1,161 @@ | ||
| 1 | +import { and, dailyRewards, eq, gameRounds, gameStates, gameStatistics, sql, userGameStats, userSettings, users, type Tx } from "@spinza/database"; | |
| 2 | +import { classifyWin, type SpinResponse } from "@spinza/shared"; | |
| 3 | +import type { LockedWallet } from "./wallet"; | |
| 4 | +import { advanceMissions, awardXp, checkAchievements, spinXp, upsertLeaderboards, type UserCounters } from "./progression"; | |
| 5 | + | |
| 6 | +export interface SettleArgs { | |
| 7 | + roundId: string; | |
| 8 | + gameId: string; | |
| 9 | + gameSlug: string; | |
| 10 | + gameVersion: string; | |
| 11 | + clientRoundId: string; | |
| 12 | + bet: number; | |
| 13 | + win: number; | |
| 14 | + multiplier: number; | |
| 15 | + result: Record<string, unknown>; | |
| 16 | + features: string[]; | |
| 17 | + freeSpins: boolean; | |
| 18 | + bonus: boolean; | |
| 19 | + jackpotTier: string | null; | |
| 20 | + rngReference: string; | |
| 21 | + durationMs: number; | |
| 22 | + /** Persistent per-game state (slots); omitted for crash/arcade games. */ | |
| 23 | + stateAfter?: Record<string, unknown>; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export interface SettleResult { | |
| 27 | + xp: SpinResponse["xp"]; | |
| 28 | + unlocked: SpinResponse["unlocked"]; | |
| 29 | + winClass: SpinResponse["winClass"]; | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** | |
| 33 | + * Shared bookkeeping for any settled round (slot spin, crash cash-out, arcade | |
| 34 | + * round): round row, per-user/per-game stats, global stats, XP + level rewards, | |
| 35 | + * achievements, missions, leaderboards. Must run inside the transaction that | |
| 36 | + * holds the wallet lock, AFTER the bet/win ledger entries have been applied. | |
| 37 | + */ | |
| 38 | +export async function settleRound(tx: Tx, wallet: LockedWallet, a: SettleArgs): Promise<SettleResult> { | |
| 39 | + const userId = wallet.userId; | |
| 40 | + const [u] = await tx | |
| 41 | + .select({ xp: users.xp, level: users.level, totalSpins: users.totalSpins, gamesPlayed: users.gamesPlayed, biggestWin: users.biggestWin, biggestMultiplier: users.biggestMultiplier }) | |
| 42 | + .from(users) | |
| 43 | + .where(eq(users.id, userId)) | |
| 44 | + .for("update"); | |
| 45 | + | |
| 46 | + const ugsExisting = await tx.query.userGameStats.findFirst({ where: and(eq(userGameStats.userId, userId), eq(userGameStats.gameId, a.gameId)), columns: { spins: true } }); | |
| 47 | + const newGame = !ugsExisting; | |
| 48 | + const bonus = a.bonus || a.freeSpins; | |
| 49 | + const cls = classifyWin(a.multiplier); | |
| 50 | + const isBigWin = cls !== "none" && cls !== "regular" && cls !== "win"; | |
| 51 | + const facts = { gameSlug: a.gameSlug, bet: a.bet, win: a.win, multiplier: a.multiplier, bonus, jackpot: !!a.jackpotTier, newGame }; | |
| 52 | + | |
| 53 | + const totalSpins = u.totalSpins + 1; | |
| 54 | + const gamesPlayed = u.gamesPlayed + (newGame ? 1 : 0); | |
| 55 | + const biggestWin = Math.max(u.biggestWin, a.win); | |
| 56 | + const biggestMultiplier = Math.max(Number(u.biggestMultiplier), a.multiplier); | |
| 57 | + | |
| 58 | + const agg = await tx.execute(sql`select coalesce(sum(bonuses),0)::bigint as bonuses from user_game_stats where user_id = ${userId}`); | |
| 59 | + const bonusesBefore = Number((agg.rows[0] as { bonuses: number }).bonuses); | |
| 60 | + const bigAgg = await tx.execute(sql` | |
| 61 | + select count(*) filter (where multiplier >= 20)::bigint as big_wins, | |
| 62 | + count(*) filter (where win > 0)::bigint as wins, | |
| 63 | + count(*) filter (where jackpot_tier is not null)::bigint as jackpots | |
| 64 | + from game_rounds where user_id = ${userId}`); | |
| 65 | + const ba = bigAgg.rows[0] as { big_wins: number; wins: number; jackpots: number }; | |
| 66 | + const streak = await tx.query.dailyRewards.findFirst({ where: eq(dailyRewards.userId, userId), columns: { streakDay: true } }); | |
| 67 | + | |
| 68 | + await tx.insert(gameRounds).values({ | |
| 69 | + roundId: a.roundId, | |
| 70 | + userId, | |
| 71 | + gameId: a.gameId, | |
| 72 | + gameSlug: a.gameSlug, | |
| 73 | + gameVersion: a.gameVersion, | |
| 74 | + clientRoundId: a.clientRoundId, | |
| 75 | + bet: a.bet, | |
| 76 | + win: a.win, | |
| 77 | + multiplier: a.multiplier.toFixed(4), | |
| 78 | + balanceAfter: wallet.balance, | |
| 79 | + result: a.result, | |
| 80 | + features: a.features, | |
| 81 | + freeSpins: a.freeSpins, | |
| 82 | + bonus: a.bonus, | |
| 83 | + jackpotTier: a.jackpotTier, | |
| 84 | + rngReference: a.rngReference, | |
| 85 | + durationMs: a.durationMs, | |
| 86 | + }); | |
| 87 | + | |
| 88 | + if (a.stateAfter) { | |
| 89 | + await tx | |
| 90 | + .insert(gameStates) | |
| 91 | + .values({ userId, gameId: a.gameId, state: a.stateAfter }) | |
| 92 | + .onConflictDoUpdate({ target: [gameStates.userId, gameStates.gameId], set: { state: a.stateAfter, updatedAt: new Date() } }); | |
| 93 | + } | |
| 94 | + | |
| 95 | + await tx | |
| 96 | + .insert(userGameStats) | |
| 97 | + .values({ userId, gameId: a.gameId, spins: 1, wagered: a.bet, won: a.win, bonuses: bonus ? 1 : 0, biggestWin: a.win, biggestMultiplier: a.multiplier.toFixed(2) }) | |
| 98 | + .onConflictDoUpdate({ | |
| 99 | + target: [userGameStats.userId, userGameStats.gameId], | |
| 100 | + set: { | |
| 101 | + spins: sql`${userGameStats.spins} + 1`, | |
| 102 | + wagered: sql`${userGameStats.wagered} + ${a.bet}`, | |
| 103 | + won: sql`${userGameStats.won} + ${a.win}`, | |
| 104 | + bonuses: sql`${userGameStats.bonuses} + ${bonus ? 1 : 0}`, | |
| 105 | + biggestWin: sql`greatest(${userGameStats.biggestWin}, ${a.win})`, | |
| 106 | + biggestMultiplier: sql`greatest(${userGameStats.biggestMultiplier}, ${a.multiplier.toFixed(2)}::numeric)`, | |
| 107 | + lastPlayedAt: new Date(), | |
| 108 | + }, | |
| 109 | + }); | |
| 110 | + | |
| 111 | + await tx | |
| 112 | + .update(gameStatistics) | |
| 113 | + .set({ | |
| 114 | + spins: sql`${gameStatistics.spins} + 1`, | |
| 115 | + wagered: sql`${gameStatistics.wagered} + ${a.bet}`, | |
| 116 | + won: sql`${gameStatistics.won} + ${a.win}`, | |
| 117 | + wins: sql`${gameStatistics.wins} + ${a.win > 0 ? 1 : 0}`, | |
| 118 | + bonuses: sql`${gameStatistics.bonuses} + ${a.bonus ? 1 : 0}`, | |
| 119 | + freeSpins: sql`${gameStatistics.freeSpins} + ${a.freeSpins ? 1 : 0}`, | |
| 120 | + bigWins: sql`${gameStatistics.bigWins} + ${isBigWin ? 1 : 0}`, | |
| 121 | + maxWin: sql`greatest(${gameStatistics.maxWin}, ${a.win})`, | |
| 122 | + maxMultiplier: sql`greatest(${gameStatistics.maxMultiplier}, ${a.multiplier.toFixed(2)}::numeric)`, | |
| 123 | + updatedAt: new Date(), | |
| 124 | + }) | |
| 125 | + .where(eq(gameStatistics.gameId, a.gameId)); | |
| 126 | + | |
| 127 | + let xpGain = spinXp(facts); | |
| 128 | + const counters: UserCounters = { | |
| 129 | + totalSpins, | |
| 130 | + gamesPlayed, | |
| 131 | + biggestWin, | |
| 132 | + biggestMultiplier, | |
| 133 | + level: u.level, | |
| 134 | + xp: u.xp, | |
| 135 | + bonuses: bonusesBefore + (bonus ? 1 : 0), | |
| 136 | + bigWins: Number(ba.big_wins) + (isBigWin ? 1 : 0), | |
| 137 | + wins: Number(ba.wins) + (a.win > 0 ? 1 : 0), | |
| 138 | + jackpots: Number(ba.jackpots) + (a.jackpotTier ? 1 : 0), | |
| 139 | + dailyStreak: streak?.streakDay ?? 0, | |
| 140 | + }; | |
| 141 | + const ach = await checkAchievements(tx, wallet, counters); | |
| 142 | + const mis = await advanceMissions(tx, wallet, facts); | |
| 143 | + xpGain += ach.xp + mis.xp; | |
| 144 | + const lvl = await awardXp(tx, wallet, { xp: u.xp, level: u.level }, xpGain, a.roundId); | |
| 145 | + if (lvl.leveledUp) { | |
| 146 | + counters.level = lvl.level; | |
| 147 | + const ach2 = await checkAchievements(tx, wallet, counters); | |
| 148 | + ach.unlocked.push(...ach2.unlocked); | |
| 149 | + } | |
| 150 | + | |
| 151 | + await tx.update(users).set({ totalSpins, gamesPlayed, biggestWin, biggestMultiplier: biggestMultiplier.toFixed(2) }).where(eq(users.id, userId)); | |
| 152 | + | |
| 153 | + const settings = await tx.query.userSettings.findFirst({ where: eq(userSettings.userId, userId), columns: { leaderboardOptIn: true } }); | |
| 154 | + await upsertLeaderboards(tx, userId, facts, a.roundId, { totalSpins, level: lvl.level }, settings?.leaderboardOptIn ?? true); | |
| 155 | + | |
| 156 | + return { | |
| 157 | + xp: { gained: xpGain, total: lvl.xp, level: lvl.level, leveledUp: lvl.leveledUp, levelReward: lvl.reward }, | |
| 158 | + unlocked: { achievements: ach.unlocked, missions: mis.completed }, | |
| 159 | + winClass: cls, | |
| 160 | + }; | |
| 161 | +} | |
modified
apps/api/src/services/spin.ts
+8 −130
@@ -1,4 +1,4 @@ | ||
| 1 | −import { and, db, eq, gameRounds, gameStates, gameStatistics, games, sql, userGameStats, userSettings, users, dailyRewards } from "@spinza/database"; | |
| 1 | +import { and, db, eq, gameRounds, games, sql, users } from "@spinza/database"; | |
| 2 | 2 | import { runSpin, type PlayerGameState, type SpinOutcome } from "@spinza/game-core"; |
| 3 | 3 | import { getGame } from "@spinza/games"; |
| 4 | 4 | import { BET_LEVELS, classifyWin, type SpinResponse } from "@spinza/shared"; |
@@ -6,7 +6,7 @@ import { errors } from "../lib/errors"; | ||
| 6 | 6 | import { newRoundId } from "../lib/crypto"; |
| 7 | 7 | import { flag, maintenance } from "../lib/settings"; |
| 8 | 8 | import { applyCredit, lockWallet, saveWallet } from "./wallet"; |
| 9 | −import { advanceMissions, awardXp, checkAchievements, spinXp, upsertLeaderboards, type UserCounters } from "./progression"; | |
| 9 | +import { settleRound } from "./settle"; | |
| 10 | 10 | import { redis } from "../lib/redis"; |
| 11 | 11 | import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg"; |
| 12 | 12 | |
@@ -74,57 +74,15 @@ export async function spin(input: SpinInput): Promise<SpinResponse> { | ||
| 74 | 74 | await applyCredit(tx, wallet, "BET", -input.bet, roundId, { game: def.slug }); |
| 75 | 75 | if (outcome.totalWin > 0) await applyCredit(tx, wallet, "WIN", outcome.totalWin, roundId, { game: def.slug, multiplier: outcome.multiplier }); |
| 76 | 76 | |
| 77 | − // User counters. | |
| 78 | − const [u] = await tx | |
| 79 | − .select({ | |
| 80 | − xp: users.xp, | |
| 81 | − level: users.level, | |
| 82 | − totalSpins: users.totalSpins, | |
| 83 | − gamesPlayed: users.gamesPlayed, | |
| 84 | − biggestWin: users.biggestWin, | |
| 85 | − biggestMultiplier: users.biggestMultiplier, | |
| 86 | − }) | |
| 87 | − .from(users) | |
| 88 | − .where(eq(users.id, input.userId)) | |
| 89 | − .for("update"); | |
| 90 | − | |
| 91 | − const ugsExisting = await tx.query.userGameStats.findFirst({ where: and(eq(userGameStats.userId, input.userId), eq(userGameStats.gameId, game.id)) }); | |
| 92 | − const newGame = !ugsExisting; | |
| 93 | − const bonus = outcome.bonusTriggered || outcome.freeSpinsTriggered; | |
| 94 | − const cls = classifyWin(outcome.multiplier); | |
| 95 | − const isBigWin = cls !== "none" && cls !== "regular" && cls !== "win"; | |
| 96 | − | |
| 97 | − const facts = { gameSlug: def.slug, bet: input.bet, win: outcome.totalWin, multiplier: outcome.multiplier, bonus, jackpot: !!outcome.jackpot, newGame }; | |
| 98 | − | |
| 99 | − const totalSpins = u.totalSpins + 1; | |
| 100 | − const gamesPlayed = u.gamesPlayed + (newGame ? 1 : 0); | |
| 101 | − const biggestWin = Math.max(u.biggestWin, outcome.totalWin); | |
| 102 | − const biggestMultiplier = Math.max(Number(u.biggestMultiplier), outcome.multiplier); | |
| 103 | − | |
| 104 | − // Aggregate counters for achievements (bonuses / big wins / wins / jackpots from ledger-independent stats). | |
| 105 | − const agg = await tx.execute(sql` | |
| 106 | − select coalesce(sum(bonuses),0)::bigint as bonuses from user_game_stats where user_id = ${input.userId}`); | |
| 107 | − const bonusesBefore = Number((agg.rows[0] as { bonuses: number }).bonuses); | |
| 108 | − const bigAgg = await tx.execute(sql` | |
| 109 | − select count(*) filter (where multiplier >= 20)::bigint as big_wins, | |
| 110 | − count(*) filter (where win > 0)::bigint as wins, | |
| 111 | − count(*) filter (where jackpot_tier is not null)::bigint as jackpots | |
| 112 | − from game_rounds where user_id = ${input.userId}`); | |
| 113 | − const ba = bigAgg.rows[0] as { big_wins: number; wins: number; jackpots: number }; | |
| 114 | − const streak = await tx.query.dailyRewards.findFirst({ where: eq(dailyRewards.userId, input.userId), columns: { streakDay: true } }); | |
| 115 | − | |
| 116 | − // Round row (unique on user+clientRoundId → duplicate protection). | |
| 117 | − await tx.insert(gameRounds).values({ | |
| 77 | + const settled = await settleRound(tx, wallet, { | |
| 118 | 78 | roundId, |
| 119 | − userId: input.userId, | |
| 120 | 79 | gameId: game.id, |
| 121 | 80 | gameSlug: def.slug, |
| 122 | 81 | gameVersion: def.version, |
| 123 | 82 | clientRoundId: input.clientRoundId, |
| 124 | 83 | bet: input.bet, |
| 125 | 84 | win: outcome.totalWin, |
| 126 | − multiplier: outcome.multiplier.toFixed(4), | |
| 127 | − balanceAfter: wallet.balance, | |
| 85 | + multiplier: outcome.multiplier, | |
| 128 | 86 | result: storableResult(outcome), |
| 129 | 87 | features: outcome.features, |
| 130 | 88 | freeSpins: outcome.freeSpinsTriggered, |
@@ -132,89 +90,9 @@ export async function spin(input: SpinInput): Promise<SpinResponse> { | ||
| 132 | 90 | jackpotTier: outcome.jackpot?.tier ?? null, |
| 133 | 91 | rngReference: outcome.rngReference, |
| 134 | 92 | durationMs: Date.now() - started, |
| 93 | + stateAfter: outcome.stateAfter as unknown as Record<string, unknown>, | |
| 135 | 94 | }); |
| 136 | − | |
| 137 | − // Persistent game state. | |
| 138 | − await tx | |
| 139 | − .insert(gameStates) | |
| 140 | − .values({ userId: input.userId, gameId: game.id, state: outcome.stateAfter as unknown as Record<string, unknown> }) | |
| 141 | − .onConflictDoUpdate({ target: [gameStates.userId, gameStates.gameId], set: { state: outcome.stateAfter as unknown as Record<string, unknown>, updatedAt: new Date() } }); | |
| 142 | − | |
| 143 | − // Per-user per-game stats. | |
| 144 | − await tx | |
| 145 | − .insert(userGameStats) | |
| 146 | − .values({ | |
| 147 | − userId: input.userId, | |
| 148 | − gameId: game.id, | |
| 149 | − spins: 1, | |
| 150 | − wagered: input.bet, | |
| 151 | − won: outcome.totalWin, | |
| 152 | − bonuses: bonus ? 1 : 0, | |
| 153 | − biggestWin: outcome.totalWin, | |
| 154 | − biggestMultiplier: outcome.multiplier.toFixed(2), | |
| 155 | − }) | |
| 156 | − .onConflictDoUpdate({ | |
| 157 | − target: [userGameStats.userId, userGameStats.gameId], | |
| 158 | − set: { | |
| 159 | − spins: sql`${userGameStats.spins} + 1`, | |
| 160 | − wagered: sql`${userGameStats.wagered} + ${input.bet}`, | |
| 161 | − won: sql`${userGameStats.won} + ${outcome.totalWin}`, | |
| 162 | − bonuses: sql`${userGameStats.bonuses} + ${bonus ? 1 : 0}`, | |
| 163 | − biggestWin: sql`greatest(${userGameStats.biggestWin}, ${outcome.totalWin})`, | |
| 164 | − biggestMultiplier: sql`greatest(${userGameStats.biggestMultiplier}, ${outcome.multiplier.toFixed(2)}::numeric)`, | |
| 165 | − lastPlayedAt: new Date(), | |
| 166 | − }, | |
| 167 | − }); | |
| 168 | − | |
| 169 | − // Global game statistics. | |
| 170 | − await tx | |
| 171 | − .update(gameStatistics) | |
| 172 | − .set({ | |
| 173 | − spins: sql`${gameStatistics.spins} + 1`, | |
| 174 | − wagered: sql`${gameStatistics.wagered} + ${input.bet}`, | |
| 175 | − won: sql`${gameStatistics.won} + ${outcome.totalWin}`, | |
| 176 | − wins: sql`${gameStatistics.wins} + ${outcome.totalWin > 0 ? 1 : 0}`, | |
| 177 | − bonuses: sql`${gameStatistics.bonuses} + ${outcome.bonusTriggered ? 1 : 0}`, | |
| 178 | − freeSpins: sql`${gameStatistics.freeSpins} + ${outcome.freeSpinsTriggered ? 1 : 0}`, | |
| 179 | − bigWins: sql`${gameStatistics.bigWins} + ${isBigWin ? 1 : 0}`, | |
| 180 | − maxWin: sql`greatest(${gameStatistics.maxWin}, ${outcome.totalWin})`, | |
| 181 | − maxMultiplier: sql`greatest(${gameStatistics.maxMultiplier}, ${outcome.multiplier.toFixed(2)}::numeric)`, | |
| 182 | − updatedAt: new Date(), | |
| 183 | − }) | |
| 184 | − .where(eq(gameStatistics.gameId, game.id)); | |
| 185 | − | |
| 186 | − // XP, achievements, missions. | |
| 187 | − let xpGain = spinXp(facts); | |
| 188 | − const counters: UserCounters = { | |
| 189 | − totalSpins, | |
| 190 | − gamesPlayed, | |
| 191 | − biggestWin, | |
| 192 | − biggestMultiplier, | |
| 193 | − level: u.level, | |
| 194 | − xp: u.xp, | |
| 195 | − bonuses: bonusesBefore + (bonus ? 1 : 0), | |
| 196 | − bigWins: Number(ba.big_wins) + (isBigWin ? 1 : 0), | |
| 197 | − wins: Number(ba.wins) + (outcome.totalWin > 0 ? 1 : 0), | |
| 198 | − jackpots: Number(ba.jackpots) + (outcome.jackpot ? 1 : 0), | |
| 199 | − dailyStreak: streak?.streakDay ?? 0, | |
| 200 | − }; | |
| 201 | − const ach = await checkAchievements(tx, wallet, counters); | |
| 202 | − const mis = await advanceMissions(tx, wallet, facts); | |
| 203 | − xpGain += ach.xp + mis.xp; | |
| 204 | − const lvl = await awardXp(tx, wallet, { xp: u.xp, level: u.level }, xpGain, roundId); | |
| 205 | − if (lvl.leveledUp) { | |
| 206 | − counters.level = lvl.level; | |
| 207 | − const ach2 = await checkAchievements(tx, wallet, counters); | |
| 208 | − ach.unlocked.push(...ach2.unlocked); | |
| 209 | − } | |
| 210 | − | |
| 211 | − await tx | |
| 212 | − .update(users) | |
| 213 | − .set({ totalSpins, gamesPlayed, biggestWin, biggestMultiplier: biggestMultiplier.toFixed(2) }) | |
| 214 | − .where(eq(users.id, input.userId)); | |
| 215 | − | |
| 216 | − const settings = await tx.query.userSettings.findFirst({ where: eq(userSettings.userId, input.userId), columns: { leaderboardOptIn: true } }); | |
| 217 | − await upsertLeaderboards(tx, input.userId, facts, roundId, { totalSpins, level: lvl.level }, settings?.leaderboardOptIn ?? true); | |
| 95 | + const cls = settled.winClass; | |
| 218 | 96 | |
| 219 | 97 | // Balance after the round (before rewards) is recorded on the round row; the returned balance includes rewards. |
| 220 | 98 | await saveWallet(tx, wallet); |
@@ -238,9 +116,9 @@ export async function spin(input: SpinInput): Promise<SpinResponse> { | ||
| 238 | 116 | multiplier: outcome.multiplier, |
| 239 | 117 | winClass: cls, |
| 240 | 118 | balance: wallet.balance, |
| 241 | − xp: { gained: xpGain, total: lvl.xp, level: lvl.level, leveledUp: lvl.leveledUp, levelReward: lvl.reward }, | |
| 119 | + xp: settled.xp, | |
| 242 | 120 | result: storableResult(outcome), |
| 243 | − unlocked: { achievements: ach.unlocked, missions: mis.completed }, | |
| 121 | + unlocked: settled.unlocked, | |
| 244 | 122 | } satisfies SpinResponse; |
| 245 | 123 | }); |
| 246 | 124 | } catch (e) { |
added
apps/api/test/crash.test.ts
+99 −0
@@ -0,0 +1,99 @@ | ||
| 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 { verifyCommitment } from "@spinza/game-core"; | |
| 6 | +import { buildApp } from "../src/app"; | |
| 7 | +import { closeRedis } from "../src/lib/redis"; | |
| 8 | +import type { FastifyInstance } from "fastify"; | |
| 9 | + | |
| 10 | +let app: FastifyInstance; | |
| 11 | +let cookie = ""; | |
| 12 | +let userId = ""; | |
| 13 | +const username = `c_${randomUUID().slice(0, 8)}`; | |
| 14 | +const origin = "http://localhost:8230"; | |
| 15 | +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| 16 | + | |
| 17 | +beforeAll(async () => { | |
| 18 | + process.env.NODE_ENV = "test"; | |
| 19 | + app = await buildApp(); | |
| 20 | + await app.ready(); | |
| 21 | + await syncGames(db); | |
| 22 | + 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 } }); | |
| 24 | + cookie = res.cookies.find((c) => c.name === "spinza_session")!.value; | |
| 25 | + userId = res.json().user.id; | |
| 26 | +}); | |
| 27 | + | |
| 28 | +afterAll(async () => { | |
| 29 | + if (userId) await db.delete(users).where(eq(users.id, userId)); | |
| 30 | + await app.close(); | |
| 31 | + await closeDb(); | |
| 32 | + await closeRedis(); | |
| 33 | +}); | |
| 34 | + | |
| 35 | +const headers = () => ({ origin, cookie: `spinza_session=${cookie}` }); | |
| 36 | + | |
| 37 | +describe("crash games", () => { | |
| 38 | + it("starts a round (bet debited, commitment given, crash hidden) and cashes out at the displayed multiplier", async () => { | |
| 39 | + const start = await app.inject({ method: "POST", url: "/api/crash/skyfall/start", headers: headers(), payload: { bet: 100, clientRoundId: randomUUID() } }); | |
| 40 | + expect(start.statusCode).toBe(200); | |
| 41 | + const r = start.json().round; | |
| 42 | + expect(r.status).toBe("running"); | |
| 43 | + expect(r.crashMultiplier).toBeNull(); | |
| 44 | + expect(r.seed).toBeNull(); | |
| 45 | + expect(r.commitment).toHaveLength(64); | |
| 46 | + expect(start.json().balance).toBe(9900); | |
| 47 | + await wait(150); | |
| 48 | + const out = await app.inject({ method: "POST", url: `/api/crash/skyfall/cashout`, headers: headers(), payload: { roundId: r.roundId, claimedMultiplier: 1.0 } }); | |
| 49 | + expect(out.statusCode).toBe(200); | |
| 50 | + const settled = out.json().round; | |
| 51 | + expect(["cashed", "crashed"]).toContain(settled.status); | |
| 52 | + expect(settled.crashMultiplier).toBeGreaterThanOrEqual(1); | |
| 53 | + expect(verifyCommitment(settled.seed, settled.crashMultiplier, settled.commitment)).toBe(true); | |
| 54 | + if (settled.status === "cashed") { | |
| 55 | + expect(settled.cashoutMultiplier).toBe(1); | |
| 56 | + expect(settled.win).toBe(100); | |
| 57 | + expect(out.json().balance).toBe(10000); | |
| 58 | + } | |
| 59 | + const rounds = await db.select().from(gameRounds).where(eq(gameRounds.userId, userId)); | |
| 60 | + expect(rounds).toHaveLength(1); | |
| 61 | + const ledger = await db.select().from(creditTransactions).where(eq(creditTransactions.userId, userId)); | |
| 62 | + expect(ledger.filter((t) => t.type === "BET")).toHaveLength(1); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + it("refuses a cash-out above the server multiplier and never pays past the crash", async () => { | |
| 66 | + const start = await app.inject({ method: "POST", url: "/api/crash/skyfall/start", headers: headers(), payload: { bet: 50, clientRoundId: randomUUID() } }); | |
| 67 | + const r = start.json().round; | |
| 68 | + const out = await app.inject({ method: "POST", url: `/api/crash/skyfall/cashout`, headers: headers(), payload: { roundId: r.roundId, claimedMultiplier: 9999 } }); | |
| 69 | + const s = out.json().round; | |
| 70 | + if (s.status === "cashed") { | |
| 71 | + expect(s.cashoutMultiplier).toBeLessThan(s.crashMultiplier); | |
| 72 | + expect(s.cashoutMultiplier).toBeLessThanOrEqual(1.05); // ~0 s elapsed → server multiplier ≈ 1.00 | |
| 73 | + } else expect(s.win).toBe(0); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + it("start is idempotent and only one round may run at a time", async () => { | |
| 77 | + const id = randomUUID(); | |
| 78 | + const a = await app.inject({ method: "POST", url: "/api/crash/elevator-999/start", headers: headers(), payload: { bet: 10, clientRoundId: id } }); | |
| 79 | + const b = await app.inject({ method: "POST", url: "/api/crash/elevator-999/start", headers: headers(), payload: { bet: 10, clientRoundId: id } }); | |
| 80 | + expect(b.json().round.roundId).toBe(a.json().round.roundId); | |
| 81 | + expect(b.json().replayed).toBe(true); | |
| 82 | + const c = await app.inject({ method: "POST", url: "/api/crash/elevator-999/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID() } }); | |
| 83 | + expect([409, 200]).toContain(c.statusCode); // 200 only if the first round already crashed (instant crash) | |
| 84 | + await app.inject({ method: "POST", url: `/api/crash/elevator-999/cashout`, headers: headers(), payload: { roundId: a.json().round.roundId } }); | |
| 85 | + const [{ total }] = await db.select({ total: sql<number>`coalesce(sum(amount),0)::bigint` }).from(creditTransactions).where(eq(creditTransactions.userId, userId)); | |
| 86 | + const w = await db.query.wallets.findFirst({ where: eq(wallets.userId, userId) }); | |
| 87 | + expect(Number(total)).toBe(w!.balance); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + it("auto cash-out settles at the target when reached", async () => { | |
| 91 | + const start = await app.inject({ method: "POST", url: "/api/crash/skyfall/start", headers: headers(), payload: { bet: 10, clientRoundId: randomUUID(), autoCashout: 1.01 } }); | |
| 92 | + const r = start.json().round; | |
| 93 | + await wait(400); // curve reaches 1.01 in ~130 ms (k = 0.075) | |
| 94 | + const st = await app.inject({ method: "GET", url: `/api/crash/skyfall/rounds/${r.roundId}`, headers: headers() }); | |
| 95 | + const s = st.json().round; | |
| 96 | + expect(["cashed", "crashed"]).toContain(s.status); | |
| 97 | + if (s.status === "cashed") expect(s.cashoutMultiplier).toBe(1.01); | |
| 98 | + }); | |
| 99 | +}); | |
modified
apps/web/src/app/games/[slug]/page.tsx
+5 −2
@@ -2,8 +2,10 @@ import type { Metadata } from "next"; | ||
| 2 | 2 | import { notFound } from "next/navigation"; |
| 3 | 3 | import { apiServer } from "@/lib/api-server"; |
| 4 | 4 | import type { GameInfo } from "@spinza/shared"; |
| 5 | +import type { CrashGameDefinition } from "@spinza/game-core/client"; | |
| 5 | 6 | import type { ClientDefinition } from "@/components/game/types"; |
| 6 | 7 | import { GameClient } from "@/components/game/game-client"; |
| 8 | +import { CrashClient } from "@/components/crash/crash-client"; | |
| 7 | 9 | |
| 8 | 10 | type Params = { params: Promise<{ slug: string }> }; |
| 9 | 11 | |
@@ -16,7 +18,8 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> { | ||
| 16 | 18 | |
| 17 | 19 | export default async function GamePage({ params }: Params) { |
| 18 | 20 | const { slug } = await params; |
| 19 | − const data = await apiServer<{ game: GameInfo; definition: ClientDefinition }>(`/api/games/${slug}`); | |
| 21 | + const data = await apiServer<{ game: GameInfo; definition: ClientDefinition | CrashGameDefinition }>(`/api/games/${slug}`); | |
| 20 | 22 | if (!data) notFound(); |
| 21 | − return <GameClient game={data.game} definition={data.definition} />; | |
| 23 | + if (data.game.kind === "crash") return <CrashClient game={data.game} definition={data.definition as CrashGameDefinition} />; | |
| 24 | + return <GameClient game={data.game} definition={data.definition as ClientDefinition} />; | |
| 22 | 25 | } |
added
apps/web/src/components/crash/crash-client.tsx
+598 −0
@@ -0,0 +1,598 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 4 | +import Link from "next/link"; | |
| 5 | +import { useRouter } from "next/navigation"; | |
| 6 | +import { AnimatePresence, motion } from "framer-motion"; | |
| 7 | +import { ArrowLeft, Heart, History, Info, Minus, Plus, ShieldCheck, Volume2, VolumeX } from "lucide-react"; | |
| 8 | +import { BET_LEVELS, classifyWin, formatMultiplier, formatSC, WIN_CLASSES, type GameInfo } from "@spinza/shared"; | |
| 9 | +import { floorAt, multiplierAt, type CrashEvent, type CrashGameDefinition } from "@spinza/game-core/client"; | |
| 10 | +import { api, ApiClientError } from "@/lib/api"; | |
| 11 | +import { toast, useSession } from "@/lib/store"; | |
| 12 | +import { cn } from "@/lib/utils"; | |
| 13 | +import { Button, Credits, Sheet, Tabs } from "@/components/ui"; | |
| 14 | +import { SpinzaMark } from "@/components/brand/logo"; | |
| 15 | +import { getSound } from "@/components/game/sound"; | |
| 16 | +import { SCENES, progressFor } from "./scenes"; | |
| 17 | + | |
| 18 | +interface RoundView { | |
| 19 | + roundId: string; | |
| 20 | + game: string; | |
| 21 | + version: string; | |
| 22 | + bet: number; | |
| 23 | + status: "running" | "cashed" | "crashed"; | |
| 24 | + startedAt: string; | |
| 25 | + serverNow: number; | |
| 26 | + elapsedMs: number; | |
| 27 | + commitment: string; | |
| 28 | + events: CrashEvent[]; | |
| 29 | + autoCashout: number | null; | |
| 30 | + crashMultiplier: number | null; | |
| 31 | + seed: string | null; | |
| 32 | + cashoutMultiplier: number | null; | |
| 33 | + win: number | null; | |
| 34 | +} | |
| 35 | + | |
| 36 | +interface Progression { | |
| 37 | + xp: { gained: number; total: number; level: number; leveledUp: boolean; levelReward: number }; | |
| 38 | + unlocked: { achievements: string[]; missions: string[] }; | |
| 39 | +} | |
| 40 | + | |
| 41 | +type Phase = "idle" | "starting" | "running" | "cashed" | "crashed"; | |
| 42 | + | |
| 43 | +const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms)); | |
| 44 | + | |
| 45 | +export function CrashClient({ game, definition }: { game: GameInfo; definition: CrashGameDefinition }) { | |
| 46 | + const router = useRouter(); | |
| 47 | + const { status, wallet, settings, setBalance, setUserXp } = useSession(); | |
| 48 | + const canvasRef = useRef<HTMLCanvasElement>(null); | |
| 49 | + const soundRef = useRef(getSound()); | |
| 50 | + const [bet, setBet] = useState(100); | |
| 51 | + const [autoOn, setAutoOn] = useState(false); | |
| 52 | + const [autoTarget, setAutoTarget] = useState("2.00"); | |
| 53 | + const [phase, setPhase] = useState<Phase>("idle"); | |
| 54 | + const [round, setRound] = useState<RoundView | null>(null); | |
| 55 | + const [display, setDisplay] = useState(1); | |
| 56 | + const [history, setHistory] = useState<{ crash: number; at: string }[]>([]); | |
| 57 | + const [lastResult, setLastResult] = useState<{ status: "cashed" | "crashed"; multiplier: number; win: number; crash: number } | null>(null); | |
| 58 | + const [error, setError] = useState<string | null>(null); | |
| 59 | + const [infoSheet, setInfoSheet] = useState(false); | |
| 60 | + const [historySheet, setHistorySheet] = useState(false); | |
| 61 | + const [fairSheet, setFairSheet] = useState(false); | |
| 62 | + const [favorite, setFavorite] = useState(!!game.favorite); | |
| 63 | + const [activeEvent, setActiveEvent] = useState<string | null>(null); | |
| 64 | + const [milestone, setMilestone] = useState<string | null>(null); | |
| 65 | + const offsetRef = useRef(0); // serverNow - clientNow | |
| 66 | + const phaseRef = useRef<Phase>("idle"); | |
| 67 | + const phaseAtRef = useRef(0); | |
| 68 | + const roundRef = useRef<RoundView | null>(null); | |
| 69 | + const displayRef = useRef(1); | |
| 70 | + const cashingRef = useRef(false); | |
| 71 | + const shownMilestones = useRef(new Set<number>()); | |
| 72 | + const soundOn = settings?.soundEnabled ?? true; | |
| 73 | + const palette = definition.presentation.palette; | |
| 74 | + const bets = useMemo(() => (BET_LEVELS as readonly number[]).filter((b) => b >= definition.minBet && b <= definition.maxBet), [definition]); | |
| 75 | + const balance = wallet?.balance ?? 0; | |
| 76 | + | |
| 77 | + const setPhaseBoth = useCallback((p: Phase) => { | |
| 78 | + phaseRef.current = p; | |
| 79 | + phaseAtRef.current = performance.now(); | |
| 80 | + setPhase(p); | |
| 81 | + }, []); | |
| 82 | + | |
| 83 | + useEffect(() => { | |
| 84 | + if (status === "guest") router.replace(`/login?next=/games/${game.slug}`); | |
| 85 | + }, [status, router, game.slug]); | |
| 86 | + | |
| 87 | + const adoptRound = useCallback((r: RoundView) => { | |
| 88 | + offsetRef.current = r.serverNow - Date.now(); | |
| 89 | + roundRef.current = r; | |
| 90 | + setRound(r); | |
| 91 | + shownMilestones.current.clear(); | |
| 92 | + setLastResult(null); | |
| 93 | + setPhaseBoth("running"); | |
| 94 | + }, [setPhaseBoth]); | |
| 95 | + | |
| 96 | + const finish = useCallback((r: RoundView, bal: number | null, prog: Progression | null) => { | |
| 97 | + if (phaseRef.current !== "running") return; | |
| 98 | + roundRef.current = r; | |
| 99 | + setRound(r); | |
| 100 | + const cashed = r.status === "cashed"; | |
| 101 | + setPhaseBoth(cashed ? "cashed" : "crashed"); | |
| 102 | + const m = cashed ? (r.cashoutMultiplier ?? 0) : (r.crashMultiplier ?? 0); | |
| 103 | + displayRef.current = m; | |
| 104 | + setDisplay(m); | |
| 105 | + setLastResult({ status: cashed ? "cashed" : "crashed", multiplier: m, win: r.win ?? 0, crash: r.crashMultiplier ?? 0 }); | |
| 106 | + if (bal !== null) setBalance(bal); | |
| 107 | + if (cashed) { | |
| 108 | + const cls = classifyWin(m); | |
| 109 | + if (cls === "big" || cls === "mega" || cls === "epic" || cls === "legendary") soundRef.current.bigWin(); | |
| 110 | + else soundRef.current.win(2); | |
| 111 | + } else soundRef.current.error(); | |
| 112 | + if (prog) { | |
| 113 | + setUserXp(prog.xp.total, prog.xp.level); | |
| 114 | + if (prog.xp.leveledUp) toast({ title: `Level ${prog.xp.level} reached`, description: `+${formatSC(prog.xp.levelReward)} level reward`, tone: "credit" }); | |
| 115 | + for (const a of prog.unlocked.achievements) toast({ title: "Achievement unlocked", description: a.replace(/-/g, " "), tone: "success" }); | |
| 116 | + } | |
| 117 | + setHistory((h) => [{ crash: r.crashMultiplier ?? 0, at: new Date().toISOString() }, ...h].slice(0, 24)); | |
| 118 | + cashingRef.current = false; | |
| 119 | + }, [setBalance, setUserXp, setPhaseBoth]); | |
| 120 | + | |
| 121 | + | |
| 122 | + // Load history + resume a running round. | |
| 123 | + useEffect(() => { | |
| 124 | + if (status !== "authenticated") return; | |
| 125 | + api<{ history: { crash: number; at: string }[] }>(`/api/crash/${game.slug}/history`).then((r) => setHistory(r.history)).catch(() => {}); | |
| 126 | + api(`/api/games/${game.slug}/launch`, { method: "POST" }).catch(() => {}); | |
| 127 | + api<{ round: RoundView | null }>(`/api/crash/${game.slug}/current`) | |
| 128 | + .then((r) => { | |
| 129 | + if (r.round && r.round.status === "running") adoptRound(r.round); | |
| 130 | + }) | |
| 131 | + .catch(() => {}); | |
| 132 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 133 | + }, [status, game.slug]); | |
| 134 | + | |
| 135 | + useEffect(() => { | |
| 136 | + const s = soundRef.current; | |
| 137 | + s.setLevels({ enabled: soundOn, master: settings?.masterVolume ?? 0.8, music: settings?.musicVolume ?? 0.6, effects: settings?.effectsVolume ?? 0.8 }); | |
| 138 | + s.setAmbience(definition.presentation.ambience); | |
| 139 | + return () => s.destroy(); | |
| 140 | + }, [soundOn, settings?.masterVolume, settings?.musicVolume, settings?.effectsVolume, definition.presentation.ambience]); | |
| 141 | + | |
| 142 | + | |
| 143 | + /* ------------------------------------------------------- animation loop */ | |
| 144 | + useEffect(() => { | |
| 145 | + const canvas = canvasRef.current; | |
| 146 | + if (!canvas) return; | |
| 147 | + const ctx = canvas.getContext("2d"); | |
| 148 | + if (!ctx) return; | |
| 149 | + let raf = 0; | |
| 150 | + let lastTick = 0; | |
| 151 | + const start = performance.now(); | |
| 152 | + const loop = () => { | |
| 153 | + const dpr = Math.min(2, window.devicePixelRatio || 1); | |
| 154 | + const rect = canvas.getBoundingClientRect(); | |
| 155 | + if (canvas.width !== Math.round(rect.width * dpr) || canvas.height !== Math.round(rect.height * dpr)) { | |
| 156 | + canvas.width = Math.round(rect.width * dpr); | |
| 157 | + canvas.height = Math.round(rect.height * dpr); | |
| 158 | + } | |
| 159 | + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| 160 | + const r = roundRef.current; | |
| 161 | + const ph = phaseRef.current; | |
| 162 | + let m = displayRef.current; | |
| 163 | + let t = (performance.now() - start) / 1000; | |
| 164 | + let event: string | null = null; | |
| 165 | + if (r && ph === "running") { | |
| 166 | + const elapsed = Date.now() + offsetRef.current - new Date(r.startedAt).getTime(); | |
| 167 | + m = multiplierAt(definition, r.events, elapsed); | |
| 168 | + t = elapsed / 1000; | |
| 169 | + const ev = r.events.find((e) => elapsed >= e.startMs && elapsed < e.endMs); | |
| 170 | + event = ev?.label ?? null; | |
| 171 | + if (m !== displayRef.current) { | |
| 172 | + displayRef.current = m; | |
| 173 | + setDisplay(m); | |
| 174 | + if (performance.now() - lastTick > 90) { | |
| 175 | + lastTick = performance.now(); | |
| 176 | + soundRef.current.tick(); | |
| 177 | + } | |
| 178 | + } | |
| 179 | + for (const ms of definition.milestones) { | |
| 180 | + if (m >= ms.multiplier && !shownMilestones.current.has(ms.multiplier)) { | |
| 181 | + shownMilestones.current.add(ms.multiplier); | |
| 182 | + setMilestone(ms.label); | |
| 183 | + setTimeout(() => setMilestone(null), 1800); | |
| 184 | + } | |
| 185 | + } | |
| 186 | + } else if (r && (ph === "cashed" || ph === "crashed")) { | |
| 187 | + m = ph === "cashed" ? (r.cashoutMultiplier ?? displayRef.current) : (r.crashMultiplier ?? displayRef.current); | |
| 188 | + t = r.status !== "running" ? (r.elapsedMs ?? 0) / 1000 : t; | |
| 189 | + } | |
| 190 | + setActiveEvent((prev) => (prev === event ? prev : event)); | |
| 191 | + const painter = SCENES[definition.presentation.scene]; | |
| 192 | + ctx.save(); | |
| 193 | + painter({ | |
| 194 | + ctx, | |
| 195 | + w: rect.width, | |
| 196 | + h: rect.height, | |
| 197 | + p: progressFor(m), | |
| 198 | + t, | |
| 199 | + multiplier: m, | |
| 200 | + phase: ph === "starting" || ph === "idle" ? "idle" : ph, | |
| 201 | + phaseT: (performance.now() - phaseAtRef.current) / 1000, | |
| 202 | + palette, | |
| 203 | + event, | |
| 204 | + floor: floorAt(definition, m), | |
| 205 | + reduceMotion: settings?.reduceMotion ?? false, | |
| 206 | + }); | |
| 207 | + ctx.restore(); | |
| 208 | + raf = requestAnimationFrame(loop); | |
| 209 | + }; | |
| 210 | + raf = requestAnimationFrame(loop); | |
| 211 | + return () => cancelAnimationFrame(raf); | |
| 212 | + }, [definition, palette, settings?.reduceMotion]); | |
| 213 | + | |
| 214 | + /* --------------------------------------------------------------- polling */ | |
| 215 | + const roundId = round?.roundId; | |
| 216 | + useEffect(() => { | |
| 217 | + if (phase !== "running" || !roundId) return; | |
| 218 | + let alive = true; | |
| 219 | + const poll = async () => { | |
| 220 | + while (alive && phaseRef.current === "running") { | |
| 221 | + try { | |
| 222 | + const r = await api<{ round: RoundView; balance: number | null; progression: Progression | null }>(`/api/crash/${game.slug}/rounds/${roundId}`); | |
| 223 | + offsetRef.current = r.round.serverNow - Date.now(); | |
| 224 | + if (r.round.status !== "running") { | |
| 225 | + finish(r.round, r.balance, r.progression); | |
| 226 | + break; | |
| 227 | + } | |
| 228 | + } catch { | |
| 229 | + /* keep polling */ | |
| 230 | + } | |
| 231 | + await wait(350); | |
| 232 | + } | |
| 233 | + }; | |
| 234 | + void poll(); | |
| 235 | + return () => { | |
| 236 | + alive = false; | |
| 237 | + }; | |
| 238 | + }, [phase, roundId, finish, game.slug]); | |
| 239 | + | |
| 240 | + /* ---------------------------------------------------------------- actions */ | |
| 241 | + const start = useCallback(async () => { | |
| 242 | + if (phaseRef.current === "running" || phaseRef.current === "starting") return; | |
| 243 | + if (balance < bet) { | |
| 244 | + setError("Not enough Spinza Credits for this bet."); | |
| 245 | + soundRef.current.error(); | |
| 246 | + return; | |
| 247 | + } | |
| 248 | + setError(null); | |
| 249 | + soundRef.current.unlock(); | |
| 250 | + soundRef.current.spinStart(); | |
| 251 | + setPhaseBoth("starting"); | |
| 252 | + setLastResult(null); | |
| 253 | + displayRef.current = 1; | |
| 254 | + setDisplay(1); | |
| 255 | + const auto = autoOn ? Number(autoTarget) : null; | |
| 256 | + try { | |
| 257 | + const r = await api<{ round: RoundView; balance: number | null }>(`/api/crash/${game.slug}/start`, { json: { bet, clientRoundId: crypto.randomUUID(), autoCashout: auto && auto >= 1.01 ? Math.round(auto * 100) / 100 : undefined } }); | |
| 258 | + if (r.balance !== null) setBalance(r.balance); | |
| 259 | + adoptRound(r.round); | |
| 260 | + } catch (e) { | |
| 261 | + setPhaseBoth("idle"); | |
| 262 | + if (e instanceof ApiClientError) { | |
| 263 | + if (e.status === 401) router.replace(`/login?next=/games/${game.slug}`); | |
| 264 | + else if (e.code === "ROUND_IN_PROGRESS") api<{ round: RoundView | null }>(`/api/crash/${game.slug}/current`).then((c) => c.round && adoptRound(c.round)).catch(() => {}); | |
| 265 | + else setError(e.message); | |
| 266 | + } else setError("Connection lost. Try again."); | |
| 267 | + soundRef.current.error(); | |
| 268 | + } | |
| 269 | + }, [balance, bet, autoOn, autoTarget, game.slug, router, setBalance, adoptRound, setPhaseBoth]); | |
| 270 | + | |
| 271 | + const cashout = useCallback(async () => { | |
| 272 | + const r = roundRef.current; | |
| 273 | + if (!r || phaseRef.current !== "running" || cashingRef.current) return; | |
| 274 | + cashingRef.current = true; | |
| 275 | + soundRef.current.click(); | |
| 276 | + try { | |
| 277 | + const res = await api<{ round: RoundView; balance: number; progression: Progression | null }>(`/api/crash/${game.slug}/cashout`, { json: { roundId: r.roundId, claimedMultiplier: displayRef.current } }); | |
| 278 | + finish(res.round, res.balance, res.progression); | |
| 279 | + } catch { | |
| 280 | + cashingRef.current = false; | |
| 281 | + } | |
| 282 | + }, [game.slug, finish]); | |
| 283 | + | |
| 284 | + useEffect(() => { | |
| 285 | + const onKey = (e: KeyboardEvent) => { | |
| 286 | + if (e.code === "Space" && !e.repeat && !infoSheet && !historySheet && !fairSheet) { | |
| 287 | + e.preventDefault(); | |
| 288 | + if (phaseRef.current === "running") void cashout(); | |
| 289 | + else void start(); | |
| 290 | + } | |
| 291 | + }; | |
| 292 | + window.addEventListener("keydown", onKey); | |
| 293 | + return () => window.removeEventListener("keydown", onKey); | |
| 294 | + }, [start, cashout, infoSheet, historySheet, fairSheet]); | |
| 295 | + | |
| 296 | + const toggleFavorite = async () => { | |
| 297 | + setFavorite((f) => !f); | |
| 298 | + try { | |
| 299 | + const r = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" }); | |
| 300 | + setFavorite(r.favorite); | |
| 301 | + } catch { | |
| 302 | + setFavorite((f) => !f); | |
| 303 | + } | |
| 304 | + }; | |
| 305 | + | |
| 306 | + if (status === "guest") return null; | |
| 307 | + const running = phase === "running"; | |
| 308 | + const betIndex = bets.indexOf(bet); | |
| 309 | + const unitValue = definition.curve.type === "steps" ? floorAt(definition, display) : Math.round((display - 1) * definition.presentation.unitScale); | |
| 310 | + const potential = Math.round(bet * display); | |
| 311 | + const cls = lastResult?.status === "cashed" ? classifyWin(lastResult.multiplier) : "none"; | |
| 312 | + const winLabel = WIN_CLASSES.find((c) => c.id === cls)?.label; | |
| 313 | + | |
| 314 | + return ( | |
| 315 | + <div className="fixed inset-0 flex flex-col" style={{ background: palette.bg }}> | |
| 316 | + {/* Top bar */} | |
| 317 | + <div className="flex items-center justify-between gap-2 px-3 py-2" style={{ paddingTop: "calc(var(--safe-top) + 8px)" }}> | |
| 318 | + <div className="flex items-center gap-2"> | |
| 319 | + <Link href="/" className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Back to lobby"> | |
| 320 | + <ArrowLeft className="h-5 w-5" /> | |
| 321 | + </Link> | |
| 322 | + <div className="leading-tight"> | |
| 323 | + <div className="text-[15px] font-semibold tracking-tight">{game.name}</div> | |
| 324 | + <div className="text-[11px] text-fg-3">Risk game · cash out anytime</div> | |
| 325 | + </div> | |
| 326 | + </div> | |
| 327 | + <div className="flex items-center gap-1"> | |
| 328 | + <button onClick={toggleFavorite} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Favourite"> | |
| 329 | + <Heart className={cn("h-5 w-5", favorite && "fill-danger text-danger")} /> | |
| 330 | + </button> | |
| 331 | + <button onClick={() => setHistorySheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="History"> | |
| 332 | + <History className="h-5 w-5" /> | |
| 333 | + </button> | |
| 334 | + <button onClick={() => setFairSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Provably fair"> | |
| 335 | + <ShieldCheck className="h-5 w-5" /> | |
| 336 | + </button> | |
| 337 | + <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"> | |
| 338 | + <Info className="h-5 w-5" /> | |
| 339 | + </button> | |
| 340 | + <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"> | |
| 341 | + {soundOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />} | |
| 342 | + </button> | |
| 343 | + </div> | |
| 344 | + </div> | |
| 345 | + | |
| 346 | + {/* History strip */} | |
| 347 | + <div className="flex gap-1.5 overflow-x-auto px-3 pb-2 scrollbar-none"> | |
| 348 | + {history.length === 0 ? <span className="text-[11px] text-fg-4">No rounds yet — be the first.</span> : null} | |
| 349 | + {history.map((h, i) => ( | |
| 350 | + <span key={i} className={cn("shrink-0 rounded-full px-2 py-0.5 text-[11px] font-bold tabular", h.crash < 1.5 ? "bg-danger/15 text-danger" : h.crash < 3 ? "bg-white/10 text-fg-2" : h.crash < 10 ? "bg-success/15 text-success" : "bg-accent-soft text-accent-2")}> | |
| 351 | + {formatMultiplier(h.crash)} | |
| 352 | + </span> | |
| 353 | + ))} | |
| 354 | + </div> | |
| 355 | + | |
| 356 | + {/* Scene */} | |
| 357 | + <div className="relative flex-1 min-h-0"> | |
| 358 | + <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" /> | |
| 359 | + {/* Multiplier */} | |
| 360 | + <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-start pt-[9%]"> | |
| 361 | + <AnimatePresence mode="wait"> | |
| 362 | + {phase === "idle" || phase === "starting" ? ( | |
| 363 | + <motion.div key="idle" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="text-center"> | |
| 364 | + {lastResult ? ( | |
| 365 | + <div className={cn("mb-3 rounded-full px-4 py-1.5 text-sm font-semibold", lastResult.status === "cashed" ? "bg-success/15 text-success" : "bg-danger/15 text-danger")}> | |
| 366 | + {lastResult.status === "cashed" ? `Cashed out at ${formatMultiplier(lastResult.multiplier)} · +${formatSC(lastResult.win)}` : `Crashed at ${formatMultiplier(lastResult.crash)}`} | |
| 367 | + </div> | |
| 368 | + ) : null} | |
| 369 | + <div className="text-[clamp(56px,14vw,120px)] font-extrabold leading-none tabular tracking-tight text-white/90" style={{ textShadow: `0 0 40px ${palette.glow}66` }}> | |
| 370 | + {phase === "starting" ? "…" : "1.00×"} | |
| 371 | + </div> | |
| 372 | + <div className="mt-2 text-sm text-fg-2">{phase === "starting" ? "Starting round" : `Press ${definition.presentation.verb} before it ends`}</div> | |
| 373 | + </motion.div> | |
| 374 | + ) : ( | |
| 375 | + <motion.div key="live" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} className="text-center"> | |
| 376 | + <div | |
| 377 | + className={cn("text-[clamp(64px,16vw,140px)] font-extrabold leading-none tabular tracking-tight", phase === "crashed" ? "text-danger" : phase === "cashed" ? "text-credit" : "text-white")} | |
| 378 | + style={{ textShadow: phase === "running" ? `0 0 ${20 + progressFor(display) * 60}px ${palette.glow}` : undefined }} | |
| 379 | + > | |
| 380 | + {formatMultiplier(display)} | |
| 381 | + </div> | |
| 382 | + <div className="mt-2 text-sm text-fg-2 tabular"> | |
| 383 | + {definition.curve.type === "steps" ? `Floor ${unitValue}` : `${Math.abs(unitValue).toLocaleString("en-US")} ${definition.presentation.unit}`} · {running ? `${formatSC(potential)} on the line` : ""} | |
| 384 | + </div> | |
| 385 | + {phase === "cashed" && lastResult ? ( | |
| 386 | + <motion.div initial={{ y: 10, opacity: 0 }} animate={{ y: 0, opacity: 1 }} className="mt-4"> | |
| 387 | + {winLabel ? <div className="text-2xl font-extrabold uppercase tracking-tight shimmer-text">{winLabel}</div> : null} | |
| 388 | + <div className="text-xl font-bold text-credit tabular">+{formatSC(lastResult.win)}</div> | |
| 389 | + <div className="text-[12px] text-fg-3">The round would have ended at {formatMultiplier(lastResult.crash)}</div> | |
| 390 | + </motion.div> | |
| 391 | + ) : null} | |
| 392 | + {phase === "crashed" ? ( | |
| 393 | + <motion.div initial={{ scale: 0.6 }} animate={{ scale: 1 }} className="mt-4 text-2xl font-extrabold uppercase tracking-tight text-danger"> | |
| 394 | + {crashWord(definition.presentation.scene)} | |
| 395 | + </motion.div> | |
| 396 | + ) : null} | |
| 397 | + </motion.div> | |
| 398 | + )} | |
| 399 | + </AnimatePresence> | |
| 400 | + <AnimatePresence> | |
| 401 | + {activeEvent && running ? ( | |
| 402 | + <motion.div key={activeEvent} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute top-4 rounded-full px-3 py-1 text-[12px] font-bold uppercase tracking-wider" style={{ background: `${palette.secondary}33`, color: palette.secondary }}> | |
| 403 | + {activeEvent} | |
| 404 | + </motion.div> | |
| 405 | + ) : null} | |
| 406 | + {milestone ? ( | |
| 407 | + <motion.div key={milestone} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute bottom-6 rounded-full bg-black/50 px-3 py-1 text-[12px] font-semibold text-fg-2"> | |
| 408 | + {milestone} | |
| 409 | + </motion.div> | |
| 410 | + ) : null} | |
| 411 | + </AnimatePresence> | |
| 412 | + </div> | |
| 413 | + <AnimatePresence> | |
| 414 | + {error ? ( | |
| 415 | + <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="absolute inset-x-4 bottom-3 mx-auto max-w-sm glass rounded-md p-3 text-center text-sm"> | |
| 416 | + <div className="text-fg-2">{error}</div> | |
| 417 | + <div className="mt-2 flex justify-center gap-2"> | |
| 418 | + <Button size="sm" variant="secondary" onClick={() => setError(null)}> | |
| 419 | + Dismiss | |
| 420 | + </Button> | |
| 421 | + <Button size="sm" variant="accent" href="/rewards"> | |
| 422 | + Get rewards | |
| 423 | + </Button> | |
| 424 | + </div> | |
| 425 | + </motion.div> | |
| 426 | + ) : null} | |
| 427 | + </AnimatePresence> | |
| 428 | + </div> | |
| 429 | + | |
| 430 | + {/* Controls */} | |
| 431 | + <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` }}> | |
| 432 | + <div className="mx-auto grid max-w-3xl grid-cols-[1fr_auto_1fr] items-center gap-3"> | |
| 433 | + <div className="flex flex-col gap-2"> | |
| 434 | + <div> | |
| 435 | + <div className="eyebrow">Balance</div> | |
| 436 | + <Credits amount={balance} size="md" /> | |
| 437 | + </div> | |
| 438 | + <div className="flex items-center gap-1"> | |
| 439 | + <button disabled={running || 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"> | |
| 440 | + <Minus className="h-4 w-4" /> | |
| 441 | + </button> | |
| 442 | + <div className="flex h-10 min-w-[92px] flex-col items-center justify-center rounded-md surface-2 px-2"> | |
| 443 | + <span className="text-[10px] uppercase tracking-wider text-fg-3">Bet</span> | |
| 444 | + <span className="text-sm font-bold tabular text-fg">{formatSC(bet)}</span> | |
| 445 | + </div> | |
| 446 | + <button disabled={running || 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"> | |
| 447 | + <Plus className="h-4 w-4" /> | |
| 448 | + </button> | |
| 449 | + </div> | |
| 450 | + </div> | |
| 451 | + | |
| 452 | + <div className="flex flex-col items-center gap-2"> | |
| 453 | + {running ? ( | |
| 454 | + <button | |
| 455 | + onClick={() => void cashout()} | |
| 456 | + className="relative grid h-[92px] w-[92px] place-items-center rounded-full text-center text-[13px] font-extrabold leading-tight tracking-wide text-[#1a1406] transition-transform active:scale-95 focus-ring" | |
| 457 | + style={{ background: `linear-gradient(180deg, #f3e2ad, #c9a961 60%, #9a7b3a)`, boxShadow: `0 0 0 6px rgba(201,169,97,0.25), 0 0 60px -10px rgba(201,169,97,0.9)` }} | |
| 458 | + aria-label={definition.presentation.verb} | |
| 459 | + > | |
| 460 | + <span className="px-2">{definition.presentation.verb}</span> | |
| 461 | + </button> | |
| 462 | + ) : ( | |
| 463 | + <button | |
| 464 | + onClick={() => void start()} | |
| 465 | + disabled={phase === "starting"} | |
| 466 | + className="relative grid h-[92px] w-[92px] place-items-center rounded-full text-center text-[15px] font-extrabold tracking-wide transition-transform active:scale-95 focus-ring disabled:opacity-70" | |
| 467 | + style={{ background: `linear-gradient(180deg, ${palette.primary}, ${palette.secondary})`, boxShadow: `0 0 0 6px ${palette.primary}2e, 0 12px 50px -12px ${palette.primary}`, color: "#0b0a06" }} | |
| 468 | + aria-label="Start round" | |
| 469 | + > | |
| 470 | + {phase === "cashed" || phase === "crashed" ? "AGAIN" : "START"} | |
| 471 | + </button> | |
| 472 | + )} | |
| 473 | + <span className="text-[11px] font-semibold uppercase tracking-wider text-fg-3">{running ? `${formatSC(potential)}` : "space = start / cash out"}</span> | |
| 474 | + </div> | |
| 475 | + | |
| 476 | + <div className="flex flex-col items-end gap-2 text-right"> | |
| 477 | + <div> | |
| 478 | + <div className="eyebrow">Auto cash-out</div> | |
| 479 | + <div className="flex items-center gap-1.5"> | |
| 480 | + <button role="switch" aria-checked={autoOn} disabled={running} onClick={() => setAutoOn((v) => !v)} className={cn("relative h-6 w-10 rounded-full border transition-colors", autoOn ? "bg-accent border-accent" : "bg-surface-3 border-line-2")}> | |
| 481 | + <span className={cn("absolute top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform", autoOn ? "translate-x-[18px]" : "translate-x-0.5")} /> | |
| 482 | + </button> | |
| 483 | + <input | |
| 484 | + type="number" | |
| 485 | + step="0.1" | |
| 486 | + min="1.01" | |
| 487 | + max={definition.maxMultiplier} | |
| 488 | + value={autoTarget} | |
| 489 | + disabled={running || !autoOn} | |
| 490 | + onChange={(e) => setAutoTarget(e.target.value)} | |
| 491 | + onBlur={() => setAutoTarget(String(Math.max(1.01, Math.min(definition.maxMultiplier, Number(autoTarget) || 2)).toFixed(2)))} | |
| 492 | + className="h-9 w-[76px] rounded-md border border-line-2 bg-bg-1 px-2 text-right text-sm font-bold tabular text-fg disabled:opacity-50 focus-ring" | |
| 493 | + aria-label="Auto cash-out multiplier" | |
| 494 | + /> | |
| 495 | + <span className="text-sm text-fg-3">×</span> | |
| 496 | + </div> | |
| 497 | + </div> | |
| 498 | + <div className="text-[11px] text-fg-4">Max {formatMultiplier(definition.maxMultiplier)}</div> | |
| 499 | + </div> | |
| 500 | + </div> | |
| 501 | + </div> | |
| 502 | + | |
| 503 | + <InfoSheet open={infoSheet} onClose={() => setInfoSheet(false)} game={game} definition={definition} /> | |
| 504 | + <FairSheet open={fairSheet} onClose={() => setFairSheet(false)} round={round} /> | |
| 505 | + <Sheet open={historySheet} onClose={() => setHistorySheet(false)} title="Recent rounds" side="right"> | |
| 506 | + {history.length === 0 ? <p className="text-sm text-fg-3">No rounds yet.</p> : ( | |
| 507 | + <ul className="grid grid-cols-4 gap-2"> | |
| 508 | + {history.map((h, i) => ( | |
| 509 | + <li key={i} className={cn("rounded-md px-2 py-2 text-center text-sm font-bold tabular", h.crash < 1.5 ? "bg-danger/15 text-danger" : h.crash < 3 ? "bg-white/10 text-fg-2" : h.crash < 10 ? "bg-success/15 text-success" : "bg-accent-soft text-accent-2")}> | |
| 510 | + {formatMultiplier(h.crash)} | |
| 511 | + </li> | |
| 512 | + ))} | |
| 513 | + </ul> | |
| 514 | + )} | |
| 515 | + </Sheet> | |
| 516 | + <span className="sr-only"> | |
| 517 | + <SpinzaMark /> | |
| 518 | + </span> | |
| 519 | + </div> | |
| 520 | + ); | |
| 521 | +} | |
| 522 | + | |
| 523 | +function crashWord(scene: string): string { | |
| 524 | + return ( | |
| 525 | + { | |
| 526 | + sky: "Vanished", | |
| 527 | + ocean: "Hull breach", | |
| 528 | + rocket: "Engine failure", | |
| 529 | + bank: "Busted", | |
| 530 | + volcano: "Eruption", | |
| 531 | + blackhole: "Pulled in", | |
| 532 | + freefall: "Too late", | |
| 533 | + reactor: "Meltdown", | |
| 534 | + storm: "Swallowed", | |
| 535 | + elevator: "Cable snapped", | |
| 536 | + }[scene] ?? "Crashed" | |
| 537 | + ); | |
| 538 | +} | |
| 539 | + | |
| 540 | +function InfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: CrashGameDefinition }) { | |
| 541 | + const [tab, setTab] = useState<"rules" | "about">("rules"); | |
| 542 | + return ( | |
| 543 | + <Sheet open={open} onClose={onClose} title={game.name} side="right"> | |
| 544 | + <Tabs value={tab} onChange={setTab} items={[{ value: "rules", label: "How to play" }, { value: "about", label: "About" }]} className="mb-4" /> | |
| 545 | + {tab === "rules" ? ( | |
| 546 | + <ul className="space-y-3 text-sm text-fg-2"> | |
| 547 | + {game.rules.map((r, i) => ( | |
| 548 | + <li key={i} className="flex gap-3"> | |
| 549 | + <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> | |
| 550 | + <span>{r}</span> | |
| 551 | + </li> | |
| 552 | + ))} | |
| 553 | + </ul> | |
| 554 | + ) : ( | |
| 555 | + <div className="space-y-4 text-sm text-fg-2"> | |
| 556 | + <p>{game.description}</p> | |
| 557 | + <dl className="grid grid-cols-2 gap-3"> | |
| 558 | + {[ | |
| 559 | + ["Volatility", game.volatility], | |
| 560 | + ["RTP", `${(game.rtp * 100).toFixed(2)}% for any strategy`], | |
| 561 | + ["Max multiplier", formatMultiplier(definition.maxMultiplier)], | |
| 562 | + ["Instant end", `${((1 - game.rtp) * 100).toFixed(0)}% of rounds`], | |
| 563 | + ["Reach 2× odds", `${((game.rtp / 2) * 100).toFixed(0)}%`], | |
| 564 | + ["Reach 10× odds", `${((game.rtp / 10) * 100).toFixed(1)}%`], | |
| 565 | + ].map(([k, v]) => ( | |
| 566 | + <div key={k} className="surface rounded-md p-3"> | |
| 567 | + <dt className="eyebrow">{k}</dt> | |
| 568 | + <dd className="mt-1 text-sm font-semibold capitalize text-fg">{v}</dd> | |
| 569 | + </div> | |
| 570 | + ))} | |
| 571 | + </dl> | |
| 572 | + <p className="text-[12px] text-fg-3">P(end ≥ x) = {game.rtp} / x. Milestones, boosts and cooling windows change the pace, never the odds. Spinza Credits are fictional and have no cash value.</p> | |
| 573 | + </div> | |
| 574 | + )} | |
| 575 | + </Sheet> | |
| 576 | + ); | |
| 577 | +} | |
| 578 | + | |
| 579 | +function FairSheet({ open, onClose, round }: { open: boolean; onClose: () => void; round: RoundView | null }) { | |
| 580 | + return ( | |
| 581 | + <Sheet open={open} onClose={onClose} title="Provably fair" side="right"> | |
| 582 | + <div className="space-y-4 text-sm text-fg-2"> | |
| 583 | + <p>Before each round starts, the server draws the end multiplier and publishes a SHA-256 commitment of <code className="font-mono text-[12px]">seed:multiplier</code>. When the round ends, the seed is revealed so you can verify the commitment yourself.</p> | |
| 584 | + {round ? ( | |
| 585 | + <div className="space-y-2 surface rounded-md p-3 text-[12px]"> | |
| 586 | + <div><span className="text-fg-3">Round</span> <span className="font-mono">{round.roundId}</span></div> | |
| 587 | + <div><span className="text-fg-3">Commitment</span> <span className="break-all font-mono">{round.commitment}</span></div> | |
| 588 | + <div><span className="text-fg-3">Seed</span> <span className="break-all font-mono">{round.seed ?? "revealed when the round ends"}</span></div> | |
| 589 | + <div><span className="text-fg-3">End multiplier</span> <span className="font-mono">{round.crashMultiplier ? round.crashMultiplier.toFixed(2) : "hidden while running"}</span></div> | |
| 590 | + </div> | |
| 591 | + ) : ( | |
| 592 | + <p className="text-fg-3">Start a round to see its commitment.</p> | |
| 593 | + )} | |
| 594 | + <p className="text-[12px] text-fg-3">Verify: sha256("seed:multiplier") with the multiplier formatted to two decimals must equal the commitment.</p> | |
| 595 | + </div> | |
| 596 | + </Sheet> | |
| 597 | + ); | |
| 598 | +} | |
added
apps/web/src/components/crash/scenes.ts
+788 −0
@@ -0,0 +1,788 @@ | ||
| 1 | +"use client"; | |
| 2 | + | |
| 3 | +import type { CrashScene } from "@spinza/game-core/client"; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Procedural 2D-canvas scenes for the ten Risk Games. Each painter receives | |
| 7 | + * the normalised progress `p` (0 at 1.00×, →1 near very high multipliers, | |
| 8 | + * log scale), the elapsed time, the current multiplier and the round phase. | |
| 9 | + * All drawing is original and parametric — no image assets. | |
| 10 | + */ | |
| 11 | + | |
| 12 | +export interface SceneFrame { | |
| 13 | + ctx: CanvasRenderingContext2D; | |
| 14 | + w: number; | |
| 15 | + h: number; | |
| 16 | + /** log-scale progress 0..1 (1 ≈ 1000×). */ | |
| 17 | + p: number; | |
| 18 | + /** Seconds since round start (0 when idle). */ | |
| 19 | + t: number; | |
| 20 | + multiplier: number; | |
| 21 | + phase: "idle" | "running" | "cashed" | "crashed"; | |
| 22 | + /** Seconds since the phase changed (for crash/cash animations). */ | |
| 23 | + phaseT: number; | |
| 24 | + palette: { primary: string; secondary: string; glow: string; bg: string; surface: string }; | |
| 25 | + /** Active event label, if any. */ | |
| 26 | + event: string | null; | |
| 27 | + /** Elevator floor (steps curves). */ | |
| 28 | + floor: number; | |
| 29 | + reduceMotion: boolean; | |
| 30 | +} | |
| 31 | + | |
| 32 | +type Painter = (f: SceneFrame) => void; | |
| 33 | + | |
| 34 | +const TAU = Math.PI * 2; | |
| 35 | + | |
| 36 | +function hashNoise(i: number, salt = 0): number { | |
| 37 | + const x = Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453; | |
| 38 | + return x - Math.floor(x); | |
| 39 | +} | |
| 40 | + | |
| 41 | +function withAlpha(hex: string, a: number): string { | |
| 42 | + const n = parseInt(hex.replace("#", ""), 16); | |
| 43 | + return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; | |
| 44 | +} | |
| 45 | + | |
| 46 | +function mix(a: string, b: string, t: number): string { | |
| 47 | + const na = parseInt(a.replace("#", ""), 16); | |
| 48 | + const nb = parseInt(b.replace("#", ""), 16); | |
| 49 | + const ch = (s: number) => Math.round(((na >> s) & 255) * (1 - t) + ((nb >> s) & 255) * t); | |
| 50 | + return `rgb(${ch(16)},${ch(8)},${ch(0)})`; | |
| 51 | +} | |
| 52 | + | |
| 53 | +function gradient(ctx: CanvasRenderingContext2D, w: number, h: number, stops: [number, string][]) { | |
| 54 | + const g = ctx.createLinearGradient(0, 0, 0, h); | |
| 55 | + for (const [o, c] of stops) g.addColorStop(o, c); | |
| 56 | + ctx.fillStyle = g; | |
| 57 | + ctx.fillRect(0, 0, w, h); | |
| 58 | +} | |
| 59 | + | |
| 60 | +function stars(f: SceneFrame, count: number, alpha: number, drift = 0) { | |
| 61 | + const { ctx, w, h, t } = f; | |
| 62 | + ctx.fillStyle = `rgba(255,255,255,${alpha})`; | |
| 63 | + for (let i = 0; i < count; i++) { | |
| 64 | + const x = (hashNoise(i, 1) * w + drift * t * (0.3 + hashNoise(i, 4))) % w; | |
| 65 | + const y = (hashNoise(i, 2) * h + drift * t * 2 * (0.3 + hashNoise(i, 5))) % h; | |
| 66 | + const r = 0.6 + hashNoise(i, 3) * 1.6; | |
| 67 | + ctx.beginPath(); | |
| 68 | + ctx.arc(x < 0 ? x + w : x, y < 0 ? y + h : y, r, 0, TAU); | |
| 69 | + ctx.fill(); | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +function shake(f: SceneFrame, amount: number) { | |
| 74 | + if (f.reduceMotion) return; | |
| 75 | + f.ctx.translate((hashNoise(Math.floor(f.t * 60), 9) - 0.5) * amount, (hashNoise(Math.floor(f.t * 60), 10) - 0.5) * amount); | |
| 76 | +} | |
| 77 | + | |
| 78 | +function crashFlash(f: SceneFrame, color: string) { | |
| 79 | + if (f.phase !== "crashed") return; | |
| 80 | + const a = Math.max(0, 0.75 - f.phaseT * 1.2); | |
| 81 | + f.ctx.fillStyle = withAlpha(color, a); | |
| 82 | + f.ctx.fillRect(0, 0, f.w, f.h); | |
| 83 | +} | |
| 84 | + | |
| 85 | +function cashGlow(f: SceneFrame) { | |
| 86 | + if (f.phase !== "cashed") return; | |
| 87 | + const a = Math.max(0, 0.35 - f.phaseT * 0.5); | |
| 88 | + const g = f.ctx.createRadialGradient(f.w / 2, f.h / 2, 0, f.w / 2, f.h / 2, Math.max(f.w, f.h) * 0.7); | |
| 89 | + g.addColorStop(0, `rgba(201,169,97,${a})`); | |
| 90 | + g.addColorStop(1, "rgba(201,169,97,0)"); | |
| 91 | + f.ctx.fillStyle = g; | |
| 92 | + f.ctx.fillRect(0, 0, f.w, f.h); | |
| 93 | +} | |
| 94 | + | |
| 95 | +/* ------------------------------------------------------------------ sky */ | |
| 96 | + | |
| 97 | +const sky: Painter = (f) => { | |
| 98 | + const { ctx, w, h, p, t } = f; | |
| 99 | + // Sky colour from dawn blue → storm grey → deep indigo → black space. | |
| 100 | + const top = p < 0.35 ? mix("#1d4ed8", "#334155", p / 0.35) : p < 0.7 ? mix("#334155", "#0b1226", (p - 0.35) / 0.35) : mix("#0b1226", "#000000", (p - 0.7) / 0.3); | |
| 101 | + const bottom = p < 0.5 ? mix("#7dd3fc", "#475569", p / 0.5) : mix("#475569", "#020617", (p - 0.5) / 0.5); | |
| 102 | + gradient(ctx, w, h, [[0, top], [1, bottom]]); | |
| 103 | + if (p > 0.6) stars(f, 90, Math.min(0.9, (p - 0.6) * 2.5), 0); | |
| 104 | + // Clouds scrolling down as we climb. | |
| 105 | + const cloudAlpha = p < 0.55 ? 0.55 : Math.max(0, 0.55 - (p - 0.55) * 3); | |
| 106 | + for (let i = 0; i < 9; i++) { | |
| 107 | + const y = ((hashNoise(i, 21) * h + t * (60 + i * 12) * (1 + p * 3)) % (h + 80)) - 40; | |
| 108 | + const x = hashNoise(i, 22) * w; | |
| 109 | + ctx.fillStyle = `rgba(255,255,255,${cloudAlpha * (0.5 + hashNoise(i, 23) * 0.5)})`; | |
| 110 | + for (let k = 0; k < 4; k++) { | |
| 111 | + ctx.beginPath(); | |
| 112 | + ctx.arc(x + k * 26 - 40, y + (k % 2) * 8, 18 + hashNoise(i + k, 24) * 16, 0, TAU); | |
| 113 | + ctx.fill(); | |
| 114 | + } | |
| 115 | + } | |
| 116 | + // Storm lightning in the middle band. | |
| 117 | + if (p > 0.3 && p < 0.65 && hashNoise(Math.floor(t * 4), 30) > 0.93) { | |
| 118 | + ctx.fillStyle = "rgba(255,255,255,0.18)"; | |
| 119 | + ctx.fillRect(0, 0, w, h); | |
| 120 | + } | |
| 121 | + // Jet. | |
| 122 | + const cx = w * 0.5; | |
| 123 | + const cy = h * (0.62 - p * 0.25) + Math.sin(t * 3) * 3; | |
| 124 | + ctx.save(); | |
| 125 | + ctx.translate(cx, cy); | |
| 126 | + if (f.phase === "crashed") { | |
| 127 | + ctx.rotate(Math.min(1.2, f.phaseT * 2.5)); | |
| 128 | + ctx.translate(f.phaseT * 60, f.phaseT * 160); | |
| 129 | + ctx.globalAlpha = Math.max(0, 1 - f.phaseT * 0.8); | |
| 130 | + } | |
| 131 | + ctx.rotate(-0.25); | |
| 132 | + // Exhaust. | |
| 133 | + const trail = ctx.createLinearGradient(-30, 0, -140, 0); | |
| 134 | + trail.addColorStop(0, withAlpha(f.palette.secondary, 0.8)); | |
| 135 | + trail.addColorStop(1, withAlpha(f.palette.secondary, 0)); | |
| 136 | + ctx.fillStyle = trail; | |
| 137 | + ctx.fillRect(-140, -3, 110, 6); | |
| 138 | + // Fuselage. | |
| 139 | + ctx.fillStyle = "#e5e7eb"; | |
| 140 | + ctx.beginPath(); | |
| 141 | + ctx.moveTo(40, 0); | |
| 142 | + ctx.lineTo(-30, -9); | |
| 143 | + ctx.lineTo(-40, 0); | |
| 144 | + ctx.lineTo(-30, 9); | |
| 145 | + ctx.closePath(); | |
| 146 | + ctx.fill(); | |
| 147 | + ctx.fillStyle = "#94a3b8"; | |
| 148 | + ctx.beginPath(); | |
| 149 | + ctx.moveTo(-5, -4); | |
| 150 | + ctx.lineTo(-40, -30); | |
| 151 | + ctx.lineTo(-22, -2); | |
| 152 | + ctx.closePath(); | |
| 153 | + ctx.fill(); | |
| 154 | + ctx.beginPath(); | |
| 155 | + ctx.moveTo(-5, 4); | |
| 156 | + ctx.lineTo(-40, 30); | |
| 157 | + ctx.lineTo(-22, 2); | |
| 158 | + ctx.closePath(); | |
| 159 | + ctx.fill(); | |
| 160 | + ctx.fillStyle = f.palette.primary; | |
| 161 | + ctx.fillRect(10, -3, 14, 6); | |
| 162 | + ctx.restore(); | |
| 163 | + crashFlash(f, "#ffffff"); | |
| 164 | + cashGlow(f); | |
| 165 | +}; | |
| 166 | + | |
| 167 | +/* ---------------------------------------------------------------- ocean */ | |
| 168 | + | |
| 169 | +const ocean: Painter = (f) => { | |
| 170 | + const { ctx, w, h, p, t } = f; | |
| 171 | + const top = mix("#0e7490", "#020617", Math.min(1, p * 1.4)); | |
| 172 | + const bottom = mix("#064e6a", "#000000", Math.min(1, p * 1.2)); | |
| 173 | + gradient(ctx, w, h, [[0, top], [1, bottom]]); | |
| 174 | + // Light rays near surface. | |
| 175 | + if (p < 0.5) { | |
| 176 | + ctx.fillStyle = `rgba(255,255,255,${0.06 * (1 - p * 2)})`; | |
| 177 | + for (let i = 0; i < 5; i++) { | |
| 178 | + const x = w * (0.1 + i * 0.2) + Math.sin(t * 0.6 + i) * 20; | |
| 179 | + ctx.beginPath(); | |
| 180 | + ctx.moveTo(x - 20, 0); | |
| 181 | + ctx.lineTo(x + 20, 0); | |
| 182 | + ctx.lineTo(x + 90, h); | |
| 183 | + ctx.lineTo(x - 90, h); | |
| 184 | + ctx.fill(); | |
| 185 | + } | |
| 186 | + } | |
| 187 | + // Rising bubbles. | |
| 188 | + ctx.strokeStyle = "rgba(255,255,255,0.35)"; | |
| 189 | + for (let i = 0; i < 24; i++) { | |
| 190 | + const y = h - ((hashNoise(i, 40) * h + t * (40 + i * 6) * (1 + p * 2)) % (h + 20)); | |
| 191 | + const x = hashNoise(i, 41) * w + Math.sin(t * 2 + i) * 6; | |
| 192 | + ctx.beginPath(); | |
| 193 | + ctx.arc(x, y, 1.5 + hashNoise(i, 42) * 3, 0, TAU); | |
| 194 | + ctx.stroke(); | |
| 195 | + } | |
| 196 | + // Depth marks. | |
| 197 | + ctx.fillStyle = "rgba(255,255,255,0.15)"; | |
| 198 | + ctx.font = "600 11px Geist, system-ui"; | |
| 199 | + for (let i = 0; i < 6; i++) { | |
| 200 | + const y = ((i * h) / 5 - ((t * 30 * (1 + p * 3)) % (h / 5)) + h) % h; | |
| 201 | + ctx.fillRect(0, y, w, 1); | |
| 202 | + } | |
| 203 | + // Leviathan silhouette during event. | |
| 204 | + if (f.event) { | |
| 205 | + ctx.fillStyle = "rgba(0,0,0,0.55)"; | |
| 206 | + ctx.beginPath(); | |
| 207 | + const ex = ((t * 120) % (w + 400)) - 200; | |
| 208 | + ctx.ellipse(ex, h * 0.3, 170, 45, 0.1, 0, TAU); | |
| 209 | + ctx.fill(); | |
| 210 | + ctx.fillStyle = withAlpha(f.palette.secondary, 0.8); | |
| 211 | + ctx.beginPath(); | |
| 212 | + ctx.arc(ex + 120, h * 0.28, 5, 0, TAU); | |
| 213 | + ctx.fill(); | |
| 214 | + } | |
| 215 | + // Submarine. | |
| 216 | + const cx = w * 0.5 + Math.sin(t * 1.2) * 6; | |
| 217 | + const cy = h * (0.35 + p * 0.3); | |
| 218 | + ctx.save(); | |
| 219 | + ctx.translate(cx, cy); | |
| 220 | + if (f.phase === "crashed") { | |
| 221 | + ctx.scale(1 + f.phaseT * 0.6, 1 - Math.min(0.9, f.phaseT * 0.9)); | |
| 222 | + ctx.globalAlpha = Math.max(0, 1 - f.phaseT); | |
| 223 | + } | |
| 224 | + ctx.fillStyle = "#fbbf24"; | |
| 225 | + ctx.beginPath(); | |
| 226 | + ctx.ellipse(0, 0, 54, 18, 0, 0, TAU); | |
| 227 | + ctx.fill(); | |
| 228 | + ctx.fillStyle = "#d97706"; | |
| 229 | + ctx.fillRect(-12, -32, 22, 16); | |
| 230 | + ctx.fillRect(-2, -42, 3, 12); | |
| 231 | + ctx.fillStyle = "#0ea5e9"; | |
| 232 | + for (const dx of [-24, -6, 12]) { | |
| 233 | + ctx.beginPath(); | |
| 234 | + ctx.arc(dx, -2, 5, 0, TAU); | |
| 235 | + ctx.fill(); | |
| 236 | + } | |
| 237 | + // Propeller wash. | |
| 238 | + ctx.fillStyle = "rgba(255,255,255,0.25)"; | |
| 239 | + ctx.fillRect(-70 - (t * 200) % 20, -2, 14, 4); | |
| 240 | + ctx.restore(); | |
| 241 | + crashFlash(f, "#38bdf8"); | |
| 242 | + cashGlow(f); | |
| 243 | +}; | |
| 244 | + | |
| 245 | +/* --------------------------------------------------------------- rocket */ | |
| 246 | + | |
| 247 | +const rocket: Painter = (f) => { | |
| 248 | + const { ctx, w, h, p, t } = f; | |
| 249 | + gradient(ctx, w, h, [[0, mix("#1e1b4b", "#000000", Math.min(1, p * 1.3))], [1, mix("#7c2d12", "#0a0612", Math.min(1, p * 1.6))]]); | |
| 250 | + stars(f, 120, 0.3 + p * 0.6, 8 + p * 40); | |
| 251 | + // Ground at the start. | |
| 252 | + if (p < 0.25) { | |
| 253 | + ctx.fillStyle = `rgba(60,30,20,${1 - p * 4})`; | |
| 254 | + ctx.fillRect(0, h * 0.85 + p * h, w, h); | |
| 255 | + } | |
| 256 | + // Mars grows near the end. | |
| 257 | + if (p > 0.55) { | |
| 258 | + const r = (p - 0.55) * 400; | |
| 259 | + ctx.fillStyle = "#c2410c"; | |
| 260 | + ctx.beginPath(); | |
| 261 | + ctx.arc(w * 0.8, h * 0.2, r, 0, TAU); | |
| 262 | + ctx.fill(); | |
| 263 | + ctx.fillStyle = "rgba(0,0,0,0.25)"; | |
| 264 | + ctx.beginPath(); | |
| 265 | + ctx.arc(w * 0.8 + r * 0.3, h * 0.2 - r * 0.2, r * 0.3, 0, TAU); | |
| 266 | + ctx.fill(); | |
| 267 | + } | |
| 268 | + const cx = w * 0.5; | |
| 269 | + const cy = h * 0.6 - Math.sin(t * 5) * 2; | |
| 270 | + ctx.save(); | |
| 271 | + ctx.translate(cx, cy); | |
| 272 | + if (f.phase === "crashed") { | |
| 273 | + shake(f, 14); | |
| 274 | + ctx.globalAlpha = Math.max(0, 1 - f.phaseT * 0.9); | |
| 275 | + ctx.rotate(f.phaseT * 3); | |
| 276 | + } | |
| 277 | + // Flame. | |
| 278 | + const flame = ctx.createLinearGradient(0, 40, 0, 40 + 80 + p * 60); | |
| 279 | + flame.addColorStop(0, "#fff7ed"); | |
| 280 | + flame.addColorStop(0.3, f.palette.primary); | |
| 281 | + flame.addColorStop(1, "rgba(251,146,60,0)"); | |
| 282 | + ctx.fillStyle = flame; | |
| 283 | + ctx.beginPath(); | |
| 284 | + ctx.moveTo(-14, 40); | |
| 285 | + ctx.lineTo(14, 40); | |
| 286 | + ctx.lineTo(Math.sin(t * 30) * 6, 40 + 80 + p * 60 + Math.sin(t * 25) * 10); | |
| 287 | + ctx.closePath(); | |
| 288 | + ctx.fill(); | |
| 289 | + // Body. | |
| 290 | + ctx.fillStyle = "#f1f5f9"; | |
| 291 | + ctx.beginPath(); | |
| 292 | + ctx.moveTo(0, -60); | |
| 293 | + ctx.bezierCurveTo(30, -20, 26, 20, 20, 40); | |
| 294 | + ctx.lineTo(-20, 40); | |
| 295 | + ctx.bezierCurveTo(-26, 20, -30, -20, 0, -60); | |
| 296 | + ctx.fill(); | |
| 297 | + ctx.fillStyle = f.palette.secondary; | |
| 298 | + ctx.beginPath(); | |
| 299 | + ctx.moveTo(-20, 15); | |
| 300 | + ctx.lineTo(-40, 50); | |
| 301 | + ctx.lineTo(-20, 40); | |
| 302 | + ctx.closePath(); | |
| 303 | + ctx.fill(); | |
| 304 | + ctx.beginPath(); | |
| 305 | + ctx.moveTo(20, 15); | |
| 306 | + ctx.lineTo(40, 50); | |
| 307 | + ctx.lineTo(20, 40); | |
| 308 | + ctx.closePath(); | |
| 309 | + ctx.fill(); | |
| 310 | + ctx.fillStyle = "#0ea5e9"; | |
| 311 | + ctx.beginPath(); | |
| 312 | + ctx.arc(0, -15, 8, 0, TAU); | |
| 313 | + ctx.fill(); | |
| 314 | + ctx.restore(); | |
| 315 | + crashFlash(f, "#fb923c"); | |
| 316 | + cashGlow(f); | |
| 317 | +}; | |
| 318 | + | |
| 319 | +/* ----------------------------------------------------------------- bank */ | |
| 320 | + | |
| 321 | +const bank: Painter = (f) => { | |
| 322 | + const { ctx, w, h, p, t } = f; | |
| 323 | + gradient(ctx, w, h, [[0, "#0b0a08"], [1, "#1b1811"]]); | |
| 324 | + // Vault wall with deposit boxes. | |
| 325 | + const cols = Math.ceil(w / 60); | |
| 326 | + const rows = Math.ceil(h / 40); | |
| 327 | + for (let i = 0; i < cols; i++) | |
| 328 | + for (let j = 0; j < rows; j++) { | |
| 329 | + ctx.fillStyle = `rgba(201,169,97,${0.04 + hashNoise(i * 31 + j, 50) * 0.04})`; | |
| 330 | + ctx.fillRect(i * 60 + 3, j * 40 + 3, 54, 34); | |
| 331 | + } | |
| 332 | + // Police light sweep intensifies with progress. | |
| 333 | + const siren = Math.max(0, p - 0.25) * 1.3; | |
| 334 | + if (siren > 0) { | |
| 335 | + const flash = Math.sin(t * 8) > 0; | |
| 336 | + ctx.fillStyle = flash ? `rgba(239,68,68,${siren * 0.18})` : `rgba(59,130,246,${siren * 0.18})`; | |
| 337 | + ctx.fillRect(0, 0, w, h); | |
| 338 | + } | |
| 339 | + // Growing bag. | |
| 340 | + const size = 50 + p * 120; | |
| 341 | + const cx = w * 0.5; | |
| 342 | + const cy = h * 0.62; | |
| 343 | + ctx.save(); | |
| 344 | + ctx.translate(cx, cy); | |
| 345 | + if (f.phase === "crashed") { | |
| 346 | + ctx.globalAlpha = Math.max(0, 1 - f.phaseT * 0.9); | |
| 347 | + ctx.translate(0, f.phaseT * 80); | |
| 348 | + } | |
| 349 | + const wobble = 1 + Math.sin(t * 6) * 0.02; | |
| 350 | + ctx.scale(wobble, 1 / wobble); | |
| 351 | + ctx.fillStyle = "#b08d57"; | |
| 352 | + ctx.beginPath(); | |
| 353 | + ctx.moveTo(-size * 0.7, size * 0.6); | |
| 354 | + ctx.bezierCurveTo(-size * 1.1, -size * 0.4, -size * 0.3, -size * 0.7, 0, -size * 0.65); | |
| 355 | + ctx.bezierCurveTo(size * 0.3, -size * 0.7, size * 1.1, -size * 0.4, size * 0.7, size * 0.6); | |
| 356 | + ctx.closePath(); | |
| 357 | + ctx.fill(); | |
| 358 | + ctx.fillStyle = "#8a6d2e"; | |
| 359 | + ctx.fillRect(-size * 0.3, -size * 0.95, size * 0.6, size * 0.3); | |
| 360 | + ctx.fillStyle = "#1a1408"; | |
| 361 | + ctx.font = `800 ${Math.round(size * 0.5)}px Geist, system-ui`; | |
| 362 | + ctx.textAlign = "center"; | |
| 363 | + ctx.textBaseline = "middle"; | |
| 364 | + ctx.fillText("SC", 0, size * 0.1); | |
| 365 | + ctx.restore(); | |
| 366 | + // Bills flying. | |
| 367 | + ctx.fillStyle = withAlpha(f.palette.primary, 0.6); | |
| 368 | + for (let i = 0; i < 12 * p + 2; i++) { | |
| 369 | + const x = (hashNoise(i, 60) * w + t * 40) % w; | |
| 370 | + const y = (hashNoise(i, 61) * h + t * 90) % h; | |
| 371 | + ctx.save(); | |
| 372 | + ctx.translate(x, y); | |
| 373 | + ctx.rotate(t * 2 + i); | |
| 374 | + ctx.fillRect(-9, -4, 18, 8); | |
| 375 | + ctx.restore(); | |
| 376 | + } | |
| 377 | + crashFlash(f, "#ef4444"); | |
| 378 | + cashGlow(f); | |
| 379 | +}; | |
| 380 | + | |
| 381 | +/* -------------------------------------------------------------- volcano */ | |
| 382 | + | |
| 383 | +const volcano: Painter = (f) => { | |
| 384 | + const { ctx, w, h, p, t } = f; | |
| 385 | + gradient(ctx, w, h, [[0, mix("#1c0a05", "#3b0a05", p)], [1, mix("#3b0a05", "#7f1d1d", p)]]); | |
| 386 | + // Cave walls. | |
| 387 | + ctx.fillStyle = "#0a0503"; | |
| 388 | + ctx.beginPath(); | |
| 389 | + ctx.moveTo(0, 0); | |
| 390 | + for (let y = 0; y <= h; y += 20) ctx.lineTo(w * 0.18 + Math.sin(y / 60 + t * 0.2) * 20, y); | |
| 391 | + ctx.lineTo(0, h); | |
| 392 | + ctx.fill(); | |
| 393 | + ctx.beginPath(); | |
| 394 | + ctx.moveTo(w, 0); | |
| 395 | + for (let y = 0; y <= h; y += 20) ctx.lineTo(w * 0.82 + Math.cos(y / 70 + t * 0.2) * 20, y); | |
| 396 | + ctx.lineTo(w, h); | |
| 397 | + ctx.fill(); | |
| 398 | + // Rising lava level (visual tension) — rises with progress + wobble. | |
| 399 | + const lavaY = h * (1 - Math.min(0.95, 0.15 + p * 0.75)) + Math.sin(t * 2) * 6; | |
| 400 | + const lava = ctx.createLinearGradient(0, lavaY, 0, h); | |
| 401 | + lava.addColorStop(0, "#fde68a"); | |
| 402 | + lava.addColorStop(0.15, f.palette.primary); | |
| 403 | + lava.addColorStop(1, "#7f1d1d"); | |
| 404 | + ctx.fillStyle = lava; | |
| 405 | + ctx.beginPath(); | |
| 406 | + ctx.moveTo(0, lavaY); | |
| 407 | + for (let x = 0; x <= w; x += 20) ctx.lineTo(x, lavaY + Math.sin(x / 40 + t * 3) * 6); | |
| 408 | + ctx.lineTo(w, h); | |
| 409 | + ctx.lineTo(0, h); | |
| 410 | + ctx.fill(); | |
| 411 | + // Crystals hanging. | |
| 412 | + for (let i = 0; i < 8; i++) { | |
| 413 | + const x = w * (0.25 + hashNoise(i, 70) * 0.5); | |
| 414 | + const y = ((hashNoise(i, 71) * h + t * 25 * (1 + p)) % (h * 0.9)); | |
| 415 | + ctx.fillStyle = withAlpha(f.palette.secondary, 0.85); | |
| 416 | + ctx.beginPath(); | |
| 417 | + ctx.moveTo(x, y - 14); | |
| 418 | + ctx.lineTo(x + 8, y); | |
| 419 | + ctx.lineTo(x, y + 14); | |
| 420 | + ctx.lineTo(x - 8, y); | |
| 421 | + ctx.closePath(); | |
| 422 | + ctx.fill(); | |
| 423 | + } | |
| 424 | + // Explorer. | |
| 425 | + const cx = w * 0.5; | |
| 426 | + const cy = h * 0.4 + Math.sin(t * 2) * 3; | |
| 427 | + ctx.save(); | |
| 428 | + ctx.translate(cx, cy); | |
| 429 | + if (f.phase === "crashed") { | |
| 430 | + shake(f, 18); | |
| 431 | + ctx.globalAlpha = Math.max(0, 1 - f.phaseT); | |
| 432 | + } | |
| 433 | + ctx.strokeStyle = "#e5e7eb"; | |
| 434 | + ctx.lineWidth = 3; | |
| 435 | + ctx.beginPath(); | |
| 436 | + ctx.moveTo(0, -300); | |
| 437 | + ctx.lineTo(0, -20); | |
| 438 | + ctx.stroke(); // rope | |
| 439 | + ctx.fillStyle = "#f59e0b"; | |
| 440 | + ctx.beginPath(); | |
| 441 | + ctx.arc(0, -14, 9, 0, TAU); | |
| 442 | + ctx.fill(); // helmet | |
| 443 | + ctx.fillStyle = "#e5e7eb"; | |
| 444 | + ctx.fillRect(-7, -6, 14, 22); | |
| 445 | + ctx.fillStyle = "#fde68a"; | |
| 446 | + ctx.beginPath(); | |
| 447 | + ctx.arc(0, -14, 3, 0, TAU); | |
| 448 | + ctx.fill(); // lamp | |
| 449 | + ctx.restore(); | |
| 450 | + if (p > 0.6) shake(f, (p - 0.6) * 10); | |
| 451 | + crashFlash(f, "#ff5c3d"); | |
| 452 | + cashGlow(f); | |
| 453 | +}; | |
| 454 | + | |
| 455 | +/* ------------------------------------------------------------ blackhole */ | |
| 456 | + | |
| 457 | +const blackhole: Painter = (f) => { | |
| 458 | + const { ctx, w, h, p, t } = f; | |
| 459 | + gradient(ctx, w, h, [[0, "#050308"], [1, "#0f0a1e"]]); | |
| 460 | + stars(f, 140, 0.7, 2 + p * 30); | |
| 461 | + const cx = w * 0.72; | |
| 462 | + const cy = h * 0.42; | |
| 463 | + const r = 40 + p * 120; | |
| 464 | + // Accretion disk. | |
| 465 | + for (let i = 0; i < 5; i++) { | |
| 466 | + ctx.strokeStyle = withAlpha(i % 2 ? f.palette.primary : f.palette.secondary, 0.35 - i * 0.05); | |
| 467 | + ctx.lineWidth = 6 - i; | |
| 468 | + ctx.beginPath(); | |
| 469 | + ctx.ellipse(cx, cy, r * (1.5 + i * 0.3), r * (0.45 + i * 0.1), 0.35 + t * 0.04 * (i + 1), 0, TAU); | |
| 470 | + ctx.stroke(); | |
| 471 | + } | |
| 472 | + // Lensing glow + hole. | |
| 473 | + const glow = ctx.createRadialGradient(cx, cy, r * 0.9, cx, cy, r * 2.2); | |
| 474 | + glow.addColorStop(0, withAlpha(f.palette.glow, 0.5)); | |
| 475 | + glow.addColorStop(1, "rgba(0,0,0,0)"); | |
| 476 | + ctx.fillStyle = glow; | |
| 477 | + ctx.fillRect(0, 0, w, h); | |
| 478 | + ctx.fillStyle = "#000"; | |
| 479 | + ctx.beginPath(); | |
| 480 | + ctx.arc(cx, cy, r, 0, TAU); | |
| 481 | + ctx.fill(); | |
| 482 | + // Ship spiralling in. | |
| 483 | + const dist = Math.max(r + 20, (w * 0.55) * (1 - p) + r); | |
| 484 | + const ang = -0.6 - t * (0.4 + p * 2); | |
| 485 | + const sx = cx + Math.cos(ang) * dist; | |
| 486 | + const sy = cy + Math.sin(ang) * dist * 0.5; | |
| 487 | + ctx.save(); | |
| 488 | + ctx.translate(sx, sy); | |
| 489 | + ctx.rotate(ang + Math.PI / 2); | |
| 490 | + if (f.phase === "crashed") { | |
| 491 | + ctx.scale(1 - Math.min(0.95, f.phaseT), 1 + f.phaseT * 3); | |
| 492 | + ctx.globalAlpha = Math.max(0, 1 - f.phaseT); | |
| 493 | + } | |
| 494 | + ctx.fillStyle = "#e2e8f0"; | |
| 495 | + ctx.beginPath(); | |
| 496 | + ctx.moveTo(0, -18); | |
| 497 | + ctx.lineTo(12, 14); | |
| 498 | + ctx.lineTo(0, 8); | |
| 499 | + ctx.lineTo(-12, 14); | |
| 500 | + ctx.closePath(); | |
| 501 | + ctx.fill(); | |
| 502 | + ctx.fillStyle = f.palette.secondary; | |
| 503 | + ctx.fillRect(-3, 12, 6, 12 + p * 20); | |
| 504 | + ctx.restore(); | |
| 505 | + // Chromatic distortion near horizon. | |
| 506 | + if (p > 0.5 && !f.reduceMotion) { | |
| 507 | + ctx.fillStyle = `rgba(167,139,250,${(p - 0.5) * 0.15})`; | |
| 508 | + ctx.fillRect(Math.sin(t * 10) * 4, 0, w, h); | |
| 509 | + } | |
| 510 | + crashFlash(f, "#a78bfa"); | |
| 511 | + cashGlow(f); | |
| 512 | +}; | |
| 513 | + | |
| 514 | +/* ------------------------------------------------------------- freefall */ | |
| 515 | + | |
| 516 | +const freefall: Painter = (f) => { | |
| 517 | + const { ctx, w, h, p, t } = f; | |
| 518 | + gradient(ctx, w, h, [[0, mix("#020617", "#1d4ed8", p)], [1, mix("#1e3a8a", "#7dd3fc", p)]]); | |
| 519 | + if (p < 0.4) stars(f, 60, 0.6 * (1 - p * 2.5), 0); | |
| 520 | + // Ground approaching: patchwork fields scale up. | |
| 521 | + const scale = 0.2 + p * p * 3; | |
| 522 | + ctx.save(); | |
| 523 | + ctx.translate(w / 2, h * 0.95); | |
| 524 | + ctx.scale(scale, scale * 0.35); | |
| 525 | + for (let i = -6; i < 6; i++) | |
| 526 | + for (let j = -6; j < 6; j++) { | |
| 527 | + ctx.fillStyle = `rgba(${60 + hashNoise(i * 13 + j, 80) * 60},${110 + hashNoise(i * 7 + j, 81) * 80},${50},${Math.min(1, 0.15 + p)})`; | |
| 528 | + ctx.fillRect(i * 60, j * 60 - 200, 56, 56); | |
| 529 | + } | |
| 530 | + ctx.restore(); | |
| 531 | + // Clouds rushing up. | |
| 532 | + ctx.fillStyle = "rgba(255,255,255,0.35)"; | |
| 533 | + for (let i = 0; i < 8; i++) { | |
| 534 | + const y = (h - ((hashNoise(i, 90) * h + t * (120 + p * 500)) % (h + 100))) + 50; | |
| 535 | + const x = hashNoise(i, 91) * w; | |
| 536 | + ctx.beginPath(); | |
| 537 | + ctx.ellipse(x, y, 60 + hashNoise(i, 92) * 60, 14, 0, 0, TAU); | |
| 538 | + ctx.fill(); | |
| 539 | + } | |
| 540 | + // Skydiver. | |
| 541 | + const cx = w * 0.5 + Math.sin(t * 2) * 8; | |
| 542 | + const cy = h * 0.42; | |
| 543 | + ctx.save(); | |
| 544 | + ctx.translate(cx, cy); | |
| 545 | + if (f.phase === "cashed") { | |
| 546 | + // Parachute opens. | |
| 547 | + const o = Math.min(1, f.phaseT * 3); | |
| 548 | + ctx.fillStyle = f.palette.secondary; | |
| 549 | + ctx.beginPath(); | |
| 550 | + ctx.arc(0, -70 * o, 60 * o, Math.PI, 0); | |
| 551 | + ctx.fill(); | |
| 552 | + ctx.strokeStyle = "rgba(255,255,255,0.7)"; | |
| 553 | + ctx.lineWidth = 1.5; | |
| 554 | + for (const dx of [-50, -20, 20, 50]) { | |
| 555 | + ctx.beginPath(); | |
| 556 | + ctx.moveTo(dx * o, -70 * o); | |
| 557 | + ctx.lineTo(0, -10); | |
| 558 | + ctx.stroke(); | |
| 559 | + } | |
| 560 | + } | |
| 561 | + if (f.phase === "crashed") ctx.globalAlpha = Math.max(0, 1 - f.phaseT); | |
| 562 | + ctx.rotate(Math.sin(t * 3) * 0.1); | |
| 563 | + ctx.fillStyle = "#f97316"; | |
| 564 | + ctx.fillRect(-8, -14, 16, 28); | |
| 565 | + ctx.fillStyle = "#fde68a"; | |
| 566 | + ctx.beginPath(); | |
| 567 | + ctx.arc(0, -22, 8, 0, TAU); | |
| 568 | + ctx.fill(); | |
| 569 | + ctx.strokeStyle = "#f97316"; | |
| 570 | + ctx.lineWidth = 5; | |
| 571 | + ctx.beginPath(); | |
| 572 | + ctx.moveTo(-8, -8); | |
| 573 | + ctx.lineTo(-28, -28); | |
| 574 | + ctx.moveTo(8, -8); | |
| 575 | + ctx.lineTo(28, -28); | |
| 576 | + ctx.moveTo(-6, 14); | |
| 577 | + ctx.lineTo(-20, 36); | |
| 578 | + ctx.moveTo(6, 14); | |
| 579 | + ctx.lineTo(20, 36); | |
| 580 | + ctx.stroke(); | |
| 581 | + ctx.restore(); | |
| 582 | + if (p > 0.7) shake(f, (p - 0.7) * 12); | |
| 583 | + crashFlash(f, "#ffffff"); | |
| 584 | + cashGlow(f); | |
| 585 | +}; | |
| 586 | + | |
| 587 | +/* --------------------------------------------------------------- reactor */ | |
| 588 | + | |
| 589 | +const reactor: Painter = (f) => { | |
| 590 | + const { ctx, w, h, p, t } = f; | |
| 591 | + gradient(ctx, w, h, [[0, "#070c06"], [1, mix("#111c0e", "#3f0f0f", p)]]); | |
| 592 | + // Panel grid. | |
| 593 | + ctx.strokeStyle = "rgba(163,230,53,0.08)"; | |
| 594 | + for (let x = 0; x < w; x += 40) { | |
| 595 | + ctx.beginPath(); | |
| 596 | + ctx.moveTo(x, 0); | |
| 597 | + ctx.lineTo(x, h); | |
| 598 | + ctx.stroke(); | |
| 599 | + } | |
| 600 | + // Core. | |
| 601 | + const cx = w * 0.5; | |
| 602 | + const cy = h * 0.45; | |
| 603 | + const heat = p; | |
| 604 | + const coreColor = mix("#a3e635", "#ef4444", heat); | |
| 605 | + const pulse = 1 + Math.sin(t * (2 + heat * 12)) * 0.05 * (1 + heat); | |
| 606 | + const glow = ctx.createRadialGradient(cx, cy, 10, cx, cy, 150 * pulse); | |
| 607 | + glow.addColorStop(0, withAlpha(coreColor.startsWith("#") ? coreColor : f.palette.primary, 0.9)); | |
| 608 | + glow.addColorStop(0.4, coreColor.replace("rgb(", "rgba(").replace(")", ",0.45)")); | |
| 609 | + glow.addColorStop(1, "rgba(0,0,0,0)"); | |
| 610 | + ctx.fillStyle = glow; | |
| 611 | + ctx.fillRect(0, 0, w, h); | |
| 612 | + // Hex containment. | |
| 613 | + ctx.strokeStyle = withAlpha(f.palette.primary, 0.6); | |
| 614 | + ctx.lineWidth = 3; | |
| 615 | + ctx.beginPath(); | |
| 616 | + for (let i = 0; i < 6; i++) { | |
| 617 | + const a = (i / 6) * TAU; | |
| 618 | + const x = cx + Math.cos(a) * 110; | |
| 619 | + const y = cy + Math.sin(a) * 110; | |
| 620 | + if (i === 0) ctx.moveTo(x, y); | |
| 621 | + else ctx.lineTo(x, y); | |
| 622 | + } | |
| 623 | + ctx.closePath(); | |
| 624 | + ctx.stroke(); | |
| 625 | + // Temperature gauge. | |
| 626 | + const gx = w * 0.5; | |
| 627 | + const gy = h * 0.86; | |
| 628 | + ctx.fillStyle = "rgba(255,255,255,0.08)"; | |
| 629 | + ctx.fillRect(gx - 150, gy - 8, 300, 16); | |
| 630 | + const tg = ctx.createLinearGradient(gx - 150, 0, gx + 150, 0); | |
| 631 | + tg.addColorStop(0, "#a3e635"); | |
| 632 | + tg.addColorStop(0.6, "#facc15"); | |
| 633 | + tg.addColorStop(1, "#ef4444"); | |
| 634 | + ctx.fillStyle = tg; | |
| 635 | + ctx.fillRect(gx - 150, gy - 8, 300 * Math.min(1, heat + 0.05), 16); | |
| 636 | + ctx.fillStyle = "#ffffff"; | |
| 637 | + ctx.fillRect(gx + 150 * 0.8, gy - 14, 2, 28); // limit marker | |
| 638 | + if (f.event) { | |
| 639 | + ctx.fillStyle = f.event.toLowerCase().includes("cool") ? "rgba(56,189,248,0.12)" : "rgba(239,68,68,0.12)"; | |
| 640 | + ctx.fillRect(0, 0, w, h); | |
| 641 | + } | |
| 642 | + if (heat > 0.6) shake(f, (heat - 0.6) * 12); | |
| 643 | + crashFlash(f, "#ef4444"); | |
| 644 | + cashGlow(f); | |
| 645 | +}; | |
| 646 | + | |
| 647 | +/* ----------------------------------------------------------------- storm */ | |
| 648 | + | |
| 649 | +const storm: Painter = (f) => { | |
| 650 | + const { ctx, w, h, p, t } = f; | |
| 651 | + gradient(ctx, w, h, [[0, mix("#1e293b", "#0a0d12", p)], [1, mix("#334155", "#1a212c", p)]]); | |
| 652 | + // Road. | |
| 653 | + ctx.fillStyle = "#0b0e14"; | |
| 654 | + ctx.beginPath(); | |
| 655 | + ctx.moveTo(w * 0.35, h); | |
| 656 | + ctx.lineTo(w * 0.48, h * 0.55); | |
| 657 | + ctx.lineTo(w * 0.52, h * 0.55); | |
| 658 | + ctx.lineTo(w * 0.65, h); | |
| 659 | + ctx.fill(); | |
| 660 | + ctx.strokeStyle = "rgba(255,255,255,0.5)"; | |
| 661 | + ctx.setLineDash([12, 18]); | |
| 662 | + ctx.lineDashOffset = -t * 300; | |
| 663 | + ctx.lineWidth = 3; | |
| 664 | + ctx.beginPath(); | |
| 665 | + ctx.moveTo(w * 0.5, h); | |
| 666 | + ctx.lineTo(w * 0.5, h * 0.55); | |
| 667 | + ctx.stroke(); | |
| 668 | + ctx.setLineDash([]); | |
| 669 | + // Tornado: grows as we approach. | |
| 670 | + const size = 60 + p * 260; | |
| 671 | + const tx = w * 0.5 + Math.sin(t * 0.7) * 30 * (1 - p); | |
| 672 | + const ty = h * 0.55; | |
| 673 | + for (let i = 0; i < 9; i++) { | |
| 674 | + const yy = ty - i * (size * 0.12); | |
| 675 | + const rw = size * (0.15 + i * 0.1); | |
| 676 | + ctx.fillStyle = `rgba(${120 + i * 8},${130 + i * 8},${150 + i * 6},${0.35 + i * 0.05})`; | |
| 677 | + ctx.beginPath(); | |
| 678 | + ctx.ellipse(tx + Math.sin(t * 6 + i) * 6, yy, rw, size * 0.07, 0, 0, TAU); | |
| 679 | + ctx.fill(); | |
| 680 | + } | |
| 681 | + // Debris. | |
| 682 | + ctx.fillStyle = "rgba(200,200,200,0.5)"; | |
| 683 | + for (let i = 0; i < 20 * p + 4; i++) { | |
| 684 | + const a = t * (3 + hashNoise(i, 100) * 3) + i; | |
| 685 | + const rr = size * (0.3 + hashNoise(i, 101) * 0.6); | |
| 686 | + ctx.fillRect(tx + Math.cos(a) * rr, ty - size * 0.5 + Math.sin(a) * rr * 0.4, 5, 3); | |
| 687 | + } | |
| 688 | + // Lightning. | |
| 689 | + if (hashNoise(Math.floor(t * 6), 110) > 0.9 - p * 0.3) { | |
| 690 | + ctx.fillStyle = "rgba(255,255,255,0.14)"; | |
| 691 | + ctx.fillRect(0, 0, w, h); | |
| 692 | + } | |
| 693 | + // Truck. | |
| 694 | + ctx.save(); | |
| 695 | + ctx.translate(w * 0.5, h * 0.88); | |
| 696 | + if (f.phase === "crashed") { | |
| 697 | + ctx.rotate(f.phaseT * 4); | |
| 698 | + ctx.translate(0, -f.phaseT * 200); | |
| 699 | + ctx.globalAlpha = Math.max(0, 1 - f.phaseT); | |
| 700 | + } | |
| 701 | + shake(f, p * 4); | |
| 702 | + ctx.fillStyle = f.palette.secondary; | |
| 703 | + ctx.fillRect(-26, -30, 52, 30); | |
| 704 | + ctx.fillStyle = "#0f172a"; | |
| 705 | + ctx.fillRect(-20, -26, 40, 12); | |
| 706 | + ctx.fillStyle = "#111"; | |
| 707 | + ctx.beginPath(); | |
| 708 | + ctx.arc(-18, 2, 8, 0, TAU); | |
| 709 | + ctx.arc(18, 2, 8, 0, TAU); | |
| 710 | + ctx.fill(); | |
| 711 | + ctx.fillStyle = "#fde68a"; | |
| 712 | + ctx.fillRect(-26, -12, 6, 5); | |
| 713 | + ctx.fillRect(20, -12, 6, 5); | |
| 714 | + ctx.restore(); | |
| 715 | + crashFlash(f, "#cbd5e1"); | |
| 716 | + cashGlow(f); | |
| 717 | +}; | |
| 718 | + | |
| 719 | +/* -------------------------------------------------------------- elevator */ | |
| 720 | + | |
| 721 | +const elevator: Painter = (f) => { | |
| 722 | + const { ctx, w, h, t, floor } = f; | |
| 723 | + gradient(ctx, w, h, [[0, "#08090d"], [1, "#151821"]]); | |
| 724 | + // Shaft with floor lines scrolling. | |
| 725 | + const speed = 60 + Math.min(600, floor * 3); | |
| 726 | + ctx.strokeStyle = "rgba(232,207,143,0.25)"; | |
| 727 | + ctx.fillStyle = "rgba(232,207,143,0.6)"; | |
| 728 | + ctx.font = "700 12px Geist, system-ui"; | |
| 729 | + ctx.textAlign = "right"; | |
| 730 | + const spacing = 110; | |
| 731 | + const offset = (t * speed) % spacing; | |
| 732 | + for (let i = -1; i < h / spacing + 2; i++) { | |
| 733 | + const y = i * spacing + offset; | |
| 734 | + const fl = floor + Math.round((h / 2 - y) / spacing); | |
| 735 | + ctx.beginPath(); | |
| 736 | + ctx.moveTo(w * 0.2, y); | |
| 737 | + ctx.lineTo(w * 0.8, y); | |
| 738 | + ctx.stroke(); | |
| 739 | + if (fl >= 0) ctx.fillText(String(fl), w * 0.18, y - 6); | |
| 740 | + if ([25, 50, 100, 250].includes(fl)) { | |
| 741 | + ctx.fillStyle = "rgba(56,189,248,0.25)"; | |
| 742 | + ctx.fillRect(w * 0.2, y - spacing, w * 0.6, spacing); | |
| 743 | + ctx.fillStyle = "rgba(232,207,143,0.6)"; | |
| 744 | + } | |
| 745 | + } | |
| 746 | + // Rails. | |
| 747 | + ctx.fillStyle = "rgba(255,255,255,0.08)"; | |
| 748 | + ctx.fillRect(w * 0.2, 0, 4, h); | |
| 749 | + ctx.fillRect(w * 0.8 - 4, 0, 4, h); | |
| 750 | + // Cabin. | |
| 751 | + ctx.save(); | |
| 752 | + ctx.translate(w * 0.5, h * 0.5); | |
| 753 | + if (f.phase === "crashed") { | |
| 754 | + ctx.translate(0, f.phaseT * f.phaseT * 900); | |
| 755 | + ctx.rotate(Math.sin(f.phaseT * 20) * 0.05); | |
| 756 | + } | |
| 757 | + ctx.fillStyle = "#1f2330"; | |
| 758 | + ctx.fillRect(-70, -80, 140, 160); | |
| 759 | + ctx.strokeStyle = f.palette.primary; | |
| 760 | + ctx.lineWidth = 2; | |
| 761 | + ctx.strokeRect(-70, -80, 140, 160); | |
| 762 | + ctx.fillStyle = "rgba(232,207,143,0.2)"; | |
| 763 | + ctx.fillRect(-60, -70, 120, 30); | |
| 764 | + ctx.fillStyle = f.palette.primary; | |
| 765 | + ctx.font = "800 26px Geist, system-ui"; | |
| 766 | + ctx.textAlign = "center"; | |
| 767 | + ctx.textBaseline = "middle"; | |
| 768 | + ctx.fillText(String(floor), 0, -55); | |
| 769 | + // Doors (open on cash-out). | |
| 770 | + const open = f.phase === "cashed" ? Math.min(1, f.phaseT * 2) * 50 : 0; | |
| 771 | + ctx.fillStyle = "#2b3040"; | |
| 772 | + ctx.fillRect(-60 - open, -30, 58, 100); | |
| 773 | + ctx.fillRect(2 + open, -30, 58, 100); | |
| 774 | + if (open > 0) { | |
| 775 | + ctx.fillStyle = "rgba(232,207,143,0.6)"; | |
| 776 | + ctx.fillRect(-2, -30, 4, 100); | |
| 777 | + } | |
| 778 | + ctx.restore(); | |
| 779 | + crashFlash(f, "#ef4444"); | |
| 780 | + cashGlow(f); | |
| 781 | +}; | |
| 782 | + | |
| 783 | +export const SCENES: Record<CrashScene, Painter> = { sky, ocean, rocket, bank, volcano, blackhole, freefall, reactor, storm, elevator }; | |
| 784 | + | |
| 785 | +/** Log-scale progress helper: 1.00× → 0, 1000× → 1. */ | |
| 786 | +export function progressFor(multiplier: number): number { | |
| 787 | + return Math.max(0, Math.min(1, Math.log10(Math.max(1, multiplier)) / 3)); | |
| 788 | +} | |
modified
apps/web/src/components/lobby/games-browser.tsx
+8 −0
@@ -16,6 +16,8 @@ const FILTERS = [ | ||
| 16 | 16 | { key: "high", label: "High volatility" }, |
| 17 | 17 | { key: "relaxed", label: "Relaxed" }, |
| 18 | 18 | { key: "jackpot", label: "Jackpot" }, |
| 19 | + { key: "risk", label: "Risk · Cash out" }, | |
| 20 | + { key: "originals", label: "Beyond Slots" }, | |
| 19 | 21 | { key: "favourites", label: "Favourites" }, |
| 20 | 22 | ] as const; |
| 21 | 23 | type FilterKey = (typeof FILTERS)[number]["key"]; |
@@ -53,6 +55,10 @@ export function GamesBrowser({ games }: { games: GameCardData[] }) { | ||
| 53 | 55 | return g.volatility === "low" || g.volatility === "medium"; |
| 54 | 56 | case "jackpot": |
| 55 | 57 | return g.isJackpot; |
| 58 | + case "risk": | |
| 59 | + return g.kind === "crash"; | |
| 60 | + case "originals": | |
| 61 | + return g.kind === "arcade"; | |
| 56 | 62 | case "favourites": |
| 57 | 63 | return favs[g.slug]; |
| 58 | 64 | default: |
@@ -70,6 +76,8 @@ export function GamesBrowser({ games }: { games: GameCardData[] }) { | ||
| 70 | 76 | high: games.filter((g) => g.volatility === "high" || g.volatility === "extreme").length, |
| 71 | 77 | relaxed: games.filter((g) => g.volatility === "low" || g.volatility === "medium").length, |
| 72 | 78 | jackpot: games.filter((g) => g.isJackpot).length, |
| 79 | + risk: games.filter((g) => g.kind === "crash").length, | |
| 80 | + originals: games.filter((g) => g.kind === "arcade").length, | |
| 73 | 81 | favourites: Object.values(favs).filter(Boolean).length, |
| 74 | 82 | }), |
| 75 | 83 | [games, favs], |
modified
apps/web/src/components/lobby/lobby.tsx
+8 −3
@@ -21,14 +21,17 @@ const FLAGSHIP_SLUG = "spinza-original"; | ||
| 21 | 21 | |
| 22 | 22 | export function Lobby({ user, games, livePlayers, recommendations }: LobbyProps) { |
| 23 | 23 | const list = games ?? []; |
| 24 | + const slots = list.filter((g) => (g.kind ?? "slot") === "slot"); | |
| 25 | + const risk = list.filter((g) => g.kind === "crash"); | |
| 26 | + const originals = list.filter((g) => g.kind === "arcade"); | |
| 24 | 27 | const flagship = list.find((g) => g.slug === FLAGSHIP_SLUG) ?? list.find((g) => g.isFeatured) ?? list[0] ?? null; |
| 25 | 28 | const continuePlaying = list.filter((g) => g.lastPlayedAt).sort((a, b) => new Date(b.lastPlayedAt!).getTime() - new Date(a.lastPlayedAt!).getTime()); |
| 26 | 29 | const featured = list.filter((g) => g.isFeatured); |
| 27 | 30 | const fresh = list.filter((g) => g.isNew); |
| 28 | 31 | const popular = [...list].sort((a, b) => b.popularity - a.popularity).slice(0, 10); |
| 29 | − const highVol = list.filter((g) => g.volatility === "high" || g.volatility === "extreme"); | |
| 30 | − const relaxed = list.filter((g) => g.volatility === "low" || g.volatility === "medium"); | |
| 31 | − const jackpots = list.filter((g) => g.isJackpot); | |
| 32 | + const highVol = slots.filter((g) => g.volatility === "high" || g.volatility === "extreme"); | |
| 33 | + const relaxed = slots.filter((g) => g.volatility === "low" || g.volatility === "medium"); | |
| 34 | + const jackpots = slots.filter((g) => g.isJackpot); | |
| 32 | 35 | const picks = recommendations.map((s) => list.find((g) => g.slug === s)).filter((g): g is GameCardData => !!g); |
| 33 | 36 | const welcomePicks = picks.length ? picks : list.slice(0, 3); |
| 34 | 37 | |
@@ -103,6 +106,8 @@ export function Lobby({ user, games, livePlayers, recommendations }: LobbyProps) | ||
| 103 | 106 | </section> |
| 104 | 107 | ) : null} |
| 105 | 108 | |
| 109 | + <GameRow id="risk" title="Risk Games — Cash Out" eyebrow="One decision: take it now, or push?" games={risk} href="/games?filter=risk" /> | |
| 110 | + <GameRow id="originals" title="Spinza Originals — Beyond Slots" eyebrow="Interactive originals, no reels" games={originals} href="/games?filter=originals" /> | |
| 106 | 111 | <GameRow id="featured" title="Featured games" eyebrow="Curated tonight" games={featured} href="/games?filter=featured" /> |
| 107 | 112 | <GameRow id="new" title="New releases" eyebrow="Fresh from the studio" games={fresh} href="/games?filter=new" /> |
| 108 | 113 | <GameRow id="popular" title="Popular" eyebrow="Most played this week" games={popular} href="/games" /> |
modified
packages/shared/src/types.ts
+2 −0
@@ -50,6 +50,8 @@ export interface GameCard { | ||
| 50 | 50 | /** Renderer presentation hints (frame, backdrop, particles, ambience). */ |
| 51 | 51 | presentation?: { frame: string; backdrop: string; particles: string; ambience: string }; |
| 52 | 52 | lifecycle: GameLifecycle; |
| 53 | + kind: "slot" | "crash" | "arcade"; | |
| 54 | + category: "slots" | "risk" | "originals"; | |
| 53 | 55 | isNew: boolean; |
| 54 | 56 | isFeatured: boolean; |
| 55 | 57 | isJackpot: boolean; |
| 56 | 58 | |