Files
CaseGambling/client/src/api.js
T
2026-07-26 18:43:51 +02:00

380 lines
12 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 → "1234.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 '—';
}
}