TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import { achievements, and, eq, inArray, leaderboards, missions, sql, userAchievements, userMissions, users, type Tx } from "@spinza/database";2import { XP_PER_BONUS, XP_PER_NEW_GAME, XP_PER_SPIN, classifyWin, levelForXp, levelUpReward } from "@spinza/shared";3import { applyCredit, type LockedWallet } from "./wallet";4import { flag } from "../lib/settings";56export interface SpinFacts {7 gameSlug: string;8 bet: number;9 win: number;10 multiplier: number;11 bonus: boolean;12 jackpot: boolean;13 newGame: boolean;14}1516export interface UserCounters {17 totalSpins: number;18 gamesPlayed: number;19 biggestWin: number;20 biggestMultiplier: number;21 level: number;22 xp: number;23 bonuses: number;24 bigWins: number;25 wins: number;26 jackpots: number;27 dailyStreak: number;28}2930export interface ProgressionResult {31 xpGained: number;32 xpTotal: number;33 level: number;34 leveledUp: boolean;35 levelReward: number;36 achievements: string[];37 missions: string[];38 creditsGranted: number;39}4041/* --------------------------------------------------------------- periods */4243export function dayKey(d = new Date()): string {44 return d.toISOString().slice(0, 10);45}4647export function weekKey(d = new Date()): string {48 const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));49 const day = date.getUTCDay() || 7;50 date.setUTCDate(date.getUTCDate() + 4 - day);51 const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));52 const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);53 return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;54}5556export function periodEnd(period: "daily" | "weekly", d = new Date()): Date {57 if (period === "daily") return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1));58 const day = d.getUTCDay() || 7;59 return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + (8 - day)));60}6162/* ------------------------------------------------------------ XP + level */6364export 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 }> {65 const xp = current.xp + amount;66 const { level } = levelForXp(xp);67 let reward = 0;68 if (level > current.level) {69 for (let l = current.level + 1; l <= level; l++) reward += levelUpReward(l);70 await applyCredit(tx, wallet, "LEVEL_UP", reward, `level:${level}`, { from: current.level, to: level });71 }72 await tx.update(users).set({ xp, level }).where(eq(users.id, wallet.userId));73 return { level, xp, leveledUp: level > current.level, reward };74}7576/* --------------------------------------------------------- achievements */7778const metricValue = (c: UserCounters, metric: string): number => {79 switch (metric) {80 case "spins":81 return c.totalSpins;82 case "bonuses":83 return c.bonuses;84 case "games_played":85 return c.gamesPlayed;86 case "big_wins":87 return c.bigWins;88 case "multiplier":89 return c.biggestMultiplier;90 case "wins":91 return c.wins;92 case "level":93 return c.level;94 case "daily_streak":95 return c.dailyStreak;96 case "jackpots":97 return c.jackpots;98 default:99 return 0;100 }101};102103/** Unlock every achievement whose target is now reached. Grants credits into the locked wallet. */104export async function checkAchievements(tx: Tx, wallet: LockedWallet, counters: UserCounters): Promise<{ unlocked: string[]; credits: number; xp: number }> {105 if (!flag("achievements.enabled")) return { unlocked: [], credits: 0, xp: 0 };106 const all = await tx.select().from(achievements).where(eq(achievements.enabled, true));107 const have = new Set((await tx.select({ k: userAchievements.achievementKey }).from(userAchievements).where(eq(userAchievements.userId, wallet.userId))).map((r) => r.k));108 const unlocked: string[] = [];109 let credits = 0;110 let xp = 0;111 for (const a of all) {112 if (have.has(a.key)) continue;113 if (metricValue(counters, a.metric) >= a.target) {114 await tx.insert(userAchievements).values({ userId: wallet.userId, achievementKey: a.key }).onConflictDoNothing();115 if (a.rewardCredits > 0) await applyCredit(tx, wallet, "ACHIEVEMENT", a.rewardCredits, `achievement:${a.key}`, { name: a.name });116 credits += a.rewardCredits;117 xp += a.rewardXp;118 unlocked.push(a.key);119 }120 }121 return { unlocked, credits, xp };122}123124/* --------------------------------------------------------------- missions */125126/** Advance mission progress after a spin. Completed missions are auto-claimed (credits granted). */127export async function advanceMissions(tx: Tx, wallet: LockedWallet, facts: SpinFacts): Promise<{ completed: string[]; credits: number; xp: number }> {128 if (!flag("missions.enabled")) return { completed: [], credits: 0, xp: 0 };129 const defs = await tx.select().from(missions).where(eq(missions.enabled, true));130 if (!defs.length) return { completed: [], credits: 0, xp: 0 };131 const now = new Date();132 const keys = { daily: dayKey(now), weekly: weekKey(now) };133 // Ensure rows exist for this period.134 for (const m of defs) {135 const period = m.period as "daily" | "weekly";136 await tx137 .insert(userMissions)138 .values({ userId: wallet.userId, missionKey: m.key, periodKey: keys[period], expiresAt: periodEnd(period, now) })139 .onConflictDoNothing();140 }141 const rows = await tx142 .select()143 .from(userMissions)144 .where(and(eq(userMissions.userId, wallet.userId), inArray(userMissions.periodKey, [keys.daily, keys.weekly])));145 const completed: string[] = [];146 let credits = 0;147 let xp = 0;148 for (const m of defs) {149 const period = m.period as "daily" | "weekly";150 const row = rows.find((r) => r.missionKey === m.key && r.periodKey === keys[period]);151 if (!row || row.completedAt) continue;152 let progress = row.progress;153 let set = row.progressSet;154 switch (m.metric) {155 case "spins":156 progress += 1;157 break;158 case "wins":159 if (facts.win > 0) progress += 1;160 break;161 case "bonuses":162 if (facts.bonus) progress += 1;163 break;164 case "wagered":165 progress += facts.bet;166 break;167 case "multiplier":168 progress = Math.max(progress, Math.floor(facts.multiplier));169 break;170 case "games":171 if (!set.includes(facts.gameSlug)) set = [...set, facts.gameSlug];172 progress = set.length;173 break;174 }175 if (progress === row.progress && set === row.progressSet) continue;176 const done = progress >= m.target;177 await tx178 .update(userMissions)179 .set({ progress, progressSet: set, completedAt: done ? now : null, claimedAt: done ? now : null })180 .where(eq(userMissions.id, row.id));181 if (done) {182 if (m.rewardCredits > 0) await applyCredit(tx, wallet, "MISSION", m.rewardCredits, `mission:${m.key}:${row.periodKey}`, { name: m.name });183 credits += m.rewardCredits;184 xp += m.rewardXp;185 completed.push(m.key);186 }187 }188 return { completed, credits, xp };189}190191/* ------------------------------------------------------------ leaderboards */192193export async function upsertLeaderboards(tx: Tx, userId: string, facts: SpinFacts, roundId: string, counters: { totalSpins: number; level: number }, optIn: boolean): Promise<void> {194 if (!optIn || !flag("leaderboards.enabled")) return;195 const now = new Date();196 const entries: { category: string; periodKey: string; value: number; gameSlug?: string; roundId?: string; mode: "max" | "set" }[] = [];197 if (facts.win > 0) {198 entries.push({ category: "biggest_win", periodKey: dayKey(now), value: facts.win, gameSlug: facts.gameSlug, roundId, mode: "max" });199 entries.push({ category: "biggest_win", periodKey: weekKey(now), value: facts.win, gameSlug: facts.gameSlug, roundId, mode: "max" });200 entries.push({ category: "biggest_multiplier", periodKey: "all", value: facts.multiplier, gameSlug: facts.gameSlug, roundId, mode: "max" });201 entries.push({ category: "biggest_multiplier", periodKey: weekKey(now), value: facts.multiplier, gameSlug: facts.gameSlug, roundId, mode: "max" });202 }203 entries.push({ category: "most_spins", periodKey: "all", value: counters.totalSpins, mode: "set" });204 entries.push({ category: "most_spins", periodKey: weekKey(now), value: 1, mode: "set" }); // handled as increment below205 entries.push({ category: "highest_level", periodKey: "all", value: counters.level, mode: "set" });206 for (const e of entries) {207 if (e.category === "most_spins" && e.periodKey !== "all") {208 await tx209 .insert(leaderboards)210 .values({ category: e.category, periodKey: e.periodKey, userId, value: "1" })211 .onConflictDoUpdate({ target: [leaderboards.category, leaderboards.periodKey, leaderboards.userId], set: { value: sql`${leaderboards.value} + 1`, updatedAt: now } });212 continue;213 }214 await tx215 .insert(leaderboards)216 .values({ category: e.category, periodKey: e.periodKey, userId, value: String(e.value), gameSlug: e.gameSlug ?? null, roundId: e.roundId ?? null })217 .onConflictDoUpdate({218 target: [leaderboards.category, leaderboards.periodKey, leaderboards.userId],219 set:220 e.mode === "max"221 ? {222 value: sql`greatest(${leaderboards.value}, ${String(e.value)}::numeric)`,223 gameSlug: sql`case when ${String(e.value)}::numeric > ${leaderboards.value} then ${e.gameSlug ?? null} else ${leaderboards.gameSlug} end`,224 roundId: sql`case when ${String(e.value)}::numeric > ${leaderboards.value} then ${e.roundId ?? null} else ${leaderboards.roundId} end`,225 updatedAt: now,226 }227 : { value: String(e.value), updatedAt: now },228 });229 }230}231232export function spinXp(facts: SpinFacts): number {233 let xp = XP_PER_SPIN;234 if (facts.bonus) xp += XP_PER_BONUS;235 if (facts.newGame) xp += XP_PER_NEW_GAME;236 const cls = classifyWin(facts.multiplier);237 if (cls === "big") xp += 10;238 else if (cls === "mega") xp += 25;239 else if (cls === "epic") xp += 60;240 else if (cls === "legendary") xp += 200;241 return xp;242}243