import type { FastifyInstance } from "fastify"; import { achievements, and, dailyRewards, db, desc, eq, gameRounds, games, inArray, leaderboards, missions, sql, userAchievements, userGameStats, userMissions, users, wallets, userSettings } from "@spinza/database"; import { DAILY_STREAK_GRACE_DAYS, type AchievementView, type DailyRewardStatus, type LeaderboardView, type MissionView, type RescueStatus } from "@spinza/shared"; import { requireUser } from "../plugins/auth"; import { errors } from "../lib/errors"; import { dailySchedule, flag, rescueConfig } from "../lib/settings"; import { applyCredit, lockWallet, saveWallet } from "../services/wallet"; import { awardXp, checkAchievements, dayKey, periodEnd, weekKey, type UserCounters } from "../services/progression"; import { z } from "zod"; const DAY = 86_400_000; function nextStreakDay(current: number, lastClaimedAt: Date | null, now: Date, scheduleLen: number): number { if (!lastClaimedAt) return 1; const gapDays = Math.floor((startOfDay(now).getTime() - startOfDay(lastClaimedAt).getTime()) / DAY); if (gapDays <= 1) return (current % scheduleLen) + 1; // consecutive day (after a full week, loop back to day 1) if (gapDays <= 1 + DAILY_STREAK_GRACE_DAYS) return Math.max(1, current - 1); // soft reset: lose one step return 1; } function startOfDay(d: Date): Date { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); } async function counters(userId: string): Promise { const u = await db.query.users.findFirst({ where: eq(users.id, userId) }); const agg = await db.execute(sql` select (select coalesce(sum(bonuses),0) from user_game_stats where user_id = ${userId})::bigint as bonuses, count(*) filter (where multiplier >= 20)::bigint as big_wins, count(*) filter (where win > 0)::bigint as wins, count(*) filter (where jackpot_tier is not null)::bigint as jackpots from game_rounds where user_id = ${userId}`); const a = agg.rows[0] as { bonuses: number; big_wins: number; wins: number; jackpots: number }; const streak = await db.query.dailyRewards.findFirst({ where: eq(dailyRewards.userId, userId) }); return { totalSpins: u!.totalSpins, gamesPlayed: u!.gamesPlayed, biggestWin: u!.biggestWin, biggestMultiplier: Number(u!.biggestMultiplier), level: u!.level, xp: u!.xp, bonuses: Number(a.bonuses), bigWins: Number(a.big_wins), wins: Number(a.wins), jackpots: Number(a.jackpots), dailyStreak: streak?.streakDay ?? 0, }; } export async function rewardRoutes(app: FastifyInstance) { /* ------------------------------------------------------------ daily */ app.get("/api/rewards/daily", async (req) => { const user = requireUser(req); const schedule = dailySchedule(); const row = (await db.query.dailyRewards.findFirst({ where: eq(dailyRewards.userId, user.id) })) ?? { streakDay: 0, lastClaimedAt: null, nextAvailableAt: null }; const now = new Date(); const available = flag("dailyRewards.enabled") && (!row.nextAvailableAt || row.nextAvailableAt <= now); const day = available ? nextStreakDay(row.streakDay, row.lastClaimedAt, now, schedule.length) : Math.min(row.streakDay, schedule.length); const status: DailyRewardStatus = { available, streakDay: row.streakDay, nextAmount: schedule[(available ? day : Math.min(row.streakDay % schedule.length + 1, schedule.length)) - 1] ?? schedule[0], nextAvailableAt: row.nextAvailableAt?.toISOString() ?? null, schedule, claimedToday: !!row.lastClaimedAt && startOfDay(row.lastClaimedAt).getTime() === startOfDay(now).getTime(), }; return status; }); app.post("/api/rewards/daily/claim", async (req) => { const user = requireUser(req); if (!flag("dailyRewards.enabled")) throw errors.forbidden("Daily rewards are paused."); const schedule = dailySchedule(); return db.transaction(async (tx) => { const lock = await tx.execute(sql`select streak_day, last_claimed_at, next_available_at from daily_rewards where user_id = ${user.id} for update`); const row = lock.rows[0] as { streak_day: number; last_claimed_at: Date | null; next_available_at: Date | null } | undefined; const now = new Date(); if (row?.next_available_at && new Date(row.next_available_at) > now) throw errors.conflict("ALREADY_CLAIMED", "Come back tomorrow for your next reward."); const day = nextStreakDay(row?.streak_day ?? 0, row?.last_claimed_at ? new Date(row.last_claimed_at) : null, now, schedule.length); const amount = schedule[day - 1]; const nextAvailable = new Date(startOfDay(now).getTime() + DAY); await tx .insert(dailyRewards) .values({ userId: user.id, streakDay: day, lastClaimedAt: now, nextAvailableAt: nextAvailable, totalClaimed: amount, claims: 1 }) .onConflictDoUpdate({ target: dailyRewards.userId, set: { streakDay: day, lastClaimedAt: now, nextAvailableAt: nextAvailable, totalClaimed: sql`${dailyRewards.totalClaimed} + ${amount}`, claims: sql`${dailyRewards.claims} + 1` } }); const w = await lockWallet(tx, user.id); await applyCredit(tx, w, "DAILY_REWARD", amount, `daily:${dayKey(now)}`, { streakDay: day }); const c = await counters(user.id); c.dailyStreak = day; const ach = await checkAchievements(tx, w, c); const u = await tx.query.users.findFirst({ where: eq(users.id, user.id), columns: { xp: true, level: true } }); const lvl = await awardXp(tx, w, u!, 25 + ach.xp, `daily:${dayKey(now)}`); await saveWallet(tx, w); return { amount, streakDay: day, balance: w.balance, nextAvailableAt: nextAvailable.toISOString(), unlocked: ach.unlocked, xp: lvl }; }); }); /* ----------------------------------------------------------- rescue */ app.get("/api/rewards/rescue", async (req) => { const user = requireUser(req); const cfg = rescueConfig(); const [w, u] = await Promise.all([db.query.wallets.findFirst({ where: eq(wallets.userId, user.id) }), db.query.users.findFirst({ where: eq(users.id, user.id), columns: { lastRescueAt: true } })]); const nextAt = u?.lastRescueAt ? new Date(u.lastRescueAt.getTime() + cfg.cooldownHours * 3600_000) : null; const eligible = flag("rescue.enabled") && (w?.balance ?? 0) <= cfg.threshold && (!nextAt || nextAt <= new Date()); const status: RescueStatus = { eligible, amount: cfg.amount, balance: w?.balance ?? 0, nextAvailableAt: nextAt && nextAt > new Date() ? nextAt.toISOString() : null }; return status; }); app.post("/api/rewards/rescue/claim", async (req) => { const user = requireUser(req); const cfg = rescueConfig(); if (!flag("rescue.enabled")) throw errors.forbidden("Rescue credits are paused."); return db.transaction(async (tx) => { const w = await lockWallet(tx, user.id); const [u] = await tx.select({ lastRescueAt: users.lastRescueAt }).from(users).where(eq(users.id, user.id)).for("update"); if (w.balance > cfg.threshold) throw errors.conflict("NOT_ELIGIBLE", "Rescue credits are only available when your balance reaches zero."); if (u.lastRescueAt && u.lastRescueAt.getTime() + cfg.cooldownHours * 3600_000 > Date.now()) throw errors.conflict("COOLDOWN", "Rescue credits are recharging."); await applyCredit(tx, w, "RESCUE_CREDITS", cfg.amount, "rescue", { cooldownHours: cfg.cooldownHours }); await tx.update(users).set({ lastRescueAt: new Date() }).where(eq(users.id, user.id)); await saveWallet(tx, w); return { amount: cfg.amount, balance: w.balance, nextAvailableAt: new Date(Date.now() + cfg.cooldownHours * 3600_000).toISOString() }; }); }); /* ------------------------------------------------------ achievements */ app.get("/api/achievements", async (req) => { const user = requireUser(req); const [all, mine, c] = await Promise.all([db.select().from(achievements).where(eq(achievements.enabled, true)).orderBy(achievements.sortOrder), db.select().from(userAchievements).where(eq(userAchievements.userId, user.id)), counters(user.id)]); const unlocked = new Map(mine.map((m) => [m.achievementKey, m.unlockedAt])); const value = (metric: string) => ({ spins: c.totalSpins, bonuses: c.bonuses, games_played: c.gamesPlayed, big_wins: c.bigWins, multiplier: c.biggestMultiplier, wins: c.wins, level: c.level, daily_streak: c.dailyStreak, jackpots: c.jackpots })[metric] ?? 0; const views: AchievementView[] = all.map((a) => ({ key: a.key, name: a.name, description: a.description, rewardCredits: a.rewardCredits, rewardXp: a.rewardXp, icon: a.icon, unlockedAt: unlocked.get(a.key)?.toISOString() ?? null, progress: Math.min(value(a.metric), a.target), target: a.target, })); return { achievements: views, unlocked: mine.length, total: all.length }; }); /* ---------------------------------------------------------- missions */ app.get("/api/missions", async (req) => { const user = requireUser(req); const defs = await db.select().from(missions).where(eq(missions.enabled, true)).orderBy(missions.sortOrder); const now = new Date(); const keys = { daily: dayKey(now), weekly: weekKey(now) }; const rows = await db.select().from(userMissions).where(and(eq(userMissions.userId, user.id), inArray(userMissions.periodKey, [keys.daily, keys.weekly]))); const views: MissionView[] = defs.map((m) => { const period = m.period as "daily" | "weekly"; const r = rows.find((x) => x.missionKey === m.key && x.periodKey === keys[period]); return { key: m.key, name: m.name, description: m.description, period, target: m.target, progress: Math.min(r?.progress ?? 0, m.target), rewardCredits: m.rewardCredits, rewardXp: m.rewardXp, completedAt: r?.completedAt?.toISOString() ?? null, claimedAt: r?.claimedAt?.toISOString() ?? null, expiresAt: periodEnd(period, now).toISOString(), }; }); return { missions: views, enabled: flag("missions.enabled") }; }); /* ------------------------------------------------------ leaderboards */ app.get("/api/leaderboards", async (req) => { const q = z.object({ category: z.enum(["biggest_win_today", "biggest_win_week", "biggest_multiplier", "most_spins", "highest_level"]).default("biggest_win_today") }).parse(req.query); if (!flag("leaderboards.enabled")) return { category: q.category, label: "", entries: [], you: null, updatedAt: new Date().toISOString() } satisfies LeaderboardView; const now = new Date(); const map: Record = { biggest_win_today: { category: "biggest_win", periodKey: dayKey(now), label: "Biggest Win Today" }, biggest_win_week: { category: "biggest_win", periodKey: weekKey(now), label: "Biggest Win This Week" }, biggest_multiplier: { category: "biggest_multiplier", periodKey: "all", label: "Biggest Multiplier" }, most_spins: { category: "most_spins", periodKey: "all", label: "Most Spins" }, highest_level: { category: "highest_level", periodKey: "all", label: "Highest Level" }, }; const sel = map[q.category]; const rows = await db .select({ userId: leaderboards.userId, username: users.username, level: users.level, value: leaderboards.value, gameSlug: leaderboards.gameSlug, optIn: userSettings.leaderboardOptIn }) .from(leaderboards) .innerJoin(users, eq(users.id, leaderboards.userId)) .leftJoin(userSettings, eq(userSettings.userId, leaderboards.userId)) .where(and(eq(leaderboards.category, sel.category), eq(leaderboards.periodKey, sel.periodKey), eq(users.status, "active"))) .orderBy(desc(leaderboards.value)) .limit(200); const visible = rows.filter((r) => r.optIn !== false); const entries = visible.slice(0, 50).map((r, i) => ({ rank: i + 1, username: r.username, level: r.level, value: Number(r.value), game: r.gameSlug, isYou: req.user?.id === r.userId })); const youIdx = req.user ? visible.findIndex((r) => r.userId === req.user!.id) : -1; const you = youIdx >= 0 ? { rank: youIdx + 1, username: visible[youIdx].username, level: visible[youIdx].level, value: Number(visible[youIdx].value), game: visible[youIdx].gameSlug, isYou: true } : null; return { category: q.category, label: sel.label, entries, you, updatedAt: now.toISOString() } satisfies LeaderboardView; }); /** Recent notable wins (public, anonymised to username). */ app.get("/api/feed/wins", async () => { const rows = await db .select({ username: users.username, game: games.name, slug: games.slug, win: gameRounds.win, multiplier: gameRounds.multiplier, at: gameRounds.createdAt }) .from(gameRounds) .innerJoin(users, eq(users.id, gameRounds.userId)) .innerJoin(games, eq(games.id, gameRounds.gameId)) .leftJoin(userSettings, eq(userSettings.userId, gameRounds.userId)) .where(and(sql`${gameRounds.multiplier} >= 20`, sql`coalesce(${userSettings.leaderboardOptIn}, true)`)) .orderBy(desc(gameRounds.createdAt)) .limit(20); return { wins: rows.map((r) => ({ username: r.username, game: r.game, slug: r.slug, win: r.win, multiplier: Number(r.multiplier), at: r.at.toISOString() })) }; }); void userGameStats; }