fix shop, added SEO and Hackatime sync intervals

This commit is contained in:
Nathan 2026-02-05 10:44:59 -05:00
parent 5a36984110
commit 298466d0a2
13 changed files with 552 additions and 82 deletions

171
backend/dist/index.js vendored
View file

@ -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();

View file

@ -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()

View file

@ -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<number> {
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<void> {
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<typeof setInterval> | 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')
}
}

View file

@ -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<number, number> = {
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)

View file

@ -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) {

View file

@ -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: {

View file

@ -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<number>`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`
boostPercent: sql<number>`COALESCE(SUM(${refineryOrdersTable.boostAmount}), 0)`,
upgradeCount: sql<number>`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<number>`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) }
})

View file

@ -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<string | null>(null);
let alertType = $state<'error' | 'info'>('info');
@ -223,7 +224,7 @@
<div class="flex items-center gap-4">
<span class="flex items-center gap-1 text-xl font-bold">
<Spool size={20} />
{item.price}
{rollCost}
</span>
<span class="text-sm text-gray-500">{item.count} left</span>
</div>
@ -391,8 +392,7 @@
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-4 text-2xl font-bold">confirm try your luck</h2>
<p class="mb-6 text-gray-600">
are you sure you want to try your luck? this will cost <strong>{item.price} scraps</strong
>.
are you sure you want to try your luck? this will cost <strong>{rollCost} scraps</strong>.
<span class="mt-2 block">
your chance: <strong class={getProbabilityColor(item.effectiveProbability)}
>{item.effectiveProbability.toFixed(1)}%</strong

View file

@ -31,7 +31,9 @@ export interface ShopItem {
costMultiplier: number;
boostAmount: number;
userBoostPercent: number;
upgradeCount: number;
effectiveProbability: number;
nextUpgradeCost: number | null;
}
export interface LeaderboardEntry {

View file

@ -44,10 +44,21 @@
}
</script>
<svelte:head
><link rel="icon" href={favicon} />
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="apple-touch-icon" href={favicon} />
<title>scraps</title>
<meta name="description" content="a ysws where you get scraps from hack club hq" />
<meta name="description" content="scraps - earn scraps by building projects and spend them in the shop to win prizes from hack club hq" />
<meta name="keywords" content="hack club, scraps, ysws, projects, coding, prizes" />
<meta name="author" content="Hack Club" />
<meta property="og:title" content="scraps" />
<meta property="og:description" content="earn scraps by building projects and spend them in the shop to win prizes from hack club hq" />
<meta property="og:type" content="website" />
<meta property="og:image" content={favicon} />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="scraps" />
<meta name="twitter:description" content="earn scraps by building projects and spend them in the shop to win prizes from hack club hq" />
<meta name="theme-color" content="#000000" />
</svelte:head>
<div class="flex min-h-dvh flex-col">

View file

@ -46,17 +46,66 @@
let formBoostAmount = $state(1);
let formMonetaryValue = $state(0);
let formError = $state<string | null>(null);
let errorModal = $state<string | null>(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<number | null>(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"
/>
<p class="mt-1 text-xs text-gray-500">
= {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
</p>
{#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}
<p class="mt-1 text-xs text-gray-500">
roll cost: {rollCost} scraps · upgrades to 100%: {Math.round(totalUpgradeCost)} scraps
</p>
<p
class="mt-1 text-xs {totalCost > maxBudget ? 'font-bold text-red-600' : 'text-gray-500'}"
>
total: {Math.round(totalCost)} scraps ({upgradesNeeded} upgrades + roll) ·
budget: {Math.round(maxBudget)} (1.5×)
{#if totalCost > maxBudget}· ⚠️ over budget!{/if}
</p>
{/if}
</div>
<div class="grid grid-cols-2 gap-4">
@ -359,10 +433,12 @@
<input
id="count"
type="number"
bind:value={formCount}
value={formCount}
oninput={(e) => 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"
/>
<p class="mt-1 text-xs text-gray-500">affects rarity calculation</p>
</div>
<div>
<label for="category" class="mb-1 block text-sm font-bold">categories</label>
@ -487,3 +563,24 @@
</div>
</div>
{/if}
{#if errorModal}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={(e) => e.target === e.currentTarget && (errorModal = null)}
onkeydown={(e) => e.key === 'Escape' && (errorModal = null)}
role="dialog"
tabindex="-1"
>
<div class="w-full max-w-md rounded-2xl border-4 border-black bg-white p-6">
<h2 class="mb-4 text-2xl font-bold">error</h2>
<p class="mb-6 text-gray-600">{errorModal}</p>
<button
onclick={() => (errorModal = null)}
class="w-full cursor-pointer rounded-full border-4 border-black bg-black px-4 py-2 font-bold text-white transition-all duration-200 hover:border-dashed"
>
ok
</button>
</div>
</div>
{/if}

View file

@ -7,12 +7,6 @@
let upgrading = $state<number | null>(null);
let alertMessage = $state<string | null>(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}
<div class="space-y-6">
{#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}
<div
class="rounded-2xl border-4 border-black p-4 transition-all hover:border-dashed sm:p-6"
>
@ -116,7 +111,7 @@
class="inline-block rounded-full bg-gray-200 px-4 py-2 font-bold text-gray-600"
>maxed</span
>
{:else}
{:else if nextCost !== null}
<button
onclick={() => upgradeProbability(item)}
disabled={upgrading === item.id || $userScrapsStore < nextCost}

View file

@ -244,6 +244,7 @@
<!-- Items Grid -->
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each sortedItems as item (item.id)}
{@const rollCost = Math.max(1, Math.round(item.price * (item.baseProbability / 100)))}
<button
onclick={() => (selectedItem = item)}
class="relative cursor-pointer overflow-hidden rounded-2xl border-4 border-black p-4 text-left transition-all hover:border-dashed {item.count ===
@ -275,10 +276,10 @@
<p class="mb-2 text-sm text-gray-600">{item.description}</p>
<div class="mb-3">
<span class="flex items-center gap-1 text-lg font-bold"
><Spool size={18} />{item.price}</span
><Spool size={18} />{rollCost}</span
>
<span class="mt-1 flex items-center gap-1 text-xs text-gray-500"
><Clock size={14} />~{estimateHours(item.price)}h</span
><Clock size={14} />~{estimateHours(rollCost)}h</span
>
<div class="mt-2 flex flex-wrap gap-1">
{#each item.category