diff --git a/README.md b/README.md index 93cfea2..d4a9fbc 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,10 @@ New registrations receive **100.00** starting credits and **100 osu** (`STARTING - Profile (username in nav): avatar, bio, password, stats, logout - **Presence**: online status (Socket.IO), total play time, last connection — shown on own and public profiles - **Achievements** (`/achievements`, also from profile): 10 starter badges unlocked by play (cases, battles, catalog, prestige, shop, playtime); earned badges appear on the profile +- **Prestige**: pay **1 000 000 000 000.00** cr to reset; earn **Pr** (1000 base + ~1000 per **100 000 000 000.00** cr excess); spend Pr on a skill tree (chance / sell gain / key progress) - **Leaderboard**: richest players (balance + inventory value); click a row to open a public **player profile** (`/player/:username`) with stats and inventory -- Admin: manage cases, items, images (URL or upload), drop weights, enable/disable cases +- Admin: overview dashboard, players (credit adjust), cases / items / drops (split pages, search filters) + ## Docker (production) diff --git a/client/src/App.jsx b/client/src/App.jsx index 4ba9217..66f64d6 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -38,7 +38,7 @@ export default function App() { } /> } /> - } /> + } /> }> } /> } /> diff --git a/client/src/admin/AdminCases.jsx b/client/src/admin/AdminCases.jsx new file mode 100644 index 0000000..b58cfec --- /dev/null +++ b/client/src/admin/AdminCases.jsx @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { api, formatCredits } from '../api'; +import ImageField from './ImageField'; + +const emptyCase = { name: '', price: 100, imageUrl: '', active: true }; + +export default function AdminCases() { + const [cases, setCases] = useState([]); + const [error, setError] = useState(''); + const [query, setQuery] = useState(''); + const [caseForm, setCaseForm] = useState(emptyCase); + const [editingId, setEditingId] = useState(null); + const [edit, setEdit] = useState(null); + + const load = useCallback(async () => { + const data = await api.adminCases(); + setCases(data.cases); + }, []); + + useEffect(() => { + load().catch((err) => setError(err.message)); + }, [load]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return cases; + return cases.filter((c) => c.name.toLowerCase().includes(q)); + }, [cases, query]); + + const startEdit = (c) => { + setEditingId(c.id); + setEdit({ + name: c.name, + price: c.price, + imageUrl: c.imageUrl || '', + active: c.active, + }); + }; + + return ( +
+
+
+

Cases

+

{cases.length} crates · create, enable, price & art

+
+ setQuery(e.target.value)} + /> +
+ + {error &&
{error}
} + +
+

Create case

+
{ + e.preventDefault(); + setError(''); + try { + await api.createCase({ ...caseForm, price: Number(caseForm.price) }); + setCaseForm(emptyCase); + await load(); + } catch (err) { + setError(err.message); + } + }} + > + + + + +
+
+ +
+ {filtered.map((c) => ( +
+ {editingId === c.id && edit ? ( +
{ + e.preventDefault(); + setError(''); + try { + await api.updateCase(editingId, { + name: edit.name, + price: Number(edit.price), + imageUrl: edit.imageUrl, + active: edit.active, + }); + setEditingId(null); + setEdit(null); + await load(); + } catch (err) { + setError(err.message); + } + }} + > +

Edit #{c.id}

+ + + + +
+ + +
+
+ ) : ( + <> +
+ {c.imageUrl ? ( +
+ +
+ ) : ( +
+ )} +
+ {c.name} +
+ {formatCredits(c.price)} cr · {c.items.length} drops ·{' '} + + {c.active ? 'active' : 'inactive'} + +
+
+ + + +
+ + )} +
+ ))} +
+
+ ); +} diff --git a/client/src/admin/AdminDashboard.jsx b/client/src/admin/AdminDashboard.jsx new file mode 100644 index 0000000..261f72f --- /dev/null +++ b/client/src/admin/AdminDashboard.jsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api, formatCredits, formatLastConnection } from '../api'; + +export default function AdminDashboard() { + const [data, setData] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await api.adminStats(); + if (!cancelled) setData(res); + } catch (err) { + if (!cancelled) setError(err.message); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + if (error) return
{error}
; + if (!data) return

Loading overview…

; + + const s = data.stats; + + return ( +
+
+
+

Overview

+

Live snapshot of CaseForge.

+
+
+ +
+
+ Players + {s.players} + {s.onlinePlayers} online · {s.openWindows} windows +
+
+ Cases + + {s.casesActive}/{s.casesTotal} + + active / total +
+
+ Catalog items + {s.items} + {s.inventoryItems} in inventories +
+
+ Case opens + {s.caseOpens} + all time +
+
+ Achievements + {s.achievementsUnlocked} + {s.achievementsDefined} defined +
+
+ +
+ + Manage players + + + Manage cases + + + Manage items + + + Drop rates + +
+ +
+

Recently connected

+ {data.recentPlayers?.length === 0 ? ( +

No players yet.

+ ) : ( +
    + {data.recentPlayers.map((p) => ( +
  • +
    + {p.avatarUrl ? ( + + ) : ( + {p.username.slice(0, 2).toUpperCase()} + )} +
    +
    + @{p.username} +
    + {formatCredits(p.balance)} cr ·{' '} + {formatLastConnection(p.lastConnection)} +
    +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/client/src/admin/AdminDrops.jsx b/client/src/admin/AdminDrops.jsx new file mode 100644 index 0000000..e17feb0 --- /dev/null +++ b/client/src/admin/AdminDrops.jsx @@ -0,0 +1,170 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { api, formatCredits } from '../api'; + +function dropPercents(items) { + const total = items.reduce((s, ci) => s + Number(ci.dropRate || 0), 0); + if (!(total > 0)) return items.map(() => 0); + return items.map((ci) => (Number(ci.dropRate) / total) * 100); +} + +export default function AdminDrops() { + const [cases, setCases] = useState([]); + const [items, setItems] = useState([]); + const [error, setError] = useState(''); + const [selectedCaseId, setSelectedCaseId] = useState(''); + const [dropForm, setDropForm] = useState({ itemId: '', dropRate: 1 }); + + const load = useCallback(async () => { + const [c, i] = await Promise.all([api.adminCases(), api.adminItems()]); + setCases(c.cases); + setItems(i.items); + setSelectedCaseId((prev) => { + if (prev && c.cases.some((x) => String(x.id) === String(prev))) return prev; + return c.cases[0] ? String(c.cases[0].id) : ''; + }); + setDropForm((f) => ({ + ...f, + itemId: f.itemId || (i.items[0] ? String(i.items[0].id) : ''), + })); + }, []); + + useEffect(() => { + load().catch((err) => setError(err.message)); + }, [load]); + + const selected = useMemo( + () => cases.find((c) => String(c.id) === String(selectedCaseId)) || null, + [cases, selectedCaseId] + ); + + const percents = useMemo( + () => (selected ? dropPercents(selected.items) : []), + [selected] + ); + + return ( +
+
+
+

Drop rates

+

Weights per case · % shown from relative weights

+
+ +
+ + {error &&
{error}
} + + {!selected ? ( +
Create a case first.
+ ) : ( + <> +
+

+ Add drop · {selected.name}{' '} + ({formatCredits(selected.price)} cr) +

+
{ + e.preventDefault(); + setError(''); + try { + await api.addCaseItem(Number(selectedCaseId), { + itemId: Number(dropForm.itemId), + dropRate: Number(dropForm.dropRate), + }); + await load(); + } catch (err) { + setError(err.message); + } + }} + > + + setDropForm({ ...dropForm, dropRate: e.target.value })} + placeholder="Weight" + required + /> + +
+
+ +
+

Pool ({selected.items.length})

+ {selected.items.length === 0 ? ( +
No drops yet.
+ ) : ( +
+ {selected.items.map((ci, idx) => ( +
+ + {ci.item.imageUrl ? ( + + ) : null} + {ci.item.name}{' '} + ({ci.item.rarity}) + + { + const dropRate = Number(e.target.value); + if (!(dropRate > 0) || dropRate === ci.dropRate) return; + try { + await api.updateCaseItem(ci.id, { dropRate }); + await load(); + } catch (err) { + setError(err.message); + } + }} + /> + {percents[idx].toFixed(2)}% + +
+ ))} +
+ )} +
+ + )} +
+ ); +} diff --git a/client/src/admin/AdminItems.jsx b/client/src/admin/AdminItems.jsx new file mode 100644 index 0000000..e333421 --- /dev/null +++ b/client/src/admin/AdminItems.jsx @@ -0,0 +1,370 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { api, formatCredits } from '../api'; +import ImageField from './ImageField'; + +const emptyItem = { + name: '', + rarity: 'MilSpec', + category: 'Misc', + marketValue: 100, + imageUrl: '', +}; + +function CategoryField({ value, onChange, categories, id }) { + const listId = id || 'item-categories'; + return ( + <> + onChange(e.target.value)} + placeholder="e.g. Rifle, Knife…" + required + /> + + {categories.map((c) => ( + + + ); +} + +export default function AdminItems() { + const [items, setItems] = useState([]); + const [rarities, setRarities] = useState([]); + const [categories, setCategories] = useState([]); + const [error, setError] = useState(''); + const [query, setQuery] = useState(''); + const [rarityFilter, setRarityFilter] = useState('all'); + const [categoryFilter, setCategoryFilter] = useState('all'); + const [sort, setSort] = useState('category'); + const [itemForm, setItemForm] = useState(emptyItem); + const [editingId, setEditingId] = useState(null); + const [edit, setEdit] = useState(null); + + const load = useCallback(async () => { + const [i, r, c] = await Promise.all([ + api.adminItems(), + api.adminRarities(), + api.adminCategories(), + ]); + setItems(i.items); + setRarities(r.rarities); + setCategories(c.categories || []); + setItemForm((f) => ({ + ...f, + rarity: f.rarity || r.rarities[0] || 'MilSpec', + category: f.category || 'Misc', + })); + }, []); + + useEffect(() => { + load().catch((err) => setError(err.message)); + }, [load]); + + const usedCategories = useMemo(() => { + const set = new Set(items.map((item) => item.category || 'Misc')); + return [...set].sort((a, b) => a.localeCompare(b)); + }, [items]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + let list = items.filter((item) => { + const cat = item.category || 'Misc'; + if (rarityFilter !== 'all' && item.rarity !== rarityFilter) return false; + if (categoryFilter !== 'all' && cat !== categoryFilter) return false; + if (!q) return true; + return ( + item.name.toLowerCase().includes(q) || + item.rarity.toLowerCase().includes(q) || + cat.toLowerCase().includes(q) + ); + }); + + list = [...list]; + list.sort((a, b) => { + if (sort === 'name') return a.name.localeCompare(b.name); + if (sort === 'value') return Number(b.marketValue) - Number(a.marketValue); + if (sort === 'rarity') return a.rarity.localeCompare(b.rarity); + // category (default): group by category, then name + const ca = (a.category || 'Misc').localeCompare(b.category || 'Misc'); + if (ca !== 0) return ca; + return a.name.localeCompare(b.name); + }); + return list; + }, [items, query, rarityFilter, categoryFilter, sort]); + + return ( +
+
+
+

Items

+

+ {items.length} skins · categories + Field-Tested reference values +

+
+
+ + + + setQuery(e.target.value)} + /> +
+
+ + {error &&
{error}
} + +
+

Create item

+
{ + e.preventDefault(); + setError(''); + try { + await api.createItem({ + ...itemForm, + marketValue: Number(itemForm.marketValue), + }); + setItemForm({ + ...emptyItem, + rarity: rarities[0] || 'MilSpec', + category: 'Misc', + }); + await load(); + } catch (err) { + setError(err.message); + } + }} + > + + + + + + +
+
+ +

+ Showing {filtered.length} / {items.length} +

+ +
+ {filtered.map((item) => ( +
+ {editingId === item.id && edit ? ( +
{ + e.preventDefault(); + setError(''); + try { + await api.updateItem(editingId, { + name: edit.name, + rarity: edit.rarity, + category: edit.category, + marketValue: Number(edit.marketValue), + imageUrl: edit.imageUrl, + }); + setEditingId(null); + setEdit(null); + await load(); + } catch (err) { + setError(err.message); + } + }} + > +

Edit #{item.id}

+ + + + + +
+ + +
+
+ ) : ( +
+ {item.imageUrl ? ( +
+ +
+ ) : ( +
?
+ )} +
+ {item.name} +
+ {item.category || 'Misc'} ·{' '} + {item.rarity} · {formatCredits(item.marketValue)} cr +
+
+ + +
+ )} +
+ ))} +
+
+ ); +} diff --git a/client/src/admin/AdminLayout.jsx b/client/src/admin/AdminLayout.jsx new file mode 100644 index 0000000..6609d43 --- /dev/null +++ b/client/src/admin/AdminLayout.jsx @@ -0,0 +1,54 @@ +import { NavLink, Outlet, useNavigate } from 'react-router-dom'; +import { useAuth } from '../AuthContext'; + +const NAV = [ + { to: '/admin', end: true, label: 'Overview' }, + { to: '/admin/players', label: 'Players' }, + { to: '/admin/cases', label: 'Cases' }, + { to: '/admin/items', label: 'Items' }, + { to: '/admin/drops', label: 'Drops' }, +]; + +export default function AdminLayout() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/client/src/admin/AdminPlayers.jsx b/client/src/admin/AdminPlayers.jsx new file mode 100644 index 0000000..5205dd2 --- /dev/null +++ b/client/src/admin/AdminPlayers.jsx @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + api, + formatCredits, + formatLastConnection, + formatPlayTime, +} from '../api'; + +export default function AdminPlayers() { + const [users, setUsers] = useState([]); + const [error, setError] = useState(''); + const [query, setQuery] = useState(''); + const [sort, setSort] = useState('wealth'); + const [adjustId, setAdjustId] = useState(null); + const [deltaCredits, setDeltaCredits] = useState('100'); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + const data = await api.adminUsers(); + setUsers(data.users); + }, []); + + useEffect(() => { + load().catch((err) => setError(err.message)); + }, [load]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + let list = [...users]; + if (q) { + list = list.filter((u) => u.username.toLowerCase().includes(q)); + } + list.sort((a, b) => { + if (sort === 'name') return a.username.localeCompare(b.username); + if (sort === 'opens') return b.casesOpened - a.casesOpened; + if (sort === 'recent') { + return ( + new Date(b.lastConnection || 0).getTime() - + new Date(a.lastConnection || 0).getTime() + ); + } + return Number(b.totalWealth) - Number(a.totalWealth); + }); + return list; + }, [users, query, sort]); + + const applyDelta = async (userId, credits) => { + const cents = Math.round(Number(credits) * 100); + if (!Number.isInteger(cents) || cents === 0) { + setError('Enter a non-zero credit amount (e.g. 100 or -50)'); + return; + } + setBusy(true); + setError(''); + try { + await api.adminUpdateUser(userId, { balanceDelta: cents }); + setAdjustId(null); + setDeltaCredits('100'); + await load(); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+

Players

+

{users.length} accounts · adjust credits when needed

+
+
+ + setQuery(e.target.value)} + /> +
+
+ + {error &&
{error}
} + +
+ {filtered.map((u) => ( +
+
+
+ {u.avatarUrl ? ( + + ) : ( + {u.username.slice(0, 2).toUpperCase()} + )} +
+
+ @{u.username} +
+ Wealth {formatCredits(u.totalWealth)} cr · bal{' '} + {formatCredits(u.balance)} · inv {formatCredits(u.inventoryValue)} ( + {u.inventoryCount}) +
+
+ Opens {u.casesOpened} · prestige {u.prestigeCount} · achievements{' '} + {u.achievements} · play {formatPlayTime(u.playTimeSeconds)} · last{' '} + {formatLastConnection(u.lastConnection)} +
+
+ +
+ {adjustId === u.id && ( +
{ + e.preventDefault(); + applyDelta(u.id, deltaCredits); + }} + > + + + + +
+ )} +
+ ))} +
+
+ ); +} diff --git a/client/src/admin/ImageField.jsx b/client/src/admin/ImageField.jsx new file mode 100644 index 0000000..3d09db2 --- /dev/null +++ b/client/src/admin/ImageField.jsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import { api } from '../api'; + +export default function ImageField({ value, onChange, disabled }) { + const [uploading, setUploading] = useState(false); + + const onFile = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + setUploading(true); + try { + const data = await api.adminUpload(file); + onChange(data.url); + } catch (err) { + alert(err.message); + } finally { + setUploading(false); + e.target.value = ''; + } + }; + + return ( +
+ {value ? ( +
+ +
+ ) : ( +
No img
+ )} + onChange(e.target.value)} + disabled={disabled} + /> + + {value ? ( + + ) : null} +
+ ); +} diff --git a/client/src/api.js b/client/src/api.js index ceda61b..741f902 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -34,10 +34,11 @@ export const api = { case: (id) => request(`/api/cases/${id}`), catalog: () => request('/api/catalog'), prestige: () => request('/api/prestige'), - doPrestige: (choice) => - request('/api/prestige', { + doPrestige: () => request('/api/prestige', { method: 'POST', body: JSON.stringify({}) }), + prestigeUpgrade: (skillId) => + request('/api/prestige/upgrade', { method: 'POST', - body: JSON.stringify({ choice }), + body: JSON.stringify({ skillId }), }), openCase: (id, count = 1) => request(`/api/cases/${id}/open`, { @@ -127,9 +128,17 @@ export const api = { method: 'POST', body: JSON.stringify({ username, password }), }), + adminStats: () => request('/api/admin/stats'), + 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'), createCase: (body) => request('/api/admin/cases', { method: 'POST', body: JSON.stringify(body) }), updateCase: (id, body) => diff --git a/client/src/components/ProfilePrestigeStats.jsx b/client/src/components/ProfilePrestigeStats.jsx index 95446c6..65ea850 100644 --- a/client/src/components/ProfilePrestigeStats.jsx +++ b/client/src/components/ProfilePrestigeStats.jsx @@ -1,3 +1,5 @@ +import { formatCredits } from '../api'; + export default function ProfilePrestigeStats({ prestige }) { if (!prestige || !prestige.count) return null; @@ -8,6 +10,11 @@ export default function ProfilePrestigeStats({ prestige }) { ×{prestige.count}
    + {typeof prestige.prBalance === 'number' && prestige.prBalance > 0 && ( +
  • + Pr {prestige.prBalance} +
  • + )}
  • Chance +{prestige.chanceBonus}%
  • @@ -17,6 +24,51 @@ export default function ProfilePrestigeStats({ prestige }) {
  • Key opens −{prestige.keyOpenReduction}
  • + {(prestige.startBonus || 0) > 0 && ( +
  • + Vault {formatCredits(prestige.startBonus)} cr +
  • + )} + {(prestige.adBonus || 0) > 0 && ( +
  • + Ad reward +{prestige.adBonus}% +
  • + )} + {(prestige.adCooldownReduction || 0) > 0 && ( +
  • + Ad cooldown −{prestige.adCooldownReduction}s +
  • + )} + {(prestige.wearBonus || 0) > 0 && ( +
  • + Polish +{prestige.wearBonus} +
  • + )} + {(prestige.prBonus || 0) > 0 && ( +
  • + Pr echo +{prestige.prBonus}% +
  • + )} + {(prestige.shopDiscount || 0) > 0 && ( +
  • + Haggle −{prestige.shopDiscount} osu +
  • + )} + {(prestige.startOsu || 0) > 0 && ( +
  • + Purse +{prestige.startOsu} osu +
  • + )} + {(prestige.dropValueBonus || 0) > 0 && ( +
  • + Magnet +{prestige.dropValueBonus}% +
  • + )} + {(prestige.openBonus || 0) > 0 && ( +
  • + Bulk +{prestige.openBonus} opens +
  • + )}
); diff --git a/client/src/index.css b/client/src/index.css index 32a1c02..17a4a71 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1431,41 +1431,304 @@ a.balance-pill-link.active { } .admin-tabs button { - background: transparent; border: 1px solid var(--stroke); - color: var(--muted); - padding: 0.5rem 0.9rem; + background: var(--bg-panel); + color: var(--text); + padding: 0.45rem 0.85rem; cursor: pointer; font-family: var(--font-display); } .admin-tabs button.active { + border-color: var(--accent); + color: var(--accent); +} + +/* —— Admin shell —— */ +.admin-shell { + display: grid; + grid-template-columns: 13.5rem 1fr; + min-height: 100vh; + background: var(--bg-deep); +} + +.admin-sidebar { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.15rem 0.85rem; + border-right: 1px solid var(--stroke); + background: var(--bg-panel); +} + +.admin-sidebar-brand { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0 0.35rem 0.5rem; + border-bottom: 1px solid var(--stroke); +} + +.admin-sidebar-tag { + font-size: 0.7rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); + font-family: var(--font-display); +} + +.admin-side-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; +} + +.admin-side-nav a { + padding: 0.55rem 0.65rem; + border: 1px solid transparent; + font-family: var(--font-display); + font-size: 0.92rem; + color: var(--muted); +} + +.admin-side-nav a:hover { color: var(--text); - border-color: var(--accent-dim); - background: rgba(61, 214, 198, 0.08); + background: rgba(61, 214, 198, 0.05); +} + +.admin-side-nav a.active { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--stroke)); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.admin-sidebar-foot { + display: flex; + flex-direction: column; + gap: 0.45rem; + padding-top: 0.75rem; + border-top: 1px solid var(--stroke); +} + +.admin-sidebar-user { + font-size: 0.8rem; + padding: 0 0.35rem; +} + +.admin-main { + padding: 1.35rem 1.5rem 2.5rem; + max-width: 1100px; + width: 100%; +} + +.admin-stat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); + gap: 0.75rem; + margin-bottom: 1.15rem; +} + +.admin-stat { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.9rem 0.95rem; + border: 1px solid var(--stroke); + background: var(--bg-panel); +} + +.admin-stat-label { + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + font-family: var(--font-display); +} + +.admin-stat strong { + font-family: var(--font-display); + font-size: 1.45rem; + font-weight: 600; +} + +.admin-stat .muted { + font-size: 0.75rem; +} + +.admin-quick-links { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.admin-recent-list { + list-style: none; + margin: 0.75rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.admin-recent-list li { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.admin-recent-avatar { + width: 2.4rem; + height: 2.4rem; + display: grid; + place-items: center; + overflow: hidden; + border: 1px solid var(--stroke); + background: var(--bg-deep); + font-family: var(--font-display); + font-size: 0.7rem; + color: var(--muted); +} + +.admin-recent-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.admin-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.admin-search { + min-width: 11rem; + padding: 0.5rem 0.65rem; + border: 1px solid var(--stroke); + background: var(--bg-deep); + color: var(--text); +} + +.admin-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem 1rem; +} + +.admin-form-grid .admin-form-span { + grid-column: 1 / -1; +} + +.admin-form-grid .btn { + justify-self: start; +} + +.admin-row-main { + flex: 1; + min-width: 0; +} + +.admin-pill-on { + color: var(--accent); +} + +.admin-pill-off { + color: var(--danger); +} + +.admin-cat-pill { + display: inline-block; + font-family: var(--font-display); + font-size: 0.72rem; + letter-spacing: 0.04em; + color: var(--accent); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--stroke)); + padding: 0.1rem 0.4rem; +} + +.admin-drop-pct { + font-family: var(--font-display); + font-size: 0.85rem; + color: var(--accent); + min-width: 4.2rem; + text-align: right; +} + +.admin-adjust-row { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + align-items: end; + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--stroke); +} + +.admin-adjust-row label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.8rem; + color: var(--muted); +} + +.admin-adjust-row input { + padding: 0.45rem 0.55rem; + border: 1px solid var(--stroke); + background: var(--bg-deep); + color: var(--text); + min-width: 8rem; +} + +.admin-card-list { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +@media (max-width: 820px) { + .admin-shell { + grid-template-columns: 1fr; + } + + .admin-sidebar { + border-right: none; + border-bottom: 1px solid var(--stroke); + } + + .admin-side-nav { + flex-direction: row; + flex-wrap: wrap; + } + + .admin-form-grid { + grid-template-columns: 1fr; + } } .admin-block { border: 1px solid var(--stroke); background: var(--bg-panel); padding: 1rem; - margin-bottom: 1rem; + margin-bottom: 0.75rem; } .admin-row { display: flex; flex-wrap: wrap; - gap: 0.5rem; + gap: 0.65rem; align-items: center; - margin-bottom: 0.65rem; } .admin-row input, .admin-row select { - background: var(--bg-deep); - border: 1px solid var(--stroke); - color: var(--text); padding: 0.45rem 0.55rem; + border: 1px solid var(--stroke); + background: var(--bg-deep); + color: var(--text); } .drop-list { @@ -2629,6 +2892,72 @@ a.achievement-badge:hover .achievement-badge-name { /* Prestige */ +.prestige-head { + align-items: flex-start; + margin-bottom: 1.25rem; +} + +.prestige-head-copy h1 { + margin-bottom: 0.35rem; +} + +.prestige-lead { + margin: 0; + max-width: 36rem; + line-height: 1.45; + color: var(--muted); + font-size: 0.98rem; +} + +.prestige-lead strong { + color: var(--gold); + font-weight: 600; +} + +.prestige-steps { + list-style: none; + margin: 0 0 1.5rem; + padding: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.prestige-steps > li { + display: flex; + gap: 0.7rem; + align-items: flex-start; + padding: 0.85rem 0.95rem; + border: 1px solid var(--stroke); + background: color-mix(in srgb, var(--bg-panel) 88%, transparent); +} + +.prestige-steps strong { + display: block; + margin-bottom: 0.2rem; + font-family: var(--font-display); + font-size: 0.95rem; +} + +.prestige-steps p { + margin: 0; + font-size: 0.82rem; + line-height: 1.35; +} + +.prestige-step-num { + flex-shrink: 0; + width: 1.55rem; + height: 1.55rem; + display: grid; + place-items: center; + border: 1px solid color-mix(in srgb, var(--gold) 50%, var(--stroke)); + background: color-mix(in srgb, var(--gold) 12%, var(--bg-deep)); + color: var(--gold); + font-family: var(--font-display); + font-size: 0.85rem; +} + .prestige-count-pill { font-family: var(--font-display); color: var(--gold); @@ -2638,6 +2967,32 @@ a.achievement-badge:hover .achievement-badge-name { white-space: nowrap; } +.prestige-pills { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.prestige-pr-pill { + font-family: var(--font-display); + border: 1px solid color-mix(in srgb, var(--gold) 45%, var(--stroke)); + padding: 0.35rem 0.7rem; + background: color-mix(in srgb, var(--gold) 10%, var(--bg-panel)); + color: var(--gold); + white-space: nowrap; +} + +.prestige-pr-est { + color: var(--gold); +} + +.prestige-pr-hint { + margin: 0; + font-size: 0.82rem; + line-height: 1.4; +} + .prestige-totals { display: grid; grid-template-columns: repeat(3, minmax(0, 10rem)); @@ -2645,6 +3000,10 @@ a.achievement-badge:hover .achievement-badge-name { margin-bottom: 1.25rem; } +.prestige-totals-wide { + grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr)); +} + .prestige-totals > div { display: flex; flex-direction: column; @@ -2658,56 +3017,192 @@ a.achievement-badge:hover .achievement-badge-name { color: var(--text); } -.prestige-cost-row { +.prestige-action { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 40rem; + margin-bottom: 1.75rem; + padding: 1.15rem 1.2rem; +} + +.prestige-action-head { display: flex; flex-wrap: wrap; align-items: baseline; + justify-content: space-between; gap: 0.5rem 1rem; - margin-bottom: 1rem; - font-size: 0.9rem; } -.prestige-choices { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 0.85rem; - margin-bottom: 1.25rem; +.prestige-action-head h2 { + margin: 0; + font-size: 1.15rem; } -.prestige-choice { - text-align: left; - background: linear-gradient(160deg, var(--bg-elevated), var(--bg-panel)); +.prestige-status { + font-size: 0.85rem; + color: var(--muted); border: 1px solid var(--stroke); - color: inherit; - padding: 1rem; - cursor: pointer; - font: inherit; + padding: 0.25rem 0.55rem; } -.prestige-choice:disabled { - opacity: 0.55; - cursor: not-allowed; +.prestige-status.ready { + color: var(--gold); + border-color: color-mix(in srgb, var(--gold) 45%, var(--stroke)); + background: color-mix(in srgb, var(--gold) 8%, transparent); } -.prestige-choice.selected { - border-color: rgba(228, 174, 57, 0.55); - box-shadow: inset 0 0 0 1px rgba(228, 174, 57, 0.25); +.prestige-action-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; } -.prestige-choice h2 { - margin: 0 0 0.4rem; +.prestige-action-stats > div { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.65rem 0.75rem; + background: var(--bg-deep); + border: 1px solid var(--stroke); + font-size: 0.82rem; +} + +.prestige-action-stats strong { + font-family: var(--font-display); font-size: 1.05rem; } -.prestige-choice p { - margin: 0 0 0.75rem; - font-size: 0.85rem; +.prestige-action-split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.85rem; } -.prestige-choice-meta { +.prestige-action-split h3 { + margin: 0 0 0.4rem; + font-size: 0.78rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); + font-weight: 600; +} + +.prestige-action-split ul { + margin: 0; + padding: 0 0 0 1.05rem; display: flex; - justify-content: space-between; - gap: 0.5rem; + flex-direction: column; + gap: 0.3rem; + font-size: 0.88rem; + line-height: 1.35; +} + +.prestige-action-btn { + align-self: flex-start; + min-width: 10rem; +} + +.prestige-action .btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +@media (max-width: 720px) { + .prestige-steps { + grid-template-columns: 1fr; + } + + .prestige-action-stats, + .prestige-action-split { + grid-template-columns: 1fr; + } +} + +.prestige-tree-section { + margin-top: 0.5rem; +} + +.prestige-tree-section h2 { + margin: 0 0 0.25rem; +} + +.prestige-tree { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 1rem; +} + +.prestige-tree-root { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + padding: 0.85rem 1.25rem; + border: 1px solid color-mix(in srgb, var(--gold) 50%, var(--stroke)); + background: + linear-gradient(165deg, rgba(228, 174, 57, 0.12) 0%, transparent 55%), + var(--bg-panel); + font-family: var(--font-display); +} + +.prestige-tree-root span { + font-size: 0.7rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} + +.prestige-tree-root strong { + color: var(--gold); + font-size: 1.15rem; +} + +.prestige-tree-branches { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(12.5rem, 1fr)); + gap: 0.75rem; + width: 100%; + max-width: 56rem; +} + +.prestige-tree-branch { + display: flex; + flex-direction: column; + align-items: center; +} + +.prestige-tree-connector { + width: 2px; + height: 1.25rem; + background: color-mix(in srgb, var(--gold) 40%, var(--stroke)); +} + +.prestige-skill-node { + width: 100%; + padding: 0.95rem 0.85rem; + border: 1px solid var(--stroke); + background: var(--bg-panel); + text-align: center; +} + +.prestige-skill-node h3 { + margin: 0 0 0.35rem; + font-family: var(--font-display); + font-size: 1.05rem; +} + +.prestige-skill-node p { + margin: 0 0 0.65rem; + font-size: 0.82rem; +} + +.prestige-skill-meta { + display: flex; + flex-direction: column; + gap: 0.2rem; + margin-bottom: 0.75rem; font-size: 0.8rem; } @@ -2716,19 +3211,6 @@ a.achievement-badge:hover .achievement-badge-name { font-family: var(--font-display); } -.prestige-confirm { - display: flex; - flex-direction: column; - gap: 0.75rem; - max-width: 32rem; -} - -.prestige-confirm label { - display: flex; - flex-direction: column; - gap: 0.35rem; -} - .prestige-history { margin-top: 1.5rem; padding-top: 1rem; @@ -2749,3 +3231,13 @@ a.achievement-badge:hover .achievement-badge-name { .prestige-profile-stats { margin-top: 0.35rem; } + +@media (max-width: 720px) { + .prestige-tree-branches { + grid-template-columns: 1fr; + } + + .prestige-totals { + grid-template-columns: 1fr; + } +} diff --git a/client/src/pages/Admin.jsx b/client/src/pages/Admin.jsx index 1c0ad2e..1393c30 100644 --- a/client/src/pages/Admin.jsx +++ b/client/src/pages/Admin.jsx @@ -1,652 +1,79 @@ -import { useEffect, useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { api, formatCredits } from '../api'; +import { useState } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; import { useAuth } from '../AuthContext'; +import AdminLayout from '../admin/AdminLayout'; +import AdminDashboard from '../admin/AdminDashboard'; +import AdminCases from '../admin/AdminCases'; +import AdminItems from '../admin/AdminItems'; +import AdminDrops from '../admin/AdminDrops'; +import AdminPlayers from '../admin/AdminPlayers'; -const emptyCase = { name: '', price: 100, imageUrl: '', active: true }; -const emptyItem = { name: '', rarity: 'MilSpec', marketValue: 100, imageUrl: '' }; +function AdminLogin() { + const { adminLogin } = useAuth(); + const [username, setUsername] = useState('admin'); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); -function ImageField({ value, onChange, disabled }) { - const [uploading, setUploading] = useState(false); - - const onFile = async (e) => { - const file = e.target.files?.[0]; - if (!file) return; - setUploading(true); + const onSubmit = async (e) => { + e.preventDefault(); + setError(''); + setBusy(true); try { - const data = await api.adminUpload(file); - onChange(data.url); + await adminLogin(username, password); } catch (err) { - alert(err.message); + setError(err.message); } finally { - setUploading(false); - e.target.value = ''; + setBusy(false); } }; return ( -
- {value ? ( -
- -
- ) : ( -
No img
- )} - onChange(e.target.value)} - disabled={disabled} - /> - - {value ? ( - - ) : null} +
+
+
CaseForge
+

Admin

+

Cases, items, drops, players & overview.

+
+ + + {error &&
{error}
} + +
+
); } export default function Admin() { - const { user, loading, adminLogin, logout } = useAuth(); - const navigate = useNavigate(); - const [tab, setTab] = useState('cases'); - const [cases, setCases] = useState([]); - const [items, setItems] = useState([]); - const [rarities, setRarities] = useState([]); - const [error, setError] = useState(''); - const [username, setUsername] = useState('admin'); - const [password, setPassword] = useState(''); - const [busy, setBusy] = useState(false); + const { user, loading } = useAuth(); - const [caseForm, setCaseForm] = useState(emptyCase); - const [itemForm, setItemForm] = useState(emptyItem); - const [editingCaseId, setEditingCaseId] = useState(null); - const [editingItemId, setEditingItemId] = useState(null); - const [caseEdit, setCaseEdit] = useState(null); - const [itemEdit, setItemEdit] = useState(null); - const [dropForm, setDropForm] = useState({ caseId: '', itemId: '', dropRate: 1 }); - - const load = async () => { - const [c, i, r] = await Promise.all([ - api.adminCases(), - api.adminItems(), - api.adminRarities(), - ]); - setCases(c.cases); - setItems(i.items); - setRarities(r.rarities); - if (!dropForm.caseId && c.cases[0]) { - setDropForm((f) => ({ ...f, caseId: String(c.cases[0].id) })); - } - if (!dropForm.itemId && i.items[0]) { - setDropForm((f) => ({ ...f, itemId: String(i.items[0].id) })); - } - if (r.rarities.length) { - setItemForm((f) => ({ ...f, rarity: f.rarity || r.rarities[0] })); - } - }; - - useEffect(() => { - if (user?.role !== 'admin') return; - load().catch((err) => setError(err.message)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user]); - - if (loading) return

Loading…

; - - if (!user || user.role !== 'admin') { - const onSubmit = async (e) => { - e.preventDefault(); - setError(''); - setBusy(true); - try { - await adminLogin(username, password); - } catch (err) { - setError(err.message); - } finally { - setBusy(false); - } - }; - - return ( -
-
-
CaseForge
-

Admin

-

Manage cases, items, and drop rates.

-
- - - {error &&
{error}
} - -
-
-
- ); - } - - const startEditCase = (c) => { - setEditingCaseId(c.id); - setCaseEdit({ - name: c.name, - price: c.price, - imageUrl: c.imageUrl || '', - active: c.active, - }); - }; - - const saveCaseEdit = async (e) => { - e.preventDefault(); - setError(''); - try { - await api.updateCase(editingCaseId, { - name: caseEdit.name, - price: Number(caseEdit.price), - imageUrl: caseEdit.imageUrl, - active: caseEdit.active, - }); - setEditingCaseId(null); - setCaseEdit(null); - await load(); - } catch (err) { - setError(err.message); - } - }; - - const startEditItem = (item) => { - setEditingItemId(item.id); - setItemEdit({ - name: item.name, - rarity: item.rarity, - marketValue: item.marketValue, - imageUrl: item.imageUrl || '', - }); - }; - - const saveItemEdit = async (e) => { - e.preventDefault(); - setError(''); - try { - await api.updateItem(editingItemId, { - name: itemEdit.name, - rarity: itemEdit.rarity, - marketValue: Number(itemEdit.marketValue), - imageUrl: itemEdit.imageUrl, - }); - setEditingItemId(null); - setItemEdit(null); - await load(); - } catch (err) { - setError(err.message); - } - }; + if (loading) return

Loading…

; + if (!user || user.role !== 'admin') return ; return ( -
-
- CaseForge Admin - -
-
-
-

Admin panel

-

Cases, loot pool, images, and drop weights.

-
-
- -
- {['cases', 'items', 'drops'].map((t) => ( - - ))} -
- - {error &&
{error}
} - - {tab === 'cases' && ( -
-
-

Create case

-
{ - e.preventDefault(); - try { - await api.createCase({ - ...caseForm, - price: Number(caseForm.price), - }); - setCaseForm(emptyCase); - await load(); - } catch (err) { - setError(err.message); - } - }} - > - - - - -
-
- - {cases.map((c) => ( -
- {editingCaseId === c.id && caseEdit ? ( -
-

Edit case #{c.id}

- - - - -
- - -
-
- ) : ( - <> -
- {c.imageUrl ? ( -
- -
- ) : null} - {c.name} - {formatCredits(c.price)} cr - {c.active ? 'active' : 'inactive'} - - - -
-
{c.items.length} drops configured
- - )} -
- ))} -
- )} - - {tab === 'items' && ( -
-
-

Create item

-
{ - e.preventDefault(); - try { - await api.createItem({ - ...itemForm, - marketValue: Number(itemForm.marketValue), - }); - setItemForm({ - ...emptyItem, - rarity: rarities[0] || 'MilSpec', - }); - await load(); - } catch (err) { - setError(err.message); - } - }} - > - - - - - -
-
- - {items.map((item) => ( -
- {editingItemId === item.id && itemEdit ? ( -
-

Edit item #{item.id}

- - - - -
- - -
-
- ) : ( -
- {item.imageUrl ? ( -
- -
- ) : null} - - {item.name} · {item.rarity} · {formatCredits(item.marketValue)} cr - - - -
- )} -
- ))} -
- )} - - {tab === 'drops' && ( -
-
-

Add drop to case

-
{ - e.preventDefault(); - try { - await api.addCaseItem(Number(dropForm.caseId), { - itemId: Number(dropForm.itemId), - dropRate: Number(dropForm.dropRate), - }); - await load(); - } catch (err) { - setError(err.message); - } - }} - > - - - setDropForm({ ...dropForm, dropRate: e.target.value })} - placeholder="Weight" - required - /> - -
-
- - {cases.map((c) => ( -
-

{c.name}

-
- {c.items.length === 0 &&
No drops yet.
} - {c.items.map((ci) => ( -
- - {ci.item.imageUrl ? ( - - ) : null} - {ci.item.name} ({ci.item.rarity}) - - { - const dropRate = Number(e.target.value); - if (!(dropRate > 0) || dropRate === ci.dropRate) return; - try { - await api.updateCaseItem(ci.id, { dropRate }); - await load(); - } catch (err) { - setError(err.message); - } - }} - /> - weight - -
- ))} -
-
- ))} -
- )} -
+ + }> + } /> + } /> + } /> + } /> + } /> + } /> + + ); } diff --git a/client/src/pages/PrestigePage.jsx b/client/src/pages/PrestigePage.jsx index 45c31ff..79d81d5 100644 --- a/client/src/pages/PrestigePage.jsx +++ b/client/src/pages/PrestigePage.jsx @@ -3,17 +3,22 @@ import { Navigate } from 'react-router-dom'; import { api, formatCredits } from '../api'; import { useAuth } from '../AuthContext'; -const CHOICE_LABELS = { - chance: 'Chance', - gain: 'Gain', - key: 'Key progress', -}; +function formatSkillValue(skill, value) { + const prefix = skill.prefix || ''; + if (skill.unit === 'cr-cents') { + return `${prefix}${formatCredits(value)} cr`.replace(/^\+\+/, '+'); + } + if (skill.unit === '%') return `${prefix || '+'}${value}%`; + if (skill.unit === 's') return `${prefix || '−'}${value}s`; + if (skill.unit === ' osu') return `${prefix || '+'}${value} osu`; + if (skill.prefix === '−') return `−${value}`; + if (value === 0 && !prefix) return `${value}`; + return `${prefix || '+'}${value}`; +} export default function PrestigePage() { const { user, loading, patchUser, refresh } = useAuth(); const [data, setData] = useState(null); - const [selected, setSelected] = useState(null); - const [confirmText, setConfirmText] = useState(''); const [error, setError] = useState(''); const [message, setMessage] = useState(''); const [busy, setBusy] = useState(false); @@ -49,22 +54,21 @@ export default function PrestigePage() { const cost = data?.prestige?.costCents ?? 0; const p = data?.prestige; - const unlockAt = data?.prestige?.visibleAtCents ?? 0; + const prBalance = p?.prBalance ?? user.prBalance ?? 0; + const canClick = Boolean(data?.canPrestige) && !busy; const onPrestige = async () => { - if (!selected || confirmText !== 'PRESTIGE') return; + if (!canClick) return; setBusy(true); setError(''); setMessage(''); try { - const result = await api.doPrestige(selected); + const result = await api.doPrestige(); patchUser(result.user); await refresh(); setMessage( - `Prestige #${result.prestige.count} complete · chose ${CHOICE_LABELS[result.choice] || result.choice}. Account reset.` + `Prestige #${result.prestige.count} · +${result.prGranted} Pr. Balance, inventory, keys & catalog reset.` ); - setSelected(null); - setConfirmText(''); await load(); } catch (err) { setError(err.message); @@ -73,28 +77,90 @@ export default function PrestigePage() { } }; + const onUpgrade = async (skillId) => { + setBusy(true); + setError(''); + setMessage(''); + try { + const result = await api.prestigeUpgrade(skillId); + patchUser(result.user); + setMessage(`Upgraded ${skillId}.`); + setData((prev) => + prev + ? { + ...prev, + prestige: result.prestige, + skills: result.skills, + } + : prev + ); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + const shortfall = Math.max(0, cost - (Number(user.balance) || 0)); + const statusLabel = !data?.canAfford + ? `Need ${formatCredits(shortfall)} cr more` + : data?.lockedItems > 0 + ? `Withdraw ${data.lockedItems} locked item${data.lockedItems === 1 ? '' : 's'} first` + : `Ready · ~${data.previewPr} Pr`; + return ( -
-
-
+
+
+

Prestige

-

- Pay {formatCredits(cost)} cr, pick one permanent boost, then reset balance, inventory, - keys, and catalog. History is kept. Unlocks at {formatCredits(unlockAt)} cr total wealth. +

+ Reset your progress for Pr, then spend Pr on permanent + skills.

- {p && ( -
- Prestige {p.count} +
+ {p && ( +
+ Prestige {p.count} +
+ )} +
+ {prBalance} Pr
- )} +
+
    +
  1. + 1 +
    + Pay the cost +

    {formatCredits(cost)} cr from your balance

    +
    +
  2. +
  3. + 2 +
    + Earn Pr +

    + +{p?.prBaseReward ?? 1000} base, more if you are over the cost +

    +
    +
  4. +
  5. + 3 +
    + Upgrade skills +

    Permanent bonuses — they survive every reset

    +
    +
  6. +
+ {error &&
{error}
} {message &&
{message}
} {p && ( -
+
Chance +{p.chanceBonus}% @@ -107,19 +173,45 @@ export default function PrestigePage() { Key opens −{p.keyOpenReduction}
+
+ Vault + {formatCredits(p.startBonus || 0)} cr +
+
+ Ad reward + +{p.adBonus || 0}% +
+
+ Ad cooldown + −{p.adCooldownReduction || 0}s +
+
+ Polish + +{p.wearBonus || 0} +
+
+ Pr echo + +{p.prBonus || 0}% +
+
+ Haggle + −{p.shopDiscount || 0} osu +
+
+ Purse + +{p.startOsu || 0} osu +
+
+ Magnet + +{p.dropValueBonus || 0}% +
+
+ Bulk + +{p.openBonus || 0} +
)} -
- Cost - {formatCredits(cost)} cr - Your balance - {formatCredits(user.balance)} cr -
- - {!data?.canAfford && ( -

You need {formatCredits(cost)} cr to prestige.

- )} {data?.lockedItems > 0 && (
Withdraw {data.lockedItems} locked item{data.lockedItems === 1 ? '' : 's'} from battles @@ -127,68 +219,126 @@ export default function PrestigePage() {
)} -
- {(data?.choices || []).map((c) => ( - - ))} -
+
+
+

Reset now

+ + {statusLabel} + +
-
-

- This permanently spends {formatCredits(cost)} cr and wipes your balance, inventory, case - keys, and catalog. Type PRESTIGE to confirm. +

+
+ Cost + {formatCredits(cost)} cr +
+
+ Your balance + {formatCredits(user.balance)} cr +
+
+ Est. Pr reward + + {data?.canAfford ? `~${data.previewPr} Pr` : '—'} + +
+
+ +
+
+

You keep

+
    +
  • All Pr already earned
  • +
  • Skill-tree bonuses
  • +
  • Account & prestige count
  • +
+
+
+

You lose

+
    +
  • Balance (minus Vault / Purse)
  • +
  • Inventory
  • +
  • Case keys & catalog progress
  • +
+
+
+ +

+ Reward: +{p?.prBaseReward ?? 1000} Pr base. Every{' '} + {formatCredits(p?.excessChunkCents ?? 0)} cr above the cost adds ~{p?.prPerExcessChunk ?? 1000}{' '} + Pr.

- +
+
+
+
+

Skill tree

+

Spend Pr on permanent branches. Costs rise with each purchase.

+
+
+ +
+
+ Core + {prBalance} Pr +
+
+ {(data?.skills || []).map((skill) => ( +
+
+
+

{skill.title}

+

{skill.description}

+
+ + Lv {skill.level} · now{' '} + {formatSkillValue(skill, skill.current)} + + + → {formatSkillValue(skill, skill.nextValue)} + +
+ +
+
+ ))} +
+
+
+ {data?.history?.length > 0 && (

History

    {data.history.map((h) => (
  • - {CHOICE_LABELS[h.choice] || h.choice}{' '} - - · {new Date(h.createdAt).toLocaleString()} - + Prestige reset + {h.prGranted > 0 ? ` · +${h.prGranted} Pr` : ''}{' '} + · {new Date(h.createdAt).toLocaleString()}
  • ))}
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 8c18b2e..4ff0203 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -13,6 +13,7 @@ model User { passwordHash String balance BigInt @default(0) osuBalance Int @default(0) + prBalance Int @default(0) lastAdAt DateTime? role String @default("user") avatarUrl String @default("") @@ -23,6 +24,15 @@ model User { prestigeChanceBonus Int @default(0) prestigeGainBonus Int @default(0) prestigeKeyOpenReduction Int @default(0) + prestigeStartBonus Int @default(0) + prestigeAdBonus Int @default(0) + prestigeAdCooldownReduction Int @default(0) + prestigeWearBonus Int @default(0) + prestigePrBonus Int @default(0) + prestigeShopDiscount Int @default(0) + prestigeStartOsu Int @default(0) + prestigeDropValueBonus Int @default(0) + prestigeOpenBonus Int @default(0) lastConnection DateTime? playTimeSeconds BigInt @default(0) createdAt DateTime @default(now()) @@ -56,6 +66,7 @@ model Item { name String imageUrl String @default("") rarity String + category String @default("Misc") marketValue BigInt createdAt DateTime @default(now()) cases CaseItem[] @@ -187,6 +198,7 @@ model PrestigeLog { id Int @id @default(autoincrement()) userId Int choice String + prGranted Int @default(0) createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) } diff --git a/server/prisma/seed.js b/server/prisma/seed.js index 8b2e5d8..77be7e1 100644 --- a/server/prisma/seed.js +++ b/server/prisma/seed.js @@ -4,24 +4,24 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); const ITEMS = [ - { name: 'Dust Fragment', rarity: 'Consumer', marketValue: 5, imageUrl: '' }, - { name: 'Scrap Token', rarity: 'Consumer', marketValue: 8, imageUrl: '' }, - { name: 'Plain Cap', rarity: 'Consumer', marketValue: 12, imageUrl: '' }, - { name: 'Blue Softpack', rarity: 'Industrial', marketValue: 25, imageUrl: '' }, - { name: 'Steel Badge', rarity: 'Industrial', marketValue: 35, imageUrl: '' }, - { name: 'Signal Chip', rarity: 'Industrial', marketValue: 40, imageUrl: '' }, - { name: 'Azure Coil', rarity: 'MilSpec', marketValue: 80, imageUrl: '' }, - { name: 'Navy Grip', rarity: 'MilSpec', marketValue: 95, imageUrl: '' }, - { name: 'Pulse Meter', rarity: 'MilSpec', marketValue: 110, imageUrl: '' }, - { name: 'Violet Core', rarity: 'Restricted', marketValue: 220, imageUrl: '' }, - { name: 'Phantom Lens', rarity: 'Restricted', marketValue: 280, imageUrl: '' }, - { name: 'Neon Circuit', rarity: 'Restricted', marketValue: 320, imageUrl: '' }, - { name: 'Pink Dynamo', rarity: 'Classified', marketValue: 650, imageUrl: '' }, - { name: 'Ruby Flux', rarity: 'Classified', marketValue: 800, imageUrl: '' }, - { name: 'Crimson Edge', rarity: 'Covert', marketValue: 1800, imageUrl: '' }, - { name: 'Blood Ember', rarity: 'Covert', marketValue: 2400, imageUrl: '' }, - { name: 'Golden Relic', rarity: 'Extraordinary', marketValue: 12000, imageUrl: '' }, - { name: 'Mythic Blade', rarity: 'Extraordinary', marketValue: 25000, imageUrl: '' }, + { name: 'Dust Fragment', rarity: 'Consumer', category: 'Misc', marketValue: 5, imageUrl: '' }, + { name: 'Scrap Token', rarity: 'Consumer', category: 'Misc', marketValue: 8, imageUrl: '' }, + { name: 'Plain Cap', rarity: 'Consumer', category: 'Agent', marketValue: 12, imageUrl: '' }, + { name: 'Blue Softpack', rarity: 'Industrial', category: 'Misc', marketValue: 25, imageUrl: '' }, + { name: 'Steel Badge', rarity: 'Industrial', category: 'Sticker', marketValue: 35, imageUrl: '' }, + { name: 'Signal Chip', rarity: 'Industrial', category: 'Misc', marketValue: 40, imageUrl: '' }, + { name: 'Azure Coil', rarity: 'MilSpec', category: 'SMG', marketValue: 80, imageUrl: '' }, + { name: 'Navy Grip', rarity: 'MilSpec', category: 'Pistol', marketValue: 95, imageUrl: '' }, + { name: 'Pulse Meter', rarity: 'MilSpec', category: 'Rifle', marketValue: 110, imageUrl: '' }, + { name: 'Violet Core', rarity: 'Restricted', category: 'Rifle', marketValue: 220, imageUrl: '' }, + { name: 'Phantom Lens', rarity: 'Restricted', category: 'Sniper', marketValue: 280, imageUrl: '' }, + { name: 'Neon Circuit', rarity: 'Restricted', category: 'SMG', marketValue: 320, imageUrl: '' }, + { name: 'Pink Dynamo', rarity: 'Classified', category: 'Rifle', marketValue: 650, imageUrl: '' }, + { name: 'Ruby Flux', rarity: 'Classified', category: 'Heavy', marketValue: 800, imageUrl: '' }, + { name: 'Crimson Edge', rarity: 'Covert', category: 'Knife', marketValue: 1800, imageUrl: '' }, + { name: 'Blood Ember', rarity: 'Covert', category: 'Knife', marketValue: 2400, imageUrl: '' }, + { name: 'Golden Relic', rarity: 'Extraordinary', category: 'Gloves', marketValue: 12000, imageUrl: '' }, + { name: 'Mythic Blade', rarity: 'Extraordinary', category: 'Knife', marketValue: 25000, imageUrl: '' }, ]; async function main() { diff --git a/server/src/caseKeys.js b/server/src/caseKeys.js index 9a0a630..3f51494 100644 --- a/server/src/caseKeys.js +++ b/server/src/caseKeys.js @@ -7,9 +7,10 @@ export const OPENS_PER_KEY_EARLY = 100; /** From key 11 onward: cost = OPENS_PER_KEY_EARLY × (keys - 9). */ export const ESCALATION_KEY_THRESHOLD = 11; -export function maxOpensForKeys(keys) { +export function maxOpensForKeys(keys, openBonus = 0) { const k = Math.max(1, keys); - return k + (BASE_MAX_OPENS - 1); + const bonus = Math.max(0, Math.floor(Number(openBonus) || 0)); + return k + (BASE_MAX_OPENS - 1) + bonus; } export function opensRequiredForNextKey(keys, keyOpenReduction = 0) { @@ -22,14 +23,14 @@ export function opensRequiredForNextKey(keys, keyOpenReduction = 0) { return Math.max(1, base - reduction); } -export function serializeKeyProgress(progress, keyOpenReduction = 0) { +export function serializeKeyProgress(progress, keyOpenReduction = 0, openBonus = 0) { const keys = progress?.keys ?? 1; const opensSinceLastKey = progress?.opensSinceLastKey ?? 0; const totalOpens = progress?.totalOpens ?? 0; const opensRequired = opensRequiredForNextKey(keys, keyOpenReduction); return { keys, - maxOpens: maxOpensForKeys(keys), + maxOpens: maxOpensForKeys(keys, openBonus), opensSinceLastKey, opensRequired, totalOpens, diff --git a/server/src/categories.js b/server/src/categories.js new file mode 100644 index 0000000..ef089c5 --- /dev/null +++ b/server/src/categories.js @@ -0,0 +1,18 @@ +/** Suggested item categories (admin can still type custom ones). */ +export const ITEM_CATEGORIES = [ + 'Rifle', + 'Pistol', + 'SMG', + 'Heavy', + 'Sniper', + 'Knife', + 'Gloves', + 'Sticker', + 'Agent', + 'Misc', +]; + +export function normalizeCategory(value) { + const cat = String(value ?? '').trim(); + return cat || 'Misc'; +} diff --git a/server/src/middleware.js b/server/src/middleware.js index 0175aa3..24a09fd 100644 --- a/server/src/middleware.js +++ b/server/src/middleware.js @@ -21,6 +21,7 @@ export function publicUser(user) { username: user.username, balance: user.balance, osuBalance: user.osuBalance ?? 0, + prBalance: user.prBalance ?? 0, lastAdAt: user.lastAdAt || null, role: user.role, avatarUrl: user.avatarUrl || '', diff --git a/server/src/presence.js b/server/src/presence.js index d905176..5067180 100644 --- a/server/src/presence.js +++ b/server/src/presence.js @@ -16,6 +16,18 @@ export function connectionCount(userId) { return online.get(Number(userId))?.sockets.size || 0; } +export function onlineUserCount() { + return online.size; +} + +export function totalSocketCount() { + let n = 0; + for (const entry of online.values()) { + n += entry.sockets.size; + } + return n; +} + /** * Track an authenticated socket. Updates lastConnection on first socket for this user. * Rejects when the account already has MAX_SOCKETS_PER_USER live windows. diff --git a/server/src/prestige.js b/server/src/prestige.js index af1498e..75604f7 100644 --- a/server/src/prestige.js +++ b/server/src/prestige.js @@ -4,23 +4,264 @@ export const PRESTIGE_COST_CENTS = 100_000_000_000_000n; /** Page unlock: 10_000_000_000.00 cr total wealth (balance + unlocked inventory). */ export const PRESTIGE_VISIBLE_AT_CENTS = 1_000_000_000_000; -export const PRESTIGE_CHOICES = { - chance: { field: 'prestigeChanceBonus', delta: 1, label: 'chance' }, - gain: { field: 'prestigeGainBonus', delta: 2, label: 'gain' }, - key: { field: 'prestigeKeyOpenReduction', delta: 10, label: 'key' }, +/** Base Pr granted on every prestige. */ +export const PR_BASE_REWARD = 1000; + +/** + * For every 100_000_000_000.00 cr above the prestige cost, +1000 Pr on average. + * (Stored in cents.) + */ +export const PR_EXCESS_CHUNK_CENTS = 10_000_000_000_000n; +export const PR_PER_EXCESS_CHUNK = 1000; + +const BASE_AD_COOLDOWN_MS = 60_000; +const MIN_AD_COOLDOWN_MS = 15_000; + +/** + * Skill tree upgrades bought with Pr (stackable branches). + * level = how many times this branch has been purchased. + */ +export const PRESTIGE_SKILLS = { + chance: { + id: 'chance', + field: 'prestigeChanceBonus', + delta: 1, + title: 'Fortune', + description: '+1% drop luck on case opens (permanent)', + unit: '%', + branch: 'drops', + }, + gain: { + id: 'gain', + field: 'prestigeGainBonus', + delta: 2, + title: 'Yield', + description: '+2% credits on sell / auto-sell (permanent)', + unit: '%', + branch: 'economy', + }, + key: { + id: 'key', + field: 'prestigeKeyOpenReduction', + delta: 10, + title: 'Keys', + description: '−10 opens required for next key (permanent)', + unit: '', + prefix: '−', + branch: 'drops', + }, + vault: { + id: 'vault', + field: 'prestigeStartBonus', + delta: 10000, + title: 'Vault', + description: '+100.00 cr kept after each prestige reset', + unit: 'cr-cents', + branch: 'economy', + }, + broadcast: { + id: 'broadcast', + field: 'prestigeAdBonus', + delta: 10, + title: 'Broadcast', + description: '+10% shop ad rewards (permanent)', + unit: '%', + branch: 'shop', + }, + tempo: { + id: 'tempo', + field: 'prestigeAdCooldownReduction', + delta: 5, + title: 'Tempo', + description: '−5s shop ad cooldown (min 15s)', + unit: 's', + prefix: '−', + branch: 'shop', + }, + polish: { + id: 'polish', + field: 'prestigeWearBonus', + delta: 1, + title: 'Polish', + description: 'Better float / wear rolls on case opens', + unit: '', + branch: 'drops', + }, + echo: { + id: 'echo', + field: 'prestigePrBonus', + delta: 5, + title: 'Echo', + description: '+5% Pr earned on prestige (permanent)', + unit: '%', + costMult: 1.25, + branch: 'meta', + }, + haggle: { + id: 'haggle', + field: 'prestigeShopDiscount', + delta: 1, + title: 'Haggle', + description: '−1 osu on shop pack prices (min 1 osu)', + unit: ' osu', + prefix: '−', + branch: 'shop', + }, + purse: { + id: 'purse', + field: 'prestigeStartOsu', + delta: 10, + title: 'Purse', + description: '+10 osu granted after each prestige reset', + unit: ' osu', + branch: 'economy', + }, + magnet: { + id: 'magnet', + field: 'prestigeDropValueBonus', + delta: 2, + title: 'Magnet', + description: '+2% item value on case drops (permanent)', + unit: '%', + branch: 'drops', + }, + bulk: { + id: 'bulk', + field: 'prestigeOpenBonus', + delta: 1, + title: 'Bulk', + description: '+1 max simultaneous case opens (permanent)', + unit: '', + branch: 'drops', + }, }; +/** Cost in Pr for the next purchase of a skill (level = current purchases). */ +export function skillUpgradeCost(skillId, level = 0) { + const skill = PRESTIGE_SKILLS[skillId]; + const lv = Math.max(0, Number(level) || 0); + const mult = skill?.costMult || 1; + return Math.round((1000 + lv * 500) * mult); +} + +export function skillLevelFromUser(user, skillId) { + const skill = PRESTIGE_SKILLS[skillId]; + if (!skill) return 0; + const value = Number(user?.[skill.field] ?? 0); + const delta = Math.max(1, Number(skill.delta) || 1); + return Math.floor(value / delta); +} + +/** + * Expected Pr (no RNG) for UI preview, including Echo bonus. + */ +export function estimatePrReward(balanceCents, prBonusPct = 0) { + const balance = BigInt(balanceCents); + const cost = PRESTIGE_COST_CENTS; + if (balance < cost) return 0; + const excess = balance - cost; + const chunk = PR_EXCESS_CHUNK_CENTS; + const exact = Number(excess) / Number(chunk); + const base = Math.round(PR_BASE_REWARD + exact * PR_PER_EXCESS_CHUNK); + return applyPrBonus(base, prBonusPct); +} + +/** + * Pr reward: 1000 base + 1000 per full 100B cr excess, plus a fractional + * roll so partial chunks average to the remaining Pr. Then Echo %. + */ +export function computePrReward(balanceCents, prBonusPct = 0) { + const balance = BigInt(balanceCents); + const cost = PRESTIGE_COST_CENTS; + const excess = balance > cost ? balance - cost : 0n; + const chunk = PR_EXCESS_CHUNK_CENTS; + const full = Number(excess / chunk); + const rem = excess % chunk; + const frac = Number(rem) / Number(chunk); + const bonus = + full * PR_PER_EXCESS_CHUNK + + (Math.random() < frac ? PR_PER_EXCESS_CHUNK : 0); + return applyPrBonus(PR_BASE_REWARD + bonus, prBonusPct); +} + +export function applyPrBonus(basePr, prBonusPct = 0) { + const base = Math.max(0, Math.floor(Number(basePr) || 0)); + const pct = Number(prBonusPct) || 0; + if (pct <= 0) return base; + return Math.floor(base * (1 + pct / 100)); +} + +export function adCooldownMs(user) { + const reductionSec = Math.max(0, Number(user?.prestigeAdCooldownReduction) || 0); + return Math.max(MIN_AD_COOLDOWN_MS, BASE_AD_COOLDOWN_MS - reductionSec * 1000); +} + +export function applyAdRewardBonus(cents, adBonusPct = 0) { + const base = Math.max(0, Math.floor(Number(cents) || 0)); + const pct = Number(adBonusPct) || 0; + if (pct <= 0) return base; + return Math.floor(base * (1 + pct / 100)); +} + export function serializePrestige(user) { return { count: user?.prestigeCount ?? 0, chanceBonus: user?.prestigeChanceBonus ?? 0, gainBonus: user?.prestigeGainBonus ?? 0, keyOpenReduction: user?.prestigeKeyOpenReduction ?? 0, + startBonus: user?.prestigeStartBonus ?? 0, + adBonus: user?.prestigeAdBonus ?? 0, + adCooldownReduction: user?.prestigeAdCooldownReduction ?? 0, + wearBonus: user?.prestigeWearBonus ?? 0, + prBonus: user?.prestigePrBonus ?? 0, + shopDiscount: user?.prestigeShopDiscount ?? 0, + startOsu: user?.prestigeStartOsu ?? 0, + dropValueBonus: user?.prestigeDropValueBonus ?? 0, + openBonus: user?.prestigeOpenBonus ?? 0, + prBalance: user?.prBalance ?? 0, costCents: Number(PRESTIGE_COST_CENTS), visibleAtCents: PRESTIGE_VISIBLE_AT_CENTS, + prBaseReward: PR_BASE_REWARD, + prPerExcessChunk: PR_PER_EXCESS_CHUNK, + excessChunkCents: Number(PR_EXCESS_CHUNK_CENTS), }; } +export function serializeSkillTree(user) { + return Object.values(PRESTIGE_SKILLS).map((skill) => { + const level = skillLevelFromUser(user, skill.id); + const current = Number(user?.[skill.field] ?? 0); + const cost = skillUpgradeCost(skill.id, level); + return { + id: skill.id, + title: skill.title, + description: skill.description, + delta: skill.delta, + unit: skill.unit, + prefix: skill.prefix || (skill.unit === '%' || skill.unit === 'cr-cents' ? '+' : ''), + branch: skill.branch || 'misc', + level, + current, + nextValue: current + skill.delta, + cost, + canAfford: (user?.prBalance ?? 0) >= cost, + }; + }); +} + +export function shopOsuCost(baseCost, discount = 0) { + const base = Math.max(1, Math.floor(Number(baseCost) || 0)); + const off = Math.max(0, Math.floor(Number(discount) || 0)); + return Math.max(1, base - off); +} + +export function applyDropValueBonus(cents, dropValueBonusPct = 0) { + const base = Math.max(1, Math.floor(Number(cents) || 0)); + const pct = Number(dropValueBonusPct) || 0; + if (pct <= 0) return base; + return Math.max(1, Math.floor(base * (1 + pct / 100))); +} + /** Apply lifetime gain % to a credit amount (cents). */ export function applyGainMultiplier(cents, gainBonusPct = 0) { const base = Number(cents) || 0; @@ -33,13 +274,14 @@ export function totalLuckPercent(catalogLuck = 0, prestigeChanceBonus = 0) { return (Number(catalogLuck) || 0) + (Number(prestigeChanceBonus) || 0); } -export function isValidPrestigeChoice(choice) { - return Boolean(PRESTIGE_CHOICES[choice]); +export function isValidSkill(skillId) { + return Boolean(PRESTIGE_SKILLS[skillId]); } /** Visible if already prestiged, or total wealth reaches the unlock threshold. */ export function isPrestigePageVisible(user, totalCents) { if ((user?.prestigeCount ?? 0) > 0) return true; + if ((user?.prBalance ?? 0) > 0) return true; return (Number(totalCents) || 0) >= PRESTIGE_VISIBLE_AT_CENTS; } diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index 9ccd04a..bbfa32f 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -4,6 +4,9 @@ import { prisma } from '../db.js'; import { requireAdmin, publicUser } from '../middleware.js'; import { isValidRarity, RARITIES } from '../rarities.js'; import { createUploader } from '../upload.js'; +import { onlineUserCount, totalSocketCount } from '../presence.js'; +import { ACHIEVEMENT_DEFS } from '../achievements.js'; +import { ITEM_CATEGORIES, normalizeCategory } from '../categories.js'; const router = Router(); const upload = createUploader('asset'); @@ -37,6 +40,194 @@ router.get('/rarities', requireAdmin, (_req, res) => { res.json({ rarities: RARITIES }); }); +router.get('/categories', requireAdmin, async (_req, res) => { + try { + const rows = await prisma.item.findMany({ + select: { category: true }, + distinct: ['category'], + orderBy: { category: 'asc' }, + }); + const fromDb = rows.map((r) => r.category).filter(Boolean); + const categories = [...new Set([...ITEM_CATEGORIES, ...fromDb])].sort((a, b) => + a.localeCompare(b) + ); + res.json({ categories, suggested: ITEM_CATEGORIES }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Failed to load categories' }); + } +}); + +router.get('/stats', requireAdmin, async (_req, res) => { + try { + const [ + players, + casesTotal, + casesActive, + items, + inventoryItems, + caseOpens, + achievementsUnlocked, + recentUsers, + ] = await Promise.all([ + prisma.user.count({ where: { role: 'user' } }), + prisma.case.count(), + prisma.case.count({ where: { active: true } }), + prisma.item.count(), + prisma.inventoryItem.count(), + prisma.transaction.count({ where: { type: 'open_case' } }), + prisma.userAchievement.count(), + prisma.user.findMany({ + where: { role: 'user' }, + orderBy: { lastConnection: 'desc' }, + take: 5, + select: { + id: true, + username: true, + avatarUrl: true, + balance: true, + lastConnection: true, + }, + }), + ]); + + res.json({ + stats: { + players, + casesTotal, + casesActive, + items, + inventoryItems, + caseOpens, + achievementsDefined: ACHIEVEMENT_DEFS.length, + achievementsUnlocked, + onlinePlayers: onlineUserCount(), + openWindows: totalSocketCount(), + }, + recentPlayers: recentUsers.map((u) => ({ + id: u.id, + username: u.username, + avatarUrl: u.avatarUrl || '', + balance: u.balance, + lastConnection: u.lastConnection, + })), + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Failed to load admin stats' }); + } +}); + +router.get('/users', requireAdmin, async (_req, res) => { + try { + const users = await prisma.user.findMany({ + where: { role: 'user' }, + include: { + inventory: { select: { valueCents: true } }, + _count: { + select: { + achievements: true, + }, + }, + }, + orderBy: { id: 'asc' }, + }); + + const openCounts = await prisma.transaction.groupBy({ + by: ['userId'], + where: { type: 'open_case' }, + _count: { _all: true }, + }); + const opensByUser = new Map(openCounts.map((r) => [r.userId, r._count._all])); + + res.json({ + users: users.map((u) => { + const inventoryValue = u.inventory.reduce((s, inv) => s + inv.valueCents, 0); + return { + id: u.id, + username: u.username, + avatarUrl: u.avatarUrl || '', + balance: u.balance, + osuBalance: u.osuBalance ?? 0, + inventoryValue, + inventoryCount: u.inventory.length, + totalWealth: u.balance + inventoryValue, + casesOpened: opensByUser.get(u.id) || 0, + achievements: u._count.achievements, + prestigeCount: u.prestigeCount ?? 0, + lastConnection: u.lastConnection, + playTimeSeconds: Number(u.playTimeSeconds ?? 0), + createdAt: u.createdAt, + }; + }), + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Failed to load users' }); + } +}); + +router.patch('/users/:id', requireAdmin, async (req, res) => { + try { + const id = Number(req.params.id); + const user = await prisma.user.findUnique({ where: { id } }); + if (!user || user.role !== 'user') { + return res.status(404).json({ error: 'Player not found' }); + } + + const data = {}; + if (req.body.balance !== undefined) { + const balance = Number(req.body.balance); + if (!Number.isInteger(balance) || balance < 0) { + return res.status(400).json({ error: 'balance must be a non-negative integer (cents)' }); + } + data.balance = balance; + } + if (req.body.balanceDelta !== undefined) { + const delta = Number(req.body.balanceDelta); + if (!Number.isInteger(delta)) { + return res.status(400).json({ error: 'balanceDelta must be an integer (cents)' }); + } + const next = Number(user.balance) + delta; + if (next < 0) { + return res.status(400).json({ error: 'Resulting balance cannot be negative' }); + } + data.balance = next; + } + if (req.body.osuBalance !== undefined) { + const osu = Number(req.body.osuBalance); + if (!Number.isInteger(osu) || osu < 0) { + return res.status(400).json({ error: 'osuBalance must be a non-negative integer' }); + } + data.osuBalance = osu; + } + + if (!Object.keys(data).length) { + return res.status(400).json({ error: 'No changes provided' }); + } + + const updated = await prisma.$transaction(async (tx) => { + const next = await tx.user.update({ where: { id }, data }); + if (data.balance !== undefined && Number(data.balance) !== Number(user.balance)) { + await tx.transaction.create({ + data: { + userId: id, + type: 'admin_adjust', + amount: Number(data.balance) - Number(user.balance), + meta: JSON.stringify({ reason: 'admin_panel', by: req.session.userId }), + }, + }); + } + return next; + }); + + res.json({ user: publicUser(updated) }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Failed to update user' }); + } +}); + router.post('/upload', requireAdmin, (req, res) => { upload.single('image')(req, res, (err) => { if (err) { @@ -72,6 +263,7 @@ router.get('/cases', requireAdmin, async (_req, res) => { id: ci.item.id, name: ci.item.name, rarity: ci.item.rarity, + category: ci.item.category || 'Misc', marketValue: ci.item.marketValue, imageUrl: ci.item.imageUrl, }, @@ -143,7 +335,7 @@ router.delete('/cases/:id', requireAdmin, async (req, res) => { // —— Items —— router.get('/items', requireAdmin, async (_req, res) => { try { - const items = await prisma.item.findMany({ orderBy: { id: 'asc' } }); + const items = await prisma.item.findMany({ orderBy: [{ category: 'asc' }, { id: 'asc' }] }); res.json({ items }); } catch (err) { console.error(err); @@ -156,6 +348,7 @@ router.post('/items', requireAdmin, async (req, res) => { const name = String(req.body.name || '').trim(); const imageUrl = String(req.body.imageUrl || ''); const rarity = String(req.body.rarity || ''); + const category = normalizeCategory(req.body.category); const marketValue = Number(req.body.marketValue); if (!name) return res.status(400).json({ error: 'Name is required' }); @@ -167,7 +360,7 @@ router.post('/items', requireAdmin, async (req, res) => { } const created = await prisma.item.create({ - data: { name, imageUrl, rarity, marketValue }, + data: { name, imageUrl, rarity, category, marketValue }, }); res.status(201).json({ item: created }); } catch (err) { @@ -188,6 +381,9 @@ router.put('/items/:id', requireAdmin, async (req, res) => { } data.rarity = req.body.rarity; } + if (req.body.category !== undefined) { + data.category = normalizeCategory(req.body.category); + } if (req.body.marketValue !== undefined) { const marketValue = Number(req.body.marketValue); if (!Number.isInteger(marketValue) || marketValue < 0) { diff --git a/server/src/routes/cases.js b/server/src/routes/cases.js index 30118fc..f31140d 100644 --- a/server/src/routes/cases.js +++ b/server/src/routes/cases.js @@ -15,7 +15,7 @@ import { fillCatalogSlots, formatCaseItemsWithLuck, } from '../catalog.js'; -import { totalLuckPercent } from '../prestige.js'; +import { totalLuckPercent, applyDropValueBonus } from '../prestige.js'; import { checkAchievements } from '../achievements.js'; const router = Router(); @@ -43,7 +43,7 @@ function formatCase(c, luckPercent = 0) { async function loadUserLuckAndKeys(userId) { if (!userId) { - return { luckPercent: 0, keyOpenReduction: 0, user: null }; + return { luckPercent: 0, keyOpenReduction: 0, openBonus: 0, dropValueBonus: 0, user: null }; } const [catalogLuck, user] = await Promise.all([ countCompletedChapters(prisma, userId), @@ -52,12 +52,17 @@ async function loadUserLuckAndKeys(userId) { select: { prestigeChanceBonus: true, prestigeKeyOpenReduction: true, + prestigeWearBonus: true, + prestigeOpenBonus: true, + prestigeDropValueBonus: true, }, }), ]); return { luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus), keyOpenReduction: user?.prestigeKeyOpenReduction ?? 0, + openBonus: user?.prestigeOpenBonus ?? 0, + dropValueBonus: user?.prestigeDropValueBonus ?? 0, user, }; } @@ -81,7 +86,7 @@ router.get('/', async (req, res) => { orderBy: { price: 'asc' }, }); const progressMap = await loadKeyProgressMap(userId); - const { luckPercent, keyOpenReduction } = await loadUserLuckAndKeys(userId); + const { luckPercent, keyOpenReduction, openBonus } = await loadUserLuckAndKeys(userId); res.json({ catalogLuckPercent: luckPercent, cases: cases.map((c) => { @@ -90,7 +95,7 @@ router.get('/', async (req, res) => { const progress = progressMap.get(c.id); return { ...formatted, - keyProgress: serializeKeyProgress(progress, keyOpenReduction), + keyProgress: serializeKeyProgress(progress, keyOpenReduction, openBonus), }; }), }); @@ -113,13 +118,13 @@ router.get('/:id', async (req, res) => { if (!c || !c.active) { return res.status(404).json({ error: 'Case not found' }); } - const { luckPercent, keyOpenReduction } = await loadUserLuckAndKeys(userId); + const { luckPercent, keyOpenReduction, openBonus } = await loadUserLuckAndKeys(userId); const formatted = formatCase(c, luckPercent); if (userId) { const progress = await prisma.caseKeyProgress.findUnique({ where: { userId_caseId: { userId, caseId: id } }, }); - formatted.keyProgress = serializeKeyProgress(progress, keyOpenReduction); + formatted.keyProgress = serializeKeyProgress(progress, keyOpenReduction, openBonus); } res.json({ case: formatted }); } catch (err) { @@ -163,7 +168,9 @@ router.post('/:id/open', requireAuth, async (req, res) => { } const progressRow = await getOrCreateProgress(tx, userId, caseId); - const maxOpens = maxOpensForKeys(progressRow.keys); + const openBonus = user.prestigeOpenBonus ?? 0; + const dropValueBonus = user.prestigeDropValueBonus ?? 0; + const maxOpens = maxOpensForKeys(progressRow.keys, openBonus); if (count > maxOpens) { const err = new Error(`You can open at most ${maxOpens} at once for this case`); err.status = 400; @@ -192,7 +199,8 @@ router.post('/:id/open', requireAuth, async (req, res) => { for (let i = 0; i < count; i += 1) { const winner = pickWeighted(weighted); const item = winner.caseItem.item; - const rolled = rollInstanceValue(item.marketValue); + const rolled = rollInstanceValue(item.marketValue, user.prestigeWearBonus ?? 0); + rolled.valueCents = applyDropValueBonus(rolled.valueCents, dropValueBonus); const inventoryItem = await tx.inventoryItem.create({ data: { @@ -272,7 +280,7 @@ router.post('/:id/open', requireAuth, async (req, res) => { user: publicUser(updatedUser), caseName: c.name, items: drops, - keyProgress: serializeKeyProgress(updatedProgress, keyOpenReduction), + keyProgress: serializeKeyProgress(updatedProgress, keyOpenReduction, openBonus), keysGained: applied.keysGained, catalog, }; diff --git a/server/src/routes/prestige.js b/server/src/routes/prestige.js index a0b5b8e..4d070fc 100644 --- a/server/src/routes/prestige.js +++ b/server/src/routes/prestige.js @@ -2,17 +2,22 @@ import { Router } from 'express'; import { prisma } from '../db.js'; import { requireAuth, publicUser } from '../middleware.js'; import { - PRESTIGE_CHOICES, PRESTIGE_COST_CENTS, + PRESTIGE_SKILLS, + computePrReward, + estimatePrReward, getUnlockedInventoryValueCents, isPrestigePageVisible, - isValidPrestigeChoice, + isValidSkill, serializePrestige, + serializeSkillTree, + skillLevelFromUser, + skillUpgradeCost, } from '../prestige.js'; import { checkAchievements } from '../achievements.js'; const router = Router(); -const COST = Number(PRESTIGE_COST_CENTS); +const COST = PRESTIGE_COST_CENTS; router.get('/', requireAuth, async (req, res) => { try { @@ -31,44 +36,28 @@ router.get('/', requireAuth, async (req, res) => { }), ]); - const totalCents = (Number(user.balance) || 0) + inventoryValue; + const balance = BigInt(user.balance); + const totalCents = Number(user.balance) + inventoryValue; const pageVisible = isPrestigePageVisible(user, totalCents); - const canAfford = user.balance >= COST; + const canAfford = balance >= COST; const canPrestige = pageVisible && canAfford && lockedCount === 0; + const previewPr = canAfford + ? estimatePrReward(user.balance, user.prestigePrBonus ?? 0) + : 0; res.json({ prestige: serializePrestige(user), + skills: serializeSkillTree(user), pageVisible, totalCents, canAfford, lockedItems: lockedCount, canPrestige, - choices: [ - { - id: 'chance', - title: 'Chance', - description: '+1% catalog-style drop luck (permanent, stacks)', - delta: PRESTIGE_CHOICES.chance.delta, - current: user.prestigeChanceBonus ?? 0, - }, - { - id: 'gain', - title: 'Gain multiplier', - description: '+2% credits on sell / auto-sell (permanent, stacks)', - delta: PRESTIGE_CHOICES.gain.delta, - current: user.prestigeGainBonus ?? 0, - }, - { - id: 'key', - title: 'Key progress', - description: '−10 opens required for next key on all cases (permanent, stacks)', - delta: PRESTIGE_CHOICES.key.delta, - current: user.prestigeKeyOpenReduction ?? 0, - }, - ], + previewPr, history: logs.map((l) => ({ id: l.id, choice: l.choice, + prGranted: l.prGranted ?? 0, createdAt: l.createdAt, })), }); @@ -80,11 +69,6 @@ router.get('/', requireAuth, async (req, res) => { router.post('/', requireAuth, async (req, res) => { try { - const choice = String(req.body?.choice || ''); - if (!isValidPrestigeChoice(choice)) { - return res.status(400).json({ error: 'Choose chance, gain, or key' }); - } - const result = await prisma.$transaction(async (tx) => { const user = await tx.user.findUnique({ where: { id: req.session.userId } }); if (!user) { @@ -97,7 +81,7 @@ router.post('/', requireAuth, async (req, res) => { err.status = 403; throw err; } - if (user.balance < COST) { + if (BigInt(user.balance) < COST) { const err = new Error('Insufficient balance for prestige'); err.status = 400; throw err; @@ -114,12 +98,10 @@ router.post('/', requireAuth, async (req, res) => { throw err; } - const boost = PRESTIGE_CHOICES[choice]; - const prestigeData = { - prestigeCount: (user.prestigeCount ?? 0) + 1, - [boost.field]: (user[boost.field] ?? 0) + boost.delta, - balance: 0, - }; + const prGranted = computePrReward(user.balance, user.prestigePrBonus ?? 0); + const prestigeCount = (user.prestigeCount ?? 0) + 1; + const startBalance = Math.max(0, Number(user.prestigeStartBonus) || 0); + const startOsu = Math.max(0, Number(user.prestigeStartOsu) || 0); await tx.inventoryItem.deleteMany({ where: { userId: user.id } }); await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } }); @@ -129,34 +111,45 @@ router.post('/', requireAuth, async (req, res) => { data: { userId: user.id, type: 'prestige', - amount: -COST, + amount: -Number(COST), meta: JSON.stringify({ - choice, - prestigeCount: prestigeData.prestigeCount, - delta: boost.delta, - field: boost.field, + prestigeCount, + prGranted, + previousBalance: Number(user.balance), + startBalance, + startOsu, }), }, }); await tx.prestigeLog.create({ - data: { userId: user.id, choice }, + data: { + userId: user.id, + choice: 'reset', + prGranted, + }, }); const updated = await tx.user.update({ where: { id: user.id }, - data: prestigeData, + data: { + prestigeCount, + balance: startBalance, + ...(startOsu > 0 ? { osuBalance: { increment: startOsu } } : {}), + prBalance: { increment: prGranted }, + }, }); - return updated; + return { user: updated, prGranted }; }); - checkAchievements(result.id).catch((err) => console.error('achievements', err)); + checkAchievements(result.user.id).catch((err) => console.error('achievements', err)); res.json({ - user: publicUser(result), - prestige: serializePrestige(result), - choice, + user: publicUser(result.user), + prestige: serializePrestige(result.user), + skills: serializeSkillTree(result.user), + prGranted: result.prGranted, }); } catch (err) { console.error(err); @@ -165,4 +158,71 @@ router.post('/', requireAuth, async (req, res) => { } }); +router.post('/upgrade', requireAuth, async (req, res) => { + try { + const skillId = String(req.body?.skillId || ''); + if (!isValidSkill(skillId)) { + return res.status(400).json({ error: 'Unknown skill' }); + } + + const skill = PRESTIGE_SKILLS[skillId]; + + const result = await prisma.$transaction(async (tx) => { + const user = await tx.user.findUnique({ where: { id: req.session.userId } }); + if (!user) { + const err = new Error('User not found'); + err.status = 401; + throw err; + } + if (user.role === 'admin') { + const err = new Error('Admins cannot buy prestige skills'); + err.status = 403; + throw err; + } + + const level = skillLevelFromUser(user, skillId); + const cost = skillUpgradeCost(skillId, level); + if ((user.prBalance ?? 0) < cost) { + const err = new Error('Not enough Pr'); + err.status = 400; + throw err; + } + + const updated = await tx.user.update({ + where: { id: user.id }, + data: { + prBalance: { decrement: cost }, + [skill.field]: { increment: skill.delta }, + }, + }); + + await tx.transaction.create({ + data: { + userId: user.id, + type: 'prestige_upgrade', + amount: 0, + meta: JSON.stringify({ + skillId, + cost, + delta: skill.delta, + field: skill.field, + }), + }, + }); + + return updated; + }); + + res.json({ + user: publicUser(result), + prestige: serializePrestige(result), + skills: serializeSkillTree(result), + }); + } catch (err) { + console.error(err); + const status = err.status || 500; + res.status(status).json({ error: err.message || 'Failed to upgrade skill' }); + } +}); + export default router; diff --git a/server/src/routes/shop.js b/server/src/routes/shop.js index 2c76256..8a25477 100644 --- a/server/src/routes/shop.js +++ b/server/src/routes/shop.js @@ -2,6 +2,7 @@ import { Router } from 'express'; import { prisma } from '../db.js'; import { requireAuth, publicUser } from '../middleware.js'; import { checkAchievements } from '../achievements.js'; +import { adCooldownMs, applyAdRewardBonus, shopOsuCost } from '../prestige.js'; const router = Router(); @@ -13,30 +14,42 @@ export const SHOP_PACKAGES = [ { id: 'whale', name: 'Whale Pack', osuCost: 100, credits: 80000, blurb: '800.00 cr' }, ]; -const AD_COOLDOWN_MS = 60 * 1000; const AD_REWARD_MIN = 10000; // 100.00 cr const AD_REWARD_MAX = 20000; // 200.00 cr +function adPayload(user, overrides = {}) { + const cooldownMs = adCooldownMs(user); + const lastAdAt = user.lastAdAt ? new Date(user.lastAdAt).getTime() : 0; + const nextAdAt = lastAdAt + cooldownMs; + const adReady = Date.now() >= nextAdAt; + const adBonus = user.prestigeAdBonus ?? 0; + return { + cooldownMs, + rewardMin: applyAdRewardBonus(AD_REWARD_MIN, adBonus), + rewardMax: applyAdRewardBonus(AD_REWARD_MAX, adBonus), + ready: adReady, + nextAdAt: adReady ? null : new Date(nextAdAt).toISOString(), + lastAdAt: user.lastAdAt, + ...overrides, + }; +} + router.get('/', requireAuth, async (req, res) => { try { const user = await prisma.user.findUnique({ where: { id: req.session.userId } }); if (!user) return res.status(404).json({ error: 'User not found' }); - const lastAdAt = user.lastAdAt ? new Date(user.lastAdAt).getTime() : 0; - const nextAdAt = lastAdAt + AD_COOLDOWN_MS; - const adReady = Date.now() >= nextAdAt; + const discount = user.prestigeShopDiscount ?? 0; + const packages = SHOP_PACKAGES.map((pack) => ({ + ...pack, + osuCost: shopOsuCost(pack.osuCost, discount), + baseOsuCost: pack.osuCost, + })); res.json({ - packages: SHOP_PACKAGES, + packages, user: publicUser(user), - ad: { - cooldownMs: AD_COOLDOWN_MS, - rewardMin: AD_REWARD_MIN, - rewardMax: AD_REWARD_MAX, - ready: adReady, - nextAdAt: adReady ? null : new Date(nextAdAt).toISOString(), - lastAdAt: user.lastAdAt, - }, + ad: adPayload(user), }); } catch (err) { console.error(err); @@ -59,7 +72,8 @@ router.post('/buy', requireAuth, async (req, res) => { err.status = 404; throw err; } - if (user.osuBalance < pack.osuCost) { + const osuCost = shopOsuCost(pack.osuCost, user.prestigeShopDiscount ?? 0); + if (user.osuBalance < osuCost) { const err = new Error('Not enough osu'); err.status = 400; throw err; @@ -68,7 +82,7 @@ router.post('/buy', requireAuth, async (req, res) => { const updated = await tx.user.update({ where: { id: user.id }, data: { - osuBalance: { decrement: pack.osuCost }, + osuBalance: { decrement: osuCost }, balance: { increment: pack.credits }, }, }); @@ -80,18 +94,22 @@ router.post('/buy', requireAuth, async (req, res) => { amount: pack.credits, meta: JSON.stringify({ packageId: pack.id, - osuCost: pack.osuCost, + osuCost, + baseOsuCost: pack.osuCost, credits: pack.credits, }), }, }); - return updated; + return { user: updated, osuCost }; }); - checkAchievements(result.id).catch((err) => console.error('achievements', err)); + checkAchievements(result.user.id).catch((err) => console.error('achievements', err)); - res.json({ user: publicUser(result), package: pack }); + res.json({ + user: publicUser(result.user), + package: { ...pack, osuCost: result.osuCost }, + }); } catch (err) { const status = err.status || 500; if (status >= 500) console.error(err); @@ -109,18 +127,20 @@ router.post('/ad', requireAuth, async (req, res) => { throw err; } + const cooldownMs = adCooldownMs(user); const lastAdAt = user.lastAdAt ? new Date(user.lastAdAt).getTime() : 0; const elapsed = Date.now() - lastAdAt; - if (elapsed < AD_COOLDOWN_MS) { - const waitSec = Math.ceil((AD_COOLDOWN_MS - elapsed) / 1000); + if (elapsed < cooldownMs) { + const waitSec = Math.ceil((cooldownMs - elapsed) / 1000); const err = new Error(`Ad available in ${waitSec}s`); err.status = 429; - err.nextAdAt = new Date(lastAdAt + AD_COOLDOWN_MS).toISOString(); + err.nextAdAt = new Date(lastAdAt + cooldownMs).toISOString(); throw err; } - const reward = + const base = AD_REWARD_MIN + Math.floor(Math.random() * (AD_REWARD_MAX - AD_REWARD_MIN + 1)); + const reward = applyAdRewardBonus(base, user.prestigeAdBonus ?? 0); const now = new Date(); const updated = await tx.user.update({ @@ -136,11 +156,11 @@ router.post('/ad', requireAuth, async (req, res) => { userId: user.id, type: 'ad_reward', amount: reward, - meta: JSON.stringify({ reward }), + meta: JSON.stringify({ reward, base, adBonus: user.prestigeAdBonus ?? 0 }), }, }); - return { user: updated, reward }; + return { user: updated, reward, cooldownMs }; }); checkAchievements(result.user.id).catch((err) => console.error('achievements', err)); @@ -148,13 +168,10 @@ router.post('/ad', requireAuth, async (req, res) => { res.json({ user: publicUser(result.user), reward: result.reward, - ad: { + ad: adPayload(result.user, { ready: false, - nextAdAt: new Date(Date.now() + AD_COOLDOWN_MS).toISOString(), - cooldownMs: AD_COOLDOWN_MS, - rewardMin: AD_REWARD_MIN, - rewardMax: AD_REWARD_MAX, - }, + nextAdAt: new Date(Date.now() + result.cooldownMs).toISOString(), + }), }); } catch (err) { const status = err.status || 500; diff --git a/server/src/wear.js b/server/src/wear.js index 3d5d66b..f779c25 100644 --- a/server/src/wear.js +++ b/server/src/wear.js @@ -111,8 +111,12 @@ export function rollPaintSeed() { } /** Full roll for a new inventory instance from catalog marketValue */ -export function rollInstanceValue(baseMarketValue) { - const floatValue = rollFloat(); +export function rollInstanceValue(baseMarketValue, wearBonus = 0) { + let floatValue = rollFloat(); + const bias = Math.min(0.55, Math.max(0, Number(wearBonus) || 0) * 0.008); + if (bias > 0) { + floatValue = clampFloat(floatValue * (1 - bias)); + } return { floatValue, wear: wearFromFloat(floatValue),