From 298466d0a26723e64ea6dadf0bb4249731352720 Mon Sep 17 00:00:00 2001 From: Nathan <70660308+NotARoomba@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:44:59 -0500 Subject: [PATCH] fix shop, added SEO and Hackatime sync intervals --- backend/dist/index.js | 171 +++++++++++++++--- backend/src/index.ts | 4 + backend/src/lib/hackatime-sync.ts | 121 +++++++++++++ backend/src/lib/scraps.ts | 71 +++++++- backend/src/routes/admin.ts | 16 +- backend/src/routes/auth.ts | 25 ++- backend/src/routes/shop.ts | 70 +++++-- .../src/lib/components/ShopItemModal.svelte | 8 +- frontend/src/lib/stores.ts | 2 + frontend/src/routes/+layout.svelte | 17 +- frontend/src/routes/admin/shop/+page.svelte | 109 ++++++++++- frontend/src/routes/refinery/+page.svelte | 15 +- frontend/src/routes/shop/+page.svelte | 5 +- 13 files changed, 552 insertions(+), 82 deletions(-) create mode 100644 backend/src/lib/hackatime-sync.ts diff --git a/backend/dist/index.js b/backend/dist/index.js index 645c086..2a113e7 100644 --- a/backend/dist/index.js +++ b/backend/dist/index.js @@ -28449,7 +28449,7 @@ async function fetchUserIdentity(accessToken) { } } async function createOrUpdateUser(identity, tokens) { - if (!identity.ysws_eligible) { + if (identity.ysws_eligible === false) { throw new Error("not-eligible"); } let username = null; @@ -28967,12 +28967,18 @@ var shopPenaltiesTable = pgTable("shop_penalties", { // src/lib/scraps.ts var PHI = (1 + Math.sqrt(5)) / 2; var MULTIPLIER = 10; +var SCRAPS_PER_HOUR = PHI * MULTIPLIER; +var DOLLARS_PER_HOUR = 5; +var SCRAPS_PER_DOLLAR = SCRAPS_PER_HOUR / DOLLARS_PER_HOUR; var TIER_MULTIPLIERS = { - 1: 0.75, + 1: 0.8, 2: 1, 3: 1.25, 4: 1.5 }; +function calculateRollCost(basePrice, baseProbability) { + return Math.max(1, Math.round(basePrice * (baseProbability / 100))); +} function calculateScrapsFromHours(hours, tier = 1) { const tierMultiplier = TIER_MULTIPLIERS[tier] ?? 1; return Math.floor(hours * PHI * MULTIPLIER * tierMultiplier); @@ -29058,6 +29064,14 @@ authRoutes.get("/callback", async ({ query, redirect: redirect2, cookie }) => { yswsEligible: identity.ysws_eligible, verificationStatus: identity.verification_status }); + if (identity.verification_status === "needs_submission") { + console.log("[AUTH] User needs to verify identity"); + return redirect2(`${FRONTEND_URL}/auth/error?reason=needs-verification`); + } + if (identity.verification_status === "ineligible") { + console.log("[AUTH] User is ineligible"); + return redirect2(`${FRONTEND_URL}/auth/error?reason=not-eligible`); + } const user = await createOrUpdateUser(identity, tokens); await db.delete(userEmailsTable).where(eq(userEmailsTable.email, identity.primary_email)); console.log("[AUTH] Deleted collected email:", identity.primary_email); @@ -29065,14 +29079,6 @@ authRoutes.get("/callback", async ({ query, redirect: redirect2, cookie }) => { console.log("[AUTH] Banned user attempted login:", { userId: user.id, username: user.username }); return redirect2("https://fraud.land"); } - if (identity.verification_status === "needs_submission") { - console.log("[AUTH] User needs to verify identity:", { userId: user.id }); - return redirect2(`${FRONTEND_URL}/auth/error?reason=needs-verification`); - } - if (identity.verification_status === "ineligible") { - console.log("[AUTH] User is ineligible:", { userId: user.id }); - return redirect2(`${FRONTEND_URL}/auth/error?reason=not-eligible`); - } const sessionToken = await createSession(user.id); console.log("[AUTH] User authenticated successfully:", { userId: user.id, username: user.username }); cookie.session.set({ @@ -29101,6 +29107,17 @@ authRoutes.get("/me", async ({ headers }) => { if (user.role === "banned") { return { user: null, banned: true }; } + if (user.tutorialCompleted) { + const existingBonus = await db.select({ id: userBonusesTable.id }).from(userBonusesTable).where(and(eq(userBonusesTable.userId, user.id), eq(userBonusesTable.reason, "tutorial_completion"))).limit(1); + if (existingBonus.length === 0) { + await db.insert(userBonusesTable).values({ + userId: user.id, + reason: "tutorial_completion", + amount: 10 + }); + console.log("[AUTH] Auto-awarded tutorial bonus for user:", user.id); + } + } const scrapsBalance = await getUserScrapsBalance(user.id); return { user: { @@ -29274,26 +29291,31 @@ shop.get("/items", async ({ headers }) => { const userHearts = await db.select({ shopItemId: shopHeartsTable.shopItemId }).from(shopHeartsTable).where(eq(shopHeartsTable.userId, user2.id)); const userBoosts = await db.select({ shopItemId: refineryOrdersTable.shopItemId, - boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)` + boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`, + upgradeCount: sql`COUNT(*)` }).from(refineryOrdersTable).where(eq(refineryOrdersTable.userId, user2.id)).groupBy(refineryOrdersTable.shopItemId); const userPenalties = await db.select({ shopItemId: shopPenaltiesTable.shopItemId, probabilityMultiplier: shopPenaltiesTable.probabilityMultiplier }).from(shopPenaltiesTable).where(eq(shopPenaltiesTable.userId, user2.id)); const heartedIds = new Set(userHearts.map((h) => h.shopItemId)); - const boostMap = new Map(userBoosts.map((b) => [b.shopItemId, Number(b.boostPercent)])); + const boostMap = new Map(userBoosts.map((b) => [b.shopItemId, { boostPercent: Number(b.boostPercent), upgradeCount: Number(b.upgradeCount) }])); const penaltyMap = new Map(userPenalties.map((p) => [p.shopItemId, p.probabilityMultiplier])); return items.map((item) => { - const userBoostPercent = boostMap.get(item.id) ?? 0; + const boostData = boostMap.get(item.id) ?? { boostPercent: 0, upgradeCount: 0 }; const penaltyMultiplier = penaltyMap.get(item.id) ?? 100; const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100); + const maxBoost = 100 - adjustedBaseProbability; + const nextUpgradeCost = boostData.boostPercent >= maxBoost ? null : Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, boostData.upgradeCount)); return { ...item, heartCount: Number(item.heartCount) || 0, - userBoostPercent, + userBoostPercent: boostData.boostPercent, + upgradeCount: boostData.upgradeCount, adjustedBaseProbability, - effectiveProbability: Math.min(adjustedBaseProbability + userBoostPercent, 100), - userHearted: heartedIds.has(item.id) + effectiveProbability: Math.min(adjustedBaseProbability + boostData.boostPercent, 100), + userHearted: heartedIds.has(item.id), + nextUpgradeCost }; }); } @@ -29301,8 +29323,10 @@ shop.get("/items", async ({ headers }) => { ...item, heartCount: Number(item.heartCount) || 0, userBoostPercent: 0, + upgradeCount: 0, effectiveProbability: Math.min(item.baseProbability, 100), - userHearted: false + userHearted: false, + nextUpgradeCost: item.baseUpgradeCost })); }); shop.get("/items/:id", async ({ params, headers }) => { @@ -29523,6 +29547,12 @@ shop.post("/items/:id/try-luck", async ({ params, headers }) => { const penaltyMultiplier = penaltyResult.length > 0 ? penaltyResult[0].probabilityMultiplier : 100; const adjustedBaseProbability = Math.floor(currentItem[0].baseProbability * penaltyMultiplier / 100); const effectiveProbability = Math.min(adjustedBaseProbability + boostPercent, 100); + const rollCost = calculateRollCost(currentItem[0].price, currentItem[0].baseProbability); + const canAffordRoll = await canAfford(user2.id, rollCost, tx); + if (!canAffordRoll) { + const { balance } = await getUserScrapsBalance(user2.id, tx); + throw { type: "insufficient_funds", balance, cost: rollCost }; + } const rolled = Math.floor(Math.random() * 100) + 1; const won = rolled <= effectiveProbability; await tx.insert(shopRollsTable).values({ @@ -29541,8 +29571,8 @@ shop.post("/items/:id/try-luck", async ({ params, headers }) => { userId: user2.id, shopItemId: itemId, quantity: 1, - pricePerItem: item.price, - totalPrice: item.price, + pricePerItem: rollCost, + totalPrice: rollCost, shippingAddress: null, status: "pending", orderType: "luck_win" @@ -29562,29 +29592,29 @@ shop.post("/items/:id/try-luck", async ({ params, headers }) => { probabilityMultiplier: 50 }); } - return { won: true, orderId: newOrder[0].id, effectiveProbability, rolled }; + return { won: true, orderId: newOrder[0].id, effectiveProbability, rolled, rollCost }; } const consolationOrder = await tx.insert(shopOrdersTable).values({ userId: user2.id, shopItemId: itemId, quantity: 1, - pricePerItem: item.price, - totalPrice: item.price, + pricePerItem: rollCost, + totalPrice: rollCost, shippingAddress: null, status: "pending", orderType: "consolation", notes: `Consolation scrap paper - rolled ${rolled}, needed ${effectiveProbability} or less` }).returning(); - return { won: false, effectiveProbability, rolled, consolationOrderId: consolationOrder[0].id }; + return { won: false, effectiveProbability, rolled, rollCost, consolationOrderId: consolationOrder[0].id }; }); if (result.won) { - return { success: true, won: true, orderId: result.orderId, effectiveProbability: result.effectiveProbability, rolled: result.rolled, refineryReset: true, probabilityHalved: true }; + return { success: true, won: true, orderId: result.orderId, effectiveProbability: result.effectiveProbability, rolled: result.rolled, rollCost: result.rollCost, refineryReset: true, probabilityHalved: true }; } - return { success: true, won: false, consolationOrderId: result.consolationOrderId, effectiveProbability: result.effectiveProbability, rolled: result.rolled }; + return { success: true, won: false, consolationOrderId: result.consolationOrderId, effectiveProbability: result.effectiveProbability, rolled: result.rolled, rollCost: result.rollCost }; } catch (e) { const err = e; if (err.type === "insufficient_funds") { - return { error: "Insufficient scraps", required: item.price, available: err.balance }; + return { error: "Insufficient scraps", required: err.cost, available: err.balance }; } if (err.type === "out_of_stock") { return { error: "Out of stock" }; @@ -29620,7 +29650,9 @@ shop.post("/items/:id/upgrade-probability", async ({ params, headers }) => { if (currentBoost >= maxBoost) { throw { type: "max_probability" }; } - const cost = Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, currentBoost)); + const upgradeCountResult = await tx.select({ count: sql`COUNT(*)` }).from(refineryOrdersTable).where(and(eq(refineryOrdersTable.userId, user2.id), eq(refineryOrdersTable.shopItemId, itemId))); + const upgradeCount = Number(upgradeCountResult[0]?.count) || 0; + const cost = Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, upgradeCount)); const affordable = await canAfford(user2.id, cost, tx); if (!affordable) { const { balance } = await getUserScrapsBalance(user2.id, tx); @@ -29634,7 +29666,8 @@ shop.post("/items/:id/upgrade-probability", async ({ params, headers }) => { cost, boostAmount }); - const nextCost = newBoost >= maxBoost ? null : Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, newBoost)); + const newUpgradeCount = upgradeCount + 1; + const nextCost = newBoost >= maxBoost ? null : Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, newUpgradeCount)); return { boostPercent: newBoost, boostAmount, nextCost, effectiveProbability: Math.min(adjustedBaseProbability + newBoost, 100) }; }); return result; @@ -30511,6 +30544,10 @@ admin.delete("/shop/items/:id", async ({ params, headers, status: status2 }) => } const itemId = parseInt(params.id); await db.delete(shopHeartsTable).where(eq(shopHeartsTable.shopItemId, itemId)); + await db.delete(shopRollsTable).where(eq(shopRollsTable.shopItemId, itemId)); + await db.delete(refineryOrdersTable).where(eq(refineryOrdersTable.shopItemId, itemId)); + await db.delete(shopPenaltiesTable).where(eq(shopPenaltiesTable.shopItemId, itemId)); + await db.delete(shopOrdersTable).where(eq(shopOrdersTable.shopItemId, itemId)); await db.delete(shopItemsTable).where(eq(shopItemsTable.id, itemId)); return { success: true }; } catch (err) { @@ -30677,6 +30714,83 @@ admin.patch("/orders/:id", async ({ params, body, headers, status: status2 }) => }); var admin_default = admin; +// src/lib/hackatime-sync.ts +var HACKATIME_API3 = "https://hackatime.hackclub.com/api/v1"; +var SCRAPS_START_DATE3 = "2026-02-03"; +var SYNC_INTERVAL_MS = 2 * 60 * 1000; +async function fetchHackatimeHours2(slackId, projectName) { + try { + const params = new URLSearchParams({ + features: "projects", + start_date: SCRAPS_START_DATE3, + filter_by_project: projectName + }); + const url = `${HACKATIME_API3}/users/${encodeURIComponent(slackId)}/stats?${params}`; + const response = await fetch(url, { + headers: { Accept: "application/json" } + }); + if (!response.ok) + return -1; + const data = await response.json(); + const project = data.data?.projects?.find((p) => p.name === projectName); + if (!project) + return 0; + return Math.round(project.total_seconds / 3600 * 10) / 10; + } catch { + return -1; + } +} +function parseHackatimeProject2(hackatimeProject) { + if (!hackatimeProject) + return null; + const slashIndex = hackatimeProject.indexOf("/"); + if (slashIndex === -1) + return null; + return { + slackId: hackatimeProject.substring(0, slashIndex), + projectName: hackatimeProject.substring(slashIndex + 1) + }; +} +async function syncAllProjects() { + console.log("[HACKATIME-SYNC] Starting sync..."); + const startTime = Date.now(); + try { + const projects2 = await db.select({ + id: projectsTable.id, + hackatimeProject: projectsTable.hackatimeProject, + hours: projectsTable.hours + }).from(projectsTable).where(and(isNotNull(projectsTable.hackatimeProject), or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)))); + let updated = 0; + let errors = 0; + for (const project of projects2) { + const parsed = parseHackatimeProject2(project.hackatimeProject); + if (!parsed) + continue; + const hours = await fetchHackatimeHours2(parsed.slackId, parsed.projectName); + if (hours < 0) { + errors++; + continue; + } + if (hours !== project.hours) { + await db.update(projectsTable).set({ hours, updatedAt: new Date }).where(eq(projectsTable.id, project.id)); + updated++; + } + } + const elapsed = Date.now() - startTime; + console.log(`[HACKATIME-SYNC] Completed: ${projects2.length} projects, ${updated} updated, ${errors} errors, ${elapsed}ms`); + } catch (error) { + console.error("[HACKATIME-SYNC] Error:", error); + } +} +var syncInterval = null; +function startHackatimeSync() { + if (syncInterval) + return; + console.log("[HACKATIME-SYNC] Starting background sync (every 2 minutes)"); + syncAllProjects(); + syncInterval = setInterval(syncAllProjects, SYNC_INTERVAL_MS); +} + // src/index.ts var api = new Elysia().use(auth_default).use(projects_default).use(news_default).use(user_default).use(shop_default).use(leaderboard_default).use(hackatime_default).use(upload_default).use(admin_default).get("/", () => "if you dm @notaroomba abt finding this you may get cool stickers"); var app = new Elysia().use(cors({ @@ -30684,3 +30798,4 @@ var app = new Elysia().use(cors({ credentials: true })).use(api).listen(config.port); console.log(`\uD83E\uDD8A Elysia is running at ${app.server?.hostname}:${app.server?.port}`); +startHackatimeSync(); diff --git a/backend/src/index.ts b/backend/src/index.ts index abd612b..6e9165d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -10,6 +10,7 @@ import leaderboard from './routes/leaderboard' import hackatime from './routes/hackatime' import upload from './routes/upload' import admin from './routes/admin' +import { startHackatimeSync } from './lib/hackatime-sync' const api = new Elysia() .use(authRoutes) @@ -34,3 +35,6 @@ const app = new Elysia() console.log( `馃 Elysia is running at ${app.server?.hostname}:${app.server?.port}` ) + +// Start background hackatime sync +startHackatimeSync() diff --git a/backend/src/lib/hackatime-sync.ts b/backend/src/lib/hackatime-sync.ts new file mode 100644 index 0000000..1616fab --- /dev/null +++ b/backend/src/lib/hackatime-sync.ts @@ -0,0 +1,121 @@ +import { db } from '../db' +import { projectsTable } from '../schemas/projects' +import { isNotNull, and, or, eq, isNull } from 'drizzle-orm' + +const HACKATIME_API = 'https://hackatime.hackclub.com/api/v1' +const SCRAPS_START_DATE = '2026-02-03' +const SYNC_INTERVAL_MS = 2 * 60 * 1000 // 2 minutes + +interface HackatimeStatsProject { + name: string + total_seconds: number +} + +interface HackatimeStatsResponse { + data: { + projects: HackatimeStatsProject[] + } +} + +async function fetchHackatimeHours(slackId: string, projectName: string): Promise { + try { + const params = new URLSearchParams({ + features: 'projects', + start_date: SCRAPS_START_DATE, + filter_by_project: projectName + }) + const url = `${HACKATIME_API}/users/${encodeURIComponent(slackId)}/stats?${params}` + const response = await fetch(url, { + headers: { 'Accept': 'application/json' } + }) + if (!response.ok) return -1 + + const data: HackatimeStatsResponse = await response.json() + const project = data.data?.projects?.find(p => p.name === projectName) + if (!project) return 0 + + return Math.round(project.total_seconds / 3600 * 10) / 10 + } catch { + return -1 + } +} + +function parseHackatimeProject(hackatimeProject: string | null): { slackId: string; projectName: string } | null { + if (!hackatimeProject) return null + const slashIndex = hackatimeProject.indexOf('/') + if (slashIndex === -1) return null + return { + slackId: hackatimeProject.substring(0, slashIndex), + projectName: hackatimeProject.substring(slashIndex + 1) + } +} + +async function syncAllProjects(): Promise { + console.log('[HACKATIME-SYNC] Starting sync...') + const startTime = Date.now() + + try { + // Get all projects with hackatime projects that are not deleted + const projects = await db + .select({ + id: projectsTable.id, + hackatimeProject: projectsTable.hackatimeProject, + hours: projectsTable.hours + }) + .from(projectsTable) + .where(and( + isNotNull(projectsTable.hackatimeProject), + or(eq(projectsTable.deleted, 0), isNull(projectsTable.deleted)) + )) + + let updated = 0 + let errors = 0 + + for (const project of projects) { + const parsed = parseHackatimeProject(project.hackatimeProject) + if (!parsed) continue + + const hours = await fetchHackatimeHours(parsed.slackId, parsed.projectName) + if (hours < 0) { + errors++ + continue + } + + // Only update if hours changed + if (hours !== project.hours) { + await db + .update(projectsTable) + .set({ hours, updatedAt: new Date() }) + .where(eq(projectsTable.id, project.id)) + updated++ + } + } + + const elapsed = Date.now() - startTime + console.log(`[HACKATIME-SYNC] Completed: ${projects.length} projects, ${updated} updated, ${errors} errors, ${elapsed}ms`) + } catch (error) { + console.error('[HACKATIME-SYNC] Error:', error) + } +} + +let syncInterval: ReturnType | null = null + +export function startHackatimeSync(): void { + if (syncInterval) return + + console.log('[HACKATIME-SYNC] Starting background sync (every 2 minutes)') + + // Run immediately on start + syncAllProjects() + + // Then run every 2 minutes + syncInterval = setInterval(syncAllProjects, SYNC_INTERVAL_MS) +} + +export function stopHackatimeSync(): void { + if (syncInterval) { + clearInterval(syncInterval) + syncInterval = null + console.log('[HACKATIME-SYNC] Stopped background sync') + } +} diff --git a/backend/src/lib/scraps.ts b/backend/src/lib/scraps.ts index 96869c1..233fc3f 100644 --- a/backend/src/lib/scraps.ts +++ b/backend/src/lib/scraps.ts @@ -7,14 +7,83 @@ import { userBonusesTable } from '../schemas/users' export const PHI = (1 + Math.sqrt(5)) / 2 export const MULTIPLIER = 10 +export const SCRAPS_PER_HOUR = PHI * MULTIPLIER +export const DOLLARS_PER_HOUR = 5 +export const SCRAPS_PER_DOLLAR = SCRAPS_PER_HOUR / DOLLARS_PER_HOUR export const TIER_MULTIPLIERS: Record = { - 1: 0.75, + 1: 0.8, 2: 1.0, 3: 1.25, 4: 1.5 } +export interface ShopItemPricing { + price: number + baseProbability: number + baseUpgradeCost: number + costMultiplier: number + boostAmount: number +} + +export function calculateShopItemPricing(monetaryValue: number, stockCount: number): ShopItemPricing { + const price = Math.round(monetaryValue * SCRAPS_PER_DOLLAR) + + // Rarity based on price and stock + // Higher price = rarer, fewer stock = rarer + // Base probability ranges from 5% (very rare) to 80% (common) + const priceRarityFactor = Math.max(0, 1 - monetaryValue / 100) // $100+ = max rarity + const stockRarityFactor = Math.min(1, stockCount / 20) // 20+ stock = common + const baseProbability = Math.max(5, Math.min(80, Math.round((priceRarityFactor * 0.4 + stockRarityFactor * 0.6) * 80))) + + // Roll cost = price * (baseProbability / 100) - fixed, doesn't change with upgrades + const rollCost = Math.max(1, Math.round(price * (baseProbability / 100))) + + // Total budget = 1.5x price + // Upgrade budget = 1.5x price - rollCost + const upgradeBudget = Math.max(0, price * 1.5 - rollCost) + + // Number of upgrades needed to go from baseProbability to 100% + const probabilityGap = 100 - baseProbability + + // Boost amount: how much each upgrade increases probability + // Target ~10-20 upgrades for expensive items, fewer for cheap + const targetUpgrades = Math.max(5, Math.min(20, Math.ceil(monetaryValue / 5))) + const boostAmount = Math.max(1, Math.round(probabilityGap / targetUpgrades)) + + // Actual number of upgrades needed to reach 100% + const actualUpgrades = Math.ceil(probabilityGap / boostAmount) + + // Calculate base cost and multiplier so sum of geometric series = upgradeBudget + // Sum = base * (mult^n - 1) / (mult - 1) + const costMultiplier = 110 // 1.10x per upgrade (stored as percentage) + const multiplierDecimal = costMultiplier / 100 + + // Calculate base cost from budget + let baseUpgradeCost: number + if (actualUpgrades <= 0 || upgradeBudget <= 0) { + baseUpgradeCost = Math.round(price * 0.05) || 1 + } else { + // Sum of geometric series: base * (r^n - 1) / (r - 1) + const seriesSum = (Math.pow(multiplierDecimal, actualUpgrades) - 1) / (multiplierDecimal - 1) + baseUpgradeCost = Math.max(1, Math.round(upgradeBudget / seriesSum)) + } + + return { + price, + baseProbability, + baseUpgradeCost, + costMultiplier, + boostAmount + } +} + +export function calculateRollCost(basePrice: number, baseProbability: number): number { + // Roll cost is fixed based on base probability, doesn't change with upgrades + // This means rarer items (lower base probability) cost less per roll + return Math.max(1, Math.round(basePrice * (baseProbability / 100))) +} + export function calculateScrapsFromHours(hours: number, tier: number = 1): number { const tierMultiplier = TIER_MULTIPLIERS[tier] ?? 1.0 return Math.floor(hours * PHI * MULTIPLIER * tierMultiplier) diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index a52bc7f..83461f1 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -4,7 +4,7 @@ import { db } from '../db' import { usersTable, userBonusesTable } from '../schemas/users' import { projectsTable } from '../schemas/projects' import { reviewsTable } from '../schemas/reviews' -import { shopItemsTable, shopOrdersTable, shopHeartsTable } from '../schemas/shop' +import { shopItemsTable, shopOrdersTable, shopHeartsTable, shopRollsTable, refineryOrdersTable, shopPenaltiesTable } from '../schemas/shop' import { newsTable } from '../schemas/news' import { activityTable } from '../schemas/activity' import { getUserFromSession } from '../lib/auth' @@ -701,13 +701,15 @@ admin.delete('/shop/items/:id', async ({ params, headers, status }) => { const itemId = parseInt(params.id) - await db - .delete(shopHeartsTable) - .where(eq(shopHeartsTable.shopItemId, itemId)) + // Delete all related records first (cascade manually) + await db.delete(shopHeartsTable).where(eq(shopHeartsTable.shopItemId, itemId)) + await db.delete(shopRollsTable).where(eq(shopRollsTable.shopItemId, itemId)) + await db.delete(refineryOrdersTable).where(eq(refineryOrdersTable.shopItemId, itemId)) + await db.delete(shopPenaltiesTable).where(eq(shopPenaltiesTable.shopItemId, itemId)) + await db.delete(shopOrdersTable).where(eq(shopOrdersTable.shopItemId, itemId)) - await db - .delete(shopItemsTable) - .where(eq(shopItemsTable.id, itemId)) + // Now delete the item itself + await db.delete(shopItemsTable).where(eq(shopItemsTable.id, itemId)) return { success: true } } catch (err) { diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 2f14ddf..751d3a7 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -12,7 +12,8 @@ import { config } from "../config" import { getUserScrapsBalance } from "../lib/scraps" import { db } from "../db" import { userEmailsTable } from "../schemas" -import { eq } from "drizzle-orm" +import { userBonusesTable } from "../schemas/users" +import { eq, and } from "drizzle-orm" const FRONTEND_URL = config.frontendUrl @@ -123,6 +124,28 @@ authRoutes.get("/me", async ({ headers }) => { if (user.role === 'banned') { return { user: null, banned: true } } + + // Auto-award tutorial bonus if tutorial is completed but bonus wasn't given + if (user.tutorialCompleted) { + const existingBonus = await db + .select({ id: userBonusesTable.id }) + .from(userBonusesTable) + .where(and( + eq(userBonusesTable.userId, user.id), + eq(userBonusesTable.reason, 'tutorial_completion') + )) + .limit(1) + + if (existingBonus.length === 0) { + await db.insert(userBonusesTable).values({ + userId: user.id, + reason: 'tutorial_completion', + amount: 10 + }) + console.log("[AUTH] Auto-awarded tutorial bonus for user:", user.id) + } + } + const scrapsBalance = await getUserScrapsBalance(user.id) return { user: { diff --git a/backend/src/routes/shop.ts b/backend/src/routes/shop.ts index 8f43626..7607e2f 100644 --- a/backend/src/routes/shop.ts +++ b/backend/src/routes/shop.ts @@ -4,7 +4,7 @@ import { db } from '../db' import { shopItemsTable, shopHeartsTable, shopOrdersTable, shopRollsTable, refineryOrdersTable, shopPenaltiesTable } from '../schemas/shop' import { usersTable } from '../schemas/users' import { getUserFromSession } from '../lib/auth' -import { getUserScrapsBalance, canAfford } from '../lib/scraps' +import { getUserScrapsBalance, canAfford, calculateRollCost } from '../lib/scraps' const shop = new Elysia({ prefix: '/shop' }) @@ -39,7 +39,8 @@ shop.get('/items', async ({ headers }) => { const userBoosts = await db .select({ shopItemId: refineryOrdersTable.shopItemId, - boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)` + boostPercent: sql`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`, + upgradeCount: sql`COUNT(*)` }) .from(refineryOrdersTable) .where(eq(refineryOrdersTable.userId, user.id)) @@ -54,20 +55,26 @@ shop.get('/items', async ({ headers }) => { .where(eq(shopPenaltiesTable.userId, user.id)) const heartedIds = new Set(userHearts.map(h => h.shopItemId)) - const boostMap = new Map(userBoosts.map(b => [b.shopItemId, Number(b.boostPercent)])) + const boostMap = new Map(userBoosts.map(b => [b.shopItemId, { boostPercent: Number(b.boostPercent), upgradeCount: Number(b.upgradeCount) }])) const penaltyMap = new Map(userPenalties.map(p => [p.shopItemId, p.probabilityMultiplier])) return items.map(item => { - const userBoostPercent = boostMap.get(item.id) ?? 0 + const boostData = boostMap.get(item.id) ?? { boostPercent: 0, upgradeCount: 0 } const penaltyMultiplier = penaltyMap.get(item.id) ?? 100 const adjustedBaseProbability = Math.floor(item.baseProbability * penaltyMultiplier / 100) + const maxBoost = 100 - adjustedBaseProbability + const nextUpgradeCost = boostData.boostPercent >= maxBoost + ? null + : Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, boostData.upgradeCount)) return { ...item, heartCount: Number(item.heartCount) || 0, - userBoostPercent, + userBoostPercent: boostData.boostPercent, + upgradeCount: boostData.upgradeCount, adjustedBaseProbability, - effectiveProbability: Math.min(adjustedBaseProbability + userBoostPercent, 100), - userHearted: heartedIds.has(item.id) + effectiveProbability: Math.min(adjustedBaseProbability + boostData.boostPercent, 100), + userHearted: heartedIds.has(item.id), + nextUpgradeCost } }) } @@ -76,8 +83,10 @@ shop.get('/items', async ({ headers }) => { ...item, heartCount: Number(item.heartCount) || 0, userBoostPercent: 0, + upgradeCount: 0, effectiveProbability: Math.min(item.baseProbability, 100), - userHearted: false + userHearted: false, + nextUpgradeCost: item.baseUpgradeCost })) }) @@ -438,6 +447,16 @@ shop.post('/items/:id/try-luck', async ({ params, headers }) => { const adjustedBaseProbability = Math.floor(currentItem[0].baseProbability * penaltyMultiplier / 100) const effectiveProbability = Math.min(adjustedBaseProbability + boostPercent, 100) + // Calculate roll cost based on BASE probability (fixed, doesn't change with upgrades) + const rollCost = calculateRollCost(currentItem[0].price, currentItem[0].baseProbability) + + // Check if user can afford the roll cost + const canAffordRoll = await canAfford(user.id, rollCost, tx) + if (!canAffordRoll) { + const { balance } = await getUserScrapsBalance(user.id, tx) + throw { type: 'insufficient_funds', balance, cost: rollCost } + } + const rolled = Math.floor(Math.random() * 100) + 1 const won = rolled <= effectiveProbability @@ -465,8 +484,8 @@ shop.post('/items/:id/try-luck', async ({ params, headers }) => { userId: user.id, shopItemId: itemId, quantity: 1, - pricePerItem: item.price, - totalPrice: item.price, + pricePerItem: rollCost, + totalPrice: rollCost, shippingAddress: null, status: 'pending', orderType: 'luck_win' @@ -511,7 +530,7 @@ shop.post('/items/:id/try-luck', async ({ params, headers }) => { }) } - return { won: true, orderId: newOrder[0].id, effectiveProbability, rolled } + return { won: true, orderId: newOrder[0].id, effectiveProbability, rolled, rollCost } } // Create consolation order for scrap paper when user loses @@ -521,8 +540,8 @@ shop.post('/items/:id/try-luck', async ({ params, headers }) => { userId: user.id, shopItemId: itemId, quantity: 1, - pricePerItem: item.price, - totalPrice: item.price, + pricePerItem: rollCost, + totalPrice: rollCost, shippingAddress: null, status: 'pending', orderType: 'consolation', @@ -530,17 +549,17 @@ shop.post('/items/:id/try-luck', async ({ params, headers }) => { }) .returning() - return { won: false, effectiveProbability, rolled, consolationOrderId: consolationOrder[0].id } + return { won: false, effectiveProbability, rolled, rollCost, consolationOrderId: consolationOrder[0].id } }) if (result.won) { - return { success: true, won: true, orderId: result.orderId, effectiveProbability: result.effectiveProbability, rolled: result.rolled, refineryReset: true, probabilityHalved: true } + return { success: true, won: true, orderId: result.orderId, effectiveProbability: result.effectiveProbability, rolled: result.rolled, rollCost: result.rollCost, refineryReset: true, probabilityHalved: true } } - return { success: true, won: false, consolationOrderId: result.consolationOrderId, effectiveProbability: result.effectiveProbability, rolled: result.rolled } + return { success: true, won: false, consolationOrderId: result.consolationOrderId, effectiveProbability: result.effectiveProbability, rolled: result.rolled, rollCost: result.rollCost } } catch (e) { - const err = e as { type?: string; balance?: number } + const err = e as { type?: string; balance?: number; cost?: number } if (err.type === 'insufficient_funds') { - return { error: 'Insufficient scraps', required: item.price, available: err.balance } + return { error: 'Insufficient scraps', required: err.cost, available: err.balance } } if (err.type === 'out_of_stock') { return { error: 'Out of stock' } @@ -606,7 +625,17 @@ shop.post('/items/:id/upgrade-probability', async ({ params, headers }) => { throw { type: 'max_probability' } } - const cost = Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, currentBoost)) + // Count number of upgrades purchased (not total boost %) + const upgradeCountResult = await tx + .select({ count: sql`COUNT(*)` }) + .from(refineryOrdersTable) + .where(and( + eq(refineryOrdersTable.userId, user.id), + eq(refineryOrdersTable.shopItemId, itemId) + )) + const upgradeCount = Number(upgradeCountResult[0]?.count) || 0 + + const cost = Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, upgradeCount)) const affordable = await canAfford(user.id, cost, tx) if (!affordable) { @@ -625,9 +654,10 @@ shop.post('/items/:id/upgrade-probability', async ({ params, headers }) => { boostAmount }) + const newUpgradeCount = upgradeCount + 1 const nextCost = newBoost >= maxBoost ? null - : Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, newBoost)) + : Math.floor(item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, newUpgradeCount)) return { boostPercent: newBoost, boostAmount, nextCost, effectiveProbability: Math.min(adjustedBaseProbability + newBoost, 100) } }) diff --git a/frontend/src/lib/components/ShopItemModal.svelte b/frontend/src/lib/components/ShopItemModal.svelte index 22d1f06..c8e14df 100644 --- a/frontend/src/lib/components/ShopItemModal.svelte +++ b/frontend/src/lib/components/ShopItemModal.svelte @@ -49,7 +49,8 @@ let showConfirmation = $state(false); let localHearted = $state(item.userHearted); let localHeartCount = $state(item.heartCount); - let canAfford = $derived($userScrapsStore >= item.price); + let rollCost = $derived(Math.max(1, Math.round(item.price * (item.baseProbability / 100)))); + let canAfford = $derived($userScrapsStore >= rollCost); let alertMessage = $state(null); let alertType = $state<'error' | 'info'>('info'); @@ -223,7 +224,7 @@
- {item.price} + {rollCost} {item.count} left
@@ -391,8 +392,7 @@

confirm try your luck

- are you sure you want to try your luck? this will cost {item.price} scraps. + are you sure you want to try your luck? this will cost {rollCost} scraps. your chance: {item.effectiveProbability.toFixed(1)}% - + + + scraps - + + + + + + + + + + +

diff --git a/frontend/src/routes/admin/shop/+page.svelte b/frontend/src/routes/admin/shop/+page.svelte index 36ed59a..5eedb20 100644 --- a/frontend/src/routes/admin/shop/+page.svelte +++ b/frontend/src/routes/admin/shop/+page.svelte @@ -46,17 +46,66 @@ let formBoostAmount = $state(1); let formMonetaryValue = $state(0); let formError = $state(null); + let errorModal = $state(null); const PHI = (1 + Math.sqrt(5)) / 2; const SCRAPS_PER_HOUR = PHI * 10; const DOLLARS_PER_HOUR = 5; const SCRAPS_PER_DOLLAR = SCRAPS_PER_HOUR / DOLLARS_PER_HOUR; + function calculatePricing(monetaryValue: number, stockCount: number) { + const price = Math.round(monetaryValue * SCRAPS_PER_DOLLAR); + + // Rarity based on price and stock + const priceRarityFactor = Math.max(0, 1 - monetaryValue / 100); + const stockRarityFactor = Math.min(1, stockCount / 20); + const baseProbability = Math.max( + 5, + Math.min(80, Math.round((priceRarityFactor * 0.4 + stockRarityFactor * 0.6) * 80)) + ); + + // Roll cost = price * (baseProbability / 100) - fixed + const rollCost = Math.max(1, Math.round(price * (baseProbability / 100))); + // Total budget = 1.5x price, upgrade budget = 1.5x price - rollCost + const upgradeBudget = Math.max(0, price * 1.5 - rollCost); + const probabilityGap = 100 - baseProbability; + + const targetUpgrades = Math.max(5, Math.min(20, Math.ceil(monetaryValue / 5))); + const boostAmount = Math.max(1, Math.round(probabilityGap / targetUpgrades)); + const actualUpgrades = Math.ceil(probabilityGap / boostAmount); + + const costMultiplier = 110; + const multiplierDecimal = costMultiplier / 100; + + let baseUpgradeCost: number; + if (actualUpgrades <= 0 || upgradeBudget <= 0) { + baseUpgradeCost = Math.round(price * 0.05) || 1; + } else { + const seriesSum = + (Math.pow(multiplierDecimal, actualUpgrades) - 1) / (multiplierDecimal - 1); + baseUpgradeCost = Math.max(1, Math.round(upgradeBudget / seriesSum)); + } + + return { price, baseProbability, baseUpgradeCost, costMultiplier, boostAmount }; + } + + function recalculatePricing() { + const pricing = calculatePricing(formMonetaryValue, formCount); + formPrice = pricing.price; + formBaseProbability = pricing.baseProbability; + formBaseUpgradeCost = pricing.baseUpgradeCost; + formCostMultiplier = pricing.costMultiplier; + formBoostAmount = pricing.boostAmount; + } + function updateFromMonetary(value: number) { formMonetaryValue = value; - formPrice = Math.round(value * SCRAPS_PER_DOLLAR); - formBaseUpgradeCost = Math.round(formPrice * 0.1) || 1; - formBaseProbability = Math.max(0.1, Math.min(100, Math.round((100 - value * 2) * 10) / 10)); + recalculatePricing(); + } + + function updateFromStock(value: number) { + formCount = value; + recalculatePricing(); } let deleteConfirmId = $state(null); @@ -185,9 +234,13 @@ }); if (response.ok) { await fetchItems(); + } else { + const data = await response.json(); + errorModal = data.error || 'Failed to delete item'; } } catch (e) { console.error('Failed to delete:', e); + errorModal = 'Failed to delete item'; } finally { deleteConfirmId = null; } @@ -348,9 +401,30 @@ class="w-full rounded-lg border-2 border-black px-4 py-2 focus:border-dashed focus:outline-none" />

- = {formPrice} scraps 路 {formBaseProbability}% base probability 路 {formBaseUpgradeCost} upgrade - cost 路 ~{(formPrice / SCRAPS_PER_HOUR).toFixed(1)} hrs + = {formPrice} scraps 路 {formBaseProbability}% base 路 +{formBoostAmount}%/upgrade 路 + ~{(formPrice / SCRAPS_PER_HOUR).toFixed(1)} hrs to earn

+ {#if formPrice > 0} + {@const rollCost = Math.max(1, Math.round(formPrice * (formBaseProbability / 100)))} + {@const probabilityGap = 100 - formBaseProbability} + {@const upgradesNeeded = Math.ceil(probabilityGap / formBoostAmount)} + {@const multiplierDecimal = formCostMultiplier / 100} + {@const totalUpgradeCost = + formBaseUpgradeCost * + ((Math.pow(multiplierDecimal, upgradesNeeded) - 1) / (multiplierDecimal - 1))} + {@const totalCost = totalUpgradeCost + rollCost} + {@const maxBudget = formPrice * 1.5} +

+ roll cost: {rollCost} scraps 路 upgrades to 100%: {Math.round(totalUpgradeCost)} scraps +

+

+ total: {Math.round(totalCost)} scraps ({upgradesNeeded} upgrades + roll) 路 + budget: {Math.round(maxBudget)} (1.5脳) + {#if totalCost > maxBudget}路 鈿狅笍 over budget!{/if} +

+ {/if}
@@ -359,10 +433,12 @@ updateFromStock(parseInt(e.currentTarget.value) || 0)} min="0" class="w-full rounded-lg border-2 border-black px-4 py-2 focus:border-dashed focus:outline-none" /> +

affects rarity calculation

@@ -487,3 +563,24 @@
{/if} + +{#if errorModal} +
e.target === e.currentTarget && (errorModal = null)} + onkeydown={(e) => e.key === 'Escape' && (errorModal = null)} + role="dialog" + tabindex="-1" + > +
+

error

+

{errorModal}

+ +
+
+{/if} diff --git a/frontend/src/routes/refinery/+page.svelte b/frontend/src/routes/refinery/+page.svelte index 693d352..d36763b 100644 --- a/frontend/src/routes/refinery/+page.svelte +++ b/frontend/src/routes/refinery/+page.svelte @@ -7,12 +7,6 @@ let upgrading = $state(null); let alertMessage = $state(null); - function calculateNextCost(item: ShopItem): number { - return Math.floor( - item.baseUpgradeCost * Math.pow(item.costMultiplier / 100, item.userBoostPercent) - ); - } - function getProbabilityColor(probability: number): string { if (probability >= 70) return 'text-green-600'; if (probability >= 40) return 'text-yellow-600'; @@ -40,7 +34,8 @@ ? { ...i, userBoostPercent: data.boostPercent, - effectiveProbability: data.effectiveProbability + effectiveProbability: data.effectiveProbability, + nextUpgradeCost: data.nextCost } : i ) @@ -74,8 +69,8 @@ {:else if probabilityItems.length > 0}
{#each probabilityItems as item (item.id)} - {@const nextCost = calculateNextCost(item)} - {@const maxed = item.effectiveProbability >= 100} + {@const nextCost = item.nextUpgradeCost} + {@const maxed = item.effectiveProbability >= 100 || nextCost === null}
@@ -116,7 +111,7 @@ class="inline-block rounded-full bg-gray-200 px-4 py-2 font-bold text-gray-600" >maxed - {:else} + {:else if nextCost !== null}