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%
10.0 KB · 243 lines typescript
Raw Blame History
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