async function request(path, options = {}) { const headers = { ...(options.headers || {}) }; if (!(options.body instanceof FormData)) { headers['Content-Type'] = headers['Content-Type'] || 'application/json'; } const res = await fetch(path, { credentials: 'include', ...options, headers, }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.error || `Request failed (${res.status})`); } return data; } export const api = { me: () => request('/api/auth/me'), login: (username, password) => request('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }), }), register: (username, password) => request('/api/auth/register', { method: 'POST', body: JSON.stringify({ username, password }), }), logout: () => request('/api/auth/logout', { method: 'POST' }), cases: () => request('/api/cases'), case: (id) => request(`/api/cases/${id}`), catalog: () => request('/api/catalog'), prestige: () => request('/api/prestige'), doPrestige: () => request('/api/prestige', { method: 'POST', body: JSON.stringify({}) }), prestigeUpgrade: (skillId, count = 1) => request('/api/prestige/upgrade', { method: 'POST', body: JSON.stringify({ skillId, count }), }), openCase: (id, count = 1) => request(`/api/cases/${id}/open`, { method: 'POST', body: JSON.stringify({ count }), }), inventory: () => request('/api/inventory'), vaultStake: (inventoryItemIds) => request('/api/vault/stake', { method: 'POST', body: JSON.stringify({ inventoryItemIds }), }), vaultUnstake: (inventoryItemIds) => request('/api/vault/unstake', { method: 'POST', body: JSON.stringify({ inventoryItemIds }), }), sellInventory: (inventoryItemIds) => request('/api/inventory/sell', { method: 'POST', body: JSON.stringify({ inventoryItemIds }), }), updateAutoSell: (body) => request('/api/inventory/auto-sell', { method: 'PATCH', body: JSON.stringify(body), }), applyPendingAutoSell: (inventoryItemIds) => request('/api/inventory/auto-sell/apply', { method: 'POST', body: JSON.stringify({ inventoryItemIds }), }), setInventoryFavorite: (inventoryItemIds, favorite) => request('/api/inventory/favorite', { method: 'PATCH', body: JSON.stringify({ inventoryItemIds, favorite }), }), shop: () => request('/api/shop'), shopBuy: (packageId) => request('/api/shop/buy', { method: 'POST', body: JSON.stringify({ packageId }), }), shopOsuCheckout: (packId) => request('/api/shop/osu/checkout', { method: 'POST', body: JSON.stringify({ packId }), }), shopOsuConfirm: (orderId) => request('/api/shop/osu/confirm', { method: 'POST', body: JSON.stringify({ orderId }), }), shopOsuOrder: (orderId) => request(`/api/shop/osu/orders/${encodeURIComponent(orderId)}`), shopAd: () => request('/api/shop/ad', { method: 'POST' }), feed: () => request('/api/feed'), feedAnnounce: (inventoryItemIds, caseName, opts = {}) => { const ids = Array.isArray(inventoryItemIds) ? inventoryItemIds : inventoryItemIds != null ? [inventoryItemIds] : []; const body = { inventoryItemIds: ids.filter(Boolean), caseName, respin: Boolean(opts.respin), }; if (opts.snapshotBest) { body.snapshotBest = opts.snapshotBest; body.openCount = opts.openCount; body.count = opts.count; } else if (opts.snapshotItems) { body.snapshotItems = opts.snapshotItems; } return request('/api/feed/announce', { method: 'POST', body: JSON.stringify(body), }); }, leaderboard: () => request('/api/leaderboard'), jackpot: () => request('/api/jackpot'), jackpotRound: (id) => request(`/api/jackpot/${id}`), jackpotCreateBet: (inventoryItemIds) => request('/api/jackpot/bet', { method: 'POST', body: JSON.stringify({ inventoryItemIds }), }), jackpotBet: (id, inventoryItemIds) => request(`/api/jackpot/${id}/bet`, { method: 'POST', body: JSON.stringify({ inventoryItemIds }), }), jackpotWithdraw: (id, inventoryItemIds) => request(`/api/jackpot/${id}/withdraw`, { method: 'POST', body: JSON.stringify({ inventoryItemIds }), }), // legacy alias jackpotCreate: (inventoryItemIds) => request('/api/jackpot/bet', { method: 'POST', body: JSON.stringify({ inventoryItemIds }), }), profile: () => request('/api/profile'), playerProfile: (username) => request(`/api/profile/player/${encodeURIComponent(username)}`), achievements: () => request('/api/achievements'), updateProfile: (body) => request('/api/profile', { method: 'PATCH', body: JSON.stringify(body) }), changePassword: (currentPassword, newPassword) => request('/api/profile/password', { method: 'POST', body: JSON.stringify({ currentPassword, newPassword }), }), uploadAvatar: (file) => { const form = new FormData(); form.append('avatar', file); return request('/api/profile/avatar', { method: 'POST', body: form }); }, adminLogin: (username, password) => request('/api/admin/login', { method: 'POST', body: JSON.stringify({ username, password }), }), adminStats: () => request('/api/admin/stats'), adminPrestigeServerPreview: () => request('/api/admin/prestige-server'), adminPrestigeServer: (body) => request('/api/admin/prestige-server', { method: 'POST', body: JSON.stringify(body), }), adminUsers: () => request('/api/admin/users'), adminUpdateUser: (id, body) => request(`/api/admin/users/${id}`, { method: 'PATCH', body: JSON.stringify(body), }), adminCases: () => request('/api/admin/cases'), adminItems: () => request('/api/admin/items'), adminRarities: () => request('/api/admin/rarities'), adminCategories: () => request('/api/admin/categories'), adminShopOsuPacks: () => request('/api/admin/shop/osu-packs'), adminShopOsuSection: (enabled) => request('/api/admin/shop/osu-section', { method: 'PUT', body: JSON.stringify({ enabled }), }), adminUpdateShopOsuPack: (id, body) => request(`/api/admin/shop/osu-packs/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(body), }), createCategory: (body) => request('/api/admin/categories', { method: 'POST', body: JSON.stringify(body) }), updateCategory: (id, body) => request(`/api/admin/categories/${id}`, { method: 'PUT', body: JSON.stringify(body) }), deleteCategory: (id) => request(`/api/admin/categories/${id}`, { method: 'DELETE' }), createCase: (body) => request('/api/admin/cases', { method: 'POST', body: JSON.stringify(body) }), updateCase: (id, body) => request(`/api/admin/cases/${id}`, { method: 'PUT', body: JSON.stringify(body) }), deleteCase: (id) => request(`/api/admin/cases/${id}`, { method: 'DELETE' }), createItem: (body) => request('/api/admin/items', { method: 'POST', body: JSON.stringify(body) }), updateItem: (id, body) => request(`/api/admin/items/${id}`, { method: 'PUT', body: JSON.stringify(body) }), deleteItem: (id) => request(`/api/admin/items/${id}`, { method: 'DELETE' }), addCaseItem: (caseId, body) => request(`/api/admin/cases/${caseId}/items`, { method: 'POST', body: JSON.stringify(body), }), updateCaseItem: (id, body) => request(`/api/admin/case-items/${id}`, { method: 'PUT', body: JSON.stringify(body), }), deleteCaseItem: (id) => request(`/api/admin/case-items/${id}`, { method: 'DELETE' }), adminUpload: (file) => { const form = new FormData(); form.append('image', file); return request('/api/admin/upload', { method: 'POST', body: form }); }, }; /** * Parse money amounts (cents) to BigInt — mirror of server `cents.js`. * Accepts bigint, number, or decimal/int string. Truncates toward zero. */ export function toCentsBigInt(value) { if (typeof value === 'bigint') return value; if (value == null || value === '') return 0n; if (typeof value === 'number') { if (!Number.isFinite(value)) return 0n; return BigInt(Math.trunc(value)); } const s = String(value).trim(); if (!s) return 0n; const m = s.match(/^([+-]?\d+)/); if (!m) return 0n; try { return BigInt(m[1]); } catch { return 0n; } } /** JSON-safe cents string. */ export function centsJson(value) { return toCentsBigInt(value).toString(); } /** Sort/compare helper: negative if a < b, 0 if equal, positive if a > b. */ export function compareCents(a, b) { const aa = toCentsBigInt(a); const bb = toCentsBigInt(b); if (aa < bb) return -1; if (aa > bb) return 1; return 0; } /** Exact credit display (cents → "1 234.56"), for tooltips / confirmations. */ export function formatCreditsExact(cents) { let bi = toCentsBigInt(cents); const neg = bi < 0n; if (neg) bi = -bi; const whole = bi / 100n; const frac = (bi % 100n).toString().padStart(2, '0'); const grouped = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f'); return `${neg ? '-' : ''}${grouped}.${frac}`; } /** Parse credits input (decimal string) → integer cents string. Null if invalid. */ export function creditsToCentsString(raw) { if (raw == null || raw === '') return null; const s = String(raw).trim().replace(',', '.'); const m = s.match(/^(\d+)(?:\.(\d{1,2})\d*)?$/); if (!m) return null; try { const whole = BigInt(m[1]); const frac = m[2] ? m[2].padEnd(2, '0') : '00'; return (whole * 100n + BigInt(frac)).toString(); } catch { return null; } } /** Cents → credits decimal string without Number precision loss. */ export function centsToCreditsString(cents) { let bi = toCentsBigInt(cents); const neg = bi < 0n; const abs = neg ? -bi : bi; const whole = abs / 100n; const frac = (abs % 100n).toString().padStart(2, '0'); const body = frac === '00' ? whole.toString() : `${whole}.${frac}`.replace(/0+$/, '').replace(/\.$/, ''); return `${neg ? '-' : ''}${body}`; } /** Alphabet suffixes after T: aa, ab, … az, ba, … (each step ×1000). */ function alphabetSuffix(index) { let num = Math.max(0, Math.floor(index)); let result = ''; do { result = String.fromCharCode(97 + (num % 26)) + result; num = Math.floor(num / 26); } while (num > 0); while (result.length < 2) result = `a${result}`; return result; } function tierSuffix(tier) { if (tier <= 0) return ''; if (tier === 1) return 'K'; if (tier === 2) return 'M'; if (tier === 3) return 'B'; if (tier === 4) return 'T'; return alphabetSuffix(tier - 5); } /** * Compact credit display for UI readability. * K / M / B / T, then aa, ab, … (×1000 each). Input is cents. */ export function formatCredits(cents) { let bi = toCentsBigInt(cents); const neg = bi < 0n; if (neg) bi = -bi; const creditsWhole = bi / 100n; const creditsFrac = bi % 100n; if (creditsWhole < 1000n) { return `${neg ? '-' : ''}${creditsWhole}.${creditsFrac.toString().padStart(2, '0')}`; } let tier = 0; let scaled = creditsWhole; let rem = 0n; while (scaled >= 1000n) { rem = scaled % 1000n; scaled /= 1000n; tier += 1; } // scaled < 1000 → safe as Number; rem gives ~3 decimal digits of precision const approx = Number(scaled) + Number(rem) / 1000; if (!Number.isFinite(approx)) return `${neg ? '-' : ''}0${tierSuffix(tier)}`; const shown = String(Number(approx.toFixed(2))); return `${neg ? '-' : ''}${shown}${tierSuffix(tier)}`; } export function formatChance(pct) { return `${Number(pct || 0).toFixed(1)}%`; } /** Format seconds as e.g. "2h 15m" or "45m". */ export function formatPlayTime(seconds) { const s = Math.max(0, Math.floor(Number(seconds) || 0)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); if (h <= 0 && m <= 0) return s > 0 ? `${s}s` : '0m'; if (h <= 0) return `${m}m`; if (m <= 0) return `${h}h`; return `${h}h ${m}m`; } export function formatLastConnection(iso) { if (!iso) return 'Never'; try { return new Date(iso).toLocaleString(); } catch { return '—'; } }