380 lines
12 KiB
JavaScript
380 lines
12 KiB
JavaScript
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 '—';
|
||
}
|
||
}
|