mirror of
https://github.com/System-End/scraps.git
synced 2026-04-19 18:35:20 +00:00
fix shop, added SEO and Hackatime sync intervals
This commit is contained in:
parent
5a36984110
commit
298466d0a2
13 changed files with 552 additions and 82 deletions
171
backend/dist/index.js
vendored
171
backend/dist/index.js
vendored
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
121
backend/src/lib/hackatime-sync.ts
Normal file
121
backend/src/lib/hackatime-sync.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ export interface ShopItem {
|
|||
costMultiplier: number;
|
||||
boostAmount: number;
|
||||
userBoostPercent: number;
|
||||
upgradeCount: number;
|
||||
effectiveProbability: number;
|
||||
nextUpgradeCost: number | null;
|
||||
}
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue