SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
13.1 KB · 216 lines typescript
Raw Blame History
1import type { FastifyInstance } from "fastify";2import { achievements, and, dailyRewards, db, desc, eq, gameRounds, games, inArray, leaderboards, missions, sql, userAchievements, userGameStats, userMissions, users, wallets, userSettings } from "@spinza/database";3import { DAILY_STREAK_GRACE_DAYS, type AchievementView, type DailyRewardStatus, type LeaderboardView, type MissionView, type RescueStatus } from "@spinza/shared";4import { requireUser } from "../plugins/auth";5import { errors } from "../lib/errors";6import { dailySchedule, flag, rescueConfig } from "../lib/settings";7import { applyCredit, lockWallet, saveWallet } from "../services/wallet";8import { awardXp, checkAchievements, dayKey, periodEnd, weekKey, type UserCounters } from "../services/progression";9import { z } from "zod";1011const DAY = 86_400_000;1213function nextStreakDay(current: number, lastClaimedAt: Date | null, now: Date, scheduleLen: number): number {14  if (!lastClaimedAt) return 1;15  const gapDays = Math.floor((startOfDay(now).getTime() - startOfDay(lastClaimedAt).getTime()) / DAY);16  if (gapDays <= 1) return (current % scheduleLen) + 1; // consecutive day (after a full week, loop back to day 1)17  if (gapDays <= 1 + DAILY_STREAK_GRACE_DAYS) return Math.max(1, current - 1); // soft reset: lose one step18  return 1;19}2021function startOfDay(d: Date): Date {22  return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));23}2425async function counters(userId: string): Promise<UserCounters> {26  const u = await db.query.users.findFirst({ where: eq(users.id, userId) });27  const agg = await db.execute(sql`28    select (select coalesce(sum(bonuses),0) from user_game_stats where user_id = ${userId})::bigint as bonuses,29           count(*) filter (where multiplier >= 20)::bigint as big_wins,30           count(*) filter (where win > 0)::bigint as wins,31           count(*) filter (where jackpot_tier is not null)::bigint as jackpots32    from game_rounds where user_id = ${userId}`);33  const a = agg.rows[0] as { bonuses: number; big_wins: number; wins: number; jackpots: number };34  const streak = await db.query.dailyRewards.findFirst({ where: eq(dailyRewards.userId, userId) });35  return {36    totalSpins: u!.totalSpins,37    gamesPlayed: u!.gamesPlayed,38    biggestWin: u!.biggestWin,39    biggestMultiplier: Number(u!.biggestMultiplier),40    level: u!.level,41    xp: u!.xp,42    bonuses: Number(a.bonuses),43    bigWins: Number(a.big_wins),44    wins: Number(a.wins),45    jackpots: Number(a.jackpots),46    dailyStreak: streak?.streakDay ?? 0,47  };48}4950export async function rewardRoutes(app: FastifyInstance) {51  /* ------------------------------------------------------------ daily */52  app.get("/api/rewards/daily", async (req) => {53    const user = requireUser(req);54    const schedule = dailySchedule();55    const row = (await db.query.dailyRewards.findFirst({ where: eq(dailyRewards.userId, user.id) })) ?? { streakDay: 0, lastClaimedAt: null, nextAvailableAt: null };56    const now = new Date();57    const available = flag("dailyRewards.enabled") && (!row.nextAvailableAt || row.nextAvailableAt <= now);58    const day = available ? nextStreakDay(row.streakDay, row.lastClaimedAt, now, schedule.length) : Math.min(row.streakDay, schedule.length);59    const status: DailyRewardStatus = {60      available,61      streakDay: row.streakDay,62      nextAmount: schedule[(available ? day : Math.min(row.streakDay % schedule.length + 1, schedule.length)) - 1] ?? schedule[0],63      nextAvailableAt: row.nextAvailableAt?.toISOString() ?? null,64      schedule,65      claimedToday: !!row.lastClaimedAt && startOfDay(row.lastClaimedAt).getTime() === startOfDay(now).getTime(),66    };67    return status;68  });6970  app.post("/api/rewards/daily/claim", async (req) => {71    const user = requireUser(req);72    if (!flag("dailyRewards.enabled")) throw errors.forbidden("Daily rewards are paused.");73    const schedule = dailySchedule();74    return db.transaction(async (tx) => {75      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`);76      const row = lock.rows[0] as { streak_day: number; last_claimed_at: Date | null; next_available_at: Date | null } | undefined;77      const now = new Date();78      if (row?.next_available_at && new Date(row.next_available_at) > now) throw errors.conflict("ALREADY_CLAIMED", "Come back tomorrow for your next reward.");79      const day = nextStreakDay(row?.streak_day ?? 0, row?.last_claimed_at ? new Date(row.last_claimed_at) : null, now, schedule.length);80      const amount = schedule[day - 1];81      const nextAvailable = new Date(startOfDay(now).getTime() + DAY);82      await tx83        .insert(dailyRewards)84        .values({ userId: user.id, streakDay: day, lastClaimedAt: now, nextAvailableAt: nextAvailable, totalClaimed: amount, claims: 1 })85        .onConflictDoUpdate({ target: dailyRewards.userId, set: { streakDay: day, lastClaimedAt: now, nextAvailableAt: nextAvailable, totalClaimed: sql`${dailyRewards.totalClaimed} + ${amount}`, claims: sql`${dailyRewards.claims} + 1` } });86      const w = await lockWallet(tx, user.id);87      await applyCredit(tx, w, "DAILY_REWARD", amount, `daily:${dayKey(now)}`, { streakDay: day });88      const c = await counters(user.id);89      c.dailyStreak = day;90      const ach = await checkAchievements(tx, w, c);91      const u = await tx.query.users.findFirst({ where: eq(users.id, user.id), columns: { xp: true, level: true } });92      const lvl = await awardXp(tx, w, u!, 25 + ach.xp, `daily:${dayKey(now)}`);93      await saveWallet(tx, w);94      return { amount, streakDay: day, balance: w.balance, nextAvailableAt: nextAvailable.toISOString(), unlocked: ach.unlocked, xp: lvl };95    });96  });9798  /* ----------------------------------------------------------- rescue */99  app.get("/api/rewards/rescue", async (req) => {100    const user = requireUser(req);101    const cfg = rescueConfig();102    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 } })]);103    const nextAt = u?.lastRescueAt ? new Date(u.lastRescueAt.getTime() + cfg.cooldownHours * 3600_000) : null;104    const eligible = flag("rescue.enabled") && (w?.balance ?? 0) <= cfg.threshold && (!nextAt || nextAt <= new Date());105    const status: RescueStatus = { eligible, amount: cfg.amount, balance: w?.balance ?? 0, nextAvailableAt: nextAt && nextAt > new Date() ? nextAt.toISOString() : null };106    return status;107  });108109  app.post("/api/rewards/rescue/claim", async (req) => {110    const user = requireUser(req);111    const cfg = rescueConfig();112    if (!flag("rescue.enabled")) throw errors.forbidden("Rescue credits are paused.");113    return db.transaction(async (tx) => {114      const w = await lockWallet(tx, user.id);115      const [u] = await tx.select({ lastRescueAt: users.lastRescueAt }).from(users).where(eq(users.id, user.id)).for("update");116      if (w.balance > cfg.threshold) throw errors.conflict("NOT_ELIGIBLE", "Rescue credits are only available when your balance reaches zero.");117      if (u.lastRescueAt && u.lastRescueAt.getTime() + cfg.cooldownHours * 3600_000 > Date.now()) throw errors.conflict("COOLDOWN", "Rescue credits are recharging.");118      await applyCredit(tx, w, "RESCUE_CREDITS", cfg.amount, "rescue", { cooldownHours: cfg.cooldownHours });119      await tx.update(users).set({ lastRescueAt: new Date() }).where(eq(users.id, user.id));120      await saveWallet(tx, w);121      return { amount: cfg.amount, balance: w.balance, nextAvailableAt: new Date(Date.now() + cfg.cooldownHours * 3600_000).toISOString() };122    });123  });124125  /* ------------------------------------------------------ achievements */126  app.get("/api/achievements", async (req) => {127    const user = requireUser(req);128    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)]);129    const unlocked = new Map(mine.map((m) => [m.achievementKey, m.unlockedAt]));130    const value = (metric: string) =>131      ({ 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;132    const views: AchievementView[] = all.map((a) => ({133      key: a.key,134      name: a.name,135      description: a.description,136      rewardCredits: a.rewardCredits,137      rewardXp: a.rewardXp,138      icon: a.icon,139      unlockedAt: unlocked.get(a.key)?.toISOString() ?? null,140      progress: Math.min(value(a.metric), a.target),141      target: a.target,142    }));143    return { achievements: views, unlocked: mine.length, total: all.length };144  });145146  /* ---------------------------------------------------------- missions */147  app.get("/api/missions", async (req) => {148    const user = requireUser(req);149    const defs = await db.select().from(missions).where(eq(missions.enabled, true)).orderBy(missions.sortOrder);150    const now = new Date();151    const keys = { daily: dayKey(now), weekly: weekKey(now) };152    const rows = await db.select().from(userMissions).where(and(eq(userMissions.userId, user.id), inArray(userMissions.periodKey, [keys.daily, keys.weekly])));153    const views: MissionView[] = defs.map((m) => {154      const period = m.period as "daily" | "weekly";155      const r = rows.find((x) => x.missionKey === m.key && x.periodKey === keys[period]);156      return {157        key: m.key,158        name: m.name,159        description: m.description,160        period,161        target: m.target,162        progress: Math.min(r?.progress ?? 0, m.target),163        rewardCredits: m.rewardCredits,164        rewardXp: m.rewardXp,165        completedAt: r?.completedAt?.toISOString() ?? null,166        claimedAt: r?.claimedAt?.toISOString() ?? null,167        expiresAt: periodEnd(period, now).toISOString(),168      };169    });170    return { missions: views, enabled: flag("missions.enabled") };171  });172173  /* ------------------------------------------------------ leaderboards */174  app.get("/api/leaderboards", async (req) => {175    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);176    if (!flag("leaderboards.enabled")) return { category: q.category, label: "", entries: [], you: null, updatedAt: new Date().toISOString() } satisfies LeaderboardView;177    const now = new Date();178    const map: Record<string, { category: string; periodKey: string; label: string }> = {179      biggest_win_today: { category: "biggest_win", periodKey: dayKey(now), label: "Biggest Win Today" },180      biggest_win_week: { category: "biggest_win", periodKey: weekKey(now), label: "Biggest Win This Week" },181      biggest_multiplier: { category: "biggest_multiplier", periodKey: "all", label: "Biggest Multiplier" },182      most_spins: { category: "most_spins", periodKey: "all", label: "Most Spins" },183      highest_level: { category: "highest_level", periodKey: "all", label: "Highest Level" },184    };185    const sel = map[q.category];186    const rows = await db187      .select({ userId: leaderboards.userId, username: users.username, level: users.level, value: leaderboards.value, gameSlug: leaderboards.gameSlug, optIn: userSettings.leaderboardOptIn })188      .from(leaderboards)189      .innerJoin(users, eq(users.id, leaderboards.userId))190      .leftJoin(userSettings, eq(userSettings.userId, leaderboards.userId))191      .where(and(eq(leaderboards.category, sel.category), eq(leaderboards.periodKey, sel.periodKey), eq(users.status, "active")))192      .orderBy(desc(leaderboards.value))193      .limit(200);194    const visible = rows.filter((r) => r.optIn !== false);195    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 }));196    const youIdx = req.user ? visible.findIndex((r) => r.userId === req.user!.id) : -1;197    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;198    return { category: q.category, label: sel.label, entries, you, updatedAt: now.toISOString() } satisfies LeaderboardView;199  });200201  /** Recent notable wins (public, anonymised to username). */202  app.get("/api/feed/wins", async () => {203    const rows = await db204      .select({ username: users.username, game: games.name, slug: games.slug, win: gameRounds.win, multiplier: gameRounds.multiplier, at: gameRounds.createdAt })205      .from(gameRounds)206      .innerJoin(users, eq(users.id, gameRounds.userId))207      .innerJoin(games, eq(games.id, gameRounds.gameId))208      .leftJoin(userSettings, eq(userSettings.userId, gameRounds.userId))209      .where(and(sql`${gameRounds.multiplier} >= 20`, sql`coalesce(${userSettings.leaderboardOptIn}, true)`))210      .orderBy(desc(gameRounds.createdAt))211      .limit(20);212    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() })) };213  });214  void userGameStats;215}216