import { achievements, and, eq, inArray, leaderboards, missions, sql, userAchievements, userMissions, users, type Tx } from "@spinza/database"; import { XP_PER_BONUS, XP_PER_NEW_GAME, XP_PER_SPIN, classifyWin, levelForXp, levelUpReward } from "@spinza/shared"; import { applyCredit, type LockedWallet } from "./wallet"; import { flag } from "../lib/settings"; export interface SpinFacts { gameSlug: string; bet: number; win: number; multiplier: number; bonus: boolean; jackpot: boolean; newGame: boolean; } export interface UserCounters { totalSpins: number; gamesPlayed: number; biggestWin: number; biggestMultiplier: number; level: number; xp: number; bonuses: number; bigWins: number; wins: number; jackpots: number; dailyStreak: number; } export interface ProgressionResult { xpGained: number; xpTotal: number; level: number; leveledUp: boolean; levelReward: number; achievements: string[]; missions: string[]; creditsGranted: number; } /* --------------------------------------------------------------- periods */ export function dayKey(d = new Date()): string { return d.toISOString().slice(0, 10); } export function weekKey(d = new Date()): string { const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); const day = date.getUTCDay() || 7; date.setUTCDate(date.getUTCDate() + 4 - day); const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; } export function periodEnd(period: "daily" | "weekly", d = new Date()): Date { if (period === "daily") return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1)); const day = d.getUTCDay() || 7; return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + (8 - day))); } /* ------------------------------------------------------------ XP + level */ export async function awardXp(tx: Tx, wallet: LockedWallet, current: { xp: number; level: number }, amount: number, reference: string): Promise<{ level: number; xp: number; leveledUp: boolean; reward: number }> { const xp = current.xp + amount; const { level } = levelForXp(xp); let reward = 0; if (level > current.level) { for (let l = current.level + 1; l <= level; l++) reward += levelUpReward(l); await applyCredit(tx, wallet, "LEVEL_UP", reward, `level:${level}`, { from: current.level, to: level }); } await tx.update(users).set({ xp, level }).where(eq(users.id, wallet.userId)); return { level, xp, leveledUp: level > current.level, reward }; } /* --------------------------------------------------------- achievements */ const metricValue = (c: UserCounters, metric: string): number => { switch (metric) { case "spins": return c.totalSpins; case "bonuses": return c.bonuses; case "games_played": return c.gamesPlayed; case "big_wins": return c.bigWins; case "multiplier": return c.biggestMultiplier; case "wins": return c.wins; case "level": return c.level; case "daily_streak": return c.dailyStreak; case "jackpots": return c.jackpots; default: return 0; } }; /** Unlock every achievement whose target is now reached. Grants credits into the locked wallet. */ export async function checkAchievements(tx: Tx, wallet: LockedWallet, counters: UserCounters): Promise<{ unlocked: string[]; credits: number; xp: number }> { if (!flag("achievements.enabled")) return { unlocked: [], credits: 0, xp: 0 }; const all = await tx.select().from(achievements).where(eq(achievements.enabled, true)); const have = new Set((await tx.select({ k: userAchievements.achievementKey }).from(userAchievements).where(eq(userAchievements.userId, wallet.userId))).map((r) => r.k)); const unlocked: string[] = []; let credits = 0; let xp = 0; for (const a of all) { if (have.has(a.key)) continue; if (metricValue(counters, a.metric) >= a.target) { await tx.insert(userAchievements).values({ userId: wallet.userId, achievementKey: a.key }).onConflictDoNothing(); if (a.rewardCredits > 0) await applyCredit(tx, wallet, "ACHIEVEMENT", a.rewardCredits, `achievement:${a.key}`, { name: a.name }); credits += a.rewardCredits; xp += a.rewardXp; unlocked.push(a.key); } } return { unlocked, credits, xp }; } /* --------------------------------------------------------------- missions */ /** Advance mission progress after a spin. Completed missions are auto-claimed (credits granted). */ export async function advanceMissions(tx: Tx, wallet: LockedWallet, facts: SpinFacts): Promise<{ completed: string[]; credits: number; xp: number }> { if (!flag("missions.enabled")) return { completed: [], credits: 0, xp: 0 }; const defs = await tx.select().from(missions).where(eq(missions.enabled, true)); if (!defs.length) return { completed: [], credits: 0, xp: 0 }; const now = new Date(); const keys = { daily: dayKey(now), weekly: weekKey(now) }; // Ensure rows exist for this period. for (const m of defs) { const period = m.period as "daily" | "weekly"; await tx .insert(userMissions) .values({ userId: wallet.userId, missionKey: m.key, periodKey: keys[period], expiresAt: periodEnd(period, now) }) .onConflictDoNothing(); } const rows = await tx .select() .from(userMissions) .where(and(eq(userMissions.userId, wallet.userId), inArray(userMissions.periodKey, [keys.daily, keys.weekly]))); const completed: string[] = []; let credits = 0; let xp = 0; for (const m of defs) { const period = m.period as "daily" | "weekly"; const row = rows.find((r) => r.missionKey === m.key && r.periodKey === keys[period]); if (!row || row.completedAt) continue; let progress = row.progress; let set = row.progressSet; switch (m.metric) { case "spins": progress += 1; break; case "wins": if (facts.win > 0) progress += 1; break; case "bonuses": if (facts.bonus) progress += 1; break; case "wagered": progress += facts.bet; break; case "multiplier": progress = Math.max(progress, Math.floor(facts.multiplier)); break; case "games": if (!set.includes(facts.gameSlug)) set = [...set, facts.gameSlug]; progress = set.length; break; } if (progress === row.progress && set === row.progressSet) continue; const done = progress >= m.target; await tx .update(userMissions) .set({ progress, progressSet: set, completedAt: done ? now : null, claimedAt: done ? now : null }) .where(eq(userMissions.id, row.id)); if (done) { if (m.rewardCredits > 0) await applyCredit(tx, wallet, "MISSION", m.rewardCredits, `mission:${m.key}:${row.periodKey}`, { name: m.name }); credits += m.rewardCredits; xp += m.rewardXp; completed.push(m.key); } } return { completed, credits, xp }; } /* ------------------------------------------------------------ leaderboards */ export async function upsertLeaderboards(tx: Tx, userId: string, facts: SpinFacts, roundId: string, counters: { totalSpins: number; level: number }, optIn: boolean): Promise { if (!optIn || !flag("leaderboards.enabled")) return; const now = new Date(); const entries: { category: string; periodKey: string; value: number; gameSlug?: string; roundId?: string; mode: "max" | "set" }[] = []; if (facts.win > 0) { entries.push({ category: "biggest_win", periodKey: dayKey(now), value: facts.win, gameSlug: facts.gameSlug, roundId, mode: "max" }); entries.push({ category: "biggest_win", periodKey: weekKey(now), value: facts.win, gameSlug: facts.gameSlug, roundId, mode: "max" }); entries.push({ category: "biggest_multiplier", periodKey: "all", value: facts.multiplier, gameSlug: facts.gameSlug, roundId, mode: "max" }); entries.push({ category: "biggest_multiplier", periodKey: weekKey(now), value: facts.multiplier, gameSlug: facts.gameSlug, roundId, mode: "max" }); } entries.push({ category: "most_spins", periodKey: "all", value: counters.totalSpins, mode: "set" }); entries.push({ category: "most_spins", periodKey: weekKey(now), value: 1, mode: "set" }); // handled as increment below entries.push({ category: "highest_level", periodKey: "all", value: counters.level, mode: "set" }); for (const e of entries) { if (e.category === "most_spins" && e.periodKey !== "all") { await tx .insert(leaderboards) .values({ category: e.category, periodKey: e.periodKey, userId, value: "1" }) .onConflictDoUpdate({ target: [leaderboards.category, leaderboards.periodKey, leaderboards.userId], set: { value: sql`${leaderboards.value} + 1`, updatedAt: now } }); continue; } await tx .insert(leaderboards) .values({ category: e.category, periodKey: e.periodKey, userId, value: String(e.value), gameSlug: e.gameSlug ?? null, roundId: e.roundId ?? null }) .onConflictDoUpdate({ target: [leaderboards.category, leaderboards.periodKey, leaderboards.userId], set: e.mode === "max" ? { value: sql`greatest(${leaderboards.value}, ${String(e.value)}::numeric)`, gameSlug: sql`case when ${String(e.value)}::numeric > ${leaderboards.value} then ${e.gameSlug ?? null} else ${leaderboards.gameSlug} end`, roundId: sql`case when ${String(e.value)}::numeric > ${leaderboards.value} then ${e.roundId ?? null} else ${leaderboards.roundId} end`, updatedAt: now, } : { value: String(e.value), updatedAt: now }, }); } } export function spinXp(facts: SpinFacts): number { let xp = XP_PER_SPIN; if (facts.bonus) xp += XP_PER_BONUS; if (facts.newGame) xp += XP_PER_NEW_GAME; const cls = classifyWin(facts.multiplier); if (cls === "big") xp += 10; else if (cls === "mega") xp += 25; else if (cls === "epic") xp += 60; else if (cls === "legendary") xp += 200; return xp; }