prstige
This commit is contained in:
@@ -61,8 +61,10 @@ New registrations receive **100.00** starting credits and **100 osu** (`STARTING
|
|||||||
- Profile (username in nav): avatar, bio, password, stats, logout
|
- 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
|
- **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
|
- **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
|
- **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)
|
## Docker (production)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/admin" element={<Admin />} />
|
<Route path="/admin/*" element={<Admin />} />
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path="/" element={<HomeRedirect />} />
|
<Route path="/" element={<HomeRedirect />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h1>Cases</h1>
|
||||||
|
<p className="muted">{cases.length} crates · create, enable, price & art</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="admin-search"
|
||||||
|
placeholder="Search cases…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="admin-block">
|
||||||
|
<h3>Create case</h3>
|
||||||
|
<form
|
||||||
|
className="form admin-form-grid"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.createCase({ ...caseForm, price: Number(caseForm.price) });
|
||||||
|
setCaseForm(emptyCase);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={caseForm.name}
|
||||||
|
onChange={(e) => setCaseForm({ ...caseForm, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Price (cents)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={caseForm.price}
|
||||||
|
onChange={(e) => setCaseForm({ ...caseForm, price: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-form-span">
|
||||||
|
Image
|
||||||
|
<ImageField
|
||||||
|
value={caseForm.imageUrl}
|
||||||
|
onChange={(imageUrl) => setCaseForm({ ...caseForm, imageUrl })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Add case
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-card-list">
|
||||||
|
{filtered.map((c) => (
|
||||||
|
<div key={c.id} className="admin-block">
|
||||||
|
{editingId === c.id && edit ? (
|
||||||
|
<form
|
||||||
|
className="form admin-form-grid"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 className="admin-form-span">Edit #{c.id}</h3>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={edit.name}
|
||||||
|
onChange={(e) => setEdit({ ...edit, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Price (cents)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={edit.price}
|
||||||
|
onChange={(e) => setEdit({ ...edit, price: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-form-span">
|
||||||
|
Image
|
||||||
|
<ImageField
|
||||||
|
value={edit.imageUrl}
|
||||||
|
onChange={(imageUrl) => setEdit({ ...edit, imageUrl })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={edit.active}
|
||||||
|
onChange={(e) => setEdit({ ...edit, active: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
<div className="row-actions admin-form-span">
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEdit(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="admin-row">
|
||||||
|
{c.imageUrl ? (
|
||||||
|
<div className="admin-thumb">
|
||||||
|
<img src={c.imageUrl} alt="" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-thumb empty">▣</div>
|
||||||
|
)}
|
||||||
|
<div className="admin-row-main">
|
||||||
|
<strong>{c.name}</strong>
|
||||||
|
<div className="muted">
|
||||||
|
{formatCredits(c.price)} cr · {c.items.length} drops ·{' '}
|
||||||
|
<span className={c.active ? 'admin-pill-on' : 'admin-pill-off'}>
|
||||||
|
{c.active ? 'active' : 'inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn secondary" onClick={() => startEdit(c)}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={async () => {
|
||||||
|
await api.updateCase(c.id, { active: !c.active });
|
||||||
|
await load();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.active ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn danger"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm(`Delete case ${c.name}?`)) return;
|
||||||
|
await api.deleteCase(c.id);
|
||||||
|
await load();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <div className="error">{error}</div>;
|
||||||
|
if (!data) return <p className="muted">Loading overview…</p>;
|
||||||
|
|
||||||
|
const s = data.stats;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h1>Overview</h1>
|
||||||
|
<p className="muted">Live snapshot of CaseForge.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-stat-grid">
|
||||||
|
<div className="admin-stat">
|
||||||
|
<span className="admin-stat-label">Players</span>
|
||||||
|
<strong>{s.players}</strong>
|
||||||
|
<span className="muted">{s.onlinePlayers} online · {s.openWindows} windows</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-stat">
|
||||||
|
<span className="admin-stat-label">Cases</span>
|
||||||
|
<strong>
|
||||||
|
{s.casesActive}/{s.casesTotal}
|
||||||
|
</strong>
|
||||||
|
<span className="muted">active / total</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-stat">
|
||||||
|
<span className="admin-stat-label">Catalog items</span>
|
||||||
|
<strong>{s.items}</strong>
|
||||||
|
<span className="muted">{s.inventoryItems} in inventories</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-stat">
|
||||||
|
<span className="admin-stat-label">Case opens</span>
|
||||||
|
<strong>{s.caseOpens}</strong>
|
||||||
|
<span className="muted">all time</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-stat">
|
||||||
|
<span className="admin-stat-label">Achievements</span>
|
||||||
|
<strong>{s.achievementsUnlocked}</strong>
|
||||||
|
<span className="muted">{s.achievementsDefined} defined</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-quick-links">
|
||||||
|
<Link to="/admin/players" className="btn secondary">
|
||||||
|
Manage players
|
||||||
|
</Link>
|
||||||
|
<Link to="/admin/cases" className="btn secondary">
|
||||||
|
Manage cases
|
||||||
|
</Link>
|
||||||
|
<Link to="/admin/items" className="btn secondary">
|
||||||
|
Manage items
|
||||||
|
</Link>
|
||||||
|
<Link to="/admin/drops" className="btn secondary">
|
||||||
|
Drop rates
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="admin-block" style={{ marginTop: '1.25rem' }}>
|
||||||
|
<h3>Recently connected</h3>
|
||||||
|
{data.recentPlayers?.length === 0 ? (
|
||||||
|
<p className="muted">No players yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="admin-recent-list">
|
||||||
|
{data.recentPlayers.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<div className="admin-recent-avatar" aria-hidden>
|
||||||
|
{p.avatarUrl ? (
|
||||||
|
<img src={p.avatarUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<span>{p.username.slice(0, 2).toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>@{p.username}</strong>
|
||||||
|
<div className="muted">
|
||||||
|
{formatCredits(p.balance)} cr ·{' '}
|
||||||
|
{formatLastConnection(p.lastConnection)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h1>Drop rates</h1>
|
||||||
|
<p className="muted">Weights per case · % shown from relative weights</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="admin-search"
|
||||||
|
value={selectedCaseId}
|
||||||
|
onChange={(e) => setSelectedCaseId(e.target.value)}
|
||||||
|
aria-label="Select case"
|
||||||
|
>
|
||||||
|
{cases.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name} ({c.items.length})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
{!selected ? (
|
||||||
|
<div className="empty">Create a case first.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="admin-block">
|
||||||
|
<h3>
|
||||||
|
Add drop · {selected.name}{' '}
|
||||||
|
<span className="muted">({formatCredits(selected.price)} cr)</span>
|
||||||
|
</h3>
|
||||||
|
<form
|
||||||
|
className="admin-row"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.addCaseItem(Number(selectedCaseId), {
|
||||||
|
itemId: Number(dropForm.itemId),
|
||||||
|
dropRate: Number(dropForm.dropRate),
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={dropForm.itemId}
|
||||||
|
onChange={(e) => setDropForm({ ...dropForm, itemId: e.target.value })}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name} ({item.rarity})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0.01"
|
||||||
|
value={dropForm.dropRate}
|
||||||
|
onChange={(e) => setDropForm({ ...dropForm, dropRate: e.target.value })}
|
||||||
|
placeholder="Weight"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Add drop
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-block">
|
||||||
|
<h3>Pool ({selected.items.length})</h3>
|
||||||
|
{selected.items.length === 0 ? (
|
||||||
|
<div className="muted">No drops yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="drop-list">
|
||||||
|
{selected.items.map((ci, idx) => (
|
||||||
|
<div key={ci.id} className="drop-row">
|
||||||
|
<span className="drop-item-cell">
|
||||||
|
{ci.item.imageUrl ? (
|
||||||
|
<img src={ci.item.imageUrl} alt="" className="drop-thumb" />
|
||||||
|
) : null}
|
||||||
|
{ci.item.name}{' '}
|
||||||
|
<span className="muted">({ci.item.rarity})</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0.01"
|
||||||
|
defaultValue={ci.dropRate}
|
||||||
|
key={`${ci.id}-${ci.dropRate}`}
|
||||||
|
onBlur={async (e) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="admin-drop-pct">{percents[idx].toFixed(2)}%</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn danger"
|
||||||
|
onClick={async () => {
|
||||||
|
await api.deleteCaseItem(ci.id);
|
||||||
|
await load();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
list={listId}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="e.g. Rifle, Knife…"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<datalist id={listId}>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c} value={c} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h1>Items</h1>
|
||||||
|
<p className="muted">
|
||||||
|
{items.length} skins · categories + Field-Tested reference values
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-toolbar">
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||||
|
aria-label="Filter category"
|
||||||
|
>
|
||||||
|
<option value="all">All categories</option>
|
||||||
|
{usedCategories.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={rarityFilter}
|
||||||
|
onChange={(e) => setRarityFilter(e.target.value)}
|
||||||
|
aria-label="Filter rarity"
|
||||||
|
>
|
||||||
|
<option value="all">All rarities</option>
|
||||||
|
{rarities.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={sort}
|
||||||
|
onChange={(e) => setSort(e.target.value)}
|
||||||
|
aria-label="Sort items"
|
||||||
|
>
|
||||||
|
<option value="category">Sort: category</option>
|
||||||
|
<option value="name">Sort: name</option>
|
||||||
|
<option value="value">Sort: value</option>
|
||||||
|
<option value="rarity">Sort: rarity</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="admin-search"
|
||||||
|
placeholder="Search items…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="admin-block">
|
||||||
|
<h3>Create item</h3>
|
||||||
|
<form
|
||||||
|
className="form admin-form-grid"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={itemForm.name}
|
||||||
|
onChange={(e) => setItemForm({ ...itemForm, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Category
|
||||||
|
<CategoryField
|
||||||
|
id="create-item-categories"
|
||||||
|
value={itemForm.category}
|
||||||
|
categories={categories}
|
||||||
|
onChange={(category) => setItemForm({ ...itemForm, category })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Rarity
|
||||||
|
<select
|
||||||
|
value={itemForm.rarity}
|
||||||
|
onChange={(e) => setItemForm({ ...itemForm, rarity: e.target.value })}
|
||||||
|
>
|
||||||
|
{rarities.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Value (cents, FT ref)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={itemForm.marketValue}
|
||||||
|
onChange={(e) => setItemForm({ ...itemForm, marketValue: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-form-span">
|
||||||
|
Image
|
||||||
|
<ImageField
|
||||||
|
value={itemForm.imageUrl}
|
||||||
|
onChange={(imageUrl) => setItemForm({ ...itemForm, imageUrl })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Add item
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="muted" style={{ marginBottom: '0.65rem' }}>
|
||||||
|
Showing {filtered.length} / {items.length}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="admin-card-list">
|
||||||
|
{filtered.map((item) => (
|
||||||
|
<div key={item.id} className="admin-block">
|
||||||
|
{editingId === item.id && edit ? (
|
||||||
|
<form
|
||||||
|
className="form admin-form-grid"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 className="admin-form-span">Edit #{item.id}</h3>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={edit.name}
|
||||||
|
onChange={(e) => setEdit({ ...edit, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Category
|
||||||
|
<CategoryField
|
||||||
|
id={`edit-item-categories-${item.id}`}
|
||||||
|
value={edit.category}
|
||||||
|
categories={categories}
|
||||||
|
onChange={(category) => setEdit({ ...edit, category })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Rarity
|
||||||
|
<select
|
||||||
|
value={edit.rarity}
|
||||||
|
onChange={(e) => setEdit({ ...edit, rarity: e.target.value })}
|
||||||
|
>
|
||||||
|
{rarities.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Value (cents)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={edit.marketValue}
|
||||||
|
onChange={(e) => setEdit({ ...edit, marketValue: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-form-span">
|
||||||
|
Image
|
||||||
|
<ImageField
|
||||||
|
value={edit.imageUrl}
|
||||||
|
onChange={(imageUrl) => setEdit({ ...edit, imageUrl })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="row-actions admin-form-span">
|
||||||
|
<button className="btn" type="submit">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEdit(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="admin-row">
|
||||||
|
{item.imageUrl ? (
|
||||||
|
<div className="admin-thumb">
|
||||||
|
<img src={item.imageUrl} alt="" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-thumb empty">?</div>
|
||||||
|
)}
|
||||||
|
<div className="admin-row-main">
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<div className="muted">
|
||||||
|
<span className="admin-cat-pill">{item.category || 'Misc'}</span> ·{' '}
|
||||||
|
{item.rarity} · {formatCredits(item.marketValue)} cr
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setEdit({
|
||||||
|
name: item.name,
|
||||||
|
rarity: item.rarity,
|
||||||
|
category: item.category || 'Misc',
|
||||||
|
marketValue: item.marketValue,
|
||||||
|
imageUrl: item.imageUrl || '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn danger"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm(`Delete ${item.name}?`)) return;
|
||||||
|
await api.deleteItem(item.id);
|
||||||
|
await load();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="admin-shell">
|
||||||
|
<aside className="admin-sidebar">
|
||||||
|
<div className="admin-sidebar-brand">
|
||||||
|
<span className="brand">CaseForge</span>
|
||||||
|
<span className="admin-sidebar-tag">Admin</span>
|
||||||
|
</div>
|
||||||
|
<nav className="admin-side-nav">
|
||||||
|
{NAV.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
className={({ isActive }) => (isActive ? 'active' : undefined)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="admin-sidebar-foot">
|
||||||
|
<div className="muted admin-sidebar-user">@{user?.username}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost"
|
||||||
|
onClick={async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/login');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div className="admin-main">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h1>Players</h1>
|
||||||
|
<p className="muted">{users.length} accounts · adjust credits when needed</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-toolbar">
|
||||||
|
<select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">
|
||||||
|
<option value="wealth">Sort: wealth</option>
|
||||||
|
<option value="opens">Sort: opens</option>
|
||||||
|
<option value="recent">Sort: last seen</option>
|
||||||
|
<option value="name">Sort: name</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="admin-search"
|
||||||
|
placeholder="Search players…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="admin-card-list">
|
||||||
|
{filtered.map((u) => (
|
||||||
|
<div key={u.id} className="admin-block admin-player-card">
|
||||||
|
<div className="admin-row">
|
||||||
|
<div className="admin-thumb">
|
||||||
|
{u.avatarUrl ? (
|
||||||
|
<img src={u.avatarUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<span>{u.username.slice(0, 2).toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-row-main">
|
||||||
|
<strong>@{u.username}</strong>
|
||||||
|
<div className="muted">
|
||||||
|
Wealth {formatCredits(u.totalWealth)} cr · bal{' '}
|
||||||
|
{formatCredits(u.balance)} · inv {formatCredits(u.inventoryValue)} (
|
||||||
|
{u.inventoryCount})
|
||||||
|
</div>
|
||||||
|
<div className="muted">
|
||||||
|
Opens {u.casesOpened} · prestige {u.prestigeCount} · achievements{' '}
|
||||||
|
{u.achievements} · play {formatPlayTime(u.playTimeSeconds)} · last{' '}
|
||||||
|
{formatLastConnection(u.lastConnection)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setAdjustId(adjustId === u.id ? null : u.id);
|
||||||
|
setDeltaCredits('100');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Adjust credits
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{adjustId === u.id && (
|
||||||
|
<form
|
||||||
|
className="admin-adjust-row"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
applyDelta(u.id, deltaCredits);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Δ credits (e.g. 100 or -50)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={deltaCredits}
|
||||||
|
onChange={(e) => setDeltaCredits(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="btn" type="submit" disabled={busy}>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost"
|
||||||
|
onClick={() => applyDelta(u.id, '1000')}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
+1000
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost"
|
||||||
|
onClick={() => applyDelta(u.id, '-100')}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
−100
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="image-field">
|
||||||
|
{value ? (
|
||||||
|
<div className="admin-thumb">
|
||||||
|
<img src={value} alt="" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-thumb empty">No img</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
placeholder="Image URL or upload"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<label className="btn secondary file-btn">
|
||||||
|
{uploading ? '…' : 'Upload'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={onFile}
|
||||||
|
disabled={disabled || uploading}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{value ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost"
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+12
-3
@@ -34,10 +34,11 @@ export const api = {
|
|||||||
case: (id) => request(`/api/cases/${id}`),
|
case: (id) => request(`/api/cases/${id}`),
|
||||||
catalog: () => request('/api/catalog'),
|
catalog: () => request('/api/catalog'),
|
||||||
prestige: () => request('/api/prestige'),
|
prestige: () => request('/api/prestige'),
|
||||||
doPrestige: (choice) =>
|
doPrestige: () => request('/api/prestige', { method: 'POST', body: JSON.stringify({}) }),
|
||||||
request('/api/prestige', {
|
prestigeUpgrade: (skillId) =>
|
||||||
|
request('/api/prestige/upgrade', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ choice }),
|
body: JSON.stringify({ skillId }),
|
||||||
}),
|
}),
|
||||||
openCase: (id, count = 1) =>
|
openCase: (id, count = 1) =>
|
||||||
request(`/api/cases/${id}/open`, {
|
request(`/api/cases/${id}/open`, {
|
||||||
@@ -127,9 +128,17 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, password }),
|
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'),
|
adminCases: () => request('/api/admin/cases'),
|
||||||
adminItems: () => request('/api/admin/items'),
|
adminItems: () => request('/api/admin/items'),
|
||||||
adminRarities: () => request('/api/admin/rarities'),
|
adminRarities: () => request('/api/admin/rarities'),
|
||||||
|
adminCategories: () => request('/api/admin/categories'),
|
||||||
createCase: (body) =>
|
createCase: (body) =>
|
||||||
request('/api/admin/cases', { method: 'POST', body: JSON.stringify(body) }),
|
request('/api/admin/cases', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
updateCase: (id, body) =>
|
updateCase: (id, body) =>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { formatCredits } from '../api';
|
||||||
|
|
||||||
export default function ProfilePrestigeStats({ prestige }) {
|
export default function ProfilePrestigeStats({ prestige }) {
|
||||||
if (!prestige || !prestige.count) return null;
|
if (!prestige || !prestige.count) return null;
|
||||||
|
|
||||||
@@ -8,6 +10,11 @@ export default function ProfilePrestigeStats({ prestige }) {
|
|||||||
<span className="catalog-badges-luck">×{prestige.count}</span>
|
<span className="catalog-badges-luck">×{prestige.count}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="stat-list prestige-profile-stats">
|
<ul className="stat-list prestige-profile-stats">
|
||||||
|
{typeof prestige.prBalance === 'number' && prestige.prBalance > 0 && (
|
||||||
|
<li>
|
||||||
|
Pr <strong>{prestige.prBalance}</strong>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
<li>
|
<li>
|
||||||
Chance <strong>+{prestige.chanceBonus}%</strong>
|
Chance <strong>+{prestige.chanceBonus}%</strong>
|
||||||
</li>
|
</li>
|
||||||
@@ -17,6 +24,51 @@ export default function ProfilePrestigeStats({ prestige }) {
|
|||||||
<li>
|
<li>
|
||||||
Key opens <strong>−{prestige.keyOpenReduction}</strong>
|
Key opens <strong>−{prestige.keyOpenReduction}</strong>
|
||||||
</li>
|
</li>
|
||||||
|
{(prestige.startBonus || 0) > 0 && (
|
||||||
|
<li>
|
||||||
|
Vault <strong>{formatCredits(prestige.startBonus)} cr</strong>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{(prestige.adBonus || 0) > 0 && (
|
||||||
|
<li>
|
||||||
|
Ad reward <strong>+{prestige.adBonus}%</strong>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{(prestige.adCooldownReduction || 0) > 0 && (
|
||||||
|
<li>
|
||||||
|
Ad cooldown <strong>−{prestige.adCooldownReduction}s</strong>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{(prestige.wearBonus || 0) > 0 && (
|
||||||
|
<li>
|
||||||
|
Polish <strong>+{prestige.wearBonus}</strong>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{(prestige.prBonus || 0) > 0 && (
|
||||||
|
<li>
|
||||||
|
Pr echo <strong>+{prestige.prBonus}%</strong>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{(prestige.shopDiscount || 0) > 0 && (
|
||||||
|
<li>
|
||||||
|
Haggle <strong>−{prestige.shopDiscount} osu</strong>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{(prestige.startOsu || 0) > 0 && (
|
||||||
|
<li>
|
||||||
|
Purse <strong>+{prestige.startOsu} osu</strong>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{(prestige.dropValueBonus || 0) > 0 && (
|
||||||
|
<li>
|
||||||
|
Magnet <strong>+{prestige.dropValueBonus}%</strong>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{(prestige.openBonus || 0) > 0 && (
|
||||||
|
<li>
|
||||||
|
Bulk <strong>+{prestige.openBonus} opens</strong>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+545
-53
@@ -1431,41 +1431,304 @@ a.balance-pill-link.active {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-tabs button {
|
.admin-tabs button {
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--stroke);
|
border: 1px solid var(--stroke);
|
||||||
color: var(--muted);
|
background: var(--bg-panel);
|
||||||
padding: 0.5rem 0.9rem;
|
color: var(--text);
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-tabs button.active {
|
.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);
|
color: var(--text);
|
||||||
border-color: var(--accent-dim);
|
background: rgba(61, 214, 198, 0.05);
|
||||||
background: rgba(61, 214, 198, 0.08);
|
}
|
||||||
|
|
||||||
|
.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 {
|
.admin-block {
|
||||||
border: 1px solid var(--stroke);
|
border: 1px solid var(--stroke);
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-row {
|
.admin-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.65rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.65rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-row input,
|
.admin-row input,
|
||||||
.admin-row select {
|
.admin-row select {
|
||||||
background: var(--bg-deep);
|
|
||||||
border: 1px solid var(--stroke);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 0.45rem 0.55rem;
|
padding: 0.45rem 0.55rem;
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: var(--bg-deep);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-list {
|
.drop-list {
|
||||||
@@ -2629,6 +2892,72 @@ a.achievement-badge:hover .achievement-badge-name {
|
|||||||
|
|
||||||
|
|
||||||
/* Prestige */
|
/* 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 {
|
.prestige-count-pill {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
color: var(--gold);
|
color: var(--gold);
|
||||||
@@ -2638,6 +2967,32 @@ a.achievement-badge:hover .achievement-badge-name {
|
|||||||
white-space: nowrap;
|
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 {
|
.prestige-totals {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 10rem));
|
grid-template-columns: repeat(3, minmax(0, 10rem));
|
||||||
@@ -2645,6 +3000,10 @@ a.achievement-badge:hover .achievement-badge-name {
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prestige-totals-wide {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.prestige-totals > div {
|
.prestige-totals > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2658,56 +3017,192 @@ a.achievement-badge:hover .achievement-badge-name {
|
|||||||
color: var(--text);
|
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;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 0.5rem 1rem;
|
gap: 0.5rem 1rem;
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prestige-choices {
|
.prestige-action-head h2 {
|
||||||
display: grid;
|
margin: 0;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
font-size: 1.15rem;
|
||||||
gap: 0.85rem;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prestige-choice {
|
.prestige-status {
|
||||||
text-align: left;
|
font-size: 0.85rem;
|
||||||
background: linear-gradient(160deg, var(--bg-elevated), var(--bg-panel));
|
color: var(--muted);
|
||||||
border: 1px solid var(--stroke);
|
border: 1px solid var(--stroke);
|
||||||
color: inherit;
|
padding: 0.25rem 0.55rem;
|
||||||
padding: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prestige-choice:disabled {
|
.prestige-status.ready {
|
||||||
opacity: 0.55;
|
color: var(--gold);
|
||||||
cursor: not-allowed;
|
border-color: color-mix(in srgb, var(--gold) 45%, var(--stroke));
|
||||||
|
background: color-mix(in srgb, var(--gold) 8%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prestige-choice.selected {
|
.prestige-action-stats {
|
||||||
border-color: rgba(228, 174, 57, 0.55);
|
display: grid;
|
||||||
box-shadow: inset 0 0 0 1px rgba(228, 174, 57, 0.25);
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prestige-choice h2 {
|
.prestige-action-stats > div {
|
||||||
margin: 0 0 0.4rem;
|
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;
|
font-size: 1.05rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prestige-choice p {
|
.prestige-action-split {
|
||||||
margin: 0 0 0.75rem;
|
display: grid;
|
||||||
font-size: 0.85rem;
|
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;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
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;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2716,19 +3211,6 @@ a.achievement-badge:hover .achievement-badge-name {
|
|||||||
font-family: var(--font-display);
|
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 {
|
.prestige-history {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
@@ -2749,3 +3231,13 @@ a.achievement-badge:hover .achievement-badge-name {
|
|||||||
.prestige-profile-stats {
|
.prestige-profile-stats {
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.prestige-tree-branches {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prestige-totals {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+26
-599
@@ -1,106 +1,20 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { api, formatCredits } from '../api';
|
|
||||||
import { useAuth } from '../AuthContext';
|
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 };
|
function AdminLogin() {
|
||||||
const emptyItem = { name: '', rarity: 'MilSpec', marketValue: 100, imageUrl: '' };
|
const { adminLogin } = useAuth();
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="image-field">
|
|
||||||
{value ? (
|
|
||||||
<div className="admin-thumb">
|
|
||||||
<img src={value} alt="" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="admin-thumb empty">No img</div>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
placeholder="Image URL or upload"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<label className="btn secondary file-btn">
|
|
||||||
{uploading ? '…' : 'Upload'}
|
|
||||||
<input type="file" accept="image/*" hidden onChange={onFile} disabled={disabled || uploading} />
|
|
||||||
</label>
|
|
||||||
{value ? (
|
|
||||||
<button type="button" className="btn ghost" onClick={() => onChange('')} disabled={disabled}>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 [username, setUsername] = useState('admin');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
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 <p className="muted">Loading…</p>;
|
|
||||||
|
|
||||||
if (!user || user.role !== 'admin') {
|
|
||||||
const onSubmit = async (e) => {
|
const onSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
@@ -119,7 +33,7 @@ export default function Admin() {
|
|||||||
<div className="auth-panel">
|
<div className="auth-panel">
|
||||||
<div className="brand">CaseForge</div>
|
<div className="brand">CaseForge</div>
|
||||||
<h1>Admin</h1>
|
<h1>Admin</h1>
|
||||||
<p className="muted">Manage cases, items, and drop rates.</p>
|
<p className="muted">Cases, items, drops, players & overview.</p>
|
||||||
<form className="form" onSubmit={onSubmit}>
|
<form className="form" onSubmit={onSubmit}>
|
||||||
<label>
|
<label>
|
||||||
Username
|
Username
|
||||||
@@ -144,509 +58,22 @@ export default function Admin() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const startEditCase = (c) => {
|
export default function Admin() {
|
||||||
setEditingCaseId(c.id);
|
const { user, loading } = useAuth();
|
||||||
setCaseEdit({
|
|
||||||
name: c.name,
|
|
||||||
price: c.price,
|
|
||||||
imageUrl: c.imageUrl || '',
|
|
||||||
active: c.active,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveCaseEdit = async (e) => {
|
if (loading) return <p className="muted" style={{ padding: '2rem' }}>Loading…</p>;
|
||||||
e.preventDefault();
|
if (!user || user.role !== 'admin') return <AdminLogin />;
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="main" style={{ paddingTop: '1.5rem' }}>
|
<Routes>
|
||||||
<header className="topnav" style={{ margin: '-1.5rem -1.5rem 1.5rem', position: 'static' }}>
|
<Route element={<AdminLayout />}>
|
||||||
<span className="brand">CaseForge Admin</span>
|
<Route index element={<AdminDashboard />} />
|
||||||
<nav className="nav-links">
|
<Route path="players" element={<AdminPlayers />} />
|
||||||
<Link to="/leaderboard">Leaderboard</Link>
|
<Route path="cases" element={<AdminCases />} />
|
||||||
<button
|
<Route path="items" element={<AdminItems />} />
|
||||||
type="button"
|
<Route path="drops" element={<AdminDrops />} />
|
||||||
className="btn ghost"
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
onClick={async () => {
|
</Route>
|
||||||
await logout();
|
</Routes>
|
||||||
navigate('/login');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<div className="section-head">
|
|
||||||
<div>
|
|
||||||
<h1>Admin panel</h1>
|
|
||||||
<p className="muted">Cases, loot pool, images, and drop weights.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-tabs">
|
|
||||||
{['cases', 'items', 'drops'].map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
type="button"
|
|
||||||
className={tab === t ? 'active' : ''}
|
|
||||||
onClick={() => setTab(t)}
|
|
||||||
>
|
|
||||||
{t}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
|
||||||
|
|
||||||
{tab === 'cases' && (
|
|
||||||
<div>
|
|
||||||
<div className="admin-block">
|
|
||||||
<h3>Create case</h3>
|
|
||||||
<form
|
|
||||||
className="form"
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
await api.createCase({
|
|
||||||
...caseForm,
|
|
||||||
price: Number(caseForm.price),
|
|
||||||
});
|
|
||||||
setCaseForm(emptyCase);
|
|
||||||
await load();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input
|
|
||||||
value={caseForm.name}
|
|
||||||
onChange={(e) => setCaseForm({ ...caseForm, name: e.target.value })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Price (cents)
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={caseForm.price}
|
|
||||||
onChange={(e) => setCaseForm({ ...caseForm, price: e.target.value })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Image
|
|
||||||
<ImageField
|
|
||||||
value={caseForm.imageUrl}
|
|
||||||
onChange={(imageUrl) => setCaseForm({ ...caseForm, imageUrl })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button className="btn" type="submit">
|
|
||||||
Add case
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{cases.map((c) => (
|
|
||||||
<div key={c.id} className="admin-block">
|
|
||||||
{editingCaseId === c.id && caseEdit ? (
|
|
||||||
<form className="form" onSubmit={saveCaseEdit}>
|
|
||||||
<h3>Edit case #{c.id}</h3>
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input
|
|
||||||
value={caseEdit.name}
|
|
||||||
onChange={(e) => setCaseEdit({ ...caseEdit, name: e.target.value })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Price (cents)
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={caseEdit.price}
|
|
||||||
onChange={(e) => setCaseEdit({ ...caseEdit, price: e.target.value })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Image
|
|
||||||
<ImageField
|
|
||||||
value={caseEdit.imageUrl}
|
|
||||||
onChange={(imageUrl) => setCaseEdit({ ...caseEdit, imageUrl })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="checkbox-row">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={caseEdit.active}
|
|
||||||
onChange={(e) => setCaseEdit({ ...caseEdit, active: e.target.checked })}
|
|
||||||
/>
|
|
||||||
Active
|
|
||||||
</label>
|
|
||||||
<div className="row-actions">
|
|
||||||
<button className="btn" type="submit">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingCaseId(null);
|
|
||||||
setCaseEdit(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="admin-row">
|
|
||||||
{c.imageUrl ? (
|
|
||||||
<div className="admin-thumb">
|
|
||||||
<img src={c.imageUrl} alt="" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<strong>{c.name}</strong>
|
|
||||||
<span className="muted">{formatCredits(c.price)} cr</span>
|
|
||||||
<span className="muted">{c.active ? 'active' : 'inactive'}</span>
|
|
||||||
<button type="button" className="btn secondary" onClick={() => startEditCase(c)}>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary"
|
|
||||||
onClick={async () => {
|
|
||||||
await api.updateCase(c.id, { active: !c.active });
|
|
||||||
await load();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c.active ? 'Disable' : 'Enable'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn danger"
|
|
||||||
onClick={async () => {
|
|
||||||
if (!confirm(`Delete case ${c.name}?`)) return;
|
|
||||||
await api.deleteCase(c.id);
|
|
||||||
await load();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="muted">{c.items.length} drops configured</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'items' && (
|
|
||||||
<div>
|
|
||||||
<div className="admin-block">
|
|
||||||
<h3>Create item</h3>
|
|
||||||
<form
|
|
||||||
className="form"
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
await api.createItem({
|
|
||||||
...itemForm,
|
|
||||||
marketValue: Number(itemForm.marketValue),
|
|
||||||
});
|
|
||||||
setItemForm({
|
|
||||||
...emptyItem,
|
|
||||||
rarity: rarities[0] || 'MilSpec',
|
|
||||||
});
|
|
||||||
await load();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input
|
|
||||||
value={itemForm.name}
|
|
||||||
onChange={(e) => setItemForm({ ...itemForm, name: e.target.value })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Rarity
|
|
||||||
<select
|
|
||||||
value={itemForm.rarity}
|
|
||||||
onChange={(e) => setItemForm({ ...itemForm, rarity: e.target.value })}
|
|
||||||
>
|
|
||||||
{rarities.map((r) => (
|
|
||||||
<option key={r} value={r}>
|
|
||||||
{r}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Value (cents, Field-Tested ref)
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={itemForm.marketValue}
|
|
||||||
onChange={(e) => setItemForm({ ...itemForm, marketValue: e.target.value })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Image
|
|
||||||
<ImageField
|
|
||||||
value={itemForm.imageUrl}
|
|
||||||
onChange={(imageUrl) => setItemForm({ ...itemForm, imageUrl })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button className="btn" type="submit">
|
|
||||||
Add item
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{items.map((item) => (
|
|
||||||
<div key={item.id} className="admin-block">
|
|
||||||
{editingItemId === item.id && itemEdit ? (
|
|
||||||
<form className="form" onSubmit={saveItemEdit}>
|
|
||||||
<h3>Edit item #{item.id}</h3>
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input
|
|
||||||
value={itemEdit.name}
|
|
||||||
onChange={(e) => setItemEdit({ ...itemEdit, name: e.target.value })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Rarity
|
|
||||||
<select
|
|
||||||
value={itemEdit.rarity}
|
|
||||||
onChange={(e) => setItemEdit({ ...itemEdit, rarity: e.target.value })}
|
|
||||||
>
|
|
||||||
{rarities.map((r) => (
|
|
||||||
<option key={r} value={r}>
|
|
||||||
{r}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Value (cents, Field-Tested ref)
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={itemEdit.marketValue}
|
|
||||||
onChange={(e) => setItemEdit({ ...itemEdit, marketValue: e.target.value })}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Image
|
|
||||||
<ImageField
|
|
||||||
value={itemEdit.imageUrl}
|
|
||||||
onChange={(imageUrl) => setItemEdit({ ...itemEdit, imageUrl })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="row-actions">
|
|
||||||
<button className="btn" type="submit">
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingItemId(null);
|
|
||||||
setItemEdit(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<div className="admin-row">
|
|
||||||
{item.imageUrl ? (
|
|
||||||
<div className="admin-thumb">
|
|
||||||
<img src={item.imageUrl} alt="" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<span>
|
|
||||||
{item.name} · {item.rarity} · {formatCredits(item.marketValue)} cr
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary"
|
|
||||||
onClick={() => startEditItem(item)}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn danger"
|
|
||||||
onClick={async () => {
|
|
||||||
if (!confirm(`Delete ${item.name}?`)) return;
|
|
||||||
await api.deleteItem(item.id);
|
|
||||||
await load();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'drops' && (
|
|
||||||
<div>
|
|
||||||
<div className="admin-block">
|
|
||||||
<h3>Add drop to case</h3>
|
|
||||||
<form
|
|
||||||
className="admin-row"
|
|
||||||
onSubmit={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
await api.addCaseItem(Number(dropForm.caseId), {
|
|
||||||
itemId: Number(dropForm.itemId),
|
|
||||||
dropRate: Number(dropForm.dropRate),
|
|
||||||
});
|
|
||||||
await load();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
value={dropForm.caseId}
|
|
||||||
onChange={(e) => setDropForm({ ...dropForm, caseId: e.target.value })}
|
|
||||||
>
|
|
||||||
{cases.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={dropForm.itemId}
|
|
||||||
onChange={(e) => setDropForm({ ...dropForm, itemId: e.target.value })}
|
|
||||||
>
|
|
||||||
{items.map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
min="0.01"
|
|
||||||
value={dropForm.dropRate}
|
|
||||||
onChange={(e) => setDropForm({ ...dropForm, dropRate: e.target.value })}
|
|
||||||
placeholder="Weight"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<button className="btn" type="submit">
|
|
||||||
Add drop
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{cases.map((c) => (
|
|
||||||
<div key={c.id} className="admin-block">
|
|
||||||
<h3>{c.name}</h3>
|
|
||||||
<div className="drop-list">
|
|
||||||
{c.items.length === 0 && <div className="muted">No drops yet.</div>}
|
|
||||||
{c.items.map((ci) => (
|
|
||||||
<div key={ci.id} className="drop-row">
|
|
||||||
<span className="drop-item-cell">
|
|
||||||
{ci.item.imageUrl ? (
|
|
||||||
<img src={ci.item.imageUrl} alt="" className="drop-thumb" />
|
|
||||||
) : null}
|
|
||||||
{ci.item.name} ({ci.item.rarity})
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.1"
|
|
||||||
min="0.01"
|
|
||||||
defaultValue={ci.dropRate}
|
|
||||||
onBlur={async (e) => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="muted">weight</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn danger"
|
|
||||||
onClick={async () => {
|
|
||||||
await api.deleteCaseItem(ci.id);
|
|
||||||
await load();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,22 @@ import { Navigate } from 'react-router-dom';
|
|||||||
import { api, formatCredits } from '../api';
|
import { api, formatCredits } from '../api';
|
||||||
import { useAuth } from '../AuthContext';
|
import { useAuth } from '../AuthContext';
|
||||||
|
|
||||||
const CHOICE_LABELS = {
|
function formatSkillValue(skill, value) {
|
||||||
chance: 'Chance',
|
const prefix = skill.prefix || '';
|
||||||
gain: 'Gain',
|
if (skill.unit === 'cr-cents') {
|
||||||
key: 'Key progress',
|
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() {
|
export default function PrestigePage() {
|
||||||
const { user, loading, patchUser, refresh } = useAuth();
|
const { user, loading, patchUser, refresh } = useAuth();
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
const [selected, setSelected] = useState(null);
|
|
||||||
const [confirmText, setConfirmText] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -49,22 +54,21 @@ export default function PrestigePage() {
|
|||||||
|
|
||||||
const cost = data?.prestige?.costCents ?? 0;
|
const cost = data?.prestige?.costCents ?? 0;
|
||||||
const p = data?.prestige;
|
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 () => {
|
const onPrestige = async () => {
|
||||||
if (!selected || confirmText !== 'PRESTIGE') return;
|
if (!canClick) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
try {
|
try {
|
||||||
const result = await api.doPrestige(selected);
|
const result = await api.doPrestige();
|
||||||
patchUser(result.user);
|
patchUser(result.user);
|
||||||
await refresh();
|
await refresh();
|
||||||
setMessage(
|
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();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
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 (
|
return (
|
||||||
<section className="section">
|
<section className="section prestige-page">
|
||||||
<div className="section-head">
|
<div className="section-head prestige-head">
|
||||||
<div>
|
<div className="prestige-head-copy">
|
||||||
<h1>Prestige</h1>
|
<h1>Prestige</h1>
|
||||||
<p className="muted">
|
<p className="prestige-lead">
|
||||||
Pay {formatCredits(cost)} cr, pick one permanent boost, then reset balance, inventory,
|
Reset your progress for <strong className="gold">Pr</strong>, then spend Pr on permanent
|
||||||
keys, and catalog. History is kept. Unlocks at {formatCredits(unlockAt)} cr total wealth.
|
skills.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="prestige-pills">
|
||||||
{p && (
|
{p && (
|
||||||
<div className="prestige-count-pill">
|
<div className="prestige-count-pill">
|
||||||
Prestige <strong>{p.count}</strong>
|
Prestige <strong>{p.count}</strong>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="prestige-pr-pill">
|
||||||
|
<strong>{prBalance}</strong> Pr
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="prestige-steps">
|
||||||
|
<li>
|
||||||
|
<span className="prestige-step-num">1</span>
|
||||||
|
<div>
|
||||||
|
<strong>Pay the cost</strong>
|
||||||
|
<p className="muted">{formatCredits(cost)} cr from your balance</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="prestige-step-num">2</span>
|
||||||
|
<div>
|
||||||
|
<strong>Earn Pr</strong>
|
||||||
|
<p className="muted">
|
||||||
|
+{p?.prBaseReward ?? 1000} base, more if you are over the cost
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="prestige-step-num">3</span>
|
||||||
|
<div>
|
||||||
|
<strong>Upgrade skills</strong>
|
||||||
|
<p className="muted">Permanent bonuses — they survive every reset</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
{message && <div className="ok-msg">{message}</div>}
|
{message && <div className="ok-msg">{message}</div>}
|
||||||
|
|
||||||
{p && (
|
{p && (
|
||||||
<div className="prestige-totals">
|
<div className="prestige-totals prestige-totals-wide">
|
||||||
<div>
|
<div>
|
||||||
<span className="muted">Chance</span>
|
<span className="muted">Chance</span>
|
||||||
<strong>+{p.chanceBonus}%</strong>
|
<strong>+{p.chanceBonus}%</strong>
|
||||||
@@ -107,19 +173,45 @@ export default function PrestigePage() {
|
|||||||
<span className="muted">Key opens</span>
|
<span className="muted">Key opens</span>
|
||||||
<strong>−{p.keyOpenReduction}</strong>
|
<strong>−{p.keyOpenReduction}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Vault</span>
|
||||||
|
<strong>{formatCredits(p.startBonus || 0)} cr</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Ad reward</span>
|
||||||
|
<strong>+{p.adBonus || 0}%</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Ad cooldown</span>
|
||||||
|
<strong>−{p.adCooldownReduction || 0}s</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Polish</span>
|
||||||
|
<strong>+{p.wearBonus || 0}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Pr echo</span>
|
||||||
|
<strong>+{p.prBonus || 0}%</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Haggle</span>
|
||||||
|
<strong>−{p.shopDiscount || 0} osu</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Purse</span>
|
||||||
|
<strong>+{p.startOsu || 0} osu</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Magnet</span>
|
||||||
|
<strong>+{p.dropValueBonus || 0}%</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Bulk</span>
|
||||||
|
<strong>+{p.openBonus || 0}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="prestige-cost-row">
|
|
||||||
<span className="muted">Cost</span>
|
|
||||||
<strong className="gold">{formatCredits(cost)} cr</strong>
|
|
||||||
<span className="muted">Your balance</span>
|
|
||||||
<strong>{formatCredits(user.balance)} cr</strong>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!data?.canAfford && (
|
|
||||||
<p className="muted">You need {formatCredits(cost)} cr to prestige.</p>
|
|
||||||
)}
|
|
||||||
{data?.lockedItems > 0 && (
|
{data?.lockedItems > 0 && (
|
||||||
<div className="error">
|
<div className="error">
|
||||||
Withdraw {data.lockedItems} locked item{data.lockedItems === 1 ? '' : 's'} from battles
|
Withdraw {data.lockedItems} locked item{data.lockedItems === 1 ? '' : 's'} from battles
|
||||||
@@ -127,68 +219,126 @@ export default function PrestigePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="prestige-choices">
|
<div className="prestige-action admin-block">
|
||||||
{(data?.choices || []).map((c) => (
|
<div className="prestige-action-head">
|
||||||
<button
|
<h2>Reset now</h2>
|
||||||
key={c.id}
|
<span className={`prestige-status${data?.canPrestige ? ' ready' : ''}`}>
|
||||||
type="button"
|
{statusLabel}
|
||||||
className={`prestige-choice${selected === c.id ? ' selected' : ''}`}
|
|
||||||
onClick={() => setSelected(c.id)}
|
|
||||||
disabled={busy || !data?.canPrestige}
|
|
||||||
>
|
|
||||||
<h2>{c.title}</h2>
|
|
||||||
<p className="muted">{c.description}</p>
|
|
||||||
<div className="prestige-choice-meta">
|
|
||||||
<span>
|
|
||||||
Now <strong>+{c.current}{c.id === 'key' ? '' : '%'}</strong>
|
|
||||||
{c.id === 'key' ? ' opens reduced' : ''}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="prestige-delta">
|
|
||||||
→ +{c.current + c.delta}
|
|
||||||
{c.id === 'key' ? '' : '%'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="prestige-confirm admin-block">
|
<div className="prestige-action-stats">
|
||||||
<p>
|
<div>
|
||||||
This permanently spends {formatCredits(cost)} cr and wipes your balance, inventory, case
|
<span className="muted">Cost</span>
|
||||||
keys, and catalog. Type <strong>PRESTIGE</strong> to confirm.
|
<strong className="gold">{formatCredits(cost)} cr</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Your balance</span>
|
||||||
|
<strong>{formatCredits(user.balance)} cr</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Est. Pr reward</span>
|
||||||
|
<strong className="prestige-pr-est">
|
||||||
|
{data?.canAfford ? `~${data.previewPr} Pr` : '—'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prestige-action-split">
|
||||||
|
<div>
|
||||||
|
<h3>You keep</h3>
|
||||||
|
<ul>
|
||||||
|
<li>All Pr already earned</li>
|
||||||
|
<li>Skill-tree bonuses</li>
|
||||||
|
<li>Account & prestige count</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>You lose</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Balance (minus Vault / Purse)</li>
|
||||||
|
<li>Inventory</li>
|
||||||
|
<li>Case keys & catalog progress</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="muted prestige-pr-hint">
|
||||||
|
Reward: +{p?.prBaseReward ?? 1000} Pr base. Every{' '}
|
||||||
|
{formatCredits(p?.excessChunkCents ?? 0)} cr above the cost adds ~{p?.prPerExcessChunk ?? 1000}{' '}
|
||||||
|
Pr.
|
||||||
</p>
|
</p>
|
||||||
<label>
|
|
||||||
Confirmation
|
|
||||||
<input
|
|
||||||
value={confirmText}
|
|
||||||
onChange={(e) => setConfirmText(e.target.value)}
|
|
||||||
placeholder="PRESTIGE"
|
|
||||||
disabled={busy || !selected}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn prestige-action-btn"
|
||||||
disabled={
|
disabled={!canClick}
|
||||||
busy || !data?.canPrestige || !selected || confirmText !== 'PRESTIGE'
|
|
||||||
}
|
|
||||||
onClick={onPrestige}
|
onClick={onPrestige}
|
||||||
|
title={
|
||||||
|
!data?.canAfford
|
||||||
|
? `Need ${formatCredits(cost)} cr`
|
||||||
|
: data?.lockedItems > 0
|
||||||
|
? 'Withdraw locked items first'
|
||||||
|
: 'Prestige now'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{busy ? 'Prestiging…' : 'Prestige now'}
|
{busy ? 'Prestiging…' : 'Prestige now'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="prestige-tree-section">
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>Skill tree</h2>
|
||||||
|
<p className="muted">Spend Pr on permanent branches. Costs rise with each purchase.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prestige-tree">
|
||||||
|
<div className="prestige-tree-root" aria-hidden>
|
||||||
|
<span>Core</span>
|
||||||
|
<strong>{prBalance} Pr</strong>
|
||||||
|
</div>
|
||||||
|
<div className="prestige-tree-branches">
|
||||||
|
{(data?.skills || []).map((skill) => (
|
||||||
|
<div key={skill.id} className="prestige-tree-branch">
|
||||||
|
<div className="prestige-tree-connector" aria-hidden />
|
||||||
|
<article className="prestige-skill-node">
|
||||||
|
<h3>{skill.title}</h3>
|
||||||
|
<p className="muted">{skill.description}</p>
|
||||||
|
<div className="prestige-skill-meta">
|
||||||
|
<span>
|
||||||
|
Lv {skill.level} · now{' '}
|
||||||
|
<strong>{formatSkillValue(skill, skill.current)}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="prestige-delta">
|
||||||
|
→ {formatSkillValue(skill, skill.nextValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
disabled={busy || !skill.canAfford}
|
||||||
|
onClick={() => onUpgrade(skill.id)}
|
||||||
|
>
|
||||||
|
Buy · {skill.cost} Pr
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{data?.history?.length > 0 && (
|
{data?.history?.length > 0 && (
|
||||||
<div className="prestige-history">
|
<div className="prestige-history">
|
||||||
<h2>History</h2>
|
<h2>History</h2>
|
||||||
<ul className="stat-list">
|
<ul className="stat-list">
|
||||||
{data.history.map((h) => (
|
{data.history.map((h) => (
|
||||||
<li key={h.id}>
|
<li key={h.id}>
|
||||||
{CHOICE_LABELS[h.choice] || h.choice}{' '}
|
Prestige reset
|
||||||
<span className="muted">
|
{h.prGranted > 0 ? ` · +${h.prGranted} Pr` : ''}{' '}
|
||||||
· {new Date(h.createdAt).toLocaleString()}
|
<span className="muted">· {new Date(h.createdAt).toLocaleString()}</span>
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ model User {
|
|||||||
passwordHash String
|
passwordHash String
|
||||||
balance BigInt @default(0)
|
balance BigInt @default(0)
|
||||||
osuBalance Int @default(0)
|
osuBalance Int @default(0)
|
||||||
|
prBalance Int @default(0)
|
||||||
lastAdAt DateTime?
|
lastAdAt DateTime?
|
||||||
role String @default("user")
|
role String @default("user")
|
||||||
avatarUrl String @default("")
|
avatarUrl String @default("")
|
||||||
@@ -23,6 +24,15 @@ model User {
|
|||||||
prestigeChanceBonus Int @default(0)
|
prestigeChanceBonus Int @default(0)
|
||||||
prestigeGainBonus Int @default(0)
|
prestigeGainBonus Int @default(0)
|
||||||
prestigeKeyOpenReduction 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?
|
lastConnection DateTime?
|
||||||
playTimeSeconds BigInt @default(0)
|
playTimeSeconds BigInt @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -56,6 +66,7 @@ model Item {
|
|||||||
name String
|
name String
|
||||||
imageUrl String @default("")
|
imageUrl String @default("")
|
||||||
rarity String
|
rarity String
|
||||||
|
category String @default("Misc")
|
||||||
marketValue BigInt
|
marketValue BigInt
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
cases CaseItem[]
|
cases CaseItem[]
|
||||||
@@ -187,6 +198,7 @@ model PrestigeLog {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
choice String
|
choice String
|
||||||
|
prGranted Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-18
@@ -4,24 +4,24 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
{ name: 'Dust Fragment', rarity: 'Consumer', marketValue: 5, imageUrl: '' },
|
{ name: 'Dust Fragment', rarity: 'Consumer', category: 'Misc', marketValue: 5, imageUrl: '' },
|
||||||
{ name: 'Scrap Token', rarity: 'Consumer', marketValue: 8, imageUrl: '' },
|
{ name: 'Scrap Token', rarity: 'Consumer', category: 'Misc', marketValue: 8, imageUrl: '' },
|
||||||
{ name: 'Plain Cap', rarity: 'Consumer', marketValue: 12, imageUrl: '' },
|
{ name: 'Plain Cap', rarity: 'Consumer', category: 'Agent', marketValue: 12, imageUrl: '' },
|
||||||
{ name: 'Blue Softpack', rarity: 'Industrial', marketValue: 25, imageUrl: '' },
|
{ name: 'Blue Softpack', rarity: 'Industrial', category: 'Misc', marketValue: 25, imageUrl: '' },
|
||||||
{ name: 'Steel Badge', rarity: 'Industrial', marketValue: 35, imageUrl: '' },
|
{ name: 'Steel Badge', rarity: 'Industrial', category: 'Sticker', marketValue: 35, imageUrl: '' },
|
||||||
{ name: 'Signal Chip', rarity: 'Industrial', marketValue: 40, imageUrl: '' },
|
{ name: 'Signal Chip', rarity: 'Industrial', category: 'Misc', marketValue: 40, imageUrl: '' },
|
||||||
{ name: 'Azure Coil', rarity: 'MilSpec', marketValue: 80, imageUrl: '' },
|
{ name: 'Azure Coil', rarity: 'MilSpec', category: 'SMG', marketValue: 80, imageUrl: '' },
|
||||||
{ name: 'Navy Grip', rarity: 'MilSpec', marketValue: 95, imageUrl: '' },
|
{ name: 'Navy Grip', rarity: 'MilSpec', category: 'Pistol', marketValue: 95, imageUrl: '' },
|
||||||
{ name: 'Pulse Meter', rarity: 'MilSpec', marketValue: 110, imageUrl: '' },
|
{ name: 'Pulse Meter', rarity: 'MilSpec', category: 'Rifle', marketValue: 110, imageUrl: '' },
|
||||||
{ name: 'Violet Core', rarity: 'Restricted', marketValue: 220, imageUrl: '' },
|
{ name: 'Violet Core', rarity: 'Restricted', category: 'Rifle', marketValue: 220, imageUrl: '' },
|
||||||
{ name: 'Phantom Lens', rarity: 'Restricted', marketValue: 280, imageUrl: '' },
|
{ name: 'Phantom Lens', rarity: 'Restricted', category: 'Sniper', marketValue: 280, imageUrl: '' },
|
||||||
{ name: 'Neon Circuit', rarity: 'Restricted', marketValue: 320, imageUrl: '' },
|
{ name: 'Neon Circuit', rarity: 'Restricted', category: 'SMG', marketValue: 320, imageUrl: '' },
|
||||||
{ name: 'Pink Dynamo', rarity: 'Classified', marketValue: 650, imageUrl: '' },
|
{ name: 'Pink Dynamo', rarity: 'Classified', category: 'Rifle', marketValue: 650, imageUrl: '' },
|
||||||
{ name: 'Ruby Flux', rarity: 'Classified', marketValue: 800, imageUrl: '' },
|
{ name: 'Ruby Flux', rarity: 'Classified', category: 'Heavy', marketValue: 800, imageUrl: '' },
|
||||||
{ name: 'Crimson Edge', rarity: 'Covert', marketValue: 1800, imageUrl: '' },
|
{ name: 'Crimson Edge', rarity: 'Covert', category: 'Knife', marketValue: 1800, imageUrl: '' },
|
||||||
{ name: 'Blood Ember', rarity: 'Covert', marketValue: 2400, imageUrl: '' },
|
{ name: 'Blood Ember', rarity: 'Covert', category: 'Knife', marketValue: 2400, imageUrl: '' },
|
||||||
{ name: 'Golden Relic', rarity: 'Extraordinary', marketValue: 12000, imageUrl: '' },
|
{ name: 'Golden Relic', rarity: 'Extraordinary', category: 'Gloves', marketValue: 12000, imageUrl: '' },
|
||||||
{ name: 'Mythic Blade', rarity: 'Extraordinary', marketValue: 25000, imageUrl: '' },
|
{ name: 'Mythic Blade', rarity: 'Extraordinary', category: 'Knife', marketValue: 25000, imageUrl: '' },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ export const OPENS_PER_KEY_EARLY = 100;
|
|||||||
/** From key 11 onward: cost = OPENS_PER_KEY_EARLY × (keys - 9). */
|
/** From key 11 onward: cost = OPENS_PER_KEY_EARLY × (keys - 9). */
|
||||||
export const ESCALATION_KEY_THRESHOLD = 11;
|
export const ESCALATION_KEY_THRESHOLD = 11;
|
||||||
|
|
||||||
export function maxOpensForKeys(keys) {
|
export function maxOpensForKeys(keys, openBonus = 0) {
|
||||||
const k = Math.max(1, keys);
|
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) {
|
export function opensRequiredForNextKey(keys, keyOpenReduction = 0) {
|
||||||
@@ -22,14 +23,14 @@ export function opensRequiredForNextKey(keys, keyOpenReduction = 0) {
|
|||||||
return Math.max(1, base - reduction);
|
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 keys = progress?.keys ?? 1;
|
||||||
const opensSinceLastKey = progress?.opensSinceLastKey ?? 0;
|
const opensSinceLastKey = progress?.opensSinceLastKey ?? 0;
|
||||||
const totalOpens = progress?.totalOpens ?? 0;
|
const totalOpens = progress?.totalOpens ?? 0;
|
||||||
const opensRequired = opensRequiredForNextKey(keys, keyOpenReduction);
|
const opensRequired = opensRequiredForNextKey(keys, keyOpenReduction);
|
||||||
return {
|
return {
|
||||||
keys,
|
keys,
|
||||||
maxOpens: maxOpensForKeys(keys),
|
maxOpens: maxOpensForKeys(keys, openBonus),
|
||||||
opensSinceLastKey,
|
opensSinceLastKey,
|
||||||
opensRequired,
|
opensRequired,
|
||||||
totalOpens,
|
totalOpens,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export function publicUser(user) {
|
|||||||
username: user.username,
|
username: user.username,
|
||||||
balance: user.balance,
|
balance: user.balance,
|
||||||
osuBalance: user.osuBalance ?? 0,
|
osuBalance: user.osuBalance ?? 0,
|
||||||
|
prBalance: user.prBalance ?? 0,
|
||||||
lastAdAt: user.lastAdAt || null,
|
lastAdAt: user.lastAdAt || null,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
avatarUrl: user.avatarUrl || '',
|
avatarUrl: user.avatarUrl || '',
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ export function connectionCount(userId) {
|
|||||||
return online.get(Number(userId))?.sockets.size || 0;
|
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.
|
* Track an authenticated socket. Updates lastConnection on first socket for this user.
|
||||||
* Rejects when the account already has MAX_SOCKETS_PER_USER live windows.
|
* Rejects when the account already has MAX_SOCKETS_PER_USER live windows.
|
||||||
|
|||||||
+248
-6
@@ -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). */
|
/** 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_VISIBLE_AT_CENTS = 1_000_000_000_000;
|
||||||
|
|
||||||
export const PRESTIGE_CHOICES = {
|
/** Base Pr granted on every prestige. */
|
||||||
chance: { field: 'prestigeChanceBonus', delta: 1, label: 'chance' },
|
export const PR_BASE_REWARD = 1000;
|
||||||
gain: { field: 'prestigeGainBonus', delta: 2, label: 'gain' },
|
|
||||||
key: { field: 'prestigeKeyOpenReduction', delta: 10, label: 'key' },
|
/**
|
||||||
|
* 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) {
|
export function serializePrestige(user) {
|
||||||
return {
|
return {
|
||||||
count: user?.prestigeCount ?? 0,
|
count: user?.prestigeCount ?? 0,
|
||||||
chanceBonus: user?.prestigeChanceBonus ?? 0,
|
chanceBonus: user?.prestigeChanceBonus ?? 0,
|
||||||
gainBonus: user?.prestigeGainBonus ?? 0,
|
gainBonus: user?.prestigeGainBonus ?? 0,
|
||||||
keyOpenReduction: user?.prestigeKeyOpenReduction ?? 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),
|
costCents: Number(PRESTIGE_COST_CENTS),
|
||||||
visibleAtCents: PRESTIGE_VISIBLE_AT_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). */
|
/** Apply lifetime gain % to a credit amount (cents). */
|
||||||
export function applyGainMultiplier(cents, gainBonusPct = 0) {
|
export function applyGainMultiplier(cents, gainBonusPct = 0) {
|
||||||
const base = Number(cents) || 0;
|
const base = Number(cents) || 0;
|
||||||
@@ -33,13 +274,14 @@ export function totalLuckPercent(catalogLuck = 0, prestigeChanceBonus = 0) {
|
|||||||
return (Number(catalogLuck) || 0) + (Number(prestigeChanceBonus) || 0);
|
return (Number(catalogLuck) || 0) + (Number(prestigeChanceBonus) || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isValidPrestigeChoice(choice) {
|
export function isValidSkill(skillId) {
|
||||||
return Boolean(PRESTIGE_CHOICES[choice]);
|
return Boolean(PRESTIGE_SKILLS[skillId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Visible if already prestiged, or total wealth reaches the unlock threshold. */
|
/** Visible if already prestiged, or total wealth reaches the unlock threshold. */
|
||||||
export function isPrestigePageVisible(user, totalCents) {
|
export function isPrestigePageVisible(user, totalCents) {
|
||||||
if ((user?.prestigeCount ?? 0) > 0) return true;
|
if ((user?.prestigeCount ?? 0) > 0) return true;
|
||||||
|
if ((user?.prBalance ?? 0) > 0) return true;
|
||||||
return (Number(totalCents) || 0) >= PRESTIGE_VISIBLE_AT_CENTS;
|
return (Number(totalCents) || 0) >= PRESTIGE_VISIBLE_AT_CENTS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+198
-2
@@ -4,6 +4,9 @@ import { prisma } from '../db.js';
|
|||||||
import { requireAdmin, publicUser } from '../middleware.js';
|
import { requireAdmin, publicUser } from '../middleware.js';
|
||||||
import { isValidRarity, RARITIES } from '../rarities.js';
|
import { isValidRarity, RARITIES } from '../rarities.js';
|
||||||
import { createUploader } from '../upload.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 router = Router();
|
||||||
const upload = createUploader('asset');
|
const upload = createUploader('asset');
|
||||||
@@ -37,6 +40,194 @@ router.get('/rarities', requireAdmin, (_req, res) => {
|
|||||||
res.json({ rarities: RARITIES });
|
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) => {
|
router.post('/upload', requireAdmin, (req, res) => {
|
||||||
upload.single('image')(req, res, (err) => {
|
upload.single('image')(req, res, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -72,6 +263,7 @@ router.get('/cases', requireAdmin, async (_req, res) => {
|
|||||||
id: ci.item.id,
|
id: ci.item.id,
|
||||||
name: ci.item.name,
|
name: ci.item.name,
|
||||||
rarity: ci.item.rarity,
|
rarity: ci.item.rarity,
|
||||||
|
category: ci.item.category || 'Misc',
|
||||||
marketValue: ci.item.marketValue,
|
marketValue: ci.item.marketValue,
|
||||||
imageUrl: ci.item.imageUrl,
|
imageUrl: ci.item.imageUrl,
|
||||||
},
|
},
|
||||||
@@ -143,7 +335,7 @@ router.delete('/cases/:id', requireAdmin, async (req, res) => {
|
|||||||
// —— Items ——
|
// —— Items ——
|
||||||
router.get('/items', requireAdmin, async (_req, res) => {
|
router.get('/items', requireAdmin, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const items = await prisma.item.findMany({ orderBy: { id: 'asc' } });
|
const items = await prisma.item.findMany({ orderBy: [{ category: 'asc' }, { id: 'asc' }] });
|
||||||
res.json({ items });
|
res.json({ items });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -156,6 +348,7 @@ router.post('/items', requireAdmin, async (req, res) => {
|
|||||||
const name = String(req.body.name || '').trim();
|
const name = String(req.body.name || '').trim();
|
||||||
const imageUrl = String(req.body.imageUrl || '');
|
const imageUrl = String(req.body.imageUrl || '');
|
||||||
const rarity = String(req.body.rarity || '');
|
const rarity = String(req.body.rarity || '');
|
||||||
|
const category = normalizeCategory(req.body.category);
|
||||||
const marketValue = Number(req.body.marketValue);
|
const marketValue = Number(req.body.marketValue);
|
||||||
|
|
||||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
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({
|
const created = await prisma.item.create({
|
||||||
data: { name, imageUrl, rarity, marketValue },
|
data: { name, imageUrl, rarity, category, marketValue },
|
||||||
});
|
});
|
||||||
res.status(201).json({ item: created });
|
res.status(201).json({ item: created });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -188,6 +381,9 @@ router.put('/items/:id', requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
data.rarity = req.body.rarity;
|
data.rarity = req.body.rarity;
|
||||||
}
|
}
|
||||||
|
if (req.body.category !== undefined) {
|
||||||
|
data.category = normalizeCategory(req.body.category);
|
||||||
|
}
|
||||||
if (req.body.marketValue !== undefined) {
|
if (req.body.marketValue !== undefined) {
|
||||||
const marketValue = Number(req.body.marketValue);
|
const marketValue = Number(req.body.marketValue);
|
||||||
if (!Number.isInteger(marketValue) || marketValue < 0) {
|
if (!Number.isInteger(marketValue) || marketValue < 0) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
fillCatalogSlots,
|
fillCatalogSlots,
|
||||||
formatCaseItemsWithLuck,
|
formatCaseItemsWithLuck,
|
||||||
} from '../catalog.js';
|
} from '../catalog.js';
|
||||||
import { totalLuckPercent } from '../prestige.js';
|
import { totalLuckPercent, applyDropValueBonus } from '../prestige.js';
|
||||||
import { checkAchievements } from '../achievements.js';
|
import { checkAchievements } from '../achievements.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -43,7 +43,7 @@ function formatCase(c, luckPercent = 0) {
|
|||||||
|
|
||||||
async function loadUserLuckAndKeys(userId) {
|
async function loadUserLuckAndKeys(userId) {
|
||||||
if (!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([
|
const [catalogLuck, user] = await Promise.all([
|
||||||
countCompletedChapters(prisma, userId),
|
countCompletedChapters(prisma, userId),
|
||||||
@@ -52,12 +52,17 @@ async function loadUserLuckAndKeys(userId) {
|
|||||||
select: {
|
select: {
|
||||||
prestigeChanceBonus: true,
|
prestigeChanceBonus: true,
|
||||||
prestigeKeyOpenReduction: true,
|
prestigeKeyOpenReduction: true,
|
||||||
|
prestigeWearBonus: true,
|
||||||
|
prestigeOpenBonus: true,
|
||||||
|
prestigeDropValueBonus: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus),
|
luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus),
|
||||||
keyOpenReduction: user?.prestigeKeyOpenReduction ?? 0,
|
keyOpenReduction: user?.prestigeKeyOpenReduction ?? 0,
|
||||||
|
openBonus: user?.prestigeOpenBonus ?? 0,
|
||||||
|
dropValueBonus: user?.prestigeDropValueBonus ?? 0,
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -81,7 +86,7 @@ router.get('/', async (req, res) => {
|
|||||||
orderBy: { price: 'asc' },
|
orderBy: { price: 'asc' },
|
||||||
});
|
});
|
||||||
const progressMap = await loadKeyProgressMap(userId);
|
const progressMap = await loadKeyProgressMap(userId);
|
||||||
const { luckPercent, keyOpenReduction } = await loadUserLuckAndKeys(userId);
|
const { luckPercent, keyOpenReduction, openBonus } = await loadUserLuckAndKeys(userId);
|
||||||
res.json({
|
res.json({
|
||||||
catalogLuckPercent: luckPercent,
|
catalogLuckPercent: luckPercent,
|
||||||
cases: cases.map((c) => {
|
cases: cases.map((c) => {
|
||||||
@@ -90,7 +95,7 @@ router.get('/', async (req, res) => {
|
|||||||
const progress = progressMap.get(c.id);
|
const progress = progressMap.get(c.id);
|
||||||
return {
|
return {
|
||||||
...formatted,
|
...formatted,
|
||||||
keyProgress: serializeKeyProgress(progress, keyOpenReduction),
|
keyProgress: serializeKeyProgress(progress, keyOpenReduction, openBonus),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -113,13 +118,13 @@ router.get('/:id', async (req, res) => {
|
|||||||
if (!c || !c.active) {
|
if (!c || !c.active) {
|
||||||
return res.status(404).json({ error: 'Case not found' });
|
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);
|
const formatted = formatCase(c, luckPercent);
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const progress = await prisma.caseKeyProgress.findUnique({
|
const progress = await prisma.caseKeyProgress.findUnique({
|
||||||
where: { userId_caseId: { userId, caseId: id } },
|
where: { userId_caseId: { userId, caseId: id } },
|
||||||
});
|
});
|
||||||
formatted.keyProgress = serializeKeyProgress(progress, keyOpenReduction);
|
formatted.keyProgress = serializeKeyProgress(progress, keyOpenReduction, openBonus);
|
||||||
}
|
}
|
||||||
res.json({ case: formatted });
|
res.json({ case: formatted });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -163,7 +168,9 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const progressRow = await getOrCreateProgress(tx, userId, caseId);
|
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) {
|
if (count > maxOpens) {
|
||||||
const err = new Error(`You can open at most ${maxOpens} at once for this case`);
|
const err = new Error(`You can open at most ${maxOpens} at once for this case`);
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
@@ -192,7 +199,8 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
for (let i = 0; i < count; i += 1) {
|
for (let i = 0; i < count; i += 1) {
|
||||||
const winner = pickWeighted(weighted);
|
const winner = pickWeighted(weighted);
|
||||||
const item = winner.caseItem.item;
|
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({
|
const inventoryItem = await tx.inventoryItem.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -272,7 +280,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
user: publicUser(updatedUser),
|
user: publicUser(updatedUser),
|
||||||
caseName: c.name,
|
caseName: c.name,
|
||||||
items: drops,
|
items: drops,
|
||||||
keyProgress: serializeKeyProgress(updatedProgress, keyOpenReduction),
|
keyProgress: serializeKeyProgress(updatedProgress, keyOpenReduction, openBonus),
|
||||||
keysGained: applied.keysGained,
|
keysGained: applied.keysGained,
|
||||||
catalog,
|
catalog,
|
||||||
};
|
};
|
||||||
|
|||||||
+112
-52
@@ -2,17 +2,22 @@ import { Router } from 'express';
|
|||||||
import { prisma } from '../db.js';
|
import { prisma } from '../db.js';
|
||||||
import { requireAuth, publicUser } from '../middleware.js';
|
import { requireAuth, publicUser } from '../middleware.js';
|
||||||
import {
|
import {
|
||||||
PRESTIGE_CHOICES,
|
|
||||||
PRESTIGE_COST_CENTS,
|
PRESTIGE_COST_CENTS,
|
||||||
|
PRESTIGE_SKILLS,
|
||||||
|
computePrReward,
|
||||||
|
estimatePrReward,
|
||||||
getUnlockedInventoryValueCents,
|
getUnlockedInventoryValueCents,
|
||||||
isPrestigePageVisible,
|
isPrestigePageVisible,
|
||||||
isValidPrestigeChoice,
|
isValidSkill,
|
||||||
serializePrestige,
|
serializePrestige,
|
||||||
|
serializeSkillTree,
|
||||||
|
skillLevelFromUser,
|
||||||
|
skillUpgradeCost,
|
||||||
} from '../prestige.js';
|
} from '../prestige.js';
|
||||||
import { checkAchievements } from '../achievements.js';
|
import { checkAchievements } from '../achievements.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const COST = Number(PRESTIGE_COST_CENTS);
|
const COST = PRESTIGE_COST_CENTS;
|
||||||
|
|
||||||
router.get('/', requireAuth, async (req, res) => {
|
router.get('/', requireAuth, async (req, res) => {
|
||||||
try {
|
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 pageVisible = isPrestigePageVisible(user, totalCents);
|
||||||
const canAfford = user.balance >= COST;
|
const canAfford = balance >= COST;
|
||||||
const canPrestige = pageVisible && canAfford && lockedCount === 0;
|
const canPrestige = pageVisible && canAfford && lockedCount === 0;
|
||||||
|
const previewPr = canAfford
|
||||||
|
? estimatePrReward(user.balance, user.prestigePrBonus ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
prestige: serializePrestige(user),
|
prestige: serializePrestige(user),
|
||||||
|
skills: serializeSkillTree(user),
|
||||||
pageVisible,
|
pageVisible,
|
||||||
totalCents,
|
totalCents,
|
||||||
canAfford,
|
canAfford,
|
||||||
lockedItems: lockedCount,
|
lockedItems: lockedCount,
|
||||||
canPrestige,
|
canPrestige,
|
||||||
choices: [
|
previewPr,
|
||||||
{
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
history: logs.map((l) => ({
|
history: logs.map((l) => ({
|
||||||
id: l.id,
|
id: l.id,
|
||||||
choice: l.choice,
|
choice: l.choice,
|
||||||
|
prGranted: l.prGranted ?? 0,
|
||||||
createdAt: l.createdAt,
|
createdAt: l.createdAt,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@@ -80,11 +69,6 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
router.post('/', requireAuth, async (req, res) => {
|
router.post('/', requireAuth, async (req, res) => {
|
||||||
try {
|
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 result = await prisma.$transaction(async (tx) => {
|
||||||
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -97,7 +81,7 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
err.status = 403;
|
err.status = 403;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (user.balance < COST) {
|
if (BigInt(user.balance) < COST) {
|
||||||
const err = new Error('Insufficient balance for prestige');
|
const err = new Error('Insufficient balance for prestige');
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
throw err;
|
||||||
@@ -114,12 +98,10 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const boost = PRESTIGE_CHOICES[choice];
|
const prGranted = computePrReward(user.balance, user.prestigePrBonus ?? 0);
|
||||||
const prestigeData = {
|
const prestigeCount = (user.prestigeCount ?? 0) + 1;
|
||||||
prestigeCount: (user.prestigeCount ?? 0) + 1,
|
const startBalance = Math.max(0, Number(user.prestigeStartBonus) || 0);
|
||||||
[boost.field]: (user[boost.field] ?? 0) + boost.delta,
|
const startOsu = Math.max(0, Number(user.prestigeStartOsu) || 0);
|
||||||
balance: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
await tx.inventoryItem.deleteMany({ where: { userId: user.id } });
|
await tx.inventoryItem.deleteMany({ where: { userId: user.id } });
|
||||||
await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } });
|
await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } });
|
||||||
@@ -129,34 +111,45 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: 'prestige',
|
type: 'prestige',
|
||||||
amount: -COST,
|
amount: -Number(COST),
|
||||||
meta: JSON.stringify({
|
meta: JSON.stringify({
|
||||||
choice,
|
prestigeCount,
|
||||||
prestigeCount: prestigeData.prestigeCount,
|
prGranted,
|
||||||
delta: boost.delta,
|
previousBalance: Number(user.balance),
|
||||||
field: boost.field,
|
startBalance,
|
||||||
|
startOsu,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.prestigeLog.create({
|
await tx.prestigeLog.create({
|
||||||
data: { userId: user.id, choice },
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
choice: 'reset',
|
||||||
|
prGranted,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updated = await tx.user.update({
|
const updated = await tx.user.update({
|
||||||
where: { id: user.id },
|
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({
|
res.json({
|
||||||
user: publicUser(result),
|
user: publicUser(result.user),
|
||||||
prestige: serializePrestige(result),
|
prestige: serializePrestige(result.user),
|
||||||
choice,
|
skills: serializeSkillTree(result.user),
|
||||||
|
prGranted: result.prGranted,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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;
|
export default router;
|
||||||
|
|||||||
+48
-31
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
|||||||
import { prisma } from '../db.js';
|
import { prisma } from '../db.js';
|
||||||
import { requireAuth, publicUser } from '../middleware.js';
|
import { requireAuth, publicUser } from '../middleware.js';
|
||||||
import { checkAchievements } from '../achievements.js';
|
import { checkAchievements } from '../achievements.js';
|
||||||
|
import { adCooldownMs, applyAdRewardBonus, shopOsuCost } from '../prestige.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -13,30 +14,42 @@ export const SHOP_PACKAGES = [
|
|||||||
{ id: 'whale', name: 'Whale Pack', osuCost: 100, credits: 80000, blurb: '800.00 cr' },
|
{ 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_MIN = 10000; // 100.00 cr
|
||||||
const AD_REWARD_MAX = 20000; // 200.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) => {
|
router.get('/', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
||||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
const lastAdAt = user.lastAdAt ? new Date(user.lastAdAt).getTime() : 0;
|
const discount = user.prestigeShopDiscount ?? 0;
|
||||||
const nextAdAt = lastAdAt + AD_COOLDOWN_MS;
|
const packages = SHOP_PACKAGES.map((pack) => ({
|
||||||
const adReady = Date.now() >= nextAdAt;
|
...pack,
|
||||||
|
osuCost: shopOsuCost(pack.osuCost, discount),
|
||||||
|
baseOsuCost: pack.osuCost,
|
||||||
|
}));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
packages: SHOP_PACKAGES,
|
packages,
|
||||||
user: publicUser(user),
|
user: publicUser(user),
|
||||||
ad: {
|
ad: adPayload(user),
|
||||||
cooldownMs: AD_COOLDOWN_MS,
|
|
||||||
rewardMin: AD_REWARD_MIN,
|
|
||||||
rewardMax: AD_REWARD_MAX,
|
|
||||||
ready: adReady,
|
|
||||||
nextAdAt: adReady ? null : new Date(nextAdAt).toISOString(),
|
|
||||||
lastAdAt: user.lastAdAt,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -59,7 +72,8 @@ router.post('/buy', requireAuth, async (req, res) => {
|
|||||||
err.status = 404;
|
err.status = 404;
|
||||||
throw err;
|
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');
|
const err = new Error('Not enough osu');
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
throw err;
|
||||||
@@ -68,7 +82,7 @@ router.post('/buy', requireAuth, async (req, res) => {
|
|||||||
const updated = await tx.user.update({
|
const updated = await tx.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
osuBalance: { decrement: pack.osuCost },
|
osuBalance: { decrement: osuCost },
|
||||||
balance: { increment: pack.credits },
|
balance: { increment: pack.credits },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -80,18 +94,22 @@ router.post('/buy', requireAuth, async (req, res) => {
|
|||||||
amount: pack.credits,
|
amount: pack.credits,
|
||||||
meta: JSON.stringify({
|
meta: JSON.stringify({
|
||||||
packageId: pack.id,
|
packageId: pack.id,
|
||||||
osuCost: pack.osuCost,
|
osuCost,
|
||||||
|
baseOsuCost: pack.osuCost,
|
||||||
credits: pack.credits,
|
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) {
|
} catch (err) {
|
||||||
const status = err.status || 500;
|
const status = err.status || 500;
|
||||||
if (status >= 500) console.error(err);
|
if (status >= 500) console.error(err);
|
||||||
@@ -109,18 +127,20 @@ router.post('/ad', requireAuth, async (req, res) => {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cooldownMs = adCooldownMs(user);
|
||||||
const lastAdAt = user.lastAdAt ? new Date(user.lastAdAt).getTime() : 0;
|
const lastAdAt = user.lastAdAt ? new Date(user.lastAdAt).getTime() : 0;
|
||||||
const elapsed = Date.now() - lastAdAt;
|
const elapsed = Date.now() - lastAdAt;
|
||||||
if (elapsed < AD_COOLDOWN_MS) {
|
if (elapsed < cooldownMs) {
|
||||||
const waitSec = Math.ceil((AD_COOLDOWN_MS - elapsed) / 1000);
|
const waitSec = Math.ceil((cooldownMs - elapsed) / 1000);
|
||||||
const err = new Error(`Ad available in ${waitSec}s`);
|
const err = new Error(`Ad available in ${waitSec}s`);
|
||||||
err.status = 429;
|
err.status = 429;
|
||||||
err.nextAdAt = new Date(lastAdAt + AD_COOLDOWN_MS).toISOString();
|
err.nextAdAt = new Date(lastAdAt + cooldownMs).toISOString();
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reward =
|
const base =
|
||||||
AD_REWARD_MIN + Math.floor(Math.random() * (AD_REWARD_MAX - AD_REWARD_MIN + 1));
|
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 now = new Date();
|
||||||
|
|
||||||
const updated = await tx.user.update({
|
const updated = await tx.user.update({
|
||||||
@@ -136,11 +156,11 @@ router.post('/ad', requireAuth, async (req, res) => {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: 'ad_reward',
|
type: 'ad_reward',
|
||||||
amount: 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));
|
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
|
||||||
@@ -148,13 +168,10 @@ router.post('/ad', requireAuth, async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
user: publicUser(result.user),
|
user: publicUser(result.user),
|
||||||
reward: result.reward,
|
reward: result.reward,
|
||||||
ad: {
|
ad: adPayload(result.user, {
|
||||||
ready: false,
|
ready: false,
|
||||||
nextAdAt: new Date(Date.now() + AD_COOLDOWN_MS).toISOString(),
|
nextAdAt: new Date(Date.now() + result.cooldownMs).toISOString(),
|
||||||
cooldownMs: AD_COOLDOWN_MS,
|
}),
|
||||||
rewardMin: AD_REWARD_MIN,
|
|
||||||
rewardMax: AD_REWARD_MAX,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = err.status || 500;
|
const status = err.status || 500;
|
||||||
|
|||||||
+6
-2
@@ -111,8 +111,12 @@ export function rollPaintSeed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Full roll for a new inventory instance from catalog marketValue */
|
/** Full roll for a new inventory instance from catalog marketValue */
|
||||||
export function rollInstanceValue(baseMarketValue) {
|
export function rollInstanceValue(baseMarketValue, wearBonus = 0) {
|
||||||
const floatValue = rollFloat();
|
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 {
|
return {
|
||||||
floatValue,
|
floatValue,
|
||||||
wear: wearFromFloat(floatValue),
|
wear: wearFromFloat(floatValue),
|
||||||
|
|||||||
Reference in New Issue
Block a user