update nice visuel

This commit is contained in:
gpatruno
2026-07-25 20:04:34 +02:00
parent 3a8871cbae
commit 810e4ac922
78 changed files with 6742 additions and 3116 deletions
+12 -18
View File
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
import { Navigate, Route, Routes } from 'react-router-dom';
import Layout from './components/Layout';
import { useAuth } from './AuthContext';
import Admin from './pages/Admin';
@@ -9,10 +9,7 @@ import Leaderboard from './pages/Leaderboard';
import Login from './pages/Login';
import Register from './pages/Register';
import BattleHub from './pages/BattleHub';
import MultiPlayersPage from './pages/MultiPlayersPage';
import DuelListPage from './pages/DuelListPage';
import DuelRoomPage from './pages/DuelRoomPage';
import CoinflipStub from './pages/CoinflipStub';
import BattleRoomPage from './pages/BattleRoomPage';
import ProfilePage from './pages/ProfilePage';
import PlayerProfilePage from './pages/PlayerProfilePage';
import CatalogPage from './pages/CatalogPage';
@@ -28,11 +25,6 @@ function HomeRedirect() {
return <Navigate to="/dashboard" replace />;
}
function BetDuelRedirect() {
const { id } = useParams();
return <Navigate to={`/battle/1vx/${id}`} replace />;
}
export default function App() {
return (
<Routes>
@@ -48,15 +40,17 @@ export default function App() {
<Route path="/prestige" element={<PrestigePage />} />
<Route path="/achievements" element={<AchievementsPage />} />
<Route path="/battle" element={<BattleHub />} />
<Route path="/battle/multi" element={<MultiPlayersPage />} />
<Route path="/battle/1vx" element={<DuelListPage />} />
<Route path="/battle/1vx/:id" element={<DuelRoomPage />} />
<Route path="/battle/coinflip" element={<CoinflipStub />} />
<Route path="/battle/create" element={<BattleRoomPage create />} />
<Route path="/battle/multi" element={<Navigate to="/battle/create" replace />} />
<Route path="/battle/1vx" element={<Navigate to="/battle" replace />} />
<Route path="/battle/1vx/:id" element={<Navigate to="/battle" replace />} />
<Route path="/battle/coinflip" element={<Navigate to="/battle" replace />} />
<Route path="/battle/:id" element={<BattleRoomPage />} />
<Route path="/bet" element={<Navigate to="/battle" replace />} />
<Route path="/bet/multi" element={<Navigate to="/battle/multi" replace />} />
<Route path="/bet/1vx" element={<Navigate to="/battle/1vx" replace />} />
<Route path="/bet/1vx/:id" element={<BetDuelRedirect />} />
<Route path="/bet/coinflip" element={<Navigate to="/battle/coinflip" replace />} />
<Route path="/bet/multi" element={<Navigate to="/battle/create" replace />} />
<Route path="/bet/1vx" element={<Navigate to="/battle" replace />} />
<Route path="/bet/1vx/:id" element={<Navigate to="/battle" replace />} />
<Route path="/bet/coinflip" element={<Navigate to="/battle" replace />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/player/:username" element={<PlayerProfilePage />} />
<Route path="/shop" element={<ShopPage />} />
+18
View File
@@ -1,5 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { api } from './api';
import { getSocket, refreshSocketSession } from './socket';
const AuthContext = createContext(null);
@@ -22,6 +23,23 @@ export function AuthProvider({ children }) {
refresh();
}, [refresh]);
// Keep Socket.IO presence in sync with HTTP session (login / logout / boot).
useEffect(() => {
if (loading) return;
refreshSocketSession();
}, [loading, user?.id]);
useEffect(() => {
const socket = getSocket();
const onReset = () => {
refresh();
};
socket.on('server:prestige-reset', onReset);
return () => {
socket.off('server:prestige-reset', onReset);
};
}, [refresh]);
const login = async (username, password) => {
const data = await api.login(username, password);
setUser(data.user);
+3 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, formatCredits } from '../api';
import { api, formatCredits, centsJson, toCentsBigInt } from '../api';
import ImageField from './ImageField';
const emptyCase = { name: '', price: 100, imageUrl: '', active: true };
@@ -62,7 +62,7 @@ export default function AdminCases() {
e.preventDefault();
setError('');
try {
await api.createCase({ ...caseForm, price: Number(caseForm.price) });
await api.createCase({ ...caseForm, price: centsJson(toCentsBigInt(caseForm.price)) });
setCaseForm(emptyCase);
await load();
} catch (err) {
@@ -112,7 +112,7 @@ export default function AdminCases() {
try {
await api.updateCase(editingId, {
name: edit.name,
price: Number(edit.price),
price: centsJson(toCentsBigInt(edit.price)),
imageUrl: edit.imageUrl,
active: edit.active,
});
+6
View File
@@ -78,6 +78,12 @@ export default function AdminDashboard() {
<Link to="/admin/drops" className="btn secondary">
Drop rates
</Link>
<Link to="/admin/shop" className="btn secondary">
Shop osu packs
</Link>
<Link to="/admin/prestige-server" className="btn secondary">
Prestige Server
</Link>
</div>
<section className="admin-block" style={{ marginTop: '1.25rem' }}>
+139 -15
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, formatCredits } from '../api';
import { api, formatCredits, compareCents } from '../api';
function dropPercents(items) {
const total = items.reduce((s, ci) => s + Number(ci.dropRate || 0), 0);
@@ -7,12 +7,54 @@ function dropPercents(items) {
return items.map((ci) => (Number(ci.dropRate) / total) * 100);
}
function sortCatalog(list, sort) {
const next = [...list];
next.sort((a, b) => {
if (sort === 'value-asc') return compareCents(a.marketValue, b.marketValue);
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'rarity') {
const r = a.rarity.localeCompare(b.rarity);
if (r !== 0) return r;
return compareCents(b.marketValue, a.marketValue);
}
// value-desc (default)
return compareCents(b.marketValue, a.marketValue);
});
return next;
}
function sortPool(list, sort) {
const next = [...list];
next.sort((a, b) => {
if (sort === 'value-desc') {
return compareCents(b.item.marketValue, a.item.marketValue);
}
if (sort === 'value-asc') {
return compareCents(a.item.marketValue, b.item.marketValue);
}
if (sort === 'name') return a.item.name.localeCompare(b.item.name);
if (sort === 'rarity') {
const r = a.item.rarity.localeCompare(b.item.rarity);
if (r !== 0) return r;
return compareCents(b.item.marketValue, a.item.marketValue);
}
if (sort === 'rate-desc') return Number(b.dropRate) - Number(a.dropRate);
// rate-asc (default)
return Number(a.dropRate) - Number(b.dropRate);
});
return next;
}
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 [itemSort, setItemSort] = useState('value-desc');
const [itemQuery, setItemQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
const [poolSort, setPoolSort] = useState('rate-asc');
const load = useCallback(async () => {
const [c, i] = await Promise.all([api.adminCases(), api.adminItems()]);
@@ -22,10 +64,6 @@ export default function AdminDrops() {
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(() => {
@@ -37,10 +75,43 @@ export default function AdminDrops() {
[cases, selectedCaseId]
);
const usedCategories = useMemo(() => {
const set = new Set(items.map((item) => item.category || 'Misc'));
return [...set].sort((a, b) => a.localeCompare(b));
}, [items]);
const catalogItems = useMemo(() => {
const q = itemQuery.trim().toLowerCase();
const filtered = items.filter((item) => {
const cat = item.category || 'Misc';
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)
);
});
return sortCatalog(filtered, itemSort);
}, [items, itemQuery, itemSort, categoryFilter]);
useEffect(() => {
if (!catalogItems.length) {
setDropForm((f) => (f.itemId ? { ...f, itemId: '' } : f));
return;
}
setDropForm((f) => {
if (f.itemId && catalogItems.some((item) => String(item.id) === String(f.itemId))) {
return f;
}
return { ...f, itemId: String(catalogItems[0].id) };
});
}, [catalogItems]);
const poolItems = useMemo(() => {
if (!selected) return [];
return [...selected.items].sort((a, b) => Number(a.dropRate) - Number(b.dropRate));
}, [selected]);
return sortPool(selected.items, poolSort);
}, [selected, poolSort]);
const percents = useMemo(() => dropPercents(poolItems), [poolItems]);
@@ -76,6 +147,37 @@ export default function AdminDrops() {
Add drop · {selected.name}{' '}
<span className="muted">({formatCredits(selected.price)} cr)</span>
</h3>
<div className="admin-toolbar" style={{ marginBottom: '0.75rem' }}>
<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={itemSort}
onChange={(e) => setItemSort(e.target.value)}
aria-label="Sort catalog items"
>
<option value="value-desc">Sort: value </option>
<option value="value-asc">Sort: value </option>
<option value="name">Sort: name</option>
<option value="rarity">Sort: rarity</option>
</select>
<input
className="admin-search"
placeholder="Search items…"
value={itemQuery}
onChange={(e) => setItemQuery(e.target.value)}
aria-label="Search catalog items"
/>
</div>
<form
className="admin-row"
onSubmit={async (e) => {
@@ -95,12 +197,18 @@ export default function AdminDrops() {
<select
value={dropForm.itemId}
onChange={(e) => setDropForm({ ...dropForm, itemId: e.target.value })}
disabled={!catalogItems.length}
aria-label="Select item to add"
>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.rarity})
</option>
))}
{catalogItems.length === 0 ? (
<option value="">No matching items</option>
) : (
catalogItems.map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.rarity}) · {formatCredits(item.marketValue)} cr
</option>
))
)}
</select>
<input
type="number"
@@ -111,14 +219,28 @@ export default function AdminDrops() {
placeholder="Weight"
required
/>
<button className="btn" type="submit">
<button className="btn" type="submit" disabled={!dropForm.itemId}>
Add drop
</button>
</form>
</div>
<div className="admin-block">
<h3>Pool ({poolItems.length})</h3>
<div className="section-head" style={{ marginBottom: '0.75rem' }}>
<h3 style={{ margin: 0 }}>Pool ({poolItems.length})</h3>
<select
value={poolSort}
onChange={(e) => setPoolSort(e.target.value)}
aria-label="Sort pool"
>
<option value="rate-asc">Sort: weight </option>
<option value="rate-desc">Sort: weight </option>
<option value="value-desc">Sort: value </option>
<option value="value-asc">Sort: value </option>
<option value="name">Sort: name</option>
<option value="rarity">Sort: rarity</option>
</select>
</div>
{poolItems.length === 0 ? (
<div className="muted">No drops yet.</div>
) : (
@@ -130,7 +252,9 @@ export default function AdminDrops() {
<img src={ci.item.imageUrl} alt="" className="drop-thumb" />
) : null}
{ci.item.name}{' '}
<span className="muted">({ci.item.rarity})</span>
<span className="muted">
({ci.item.rarity}) · {formatCredits(ci.item.marketValue)} cr
</span>
</span>
<input
type="number"
+37 -32
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, formatCredits } from '../api';
import { api, formatCredits, compareCents, centsJson, toCentsBigInt } from '../api';
import ImageField from './ImageField';
const emptyItem = {
@@ -10,23 +10,21 @@ const emptyItem = {
imageUrl: '',
};
function CategoryField({ value, onChange, categories, id }) {
const listId = id || 'item-categories';
function CategoryField({ value, onChange, categories }) {
const options =
value && !categories.includes(value) ? [...categories, value] : 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>
</>
<select value={value} onChange={(e) => onChange(e.target.value)} required>
{options.length === 0 ? (
<option value="">No categories create one first</option>
) : (
options.map((c) => (
<option key={c} value={c}>
{c}
</option>
))
)}
</select>
);
}
@@ -51,16 +49,23 @@ export default function AdminItems() {
]);
setItems(i.items);
setRarities(r.rarities);
setCategories(
(c.names || c.categories || []).map((entry) =>
typeof entry === 'string' ? entry : entry.name
)
const names = (c.names || c.categories || []).map((entry) =>
typeof entry === 'string' ? entry : entry.name
);
setItemForm((f) => ({
...f,
rarity: f.rarity || r.rarities[0] || 'MilSpec',
category: f.category || 'Misc',
}));
setCategories(names);
setItemForm((f) => {
const nextCategory =
f.category && names.includes(f.category)
? f.category
: names.includes('Misc')
? 'Misc'
: names[0] || '';
return {
...f,
rarity: f.rarity || r.rarities[0] || 'MilSpec',
category: nextCategory,
};
});
}, []);
useEffect(() => {
@@ -89,7 +94,7 @@ export default function AdminItems() {
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 === 'value') return compareCents(b.marketValue, 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');
@@ -164,12 +169,14 @@ export default function AdminItems() {
try {
await api.createItem({
...itemForm,
marketValue: Number(itemForm.marketValue),
marketValue: centsJson(toCentsBigInt(itemForm.marketValue)),
});
setItemForm({
...emptyItem,
rarity: rarities[0] || 'MilSpec',
category: 'Misc',
category: categories.includes('Misc')
? 'Misc'
: categories[0] || '',
});
await load();
} catch (err) {
@@ -188,7 +195,6 @@ export default function AdminItems() {
<label>
Category
<CategoryField
id="create-item-categories"
value={itemForm.category}
categories={categories}
onChange={(category) => setItemForm({ ...itemForm, category })}
@@ -247,7 +253,7 @@ export default function AdminItems() {
name: edit.name,
rarity: edit.rarity,
category: edit.category,
marketValue: Number(edit.marketValue),
marketValue: centsJson(toCentsBigInt(edit.marketValue)),
imageUrl: edit.imageUrl,
});
setEditingId(null);
@@ -270,7 +276,6 @@ export default function AdminItems() {
<label>
Category
<CategoryField
id={`edit-item-categories-${item.id}`}
value={edit.category}
categories={categories}
onChange={(category) => setEdit({ ...edit, category })}
+2
View File
@@ -8,6 +8,8 @@ const NAV = [
{ to: '/admin/items', label: 'Items' },
{ to: '/admin/categories', label: 'Categories' },
{ to: '/admin/drops', label: 'Drops' },
{ to: '/admin/shop', label: 'Shop' },
{ to: '/admin/prestige-server', label: 'Prestige Server' },
];
export default function AdminLayout() {
+200 -51
View File
@@ -4,8 +4,105 @@ import {
formatCredits,
formatLastConnection,
formatPlayTime,
compareCents,
creditsToCentsString,
toCentsBigInt,
} from '../api';
/** Credits delta (e.g. "15.5" or "-1000") → signed cents string. */
function parseDeltaCents(raw) {
const s = String(raw ?? '').trim().replace(',', '.');
if (!s || s === '0' || s === '+0' || s === '-0') return null;
const neg = s.startsWith('-');
const body = s.startsWith('+') || s.startsWith('-') ? s.slice(1) : s;
const cents = creditsToCentsString(body);
if (cents == null || cents === '0') return null;
return neg ? `-${cents}` : cents;
}
/** Whole-unit delta (Pr / osu) — integers only. */
function parseDeltaUnits(raw) {
const s = String(raw ?? '').trim();
if (!/^-?\d+$/.test(s) || s === '0' || s === '-0') return null;
try {
return BigInt(s);
} catch {
return null;
}
}
function balanceAfterDelta(balance, delta) {
try {
const next = toCentsBigInt(balance) + toCentsBigInt(delta);
return (next < 0n ? 0n : next).toString();
} catch {
return toCentsBigInt(balance).toString();
}
}
/** Compact display for whole units (Pr / osu) via credit formatter. */
function formatUnits(n) {
try {
return formatCredits((BigInt(n) * 100n).toString());
} catch {
return String(n);
}
}
function previewClass(delta) {
if (delta == null) return 'admin-adjust-preview muted';
const n = typeof delta === 'bigint' ? delta : BigInt(delta);
return n > 0n ? 'admin-adjust-preview pos' : 'admin-adjust-preview neg';
}
function formatSignedPreview(delta, suffix, isCents = false) {
if (delta == null) return '—';
const n = typeof delta === 'bigint' ? delta : BigInt(delta);
const abs = n < 0n ? -n : n;
const formatted = isCents ? formatCredits(abs.toString()) : formatUnits(abs);
return `${n > 0n ? '+' : ''}${formatted} ${suffix}`;
}
function AdjustRow({
label,
value,
onChange,
step = '1',
delta,
preview,
result,
onSubmit,
busy,
}) {
return (
<form
className="admin-adjust-row"
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<label>
{label}
<span className="admin-adjust-input-wrap">
<input
type="number"
step={step}
value={value}
onChange={(e) => onChange(e.target.value)}
required
/>
<span className={previewClass(delta)}>{preview}</span>
</span>
</label>
{result != null && <span className="admin-adjust-result muted">{result}</span>}
<button className="btn" type="submit" disabled={busy || delta == null}>
Apply
</button>
</form>
);
}
export default function AdminPlayers() {
const [users, setUsers] = useState([]);
const [error, setError] = useState('');
@@ -13,8 +110,14 @@ export default function AdminPlayers() {
const [sort, setSort] = useState('wealth');
const [adjustId, setAdjustId] = useState(null);
const [deltaCredits, setDeltaCredits] = useState('100');
const [deltaPr, setDeltaPr] = useState('1000');
const [deltaOsu, setDeltaOsu] = useState('100');
const [busy, setBusy] = useState(false);
const deltaCents = useMemo(() => parseDeltaCents(deltaCredits), [deltaCredits]);
const prDelta = useMemo(() => parseDeltaUnits(deltaPr), [deltaPr]);
const osuDelta = useMemo(() => parseDeltaUnits(deltaOsu), [deltaOsu]);
const load = useCallback(async () => {
const data = await api.adminUsers();
setUsers(data.users);
@@ -39,23 +142,24 @@ export default function AdminPlayers() {
new Date(a.lastConnection || 0).getTime()
);
}
return Number(b.totalWealth) - Number(a.totalWealth);
return compareCents(b.totalWealth, 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;
}
const resetDeltas = () => {
setDeltaCredits('100');
setDeltaPr('1000');
setDeltaOsu('100');
};
const applyPatch = async (userId, body) => {
setBusy(true);
setError('');
try {
await api.adminUpdateUser(userId, { balanceDelta: cents });
await api.adminUpdateUser(userId, body);
setAdjustId(null);
setDeltaCredits('100');
resetDeltas();
await load();
} catch (err) {
setError(err.message);
@@ -64,12 +168,44 @@ export default function AdminPlayers() {
}
};
const applyCredits = async (userId) => {
const cents = parseDeltaCents(deltaCredits);
if (cents == null) {
setError('Enter a non-zero credit amount (e.g. 100 or -50)');
return;
}
await applyPatch(userId, { balanceDelta: cents });
};
const applyPr = async (userId) => {
const delta = parseDeltaUnits(deltaPr);
if (delta == null) {
setError('Enter a non-zero Pr amount (e.g. 1000 or -100)');
return;
}
await applyPatch(userId, { prBalanceDelta: delta.toString() });
};
const applyOsu = async (userId) => {
const delta = parseDeltaUnits(deltaOsu);
if (delta == null) {
setError('Enter a non-zero osu amount (e.g. 100 or -50)');
return;
}
// osuBalance is Int — stay within safe integer range for the JSON number API
if (delta > BigInt(Number.MAX_SAFE_INTEGER) || delta < BigInt(Number.MIN_SAFE_INTEGER)) {
setError('osu delta is too large');
return;
}
await applyPatch(userId, { osuBalanceDelta: Number(delta) });
};
return (
<div>
<div className="section-head">
<div>
<h1>Players</h1>
<p className="muted">{users.length} accounts · adjust credits when needed</p>
<p className="muted">{users.length} accounts · adjust credits, Pr & osu</p>
</div>
<div className="admin-toolbar">
<select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">
@@ -108,8 +244,9 @@ export default function AdminPlayers() {
{u.inventoryCount})
</div>
<div className="muted">
Opens {u.casesOpened} · prestige {u.prestigeCount} · achievements{' '}
{u.achievements} · play {formatPlayTime(u.playTimeSeconds)} · last{' '}
{formatUnits(u.prBalance ?? 0)} Pr · {formatUnits(u.osuBalance ?? 0)} osu · opens{' '}
{u.casesOpened} · prestige {u.prestigeCount} · achievements {u.achievements} · play{' '}
{formatPlayTime(u.playTimeSeconds)} · last{' '}
{formatLastConnection(u.lastConnection)}
</div>
</div>
@@ -118,50 +255,62 @@ export default function AdminPlayers() {
className="btn secondary"
onClick={() => {
setAdjustId(adjustId === u.id ? null : u.id);
setDeltaCredits('100');
resetDeltas();
}}
>
Adjust credits
Adjust
</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 className="admin-adjust-panel">
<AdjustRow
label="Δ credits (e.g. 100 or -50)"
value={deltaCredits}
onChange={setDeltaCredits}
step="0.01"
delta={deltaCents == null ? null : BigInt(deltaCents)}
preview={formatSignedPreview(
deltaCents == null ? null : BigInt(deltaCents),
'cr',
true
)}
result={
deltaCents == null
? null
: `→ bal ${formatCredits(balanceAfterDelta(u.balance, deltaCents))} cr`
}
onSubmit={() => applyCredits(u.id)}
busy={busy}
/>
<AdjustRow
label="Δ Pr (e.g. 1000 or -100)"
value={deltaPr}
onChange={setDeltaPr}
delta={prDelta}
preview={formatSignedPreview(prDelta, 'Pr')}
result={
prDelta == null
? null
: `${formatUnits(balanceAfterDelta(u.prBalance ?? 0, prDelta))} Pr`
}
onSubmit={() => applyPr(u.id)}
busy={busy}
/>
<AdjustRow
label="Δ osu (e.g. 100 or -50)"
value={deltaOsu}
onChange={setDeltaOsu}
delta={osuDelta}
preview={formatSignedPreview(osuDelta, 'osu')}
result={
osuDelta == null
? null
: `${formatUnits(balanceAfterDelta(u.osuBalance ?? 0, osuDelta))} osu`
}
onSubmit={() => applyOsu(u.id)}
busy={busy}
/>
</div>
)}
</div>
))}
+216
View File
@@ -0,0 +1,216 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api';
function slugifyKey(raw) {
return String(raw || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_|_$/g, '')
.slice(0, 48);
}
export default function AdminPrestigeServer() {
const [preview, setPreview] = useState(null);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [achName, setAchName] = useState('Server Prestige');
const [achKey, setAchKey] = useState('server_prestige');
const [achKeyTouched, setAchKeyTouched] = useState(false);
const [achDescription, setAchDescription] = useState(
'Survived a Server Prestige wipe. Economy & prestige reset; stats & achievements kept.'
);
const [achIcon, setAchIcon] = useState('♛');
const [password, setPassword] = useState('');
const [confirmText, setConfirmText] = useState('');
const loadPreview = async () => {
const data = await api.adminPrestigeServerPreview();
setPreview(data.preview);
};
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.adminPrestigeServerPreview();
if (!cancelled) setPreview(data.preview);
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
return () => {
cancelled = true;
};
}, []);
const derivedKey = useMemo(() => {
if (achKeyTouched) return achKey;
const fromName = slugifyKey(achName);
return fromName ? `server_${fromName}`.replace(/^server_server_/, 'server_') : 'server_prestige';
}, [achName, achKey, achKeyTouched]);
const canSubmit =
!busy &&
achName.trim() &&
achDescription.trim() &&
password.length >= 1 &&
confirmText.trim().toUpperCase() === 'PRESTIGE SERVER';
const onSubmit = async (e) => {
e.preventDefault();
if (!canSubmit) return;
setError('');
setMessage('');
setBusy(true);
try {
const result = await api.adminPrestigeServer({
password,
achievement: {
key: derivedKey,
name: achName.trim(),
description: achDescription.trim(),
icon: achIcon.trim() || '♛',
},
});
setPassword('');
setConfirmText('');
setMessage(
`Server Prestige done · ${result.playersReset} players reset · achievement “${result.achievement.name}” granted`
);
await loadPreview();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<div>
<div className="section-head">
<div>
<h1>Prestige Server</h1>
<p className="muted">
Wipe economy &amp; prestige progress for every player. Kept: account (username, password,
avatar, bio), <strong>Joined</strong>, <strong>play time</strong>,{' '}
<strong>last connection</strong>, <strong>cases opened</strong>,{' '}
<strong>jackpot / battle wins</strong>, and <strong>achievements</strong>. Then grants the
wipe achievement configured below.
</p>
</div>
</div>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
{preview && (
<div className="admin-stat-grid" style={{ marginBottom: '1.25rem' }}>
<div className="admin-stat">
<span className="admin-stat-label">Players to reset</span>
<strong>{preview.players}</strong>
</div>
<div className="admin-stat">
<span className="admin-stat-label">Inventory wiped</span>
<strong>{preview.inventoryItems}</strong>
</div>
<div className="admin-stat">
<span className="admin-stat-label">Prestige logs wiped</span>
<strong>{preview.prestigeLogs}</strong>
</div>
<div className="admin-stat">
<span className="admin-stat-label">Stats txs kept</span>
<strong>{preview.statsTransactionsKept}</strong>
<span className="muted">opens / battle wins</span>
</div>
</div>
)}
<form className="admin-block form" onSubmit={onSubmit}>
<h2>Wipe achievement</h2>
<p className="muted">
Configure this before activating. Every reset player receives it (existing achievements
stay).
</p>
<div className="admin-form-grid">
<label>
Name
<input
value={achName}
onChange={(e) => setAchName(e.target.value)}
required
maxLength={80}
placeholder="Server Prestige"
/>
</label>
<label>
Icon
<input
value={achIcon}
onChange={(e) => setAchIcon(e.target.value)}
maxLength={8}
placeholder="♛"
/>
</label>
<label className="admin-form-span">
Key (unique id)
<input
value={derivedKey}
onChange={(e) => {
setAchKeyTouched(true);
setAchKey(slugifyKey(e.target.value));
}}
maxLength={48}
placeholder="server_prestige"
/>
</label>
<label className="admin-form-span">
Description
<textarea
value={achDescription}
onChange={(e) => setAchDescription(e.target.value)}
required
maxLength={240}
rows={3}
/>
</label>
</div>
<h2 style={{ marginTop: '1.5rem' }}>Confirm activation</h2>
<p className="muted">
Type <strong>PRESTIGE SERVER</strong> and enter the admin password. This cannot be undone.
</p>
<div className="admin-form-grid">
<label className="admin-form-span">
Confirmation phrase
<input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="PRESTIGE SERVER"
autoComplete="off"
/>
</label>
<label className="admin-form-span">
Admin password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</label>
</div>
<div className="row-actions" style={{ marginTop: '1rem' }}>
<button type="submit" className="btn" disabled={!canSubmit}>
{busy ? 'Wiping…' : 'Activate Prestige Server'}
</button>
</div>
</form>
</div>
);
}
+276
View File
@@ -0,0 +1,276 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '../api';
function emptyEdit(pack) {
return {
name: pack.name || '',
osu: pack.osu ?? 0,
priceCents: pack.priceCents ?? 0,
currency: pack.currency || 'eur',
badge: pack.badge || '',
blurb: pack.blurb || '',
perOsuLabel: pack.perOsuLabel || '',
enabled: Boolean(pack.enabled),
sortOrder: pack.sortOrder ?? 0,
};
}
export default function AdminShop() {
const [sectionEnabled, setSectionEnabled] = useState(true);
const [packs, setPacks] = useState([]);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [editingId, setEditingId] = useState(null);
const [edit, setEdit] = useState(null);
const load = useCallback(async () => {
const data = await api.adminShopOsuPacks();
setSectionEnabled(Boolean(data.sectionEnabled));
setPacks(data.packs || []);
}, []);
useEffect(() => {
load().catch((err) => setError(err.message));
}, [load]);
const toggleSection = async () => {
setBusy(true);
setError('');
setMessage('');
try {
const data = await api.adminShopOsuSection(!sectionEnabled);
setSectionEnabled(Boolean(data.sectionEnabled));
setPacks(data.packs || []);
setMessage(
data.sectionEnabled
? 'Get osu section enabled on the shop'
: 'Get osu section disabled on the shop'
);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const togglePack = async (pack) => {
setBusy(true);
setError('');
try {
const data = await api.adminUpdateShopOsuPack(pack.id, { enabled: !pack.enabled });
setPacks((prev) => prev.map((p) => (p.id === pack.id ? data.pack : p)));
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const saveEdit = async (e) => {
e.preventDefault();
if (!editingId || !edit) return;
setBusy(true);
setError('');
setMessage('');
try {
const data = await api.adminUpdateShopOsuPack(editingId, {
name: edit.name,
osu: Number(edit.osu),
priceCents: Number(edit.priceCents),
currency: edit.currency,
badge: edit.badge || null,
blurb: edit.blurb,
perOsuLabel: edit.perOsuLabel,
enabled: Boolean(edit.enabled),
sortOrder: Number(edit.sortOrder),
});
setPacks((prev) => prev.map((p) => (p.id === editingId ? data.pack : p)));
setEditingId(null);
setEdit(null);
setMessage(`Saved ${data.pack.name}`);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<div>
<div className="section-head">
<div>
<h1>Shop · osu packs</h1>
<p className="muted">
Configure real-money osu top-ups shown on /shop. Disable the whole section or
individual packs.
</p>
</div>
<button
type="button"
className={`btn${sectionEnabled ? ' danger' : ''}`}
disabled={busy}
onClick={toggleSection}
>
{sectionEnabled ? 'Disable Get osu section' : 'Enable Get osu section'}
</button>
</div>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
{!sectionEnabled && (
<div className="admin-block">
<p className="muted" style={{ margin: 0 }}>
The <strong>Get osu</strong> grid is hidden for players. Packs below can still be
edited.
</p>
</div>
)}
<div className="admin-card-list">
{packs.map((pack) => (
<div key={pack.id} className="admin-block">
{editingId === pack.id && edit ? (
<form className="form admin-form-grid" onSubmit={saveEdit}>
<h3 className="admin-form-span">
Edit {pack.id}
</h3>
<label>
Name
<input
value={edit.name}
onChange={(e) => setEdit({ ...edit, name: e.target.value })}
required
/>
</label>
<label>
Osu amount
<input
type="number"
min="1"
step="1"
value={edit.osu}
onChange={(e) => setEdit({ ...edit, osu: e.target.value })}
required
/>
</label>
<label>
Price (EUR cents)
<input
type="number"
min="0"
step="1"
value={edit.priceCents}
onChange={(e) => setEdit({ ...edit, priceCents: e.target.value })}
required
/>
</label>
<label>
Currency
<input
value={edit.currency}
onChange={(e) => setEdit({ ...edit, currency: e.target.value })}
required
/>
</label>
<label>
Badge
<input
value={edit.badge}
onChange={(e) => setEdit({ ...edit, badge: e.target.value })}
placeholder="Popular, Best value…"
/>
</label>
<label>
Sort order
<input
type="number"
step="1"
value={edit.sortOrder}
onChange={(e) => setEdit({ ...edit, sortOrder: e.target.value })}
/>
</label>
<label className="admin-form-span">
Blurb
<input
value={edit.blurb}
onChange={(e) => setEdit({ ...edit, blurb: e.target.value })}
/>
</label>
<label className="admin-form-span">
Per-osu label
<input
value={edit.perOsuLabel}
onChange={(e) => setEdit({ ...edit, perOsuLabel: e.target.value })}
placeholder="€0.10 / osu"
/>
</label>
<label className="admin-form-span" style={{ flexDirection: 'row', gap: '0.5rem' }}>
<input
type="checkbox"
checked={edit.enabled}
onChange={(e) => setEdit({ ...edit, enabled: e.target.checked })}
/>
Pack enabled
</label>
<div className="row-actions admin-form-span">
<button className="btn" type="submit" disabled={busy}>
Save
</button>
<button
type="button"
className="btn secondary"
onClick={() => {
setEditingId(null);
setEdit(null);
}}
>
Cancel
</button>
</div>
</form>
) : (
<div className="admin-row">
<div className="admin-row-main">
<strong>
{pack.name}{' '}
<span className="muted">({pack.id})</span>
</strong>
<div className="muted">
{pack.osu} osu · {pack.displayPrice} · sort {pack.sortOrder}
{pack.badge ? ` · ${pack.badge}` : ''}
{' · '}
<span className={pack.enabled ? 'admin-pill-on' : 'admin-pill-off'}>
{pack.enabled ? 'enabled' : 'disabled'}
</span>
</div>
{pack.blurb ? <div className="muted">{pack.blurb}</div> : null}
</div>
<button
type="button"
className="btn secondary"
disabled={busy}
onClick={() => {
setEditingId(pack.id);
setEdit(emptyEdit(pack));
}}
>
Edit
</button>
<button
type="button"
className={`btn${pack.enabled ? ' ghost' : ''}`}
disabled={busy}
onClick={() => togglePack(pack)}
>
{pack.enabled ? 'Disable' : 'Enable'}
</button>
</div>
)}
</div>
))}
</div>
</div>
);
}
+1 -1
View File
@@ -38,7 +38,7 @@ export default function ImageField({ value, onChange, disabled }) {
{uploading ? '…' : 'Upload'}
<input
type="file"
accept="image/*"
accept="image/png,image/jpeg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif"
hidden
onChange={onFile}
disabled={disabled || uploading}
+145 -39
View File
@@ -35,10 +35,10 @@ export const api = {
catalog: () => request('/api/catalog'),
prestige: () => request('/api/prestige'),
doPrestige: () => request('/api/prestige', { method: 'POST', body: JSON.stringify({}) }),
prestigeUpgrade: (skillId) =>
prestigeUpgrade: (skillId, count = 1) =>
request('/api/prestige/upgrade', {
method: 'POST',
body: JSON.stringify({ skillId }),
body: JSON.stringify({ skillId, count }),
}),
openCase: (id, count = 1) =>
request(`/api/cases/${id}/open`, {
@@ -56,7 +56,6 @@ export const api = {
method: 'POST',
body: JSON.stringify({ inventoryItemIds }),
}),
vaultClaim: () => request('/api/vault/claim', { method: 'POST' }),
sellInventory: (inventoryItemIds) =>
request('/api/inventory/sell', {
method: 'POST',
@@ -83,41 +82,65 @@ export const api = {
method: 'POST',
body: JSON.stringify({ packageId }),
}),
shopOsuCheckout: (packId) =>
request('/api/shop/osu/checkout', {
method: 'POST',
body: JSON.stringify({ packId }),
}),
shopOsuConfirm: (orderId) =>
request('/api/shop/osu/confirm', {
method: 'POST',
body: JSON.stringify({ orderId }),
}),
shopAd: () => request('/api/shop/ad', { method: 'POST' }),
feed: () => request('/api/feed'),
feedAnnounce: (inventoryItemIds, caseName) => {
feedAnnounce: (inventoryItemIds, caseName, opts = {}) => {
const ids = Array.isArray(inventoryItemIds)
? inventoryItemIds
: [inventoryItemIds];
: inventoryItemIds != null
? [inventoryItemIds]
: [];
const body = {
inventoryItemIds: ids.filter(Boolean),
caseName,
respin: Boolean(opts.respin),
};
if (opts.snapshotBest) {
body.snapshotBest = opts.snapshotBest;
body.openCount = opts.openCount;
body.count = opts.count;
} else if (opts.snapshotItems) {
body.snapshotItems = opts.snapshotItems;
}
return request('/api/feed/announce', {
method: 'POST',
body: JSON.stringify({ inventoryItemIds: ids, caseName }),
body: JSON.stringify(body),
});
},
leaderboard: () => request('/api/leaderboard'),
jackpot: () => request('/api/jackpot'),
jackpotBet: (inventoryItemIds) =>
jackpotRound: (id) => request(`/api/jackpot/${id}`),
jackpotCreateBet: (inventoryItemIds) =>
request('/api/jackpot/bet', {
method: 'POST',
body: JSON.stringify({ inventoryItemIds }),
}),
jackpotWithdraw: (inventoryItemIds) =>
request('/api/jackpot/withdraw', {
jackpotBet: (id, inventoryItemIds) =>
request(`/api/jackpot/${id}/bet`, {
method: 'POST',
body: JSON.stringify({ inventoryItemIds }),
}),
duels: () => request('/api/duels'),
duel: (id) => request(`/api/duels/${id}`),
createDuel: (body) =>
request('/api/duels', { method: 'POST', body: JSON.stringify(body) }),
duelBet: (id, inventoryItemIds) =>
request(`/api/duels/${id}/bet`, {
jackpotWithdraw: (id, inventoryItemIds) =>
request(`/api/jackpot/${id}/withdraw`, {
method: 'POST',
body: JSON.stringify({ inventoryItemIds }),
}),
// legacy alias
jackpotCreate: (inventoryItemIds) =>
request('/api/jackpot/bet', {
method: 'POST',
body: JSON.stringify({ inventoryItemIds }),
}),
duelLeave: (id) => request(`/api/duels/${id}/leave`, { method: 'POST' }),
duelCancel: (id) => request(`/api/duels/${id}/cancel`, { method: 'POST' }),
duelStart: (id) => request(`/api/duels/${id}/start`, { method: 'POST' }),
profile: () => request('/api/profile'),
playerProfile: (username) =>
request(`/api/profile/player/${encodeURIComponent(username)}`),
@@ -140,6 +163,12 @@ export const api = {
body: JSON.stringify({ username, password }),
}),
adminStats: () => request('/api/admin/stats'),
adminPrestigeServerPreview: () => request('/api/admin/prestige-server'),
adminPrestigeServer: (body) =>
request('/api/admin/prestige-server', {
method: 'POST',
body: JSON.stringify(body),
}),
adminUsers: () => request('/api/admin/users'),
adminUpdateUser: (id, body) =>
request(`/api/admin/users/${id}`, {
@@ -150,6 +179,17 @@ export const api = {
adminItems: () => request('/api/admin/items'),
adminRarities: () => request('/api/admin/rarities'),
adminCategories: () => request('/api/admin/categories'),
adminShopOsuPacks: () => request('/api/admin/shop/osu-packs'),
adminShopOsuSection: (enabled) =>
request('/api/admin/shop/osu-section', {
method: 'PUT',
body: JSON.stringify({ enabled }),
}),
adminUpdateShopOsuPack: (id, body) =>
request(`/api/admin/shop/osu-packs/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
createCategory: (body) =>
request('/api/admin/categories', { method: 'POST', body: JSON.stringify(body) }),
updateCategory: (id, body) =>
@@ -186,17 +226,77 @@ export const api = {
};
/**
* Exact credit display (cents → "1234.56"), for tooltips / confirmations.
* Parse money amounts (cents) to BigInt — mirror of server `cents.js`.
* Accepts bigint, number, or decimal/int string. Truncates toward zero.
*/
export function toCentsBigInt(value) {
if (typeof value === 'bigint') return value;
if (value == null || value === '') return 0n;
if (typeof value === 'number') {
if (!Number.isFinite(value)) return 0n;
return BigInt(Math.trunc(value));
}
const s = String(value).trim();
if (!s) return 0n;
const m = s.match(/^([+-]?\d+)/);
if (!m) return 0n;
try {
return BigInt(m[1]);
} catch {
return 0n;
}
}
/** JSON-safe cents string. */
export function centsJson(value) {
return toCentsBigInt(value).toString();
}
/** Sort/compare helper: negative if a < b, 0 if equal, positive if a > b. */
export function compareCents(a, b) {
const aa = toCentsBigInt(a);
const bb = toCentsBigInt(b);
if (aa < bb) return -1;
if (aa > bb) return 1;
return 0;
}
/** Exact credit display (cents → "1234.56"), for tooltips / confirmations. */
export function formatCreditsExact(cents) {
const n = Number(cents);
if (!Number.isFinite(n)) return '0.00';
const fixed = (n / 100).toFixed(2);
const neg = fixed.startsWith('-');
const body = neg ? fixed.slice(1) : fixed;
const [intPart, dec] = body.split('.');
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f');
return `${neg ? '-' : ''}${grouped}.${dec}`;
let bi = toCentsBigInt(cents);
const neg = bi < 0n;
if (neg) bi = -bi;
const whole = bi / 100n;
const frac = (bi % 100n).toString().padStart(2, '0');
const grouped = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f');
return `${neg ? '-' : ''}${grouped}.${frac}`;
}
/** Parse credits input (decimal string) → integer cents string. Null if invalid. */
export function creditsToCentsString(raw) {
if (raw == null || raw === '') return null;
const s = String(raw).trim().replace(',', '.');
const m = s.match(/^(\d+)(?:\.(\d{1,2})\d*)?$/);
if (!m) return null;
try {
const whole = BigInt(m[1]);
const frac = m[2] ? m[2].padEnd(2, '0') : '00';
return (whole * 100n + BigInt(frac)).toString();
} catch {
return null;
}
}
/** Cents → credits decimal string without Number precision loss. */
export function centsToCreditsString(cents) {
let bi = toCentsBigInt(cents);
const neg = bi < 0n;
const abs = neg ? -bi : bi;
const whole = abs / 100n;
const frac = (abs % 100n).toString().padStart(2, '0');
const body =
frac === '00' ? whole.toString() : `${whole}.${frac}`.replace(/0+$/, '').replace(/\.$/, '');
return `${neg ? '-' : ''}${body}`;
}
/** Alphabet suffixes after T: aa, ab, … az, ba, … (each step ×1000). */
@@ -225,25 +325,31 @@ function tierSuffix(tier) {
* K / M / B / T, then aa, ab, … (×1000 each). Input is cents.
*/
export function formatCredits(cents) {
const n = Number(cents);
if (!Number.isFinite(n)) return '0.00';
const neg = n < 0;
const credits = Math.abs(n) / 100;
let bi = toCentsBigInt(cents);
const neg = bi < 0n;
if (neg) bi = -bi;
if (credits < 1000) {
return `${neg ? '-' : ''}${credits.toFixed(2)}`;
const creditsWhole = bi / 100n;
const creditsFrac = bi % 100n;
if (creditsWhole < 1000n) {
return `${neg ? '-' : ''}${creditsWhole}.${creditsFrac.toString().padStart(2, '0')}`;
}
let tier = 0;
let scaled = credits;
while (scaled >= 1000 && tier < 1000) {
scaled /= 1000;
let scaled = creditsWhole;
let rem = 0n;
while (scaled >= 1000n) {
rem = scaled % 1000n;
scaled /= 1000n;
tier += 1;
}
const suffix = tierSuffix(tier);
const shown = String(Number(scaled.toFixed(2)));
return `${neg ? '-' : ''}${shown}${suffix}`;
// scaled < 1000 → safe as Number; rem gives ~3 decimal digits of precision
const approx = Number(scaled) + Number(rem) / 1000;
if (!Number.isFinite(approx)) return `${neg ? '-' : ''}0${tierSuffix(tier)}`;
const shown = String(Number(approx.toFixed(2)));
return `${neg ? '-' : ''}${shown}${tierSuffix(tier)}`;
}
export function formatChance(pct) {
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { formatCredits, compareCents } from '../api';
import CaseReel from './CaseReel';
import { useCountdown } from '../hooks/useCountdown';
import { rarityColor } from '../rarities';
const TOP_ITEMS = 5;
function depositsToReelItems(deposits) {
return (deposits || [])
.filter((d) => d.item)
.map((d) => ({
...d.item,
id: d.id,
ownerUsername: d.username,
ownerAvatarUrl: d.avatarUrl || '',
}));
}
/**
* Live battle preview card for the hub (mode-card-live).
*/
export default function BattleLiveCard({ battle, spinEvent }) {
const [spinning, setSpinning] = useState(false);
const [spinActive, setSpinActive] = useState(false);
const [spinDeposits, setSpinDeposits] = useState([]);
const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
const [lastWinner, setLastWinner] = useState(null);
const [revealWinner, setRevealWinner] = useState(false);
useEffect(() => {
if (!spinEvent || spinEvent.id !== battle?.id) return;
setSpinDeposits(spinEvent.deposits || []);
setSpinActive(true);
setSpinning(true);
setRevealWinner(false);
setReelWinnerItemId(spinEvent.reelWinnerItemId);
setLastWinner(spinEvent.winnerUsername);
}, [spinEvent, battle?.id]);
useEffect(() => {
if (battle?.status === 'spinning' && !spinActive) {
setSpinDeposits(battle.deposits || []);
setSpinActive(true);
setSpinning(true);
setLastWinner(battle.winnerUsername || null);
}
}, [battle, spinActive]);
useEffect(() => {
if (!revealWinner) return undefined;
const t = setTimeout(() => {
setSpinActive(false);
setRevealWinner(false);
}, 4000);
return () => clearTimeout(t);
}, [revealWinner]);
const topItems = useMemo(() => {
const deposits = [...(battle?.deposits || [])].filter((d) => d.item);
deposits.sort((a, b) => compareCents(b.valueCents, a.valueCents));
return deposits.slice(0, TOP_ITEMS);
}, [battle]);
const potItems = useMemo(
() =>
depositsToReelItems(
spinActive && spinDeposits.length ? spinDeposits : battle?.deposits
),
[battle, spinDeposits, spinActive]
);
const showReel = spinActive && potItems.length > 0;
const liveTimerEndsAt =
battle?.status === 'open' &&
battle?.endsAt &&
!battle?.waitingForPlayers &&
!spinning
? battle.endsAt
: null;
const { label: liveTimerLabel } = useCountdown(liveTimerEndsAt);
const playerCount = battle?.players?.length ?? 0;
const potValue = battle?.totalValue ?? 0;
const itemCount = battle?.deposits?.length ?? 0;
const statusLabel =
battle?.status === 'spinning' || spinning
? 'Spinning'
: battle?.waitingForPlayers
? 'Waiting for players'
: battle?.status === 'open'
? liveTimerEndsAt
? `Live ${liveTimerLabel}`
: 'Live'
: battle?.status || '…';
if (!battle) return null;
return (
<Link to={`/battle/${battle.id}`} className="mode-card mode-card-live">
<div className="mode-card-top">
<h2>{battle.name || `Battle ${battle.id}`}</h2>
<span
className={`mode-status${
battle.status === 'spinning' || spinning ? ' spinning' : ''
}`}
>
{statusLabel}
</span>
</div>
<p className="muted mode-blurb">
Join the pot. Bet up to {battle.maxItemsPerPlayer || 3} items chance scales with value.
</p>
<div className="mode-pot-stats">
<div>
<span className="muted">Pot</span>
<strong>{formatCredits(potValue)} cr</strong>
</div>
<div>
<span className="muted">Players</span>
<strong>{playerCount}</strong>
</div>
<div>
<span className="muted">Items</span>
<strong>{itemCount}</strong>
</div>
</div>
{showReel ? (
<div className="mode-card-reel">
<CaseReel
items={potItems}
winnerId={reelWinnerItemId || potItems[0]?.id}
spinning={spinning || battle.status === 'spinning'}
showOwners
compact
onDone={() => {
setSpinning(false);
setRevealWinner(true);
}}
/>
{revealWinner && lastWinner && (
<p className="mode-card-winner">
Winner: <strong>{lastWinner}</strong>
</p>
)}
</div>
) : topItems.length > 0 ? (
<div className="mode-pot-items" aria-label="Top items in pot">
{topItems.map((d) => (
<div
key={d.id}
className="mode-pot-thumb"
style={{ borderColor: rarityColor(d.item.rarity) }}
title={`${d.item.name} · ${formatCredits(d.valueCents)} cr`}
>
{d.item.imageUrl ? (
<img src={d.item.imageUrl} alt="" loading="lazy" />
) : (
<span>{d.item.name.slice(0, 2).toUpperCase()}</span>
)}
</div>
))}
{itemCount > topItems.length && (
<span className="mode-pot-more muted">+{itemCount - topItems.length}</span>
)}
</div>
) : (
<p className="muted mode-pot-empty">No items in the pot yet be the first.</p>
)}
</Link>
);
}
+14 -9
View File
@@ -55,13 +55,13 @@ export default function DropFeed() {
if (!entry?.id) return;
setDrops((prev) => {
if (prev.some((d) => d.id === entry.id)) return prev;
// Also skip near-identical batch (same user + item + count) within a few seconds
const nearDup = prev.find(
(d) =>
d.username === entry.username &&
d.item?.id === entry.item?.id &&
(d.count || 1) === (entry.count || 1) &&
(d.openCount || 1) === (entry.openCount || 1) &&
Boolean(d.respin) === Boolean(entry.respin) &&
Math.abs(new Date(d.at) - new Date(entry.at)) < 15000
);
if (nearDup) return prev;
@@ -69,7 +69,6 @@ export default function DropFeed() {
});
};
// Prefer socket history; HTTP is fallback if socket is slow
socket.emit('feed:subscribe');
socket.on('feed:history', onHistory);
socket.on('feed:drop', onDrop);
@@ -117,7 +116,11 @@ export default function DropFeed() {
visible.map((d) => {
const color = rarityColor(d.item?.rarity);
return (
<div key={d.id} className="drop-feed-row" style={{ borderLeftColor: color }}>
<div
key={d.id}
className={`drop-feed-row${d.respin ? ' is-respin' : ''}`}
style={{ borderLeftColor: color }}
>
<div className="drop-feed-item-visual" style={{ color }}>
{d.item?.imageUrl ? (
<img src={d.item.imageUrl} alt="" />
@@ -148,16 +151,18 @@ export default function DropFeed() {
{d.username}
</>
) : (
d.username
<>
{d.respin ? (
<span className="drop-feed-respin-label">Respin</span>
) : null}
{d.respin ? ' ' : null}
{d.username}
</>
)}
</span>
</div>
<div className="muted drop-feed-sub">
{d.source === 'jackpot'
? 'MultiPlayers Case · '
: d.caseName
? `${d.caseName} · `
: ''}
{d.caseName ? `${d.caseName} · ` : ''}
{formatCredits(d.item?.marketValue || 0)} cr
{d.openCount > 1
? d.source === 'jackpot'
+8 -10
View File
@@ -1,13 +1,13 @@
import { formatCredits, formatCreditsExact } from '../api';
import { formatCredits, formatCreditsExact, toCentsBigInt } from '../api';
import { rarityColor } from '../rarities';
/** Instance sell/bet price, else catalog FT reference */
/** Instance sell/bet price, else catalog FT reference (BigInt cents). */
export function itemValueCents(item) {
if (!item) return 0;
if (item.valueCents != null && Number.isFinite(Number(item.valueCents))) {
return Number(item.valueCents);
if (!item) return 0n;
if (item.valueCents != null && item.valueCents !== '') {
return toCentsBigInt(item.valueCents);
}
return Number(item.marketValue) || 0;
return toCentsBigInt(item.marketValue);
}
export function formatFloat(f) {
@@ -41,6 +41,7 @@ export default function ItemTile({
const titleParts = [];
if (staked) titleParts.push('Vault');
if (favorite) titleParts.push('Favorite');
if (item.rarity) titleParts.push(item.rarity);
if (floatTitle) titleParts.push(floatTitle);
else titleParts.push(item.name);
if (showValue) titleParts.push(`${formatCreditsExact(value)} cr`);
@@ -76,16 +77,13 @@ export default function ItemTile({
)}
</div>
<div className="item-name">{item.name}</div>
<div className="item-meta" style={{ color }}>
{item.rarity}
</div>
{showWear && wear ? (
<div className="item-wear" title={floatTitle}>
{wear}
</div>
) : null}
{showValue && (
<div className="item-meta" title={`${formatCreditsExact(value)} cr`}>
<div className="item-meta item-price" title={`${formatCreditsExact(value)} cr`}>
{formatCredits(value)} cr
</div>
)}
+74 -49
View File
@@ -5,7 +5,7 @@ import { api, formatCredits, formatCreditsExact } from '../api';
import DropFeed from './DropFeed';
import SessionLimitOverlay from './SessionLimitOverlay';
import { onInventoryChanged } from '../inventoryEvents';
import { getSocket, onSessionLimitChange } from '../socket';
import { getSocket, onSessionLimitChange, getSessionLimitPayload } from '../socket';
const FEED_KEY = 'caseorion-feed-drop';
@@ -17,27 +17,20 @@ function ScrollToTop() {
return null;
}
function countWaitingBattles(jackpotRound, duelOpen) {
function countWaitingBattles(jackpotBattles) {
let n = 0;
// MultiPlayers: only if someone has already bet and round still waits for more players
const jpPlayers = jackpotRound?.players?.length ?? 0;
const jpDeposits = jackpotRound?.deposits?.length ?? 0;
if (
jackpotRound?.waitingForPlayers &&
jackpotRound.status !== 'spinning' &&
(jpPlayers > 0 || jpDeposits > 0)
) {
n += 1;
}
// 1vX: each open room that already has at least one player with a bet
for (const room of duelOpen || []) {
if (room.status !== 'open') continue;
const hasBet =
(room.playerCount ?? room.players?.length ?? 0) > 0 ||
(room.deposits?.length ?? 0) > 0;
if (hasBet) n += 1;
for (const battle of jackpotBattles || []) {
if (battle.status === 'spinning') continue;
const jpPlayers = battle?.players?.length ?? 0;
const jpDeposits = battle?.deposits?.length ?? 0;
if (
battle?.waitingForPlayers &&
battle.status === 'open' &&
(jpPlayers > 0 || jpDeposits > 0)
) {
n += 1;
}
}
return n;
@@ -45,6 +38,7 @@ function countWaitingBattles(jackpotRound, duelOpen) {
export default function Layout() {
const { user } = useAuth();
const { pathname } = useLocation();
const [feedOpen, setFeedOpen] = useState(() => {
try {
return localStorage.getItem(FEED_KEY) === '1';
@@ -54,7 +48,12 @@ export default function Layout() {
});
const [waitingBattles, setWaitingBattles] = useState(0);
const [prestigeVisible, setPrestigeVisible] = useState(false);
const [sessionLimit, setSessionLimit] = useState(null);
const [sessionLimit, setSessionLimit] = useState(() => getSessionLimitPayload());
const [navHidden, setNavHidden] = useState(false);
useEffect(() => {
setNavHidden(false);
}, [pathname]);
useEffect(() => {
try {
@@ -64,6 +63,47 @@ export default function Layout() {
}
}, [feedOpen]);
// Mobile: hide topnav while scrolling down, reveal on scroll up / near top
useEffect(() => {
const mq = window.matchMedia('(max-width: 700px)');
let lastY = window.scrollY || 0;
let hidden = false;
const apply = (next) => {
if (hidden === next) return;
hidden = next;
setNavHidden(next);
};
const onScroll = () => {
if (!mq.matches) {
apply(false);
lastY = window.scrollY || 0;
return;
}
const y = window.scrollY || 0;
const delta = y - lastY;
lastY = y;
if (y < 48) {
apply(false);
return;
}
if (delta > 6) apply(true);
else if (delta < -6) apply(false);
};
const onMq = () => {
if (!mq.matches) apply(false);
};
window.addEventListener('scroll', onScroll, { passive: true });
mq.addEventListener?.('change', onMq);
return () => {
window.removeEventListener('scroll', onScroll);
mq.removeEventListener?.('change', onMq);
};
}, []);
useEffect(() => {
if (!user || user.role === 'admin') {
setPrestigeVisible(false);
@@ -97,24 +137,22 @@ export default function Layout() {
return undefined;
}
let jackpotRound = null;
let duelOpen = [];
let jackpotBattles = [];
let cancelled = false;
const sync = () => {
if (cancelled) return;
setWaitingBattles((prev) => {
const next = countWaitingBattles(jackpotRound, duelOpen);
const next = countWaitingBattles(jackpotBattles);
return prev === next ? prev : next;
});
};
(async () => {
try {
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
const jp = await api.jackpot();
if (cancelled) return;
jackpotRound = jp.round;
duelOpen = duels.open || [];
jackpotBattles = jp.battles || [];
sync();
} catch {
/* nav badge is optional */
@@ -125,35 +163,22 @@ export default function Layout() {
const offLimitChange = onSessionLimitChange((payload) => {
setSessionLimit(payload);
});
const onJackpot = (state) => {
jackpotRound = state;
sync();
};
const onDuels = (list) => {
duelOpen = list.open || [];
const onJackpots = (payload) => {
jackpotBattles = payload.battles || [];
sync();
};
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
socket.on('jackpot:state', onJackpot);
socket.on('jackpot:spin', onJackpot);
socket.on('jackpot:result', onJackpot);
socket.on('duels:list', onDuels);
socket.emit('jackpots:subscribe');
socket.on('jackpots:list', onJackpots);
socket.on('connect', () => {
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
socket.emit('jackpots:subscribe');
});
return () => {
cancelled = true;
offLimitChange();
socket.emit('jackpot:unsubscribe');
socket.emit('duels:unsubscribe');
socket.off('jackpot:state', onJackpot);
socket.off('jackpot:spin', onJackpot);
socket.off('jackpot:result', onJackpot);
socket.off('duels:list', onDuels);
socket.emit('jackpots:unsubscribe');
socket.off('jackpots:list', onJackpots);
};
}, [user?.id, user?.role]);
@@ -164,8 +189,8 @@ export default function Layout() {
className={`app-shell${feedOpen ? ' feed-open' : ''}${sessionLimit ? ' session-locked' : ''}`}
>
<ScrollToTop />
{sessionLimit && <SessionLimitOverlay max={sessionLimit.max || 10} />}
<header className="topnav">
{sessionLimit && <SessionLimitOverlay max={sessionLimit.max || 2} />}
<header className={`topnav${navHidden ? ' topnav--hidden' : ''}`}>
<div className="topnav-left">
<NavLink to="/dashboard" className="brand">
CaseOrion
+11 -5
View File
@@ -2,8 +2,14 @@ export default function ProfilePrestigeStats({ prestige }) {
if (!prestige || !prestige.count) return null;
const rows = [
typeof prestige.prBalance === 'number' && prestige.prBalance > 0
? { label: 'Pr', value: <strong>{prestige.prBalance}</strong> }
(() => {
try {
return BigInt(String(prestige.prBalance ?? 0)) > 0n;
} catch {
return false;
}
})()
? { label: 'Pr', value: <strong>{String(prestige.prBalance)}</strong> }
: null,
{ label: 'Fortune', value: <strong>+{prestige.chanceBonus}%</strong> },
{ label: 'Yield', value: <strong>+{prestige.gainBonus}%</strong> },
@@ -15,9 +21,9 @@ export default function ProfilePrestigeStats({ prestige }) {
label: 'Bulk',
value: <strong>+{prestige.openBonus} opens</strong>,
},
(prestige.caseDiscount || 0) > 0 && {
label: 'Discount',
value: <strong>{prestige.caseDiscount}%</strong>,
(prestige.prBonus || 0) > 0 && {
label: 'PR Farmer',
value: <strong>+{prestige.prBonus}%</strong>,
},
(prestige.qualityBonus || 0) > 0 && {
label: 'Quality',
@@ -18,7 +18,7 @@ const FAKE_ADS = [
},
];
export default function SessionLimitOverlay({ max = 10 }) {
export default function SessionLimitOverlay({ max = 2 }) {
const [ad] = useState(
() => FAKE_ADS[Math.floor(Math.random() * FAKE_ADS.length)]
);
+493 -16
View File
@@ -89,6 +89,20 @@ h3 {
position: sticky;
top: 0;
z-index: 20;
transition: transform 280ms ease;
}
@media (max-width: 700px) {
.topnav.topnav--hidden {
transform: translateY(-110%);
pointer-events: none;
}
}
@media (prefers-reduced-motion: reduce) {
.topnav {
transition: none;
}
}
.brand {
@@ -321,11 +335,23 @@ h3 {
.drop-feed {
width: 100%;
max-height: 220px;
max-height: none;
position: static;
border-right: none;
border-bottom: 1px solid var(--stroke);
}
.drop-feed-head {
display: none;
}
/* One drop row visible; scroll for the rest */
.drop-feed-list {
flex: none;
max-height: calc(1rem + 2 * 0.45rem + 5.75rem);
overflow-y: auto;
overscroll-behavior: contain;
}
}
.balance-pill {
@@ -384,9 +410,13 @@ a.balance-pill-link.active {
}
.btn.danger {
background: transparent;
border-color: rgba(235, 75, 75, 0.5);
color: var(--danger);
background: var(--danger);
border-color: transparent;
color: #1a0606;
}
.btn.danger:hover:not(:disabled) {
background: #f06565;
}
.btn.ghost {
@@ -668,7 +698,7 @@ a.balance-pill-link.active {
}
.item-wear {
color: var(--gold);
color: var(--accent);
font-size: 0.72rem;
font-family: var(--font-display);
letter-spacing: 0.02em;
@@ -693,6 +723,39 @@ a.balance-pill-link.active {
line-height: 1.2;
}
.case-hero-wrap {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 420ms ease;
}
.case-hero-wrap > .case-hero {
min-height: 0;
overflow: hidden;
opacity: 1;
transform: translateY(0);
transition:
opacity 320ms ease,
transform 420ms ease;
}
.case-hero-wrap--hidden {
grid-template-rows: 0fr;
pointer-events: none;
}
.case-hero-wrap--hidden > .case-hero {
opacity: 0;
transform: translateY(-1.25rem);
}
@media (prefers-reduced-motion: reduce) {
.case-hero-wrap,
.case-hero-wrap > .case-hero {
transition: none;
}
}
.case-hero {
display: flex;
flex-direction: column;
@@ -734,7 +797,8 @@ a.balance-pill-link.active {
transition: width 300ms ease;
}
.case-key-badge {
.case-price,
.item-price {
color: var(--gold);
font-family: var(--font-display);
}
@@ -768,6 +832,11 @@ a.balance-pill-link.active {
margin-bottom: 1rem;
}
.multi-reel-hint {
margin: 0;
font-size: 0.85rem;
}
.multi-reels.many {
gap: 1.25rem;
}
@@ -1703,6 +1772,15 @@ a.balance-pill-link.active {
border-top: 1px solid var(--stroke);
}
.admin-adjust-panel .admin-adjust-row:first-child {
margin-top: 0.75rem;
}
.admin-adjust-panel .admin-adjust-row + .admin-adjust-row {
margin-top: 0.35rem;
padding-top: 0.55rem;
}
.admin-adjust-row label {
display: flex;
flex-direction: column;
@@ -1719,6 +1797,35 @@ a.balance-pill-link.active {
min-width: 8rem;
}
.admin-adjust-input-wrap {
display: flex;
align-items: center;
gap: 0.55rem;
}
.admin-adjust-preview {
font-size: 0.9rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
min-width: 4.5rem;
}
.admin-adjust-preview.pos {
color: var(--accent);
}
.admin-adjust-preview.neg {
color: var(--danger);
}
.admin-adjust-result {
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
align-self: end;
padding-bottom: 0.55rem;
white-space: nowrap;
}
.admin-card-list {
display: flex;
flex-direction: column;
@@ -1843,18 +1950,24 @@ a.balance-pill-link.active {
.item-vault-badge {
position: absolute;
top: 0.35rem;
left: 0.35rem;
z-index: 1;
width: 1.15rem;
height: 1.15rem;
top: 0.3rem;
left: 0.3rem;
z-index: 2;
min-width: 1.35rem;
height: 1.35rem;
padding: 0 0.2rem;
display: grid;
place-items: center;
border-radius: 0.2rem;
background: rgba(80, 160, 220, 0.35);
color: #9fd4ff;
font-size: 0.7rem;
font-weight: 700;
border-radius: 0.25rem;
background: #0a121c;
color: #e8f6ff;
border: 1.5px solid #7ec8ff;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.85),
0 2px 6px rgba(0, 0, 0, 0.75);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.02em;
line-height: 1;
pointer-events: none;
}
@@ -1869,6 +1982,24 @@ a.balance-pill-link.active {
.vault-panel .section-head {
margin-bottom: 0.75rem;
align-items: flex-start;
}
.vault-timer {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.15rem;
min-width: 7.5rem;
font-family: var(--font-display);
font-size: 0.95rem;
line-height: 1.2;
}
.vault-timer strong {
font-size: 1.35rem;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.vault-stats {
@@ -1881,8 +2012,60 @@ a.balance-pill-link.active {
.vault-grid {
margin-bottom: 0.5rem;
grid-template-columns: repeat(auto-fill, minmax(96px, 112px));
gap: 0.55rem;
justify-content: start;
}
.vault-grid .item-tile {
padding: 0.45rem;
min-height: 0;
gap: 0.2rem;
}
.vault-grid .item-visual {
min-height: 56px;
font-size: 1.1rem;
}
.vault-grid .item-visual.has-img {
min-height: 56px;
}
.vault-grid .item-visual img {
max-height: 52px;
width: auto;
max-width: 100%;
object-fit: contain;
}
.vault-grid .item-name {
font-size: 0.78rem;
line-height: 1.15;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.vault-grid .item-meta,
.vault-grid .item-wear {
font-size: 0.7rem;
line-height: 1.15;
}
.vault-grid .item-vault-badge {
min-width: 1.15rem;
height: 1.15rem;
font-size: 0.68rem;
}
.vault-grid .item-favorite-badge {
font-size: 0.65rem;
padding: 0.1rem 0.28rem;
}
.item-tile.disabled {
opacity: 0.4;
pointer-events: none;
@@ -1909,6 +2092,10 @@ a.balance-pill-link.active {
min-height: 220px;
}
.mode-card-wide {
grid-column: 1 / -1;
}
.mode-card-top {
display: flex;
align-items: flex-start;
@@ -2148,6 +2335,32 @@ a.mode-card:hover {
align-items: center;
}
.row-actions .btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.btn-ico {
color: var(--gold);
font-size: 0.95em;
line-height: 1;
}
.btn-ico-vault {
display: inline-grid;
place-items: center;
min-width: 1.1em;
height: 1.1em;
padding: 0 0.15em;
border-radius: 0.2rem;
background: #0a121c;
color: #e8f6ff;
border: 1px solid #7ec8ff;
font-size: 0.72em;
font-weight: 800;
}
.room-list {
display: flex;
flex-direction: column;
@@ -2658,6 +2871,238 @@ a.achievement-badge:hover .achievement-badge-name {
padding: 0.35rem 0.7rem;
}
.shop-page {
display: flex;
flex-direction: column;
gap: 2rem;
max-width: 1100px;
}
.shop-hero {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(220px, 0.7fr);
gap: 1.5rem 2rem;
align-items: end;
padding: 0.25rem 0 0.5rem;
border-bottom: 1px solid var(--stroke);
}
.shop-kicker {
margin: 0 0 0.35rem;
font-family: var(--font-display);
font-size: 0.8rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-dim);
}
.shop-hero h1 {
margin: 0 0 0.5rem;
font-size: clamp(1.75rem, 3vw, 2.4rem);
}
.shop-lede {
margin: 0;
max-width: 38rem;
color: var(--muted);
line-height: 1.45;
}
.shop-lede strong {
color: var(--accent);
font-weight: 600;
}
.shop-wallet {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 0.85rem 1rem;
background:
linear-gradient(145deg, rgba(61, 214, 198, 0.08), transparent 55%),
var(--bg-panel);
border: 1px solid var(--stroke);
}
.shop-wallet-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.shop-wallet-osu {
font-size: 1.05rem;
}
.shop-mock-note {
margin: 0.25rem 0 0;
font-size: 0.78rem;
line-height: 1.3;
}
.shop-section {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.shop-section-head h2 {
margin: 0 0 0.2rem;
}
.shop-section-head .muted {
margin: 0;
}
.shop-osu-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.85rem;
}
.shop-osu-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 1rem 0.9rem 0.9rem;
background: linear-gradient(180deg, var(--bg-elevated), var(--bg-panel));
border: 1px solid var(--stroke);
min-height: 100%;
}
.shop-osu-card.popular {
border-color: rgba(61, 214, 198, 0.45);
}
.shop-osu-card.highlight {
border-color: rgba(228, 174, 57, 0.55);
background:
linear-gradient(160deg, rgba(228, 174, 57, 0.1), transparent 45%),
linear-gradient(180deg, var(--bg-elevated), var(--bg-panel));
}
.shop-badge {
position: absolute;
top: 0.55rem;
right: 0.55rem;
font-family: var(--font-display);
font-size: 0.68rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--bg-deep);
background: var(--accent);
padding: 0.15rem 0.4rem;
}
.shop-osu-card.highlight .shop-badge {
background: var(--gold);
}
.shop-badge.subtle {
background: transparent;
color: var(--muted);
border: 1px solid var(--stroke);
position: static;
align-self: flex-start;
margin-bottom: 0.15rem;
}
.shop-osu-card h3,
.shop-credit-card h3 {
margin: 0;
font-family: var(--font-display);
font-size: 1rem;
}
.shop-osu-amount {
margin: 0.15rem 0;
font-family: var(--font-display);
font-size: 1.65rem;
color: var(--accent);
line-height: 1.1;
}
.shop-osu-amount span {
font-size: 0.85rem;
color: var(--muted);
}
.shop-osu-blurb,
.shop-osu-rate {
margin: 0;
font-size: 0.82rem;
line-height: 1.3;
flex: 1;
}
.shop-buy-btn {
width: 100%;
margin-top: 0.5rem;
}
.shop-credit-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.75rem;
}
.shop-credit-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.85rem;
background: var(--bg-panel);
border: 1px solid var(--stroke);
}
.shop-credit-card .pack-credits {
margin: 0.1rem 0;
font-size: 1.15rem;
}
.shop-credit-cost {
margin: 0.15rem 0 0.35rem;
}
.shop-credit-scale {
margin: 0;
font-size: 0.78rem;
}
.shop-credit-card .btn {
width: 100%;
margin-top: auto;
}
.shop-ad-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
border: 1px dashed var(--stroke);
background: rgba(61, 214, 198, 0.04);
}
.shop-ad-panel h2 {
margin: 0 0 0.25rem;
}
.shop-ad-panel .muted {
margin: 0;
}
@media (max-width: 720px) {
.shop-hero {
grid-template-columns: 1fr;
align-items: start;
}
}
.inv-toolbar {
display: flex;
flex-wrap: wrap;
@@ -2667,6 +3112,17 @@ a.achievement-badge:hover .achievement-badge-name {
margin-bottom: 1rem;
}
.inv-section-head {
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.75rem 1rem;
}
.inv-section-head h1 {
margin: 0;
}
.inv-toolbar-left {
display: flex;
flex-wrap: wrap;
@@ -2786,6 +3242,13 @@ a.achievement-badge:hover .achievement-badge-name {
margin: 0.35rem 0;
}
.pack-credits {
font-family: var(--font-display);
font-size: 1.4rem;
color: var(--gold);
margin: 0.35rem 0;
}
.ad-panel {
margin-top: 0.5rem;
}
@@ -3289,6 +3752,20 @@ a.achievement-badge:hover .achievement-badge-name {
font-family: var(--font-display);
}
.prestige-skill-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
justify-content: center;
}
.prestige-skill-actions .btn {
flex: 1 1 auto;
min-width: 0;
padding: 0.45rem 0.55rem;
font-size: 0.8rem;
}
.prestige-history {
margin-top: 1.5rem;
padding-top: 1rem;
+4
View File
@@ -8,6 +8,8 @@ import AdminItems from '../admin/AdminItems';
import AdminCategories from '../admin/AdminCategories';
import AdminDrops from '../admin/AdminDrops';
import AdminPlayers from '../admin/AdminPlayers';
import AdminPrestigeServer from '../admin/AdminPrestigeServer';
import AdminShop from '../admin/AdminShop';
function AdminLogin() {
const { adminLogin } = useAuth();
@@ -74,6 +76,8 @@ export default function Admin() {
<Route path="items" element={<AdminItems />} />
<Route path="categories" element={<AdminCategories />} />
<Route path="drops" element={<AdminDrops />} />
<Route path="prestige-server" element={<AdminPrestigeServer />} />
<Route path="shop" element={<AdminShop />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
</Routes>
+47 -204
View File
@@ -1,43 +1,14 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { api } from '../api';
import { useAuth } from '../AuthContext';
import CaseReel from '../components/CaseReel';
import { useCountdown } from '../hooks/useCountdown';
import { rarityColor } from '../rarities';
import BattleLiveCard from '../components/BattleLiveCard';
import { getSocket } from '../socket';
const TOP_ITEMS = 5;
function waitingDuelRooms(open) {
return (open || []).filter(
(r) =>
r.status === 'open' &&
((r.playerCount ?? r.players?.length ?? 0) > 0 || (r.deposits?.length ?? 0) > 0)
);
}
function depositsToReelItems(deposits) {
return (deposits || [])
.filter((d) => d.item)
.map((d) => ({
...d.item,
id: d.id,
ownerUsername: d.username,
ownerAvatarUrl: d.avatarUrl || '',
}));
}
export default function BattleHub() {
const { user, loading } = useAuth();
const [round, setRound] = useState(null);
const [duelOpen, setDuelOpen] = useState([]);
const [spinning, setSpinning] = useState(false);
const [spinActive, setSpinActive] = useState(false);
const [spinDeposits, setSpinDeposits] = useState([]);
const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
const [lastWinner, setLastWinner] = useState(null);
const [revealWinner, setRevealWinner] = useState(false);
const [battles, setBattles] = useState([]);
const [spinById, setSpinById] = useState({});
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
@@ -45,103 +16,53 @@ export default function BattleHub() {
(async () => {
try {
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
if (cancelled) return;
setRound(jp.round);
setDuelOpen(duels.open || []);
if (jp.round?.status === 'spinning') {
setSpinDeposits(jp.round.deposits || []);
setSpinActive(true);
setSpinning(true);
setReelWinnerItemId(jp.round.reelWinnerItemId || null);
setLastWinner(jp.round.winnerUsername || null);
}
const data = await api.jackpot();
if (!cancelled) setBattles(data.battles || []);
} catch {
/* hub still usable without live preview */
/* hub still usable */
}
})();
const socket = getSocket();
const onState = (state) => setRound(state);
const onSpin = (state) => {
setRound(state);
setSpinDeposits(state.deposits || []);
setSpinActive(true);
setSpinning(true);
setRevealWinner(false);
setReelWinnerItemId(state.reelWinnerItemId);
setLastWinner(state.winnerUsername);
const onList = (payload) => {
setBattles(payload.battles || []);
};
const onSpin = (state) => {
if (!state?.id) return;
setSpinById((prev) => ({ ...prev, [state.id]: state }));
setBattles((prev) => {
const idx = prev.findIndex((b) => b.id === state.id);
if (idx < 0) return [...prev, state];
const next = [...prev];
next[idx] = state;
return next;
});
};
const onResult = (state) => {
const id = state?.previousRoundId || state?.id;
if (!id) return;
setSpinById((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
};
const onResult = (state) => setRound(state);
const onDuels = (list) => setDuelOpen(list.open || []);
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
socket.on('jackpot:state', onState);
socket.emit('jackpots:subscribe');
socket.on('jackpots:list', onList);
socket.on('jackpot:spin', onSpin);
socket.on('jackpot:result', onResult);
socket.on('duels:list', onDuels);
socket.on('connect', () => {
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
});
socket.on('connect', () => socket.emit('jackpots:subscribe'));
return () => {
cancelled = true;
socket.emit('jackpot:unsubscribe');
socket.emit('duels:unsubscribe');
socket.off('jackpot:state', onState);
socket.emit('jackpots:unsubscribe');
socket.off('jackpots:list', onList);
socket.off('jackpot:spin', onSpin);
socket.off('jackpot:result', onResult);
socket.off('duels:list', onDuels);
};
}, [user]);
useEffect(() => {
if (!revealWinner) return undefined;
const t = setTimeout(() => {
setSpinActive(false);
setRevealWinner(false);
}, 4000);
return () => clearTimeout(t);
}, [revealWinner]);
const topItems = useMemo(() => {
const deposits = [...(round?.deposits || [])].filter((d) => d.item);
deposits.sort((a, b) => (b.valueCents || 0) - (a.valueCents || 0));
return deposits.slice(0, TOP_ITEMS);
}, [round]);
const potItems = useMemo(
() => depositsToReelItems(spinActive && spinDeposits.length ? spinDeposits : round?.deposits),
[round, spinDeposits, spinActive]
);
const showReel = spinActive && potItems.length > 0;
const waitingRooms = useMemo(() => waitingDuelRooms(duelOpen), [duelOpen]);
const waitingRoomCount = waitingRooms.length;
const liveTimerEndsAt =
round?.status === 'open' && round?.endsAt && !round?.waitingForPlayers && !spinning
? round.endsAt
: null;
const { label: liveTimerLabel } = useCountdown(liveTimerEndsAt);
const playerCount = round?.players?.length ?? 0;
const potValue = round?.totalValue ?? 0;
const itemCount = round?.deposits?.length ?? 0;
const statusLabel = !round
? '…'
: round.status === 'spinning' || spinning
? 'Spinning'
: round.waitingForPlayers
? 'Waiting for players'
: round.status === 'open'
? liveTimerEndsAt
? `Live ${liveTimerLabel}`
: 'Live'
: round.status;
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
@@ -151,106 +72,28 @@ export default function BattleHub() {
<div className="section-head">
<div>
<h1>Battle</h1>
<p className="muted">Multiplayer item gambling modes.</p>
</div>
</div>
<div className="mode-grid">
<Link to="/battle/multi" className="mode-card mode-card-live">
<Link to="/battle/create" className="mode-card mode-card-wide">
<div className="mode-card-top">
<h2>MultiPlayers Case</h2>
<span className={`mode-status${round?.status === 'spinning' || spinning ? ' spinning' : ''}`}>
{statusLabel}
</span>
<h2>Create a Battle</h2>
</div>
<p className="muted mode-blurb">
Join the live pot. Bet up to {round?.maxItemsPerPlayer || 3} items chance scales with
value.
Bet items to open a new pot. Others can join chance scales with value. Timer starts at
2 players.
</p>
<div className="mode-pot-stats">
<div>
<span className="muted">Pot</span>
<strong>{formatCredits(potValue)} cr</strong>
</div>
<div>
<span className="muted">Players</span>
<strong>{playerCount}</strong>
</div>
<div>
<span className="muted">Items</span>
<strong>{itemCount}</strong>
</div>
</div>
{showReel ? (
<div className="mode-card-reel">
<CaseReel
items={potItems}
winnerId={reelWinnerItemId || potItems[0]?.id}
spinning={spinning || round?.status === 'spinning'}
showOwners
compact
onDone={() => {
setSpinning(false);
setRevealWinner(true);
}}
/>
{revealWinner && lastWinner && (
<p className="mode-card-winner">
Winner: <strong>{lastWinner}</strong>
</p>
)}
</div>
) : topItems.length > 0 ? (
<div className="mode-pot-items" aria-label="Top items in pot">
{topItems.map((d) => (
<div
key={d.id}
className="mode-pot-thumb"
style={{ borderColor: rarityColor(d.item.rarity) }}
title={`${d.item.name} · ${formatCredits(d.valueCents)} cr`}
>
{d.item.imageUrl ? (
<img src={d.item.imageUrl} alt="" loading="lazy" />
) : (
<span>{d.item.name.slice(0, 2).toUpperCase()}</span>
)}
</div>
))}
{itemCount > topItems.length && (
<span className="mode-pot-more muted">+{itemCount - topItems.length}</span>
)}
</div>
) : (
<p className="muted mode-pot-empty">No items in the pot yet be the first.</p>
)}
</Link>
<Link to="/battle/1vx" className="mode-card">
<div className="mode-card-top">
<h2>1vX Case</h2>
{waitingRoomCount > 0 && (
<span className="mode-status">
{waitingRoomCount} waiting
</span>
)}
</div>
<p className="muted mode-blurb">
Create or join custom rooms. Set player/item limits and min value. Spectate live duels.
</p>
<div className="mode-pot-stats">
<div>
<span className="muted">Open rooms</span>
<strong>{waitingRoomCount}</strong>
</div>
</div>
</Link>
{battles.map((b) => (
<BattleLiveCard key={b.id} battle={b} spinEvent={spinById[b.id]} />
))}
<div className="mode-card stub">
<h2>Coinflip</h2>
<p className="muted">Classic 1v1 item flip. Coming soon.</p>
<span className="badge">Soon</span>
</div>
{battles.length === 0 && (
<p className="muted mode-pot-empty" style={{ gridColumn: '1 / -1' }}>
No open battles create one to get started.
</p>
)}
</div>
</section>
);
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits, formatChance } from '../api';
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom';
import { api, formatCredits, formatChance, compareCents } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile, { itemValueCents } from '../components/ItemTile';
import CaseReel from '../components/CaseReel';
@@ -8,7 +8,13 @@ import { useCountdown } from '../hooks/useCountdown';
import { onInventoryChanged } from '../inventoryEvents';
import { getSocket } from '../socket';
export default function MultiPlayersPage() {
/**
* Create mode (`/battle/create`) or join/spectate (`/battle/:id`).
*/
export default function BattleRoomPage({ create = false }) {
const { id: paramId } = useParams();
const battleId = create ? null : Number(paramId);
const navigate = useNavigate();
const { user, loading, refresh } = useAuth();
const [round, setRound] = useState(null);
const [inventory, setInventory] = useState([]);
@@ -37,8 +43,8 @@ export default function MultiPlayersPage() {
() =>
[...inventory].sort(
(a, b) =>
itemValueCents(b.item) - itemValueCents(a.item) ||
(b.valueCents ?? 0) - (a.valueCents ?? 0)
compareCents(itemValueCents(b.item), itemValueCents(a.item)) ||
compareCents(b.valueCents, a.valueCents)
),
[inventory]
);
@@ -46,23 +52,56 @@ export default function MultiPlayersPage() {
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
const [jp] = await Promise.all([api.jackpot(), loadInventory()]);
if (!cancelled) setRound(jp.round);
await loadInventory();
if (create) {
if (!cancelled) setRound(null);
return;
}
if (!battleId) {
if (!cancelled) setError('Invalid battle');
return;
}
const data = await api.jackpotRound(battleId);
if (cancelled) return;
setRound(data.round);
if (data.round?.status === 'spinning') {
setSpinDeposits(data.round.deposits || []);
setSpinActive(true);
setSpinning(true);
setLastWinner(data.round.winnerUsername || null);
}
if (data.round?.status === 'resolved' || data.round?.status === 'cancelled') {
setLastWinner(data.round.winnerUsername || null);
if (data.round.status === 'resolved') setRevealWinner(true);
}
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
if (create || !battleId) {
return () => {
cancelled = true;
};
}
const socket = getSocket();
const onState = (state) => {
if (state?.id && state.id !== battleId) return;
setRound(state);
if (state?.status === 'cancelled') {
navigate('/battle', { replace: true });
return;
}
if (state?.status === 'open') {
loadInventory().catch(() => {});
}
};
const onSpin = (state) => {
if (state?.id && state.id !== battleId) return;
setRound(state);
setSpinDeposits(state.deposits || []);
setSpinActive(true);
@@ -72,25 +111,30 @@ export default function MultiPlayersPage() {
setLastWinner(state.winnerUsername);
};
const onResult = (state) => {
if (state?.previousRoundId && state.previousRoundId !== battleId) return;
if (state?.id && state.id !== battleId && !state.previousRoundId) return;
setRound(state);
if (state.winnerUsername) setLastWinner(state.winnerUsername);
setRevealWinner(true);
setSpinning(false);
loadInventory().catch(() => {});
refresh().catch(() => {});
};
socket.emit('jackpot:subscribe');
socket.emit('jackpot:subscribe', battleId);
socket.on('jackpot:state', onState);
socket.on('jackpot:spin', onSpin);
socket.on('jackpot:result', onResult);
socket.on('connect', () => socket.emit('jackpot:subscribe'));
socket.on('connect', () => socket.emit('jackpot:subscribe', battleId));
return () => {
cancelled = true;
socket.emit('jackpot:unsubscribe');
socket.emit('jackpot:unsubscribe', battleId);
socket.off('jackpot:state', onState);
socket.off('jackpot:spin', onSpin);
socket.off('jackpot:result', onResult);
};
}, [user, loadInventory, refresh]);
}, [user, create, battleId, loadInventory, refresh, navigate]);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
@@ -106,6 +150,8 @@ export default function MultiPlayersPage() {
[round, user]
);
const mySlotsLeft = Math.max(0, maxItems - myDeposits.length);
const canBet =
create || round?.status === 'open' || (!round && create);
const potItems = useMemo(() => {
const source = spinActive && spinDeposits.length ? spinDeposits : round?.deposits || [];
@@ -140,7 +186,14 @@ export default function MultiPlayersPage() {
setBusy(true);
setError('');
try {
const data = await api.jackpotBet(selected);
if (create && !round) {
const data = await api.jackpotCreateBet(selected);
setSelected([]);
await loadInventory();
navigate(`/battle/${data.round.id}`, { replace: true });
return;
}
const data = await api.jackpotBet(round.id, selected);
setRound(data.round);
setSelected([]);
await loadInventory();
@@ -152,10 +205,15 @@ export default function MultiPlayersPage() {
};
const withdraw = async (inventoryItemIds) => {
if (!round?.id) return;
setBusy(true);
setError('');
try {
const data = await api.jackpotWithdraw(inventoryItemIds);
const data = await api.jackpotWithdraw(round.id, inventoryItemIds);
if (data.round?.status === 'cancelled') {
navigate('/battle', { replace: true });
return;
}
setRound(data.round);
await loadInventory();
} catch (err) {
@@ -169,15 +227,22 @@ export default function MultiPlayersPage() {
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
const title = create && !round ? 'Create a Battle' : round?.name || `Battle ${battleId || ''}`;
const isOpen = create || round?.status === 'open';
return (
<div className="bet-page">
<div className="section-head">
<div>
<h1>MultiPlayers Case</h1>
<p className="muted">Live shared pot win chance = your value / total pot.</p>
<h1>{title}</h1>
<p className="muted">
{create && !round
? 'Bet items to open a new battle. Chance = your value / total pot.'
: 'Win chance = your value / total pot.'}
</p>
</div>
<Link to="/battle" className="btn secondary">
Modes
Back
</Link>
</div>
@@ -185,25 +250,27 @@ export default function MultiPlayersPage() {
<div className="jackpot-status">
<div className="timer-block">
<span className="muted">Round</span>
<span className="muted">Battle</span>
<strong>#{round?.id || '—'}</strong>
<span className={`status-pill ${round?.status || ''}`}>{round?.status || '…'}</span>
<span className={`status-pill ${round?.status || ''}`}>
{round?.status || (create ? 'new' : '…')}
</span>
</div>
<div className="timer-block">
<span className="muted">Timer</span>
<strong className="timer-digits">
{round?.status === 'spinning' || spinning
? 'SPIN'
: round?.waitingForPlayers
: round?.waitingForPlayers || (create && !round)
? 'WAIT'
: round?.status === 'open' && round?.endsAt
? timerLabel
: '—'}
</strong>
{round?.waitingForPlayers && round?.status === 'open' && (
{((round?.waitingForPlayers && round?.status === 'open') || (create && !round)) && (
<span className="muted">
Need {round.minPlayers || 2} players ({round.players?.length || 0}/
{round.minPlayers || 2})
Need {round?.minPlayers || 2} players ({round?.players?.length || 0}/
{round?.minPlayers || 2})
</span>
)}
{round?.status === 'open' &&
@@ -289,7 +356,9 @@ export default function MultiPlayersPage() {
{myDeposits.length > 0 && round?.status === 'open' && (
<section className="section">
<div className="section-head">
<h2>Your bets ({myDeposits.length}/{maxItems})</h2>
<h2>
Your bets ({myDeposits.length}/{maxItems})
</h2>
<button
type="button"
className="btn danger"
@@ -302,11 +371,11 @@ export default function MultiPlayersPage() {
</section>
)}
{round?.status === 'open' && (
{isOpen && canBet && (
<section className="section">
<div className="section-head">
<div>
<h2>Bet from inventory</h2>
<h2>{create && !round ? 'Bet to create' : 'Bet from inventory'}</h2>
<p className="muted">
Select up to {mySlotsLeft} more item{mySlotsLeft === 1 ? '' : 's'}.
</p>
@@ -317,7 +386,7 @@ export default function MultiPlayersPage() {
disabled={busy || !selected.length || mySlotsLeft === 0}
onClick={placeBet}
>
Place bet
{create && !round ? 'Create battle' : 'Place bet'}
</button>
</div>
{inventoryByValue.length === 0 ? (
@@ -331,8 +400,12 @@ export default function MultiPlayersPage() {
key={inv.id}
item={inv.item}
favorite={inv.favorite}
staked={inv.staked}
selected={selected.includes(inv.id)}
disabled={mySlotsLeft === 0 && !selected.includes(inv.id)}
disabled={
inv.staked ||
(mySlotsLeft === 0 && !selected.includes(inv.id))
}
onClick={() => toggleSelect(inv.id)}
/>
))}
+415 -167
View File
@@ -1,14 +1,16 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { Navigate, useParams } from 'react-router-dom';
import { api, formatCredits, compareCents, toCentsBigInt } from '../api';
import { useAuth } from '../AuthContext';
import CaseReel from '../components/CaseReel';
import ItemTile from '../components/ItemTile';
import ItemTile, { itemValueCents } from '../components/ItemTile';
import { notifyInventoryChanged } from '../inventoryEvents';
import { rarityColor } from '../rarities';
import { loadCaseOpenQty, resolveOpenQty, saveCaseOpenQty } from '../userPrefs';
const DEFAULT_MAX_OPENS = 10;
/** Cap simultaneous reel animations to avoid lag on large multi-opens. */
const MAX_VISIBLE_REELS = 5;
/** 15, then steps of 5 (10, 15…), plus max if not already listed. */
function buildOpenQuantityOptions(maxOpens) {
@@ -20,26 +22,69 @@ function buildOpenQuantityOptions(maxOpens) {
return options;
}
function dropValue(item) {
return itemValueCents(item);
}
/** Best drops first (by instance value), capped for reel display. */
function pickShowcaseDrops(items, limit = MAX_VISIBLE_REELS) {
return [...items]
.sort((a, b) => compareCents(dropValue(b), dropValue(a)))
.slice(0, Math.max(1, limit));
}
/** Best drop by valueCents (BigInt-safe). */
function pickBestDrop(items) {
if (!items?.length) return null;
let best = items[0];
let bestVal = dropValue(best);
for (let i = 1; i < items.length; i += 1) {
const it = items[i];
const v = dropValue(it);
if (v > bestVal) {
best = it;
bestVal = v;
}
}
return best;
}
export default function CasePage() {
const { id } = useParams();
const { user, loading, updateBalance } = useAuth();
const [crate, setCrate] = useState(null);
const [error, setError] = useState('');
const [keyMessage, setKeyMessage] = useState('');
const [catalogMessage, setCatalogMessage] = useState('');
const [openBonusMessage, setOpenBonusMessage] = useState('');
const [animReduction, setAnimReduction] = useState(0);
const [quantity, setQuantity] = useState(1);
const [opening, setOpening] = useState(false);
const [spins, setSpins] = useState([]);
const [allDrops, setAllDrops] = useState([]);
const [caseName, setCaseName] = useState('');
/** True while paused on discarded winners before the respin reels restart. */
const [respinRestarting, setRespinRestarting] = useState(false);
const announcedRef = useRef(false);
const preAnnouncedRef = useRef(false);
const finishingRef = useRef(false);
const pendingAnnounceRef = useRef({ ids: [], caseName: '' });
const pendingAnnounceRef = useRef({ ids: [], caseName: '', respin: false });
const pendingAutoSellRef = useRef([]);
/** Auto-sell ids from the discarded first roll — applied when that reel finishes. */
const pendingPreRespinAutoSellRef = useRef([]);
/** When set, first reel pass is a discarded roll; then we restart with these final items. */
const pendingRespinFinalRef = useRef(null);
/** Snapshot of first-roll drops for feed (best item announced when first reels finish). */
const pendingPreRespinAnnounceRef = useRef(null);
/**
* Respin animation phase (kept out of setState updaters — must be Strict-Mode safe):
* null | 'pre' | 'restarting' | 'final'
*/
const respinPhaseRef = useRef(null);
const quantityRef = useRef(1);
const caseIdRef = useRef(null);
const prefReadyRef = useRef(false);
/** Tracks case maxOpens so we can bump qty when a key unlocks. */
const prevMaxOpensRef = useRef(null);
const keyProgress = crate?.keyProgress ?? null;
const maxOpens = keyProgress?.maxOpens ?? DEFAULT_MAX_OPENS;
@@ -61,10 +106,15 @@ export default function CasePage() {
const announcePendingDrop = async (useKeepalive = false) => {
const ids = pendingAnnounceRef.current.ids.filter(Boolean);
const announceCaseName = pendingAnnounceRef.current.caseName || caseName || crate?.name || '';
const respin = Boolean(pendingAnnounceRef.current.respin);
if (!ids.length || announcedRef.current) return;
announcedRef.current = true;
const body = JSON.stringify({ inventoryItemIds: ids, caseName: announceCaseName });
const body = JSON.stringify({
inventoryItemIds: ids,
caseName: announceCaseName,
respin,
});
if (useKeepalive) {
fetch('/api/feed/announce', {
@@ -80,15 +130,51 @@ export default function CasePage() {
}
try {
await api.feedAnnounce(ids, announceCaseName);
await api.feedAnnounce(ids, announceCaseName, { respin });
} catch {
announcedRef.current = false;
}
};
const announcePreRespinDrop = async () => {
const pending = pendingPreRespinAnnounceRef.current;
if (!pending?.items?.length || preAnnouncedRef.current) return;
preAnnouncedRef.current = true;
pendingPreRespinAnnounceRef.current = null;
const best = pickBestDrop(pending.items);
if (!best) {
preAnnouncedRef.current = false;
return;
}
const count = pending.items.filter((it) => it.id === best.id).length;
try {
await api.feedAnnounce([], pending.caseName || caseName || crate?.name || '', {
snapshotBest: {
id: best.id,
name: best.name,
imageUrl: best.imageUrl,
rarity: best.rarity,
valueCents: best.valueCents ?? best.marketValue,
wear: best.wear,
floatValue: best.floatValue,
paintSeed: best.paintSeed,
},
openCount: pending.items.length,
count,
respin: false,
});
} catch {
preAnnouncedRef.current = false;
}
};
const applyPendingAutoSellKeepalive = () => {
const ids = pendingAutoSellRef.current.filter(Boolean);
const ids = [
...pendingPreRespinAutoSellRef.current,
...pendingAutoSellRef.current,
].filter(Boolean);
if (!ids.length) return;
pendingPreRespinAutoSellRef.current = [];
pendingAutoSellRef.current = [];
fetch('/api/inventory/auto-sell/apply', {
method: 'POST',
@@ -99,42 +185,79 @@ export default function CasePage() {
}).catch(() => {});
};
const finalizeOpens = async (completedSpins, announceCaseName) => {
const markSpinsAutoSold = (soldIds, soldDetails = []) => {
const soldSet = new Set(soldIds);
const payoutById = new Map(
(soldDetails || []).map((s) => [s.id, s.payoutCents ?? s.valueCents])
);
setSpins((curr) =>
curr.map((s) =>
soldSet.has(s.item.inventoryId)
? {
...s,
item: {
...s.item,
autoSold: true,
soldForCents:
payoutById.get(s.item.inventoryId) ?? s.item.valueCents,
},
}
: s
)
);
setAllDrops((curr) =>
curr.map((item) =>
soldSet.has(item.inventoryId)
? {
...item,
autoSold: true,
soldForCents: payoutById.get(item.inventoryId) ?? item.valueCents,
}
: item
)
);
};
/** Credit auto-sell for the discarded first roll once its reels finish. */
const applyPreRespinAutoSell = async () => {
const toSell = [...pendingPreRespinAutoSellRef.current];
pendingPreRespinAutoSellRef.current = [];
if (!toSell.length) return;
try {
const data = await api.applyPendingAutoSell(toSell);
updateBalance(data.user.balance);
notifyInventoryChanged();
if (data.soldIds?.length) markSpinsAutoSold(data.soldIds, data.sold);
} catch (err) {
// Keep ids so unload keepalive can still flush if the request failed mid-respin.
pendingPreRespinAutoSellRef.current = toSell;
setError(err.message);
}
};
const finalizeOpens = async (announceCaseName) => {
if (finishingRef.current) return;
finishingRef.current = true;
const ids = completedSpins.map((s) => s.item?.inventoryId).filter(Boolean);
pendingAnnounceRef.current = { ids, caseName: announceCaseName };
// Keep full-batch ids already stored in pending refs (not only showcase reels)
if (announceCaseName) {
pendingAnnounceRef.current = {
...pendingAnnounceRef.current,
caseName: announceCaseName,
};
}
try {
await announcePendingDrop(false);
const toSell = completedSpins
.filter((s) => s.item?.willAutoSell)
.map((s) => s.item.inventoryId)
.filter(Boolean);
const toSell = [...pendingAutoSellRef.current];
pendingAutoSellRef.current = [];
if (toSell.length) {
const data = await api.applyPendingAutoSell(toSell);
updateBalance(data.user.balance);
notifyInventoryChanged();
const soldSet = new Set(data.soldIds);
setSpins((curr) =>
curr.map((s) =>
soldSet.has(s.item.inventoryId)
? {
...s,
item: {
...s.item,
autoSold: true,
soldForCents: s.item.valueCents,
},
}
: s
)
);
if (data.soldIds?.length) markSpinsAutoSold(data.soldIds, data.sold);
}
} catch (err) {
setError(err.message);
@@ -147,6 +270,7 @@ export default function CasePage() {
useEffect(() => {
let cancelled = false;
prefReadyRef.current = false;
prevMaxOpensRef.current = null;
caseIdRef.current = null;
setCrate(null);
(async () => {
@@ -167,9 +291,16 @@ export default function CasePage() {
const maxAffordable = useMemo(() => {
if (!crate || !user) return 1;
if (crate.price <= 0) return maxOpens;
const affordable = Math.floor(user.balance / crate.price);
return Math.min(maxOpens, Math.max(affordable, 0));
try {
const price = BigInt(String(crate.price ?? 0).split('.')[0] || '0');
if (price <= 0n) return maxOpens;
const bal = BigInt(String(user.balance ?? 0).split('.')[0] || '0');
const affordable = bal / price;
const n = affordable > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(affordable);
return Math.min(maxOpens, Math.max(n, 0));
} catch {
return 1;
}
}, [crate, user, maxOpens]);
const openOptions = useMemo(() => buildOpenQuantityOptions(maxOpens), [maxOpens]);
@@ -182,18 +313,28 @@ export default function CasePage() {
if (!prefReadyRef.current) {
prefReadyRef.current = true;
prevMaxOpensRef.current = maxOpens;
setQuantity(resolved);
return;
}
const prevMax = prevMaxOpensRef.current;
prevMaxOpensRef.current = maxOpens;
// New key unlocked → jump select to highest affordable tier (new max if credits allow).
const unlockedHigher = prevMax != null && maxOpens > prevMax;
setQuantity((q) => {
const allowed = openOptions.filter((n) => n <= Math.max(1, maxAffordable));
if (!allowed.length) return 1;
// Jump to newly unlocked max only when the balance can cover it.
if (unlockedHigher && maxOpens <= Math.max(1, maxAffordable)) {
return maxOpens;
}
if (allowed.includes(q)) return q;
const below = allowed.filter((n) => n <= q);
return below.length ? below[below.length - 1] : allowed[0];
});
}, [crate, maxAffordable, openOptions, user?.id]);
}, [crate, maxAffordable, openOptions, user?.id, maxOpens]);
useEffect(() => {
if (!prefReadyRef.current || !user?.id || crate?.id == null) return;
@@ -202,6 +343,37 @@ export default function CasePage() {
useEffect(() => {
return () => {
// Flush first-spin feed if page closes mid-respin
const pre = pendingPreRespinAnnounceRef.current;
if (pre?.items?.length && !preAnnouncedRef.current) {
preAnnouncedRef.current = true;
const best = pickBestDrop(pre.items);
if (best) {
const count = pre.items.filter((it) => it.id === best.id).length;
fetch('/api/feed/announce', {
method: 'POST',
credentials: 'include',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
snapshotBest: {
id: best.id,
name: best.name,
imageUrl: best.imageUrl,
rarity: best.rarity,
valueCents: best.valueCents ?? best.marketValue,
wear: best.wear,
floatValue: best.floatValue,
paintSeed: best.paintSeed,
},
openCount: pre.items.length,
count,
caseName: pre.caseName || '',
respin: false,
}),
}).catch(() => {});
}
}
announcePendingDrop(true);
applyPendingAutoSellKeepalive();
};
@@ -213,7 +385,7 @@ export default function CasePage() {
const ca = Number(a.chance ?? a.dropRate ?? 0);
const cb = Number(b.chance ?? b.dropRate ?? 0);
if (ca !== cb) return ca - cb;
return (b.marketValue || 0) - (a.marketValue || 0);
return compareCents(b.marketValue, a.marketValue);
});
}, [crate]);
@@ -225,31 +397,57 @@ export default function CasePage() {
);
}, [keyProgress]);
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
const totalCost = useMemo(() => {
if (!crate) return '0';
return (toCentsBigInt(crate.price) * BigInt(quantity)).toString();
}, [crate, quantity]);
const totalCost = crate ? crate.price * quantity : 0;
const canOpen =
crate &&
!opening &&
crate.items.length > 0 &&
quantity >= 1 &&
quantity <= maxOpens &&
user.balance >= totalCost;
const canOpen = useMemo(() => {
if (!user || !crate || opening || !crate.items?.length) return false;
if (quantity < 1 || quantity > maxOpens) return false;
return toCentsBigInt(user.balance) >= toCentsBigInt(totalCost);
}, [user, crate, opening, quantity, maxOpens, totalCost]);
const allDone = spins.length > 0 && spins.every((s) => s.done);
const allDone =
spins.length > 0 &&
spins.every((s) => s.done) &&
!respinRestarting &&
respinPhaseRef.current !== 'pre' &&
respinPhaseRef.current !== 'restarting';
const heroHidden = opening || (spins.length > 0 && !allDone);
const beginReelSpins = (items) => {
setAllDrops(items);
const showcase = pickShowcaseDrops(items, MAX_VISIBLE_REELS);
const next = showcase.map((item, idx) => ({
key: `${item.inventoryId || item.id || 'x'}-${idx}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
item,
spinning: false,
done: false,
}));
setSpins(next);
requestAnimationFrame(() => {
setSpins((prev) => prev.map((s) => ({ ...s, spinning: true })));
});
};
const openCase = async () => {
if (!canOpen) return;
if (!canOpen || !crate) return;
setError('');
setKeyMessage('');
setCatalogMessage('');
setOpenBonusMessage('');
setSpins([]);
setAllDrops([]);
setOpening(true);
setRespinRestarting(false);
announcedRef.current = false;
preAnnouncedRef.current = false;
finishingRef.current = false;
pendingRespinFinalRef.current = null;
pendingPreRespinAnnounceRef.current = null;
pendingPreRespinAutoSellRef.current = [];
respinPhaseRef.current = null;
try {
const result = await api.openCase(crate.id, quantity);
@@ -259,10 +457,9 @@ export default function CasePage() {
if (result.animReduction != null) {
setAnimReduction(result.animReduction);
}
const bonusParts = [];
if (result.refunded) bonusParts.push('Refund! Case cost returned');
if (result.respin) bonusParts.push('Respin! All drops re-rolled');
if (bonusParts.length) setOpenBonusMessage(bonusParts.join(' · '));
if (result.refunded) {
setOpenBonusMessage('Refund! Case cost returned');
}
if (result.keyProgress) {
setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
}
@@ -272,19 +469,6 @@ export default function CasePage() {
);
}
if (result.catalog) {
const parts = [];
if (result.catalog.newlyFilled?.length) {
const n = result.catalog.newlyFilled.length;
parts.push(
`${n} catalog slot${n === 1 ? '' : 's'} filled`
);
}
if (result.catalog.chapterJustCompleted) {
parts.push('chapter complete · +1% luck');
} else if (result.catalog.luckPercent > 0 && result.catalog.newlyFilled?.length) {
parts.push(`luck +${result.catalog.luckPercent}%`);
}
if (parts.length) setCatalogMessage(parts.join(' · '));
if (result.catalog.luckPercent != null) {
setCrate((prev) =>
prev ? { ...prev, catalogLuckPercent: result.catalog.luckPercent } : prev
@@ -308,24 +492,34 @@ export default function CasePage() {
setCaseName(resolvedCaseName);
const items = result.items?.length ? result.items : result.item ? [result.item] : [];
const inventoryIds = items.map((item) => item?.inventoryId).filter(Boolean);
const pre = result.respin && result.preRespinItems?.length ? result.preRespinItems : null;
pendingAnnounceRef.current = {
ids: inventoryIds,
caseName: resolvedCaseName || '',
respin: Boolean(pre),
};
pendingAutoSellRef.current = items
.filter((item) => item?.willAutoSell)
.map((item) => item.inventoryId)
.filter(Boolean);
const next = items.map((item, idx) => ({
key: `${item.inventoryId || item.id}-${idx}-${Date.now()}`,
item,
spinning: false,
done: false,
}));
setSpins(next);
requestAnimationFrame(() => {
setSpins((prev) => prev.map((s) => ({ ...s, spinning: true })));
});
if (pre) {
pendingPreRespinAutoSellRef.current = pre
.filter((item) => item?.willAutoSell && item?.inventoryId)
.map((item) => item.inventoryId);
pendingPreRespinAnnounceRef.current = {
items: pre,
caseName: resolvedCaseName || '',
};
pendingRespinFinalRef.current = items;
respinPhaseRef.current = 'pre';
beginReelSpins(pre);
} else {
pendingPreRespinAutoSellRef.current = [];
respinPhaseRef.current = null;
beginReelSpins(items);
}
} catch (err) {
setError(err.message);
setOpening(false);
@@ -336,109 +530,149 @@ export default function CasePage() {
setSpins((prev) => {
const alreadyDone = prev.find((s) => s.key === key)?.done;
if (alreadyDone) return prev;
const next = prev.map((s) =>
return prev.map((s) =>
s.key === key ? { ...s, spinning: false, done: true } : s
);
const wasComplete = prev.length > 0 && prev.every((s) => s.done);
const nowComplete = next.length > 0 && next.every((s) => s.done);
if (nowComplete && !wasComplete) {
setOpening(false);
void finalizeOpens(next, caseName || crate?.name || '');
}
return next;
});
};
// 1) When first spin reels finish → feed line #1, start respin pause
// 2) When final reels finish → feed line #2 (respin) via finalizeOpens
useEffect(() => {
if (!spins.length || spins.some((s) => !s.done)) return;
const phase = respinPhaseRef.current;
if (phase === 'pre') {
if (!pendingRespinFinalRef.current) return;
// Block re-entry while we credit first-roll auto-sell, then pause before restart.
respinPhaseRef.current = 'restarting';
void (async () => {
await announcePreRespinDrop();
await applyPreRespinAutoSell();
setRespinRestarting(true);
})();
return;
}
if (phase === 'restarting') return;
if ((phase === 'final' || phase === null) && opening && !finishingRef.current) {
respinPhaseRef.current = null;
setOpening(false);
void finalizeOpens(caseName || crate?.name || '');
}
}, [spins, opening, caseName, crate?.name]);
// After pause, restart reels with real loot (respin feed waits until those finish)
useEffect(() => {
if (!respinRestarting) return;
const finalItems = pendingRespinFinalRef.current;
if (!finalItems) return;
const t = setTimeout(() => {
pendingRespinFinalRef.current = null;
respinPhaseRef.current = 'final';
setOpenBonusMessage((msg) =>
msg?.includes('Respin')
? msg
: msg
? `${msg} · Respin! Re-rolling…`
: 'Respin! Re-rolling all drops…'
);
beginReelSpins(finalItems);
setRespinRestarting(false);
}, 550);
return () => clearTimeout(t);
}, [respinRestarting]);
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<div className="case-page">
<Link to="/cases" className="muted">
Back to cases
</Link>
{!crate && !error && <p className="muted">Loading case</p>}
{error && <div className="error">{error}</div>}
{keyMessage && <div className="ok-msg">{keyMessage}</div>}
{catalogMessage && <div className="ok-msg">{catalogMessage}</div>}
{openBonusMessage && <div className="ok-msg">{openBonusMessage}</div>}
{crate && (
<>
<div className="case-hero">
<h1>{crate.name}</h1>
<p className="muted">
Cost {formatCredits(crate.price)} cr each
{crate.basePrice != null && Number(crate.basePrice) !== Number(crate.price)
? ` (was ${formatCredits(crate.basePrice)})`
: ''}{' '}
· Balance {formatCredits(user.balance)} cr
{crate.catalogLuckPercent > 0
? ` · Catalog luck +${crate.catalogLuckPercent}%`
: ''}
</p>
<div
className={`case-hero-wrap${heroHidden ? ' case-hero-wrap--hidden' : ''}`}
aria-hidden={heroHidden}
>
<div className="case-hero">
<h1>{crate.name}</h1>
{keyProgress && (
<div className="case-key-progress">
<div className="case-key-head">
<span>
Keys <strong>{keyProgress.keys}</strong> · Max{' '}
<strong>x{keyProgress.maxOpens}</strong>
</span>
<span className="muted">
Next key: {keyProgress.opensSinceLastKey} / {keyProgress.opensRequired} opens
</span>
</div>
<div className="case-key-bar" role="progressbar" aria-valuenow={progressPct}>
<div className="case-key-bar-fill" style={{ width: `${progressPct}%` }} />
{keyProgress && (
<div className="case-key-progress">
<div className="case-key-head">
<span>
Keys <strong>{keyProgress.keys}</strong> · Max{' '}
<strong>x{keyProgress.maxOpens}</strong>
</span>
<span className="muted">
Next key: {keyProgress.opensSinceLastKey} / {keyProgress.opensRequired} opens
</span>
</div>
<div className="case-key-bar" role="progressbar" aria-valuenow={progressPct}>
<div className="case-key-bar-fill" style={{ width: `${progressPct}%` }} />
</div>
</div>
)}
<div className="open-qty">
<label className="muted">
Open
<select
value={quantity}
disabled={opening}
onChange={(e) => {
const next = Number(e.target.value);
setQuantity(next);
persistQty(crate.id, next);
}}
>
{openOptions.map((n) => (
<option key={n} value={n} disabled={n > maxAffordable && maxAffordable > 0}>
{n}×
</option>
))}
</select>
</label>
<span className="muted">
Total {formatCredits(totalCost)} cr
{maxAffordable < quantity ? ' · insufficient balance' : ''}
</span>
</div>
)}
<div className="open-qty">
<label className="muted">
Open
<select
value={quantity}
disabled={opening}
onChange={(e) => {
const next = Number(e.target.value);
setQuantity(next);
persistQty(crate.id, next);
}}
>
{openOptions.map((n) => (
<option key={n} value={n} disabled={n > maxAffordable && maxAffordable > 0}>
{n}×
</option>
))}
</select>
</label>
<span className="muted">
Total {formatCredits(totalCost)} cr
{maxAffordable < quantity ? ' · insufficient balance' : ''}
</span>
<button
type="button"
className={`case-icon ${canOpen ? '' : 'disabled'}${crate.imageUrl ? ' has-img' : ''}`}
onClick={openCase}
disabled={!canOpen}
title="Open case"
>
{crate.imageUrl ? <img src={crate.imageUrl} alt="" /> : '▣'}
</button>
<button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
{opening
? `Opening ${quantity}×…`
: `Open ${quantity}× for ${formatCredits(totalCost)} cr`}
</button>
</div>
<button
type="button"
className={`case-icon ${canOpen ? '' : 'disabled'}${crate.imageUrl ? ' has-img' : ''}`}
onClick={openCase}
disabled={!canOpen}
title="Open case"
>
{crate.imageUrl ? <img src={crate.imageUrl} alt="" /> : '▣'}
</button>
<button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
{opening
? `Opening ${quantity}×…`
: `Open ${quantity}× for ${formatCredits(totalCost)} cr`}
</button>
</div>
{spins.length > 0 && (
<div className={`multi-reels${spins.length > 1 ? ' many' : ''}`}>
{allDrops.length > spins.length && (
<p className="muted multi-reel-hint">
Showing top {spins.length} of {allDrops.length} drops
</p>
)}
{spins.map((spin, idx) => (
<div key={spin.key} className="multi-reel-slot">
{spins.length > 1 && (
@@ -464,14 +698,18 @@ export default function CasePage() {
{spin.item.rarity}
</div>
<div className="muted">
{[
spin.item.wear || null,
`${formatCredits(spin.item.valueCents ?? spin.item.marketValue)} cr`,
]
.filter(Boolean)
.join(' · ')}
{spin.item.autoSold ? (
<>Auto-sold for {formatCredits(spin.item.soldForCents)} cr</>
) : (
<>
{formatCredits(spin.item.valueCents ?? spin.item.marketValue)} cr
{spin.item.wear ? ` · ${spin.item.wear}` : ''}
{' '}
· Auto-sold for {formatCredits(spin.item.soldForCents)} cr
</>
)}
) : null}
</div>
</div>
)}
@@ -480,13 +718,23 @@ export default function CasePage() {
</div>
)}
{allDone && spins.length > 1 && (
{allDone && allDrops.length > 1 && (
<div className="won-banner section">
<div className="muted">You unboxed {spins.length} items</div>
<div className="muted">
You unboxed {allDrops.length} items
{allDrops.length > spins.length
? ` · reels showed the top ${spins.length} by value`
: ''}
</div>
<div className="grid" style={{ marginTop: '0.75rem' }}>
{spins.map((s) => (
<ItemTile key={s.key} item={s.item} />
))}
{[...allDrops]
.sort((a, b) => compareCents(dropValue(b), dropValue(a)))
.map((item) => (
<ItemTile
key={item.inventoryId || `${item.id}-${item.floatValue}`}
item={item}
/>
))}
</div>
</div>
)}
+1 -7
View File
@@ -30,12 +30,6 @@ export default function CasesPage() {
return (
<section className="section">
<div className="section-head">
<div>
<h1>Cases</h1>
<p className="muted">Pick a crate to open.</p>
</div>
</div>
{error && <div className="error">{error}</div>}
<div className="grid case-grid">
{cases.map((c) => (
@@ -44,7 +38,7 @@ export default function CasesPage() {
{c.imageUrl ? <img src={c.imageUrl} alt="" loading="lazy" /> : '▣'}
</div>
<div className="case-name">{c.name}</div>
<div className="case-meta">{formatCredits(c.price)} cr</div>
<div className="case-meta case-price">{formatCredits(c.price)} cr</div>
<div className="case-meta">{c.items.length} possible drops</div>
{c.keyProgress && (
<div className="case-meta case-key-badge">
-35
View File
@@ -1,35 +0,0 @@
import { Navigate, Link } from 'react-router-dom';
import { useAuth } from '../AuthContext';
export default function CoinflipStub() {
const { user, loading } = useAuth();
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<section className="section">
<div className="section-head">
<div>
<h1>Coinflip</h1>
<p className="muted">1v1 item flip coming soon.</p>
</div>
<Link to="/battle" className="btn secondary">
Back
</Link>
</div>
<div className="empty stub-panel">
<p>This mode is not playable yet. Use MultiPlayers Case or 1vX Case in the meantime.</p>
<div className="row-actions" style={{ marginTop: '1rem' }}>
<Link to="/battle/multi" className="btn">
MultiPlayers
</Link>
<Link to="/battle/1vx" className="btn secondary">
1vX Case
</Link>
</div>
</div>
</section>
);
}
+253 -211
View File
@@ -1,13 +1,22 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { api, formatCredits, creditsToCentsString, centsToCreditsString, compareCents, toCentsBigInt } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile';
import { onInventoryChanged } from '../inventoryEvents';
import { getSocket, onSocketSessionOk } from '../socket';
import { loadInventorySort, saveInventorySort } from '../userPrefs';
const PAGE_SIZE = 50;
function formatVaultCountdown(ms) {
if (ms == null || !Number.isFinite(ms)) return '—';
const totalSec = Math.max(0, Math.ceil(ms / 1000));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
export default function Dashboard() {
const { user, loading, patchUser } = useAuth();
const [inventory, setInventory] = useState([]);
@@ -21,6 +30,7 @@ export default function Dashboard() {
const [maxValue, setMaxValue] = useState('10');
const [autoSellEnabled, setAutoSellEnabled] = useState(false);
const [settingsBusy, setSettingsBusy] = useState(false);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!user?.id) return;
@@ -32,7 +42,7 @@ export default function Dashboard() {
if (!user) return;
setAutoSellEnabled(Boolean(user.autoSellEnabled));
if (user.autoSellThresholdCents != null) {
setMaxValue((user.autoSellThresholdCents / 100).toFixed(2).replace(/\.?0+$/, '') || '0');
setMaxValue(centsToCreditsString(user.autoSellThresholdCents));
}
}, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]);
@@ -64,13 +74,64 @@ export default function Dashboard() {
});
}, [user]);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
const socket = getSocket();
const onYield = (payload) => {
if (payload?.user) patchUser(payload.user);
else if (payload?.balance != null) patchUser({ balance: payload.balance });
if (payload?.vault) setVault(payload.vault);
const credited = toCentsBigInt(payload?.credited);
if (credited > 0n) {
setVaultMessage(`Vault yield +${formatCredits(payload.credited)} cr`);
}
load().catch(() => {});
};
const onSession = () => {
load().catch(() => {});
};
socket.on('vault:yield', onYield);
const offSession = onSocketSessionOk(onSession);
return () => {
socket.off('vault:yield', onYield);
offSession();
};
}, [user, patchUser]);
useEffect(() => {
if (!(vault?.used > 0)) return undefined;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [vault?.used]);
useEffect(() => {
if (!(vault?.used > 0) || !vault?.nextYieldAt) return undefined;
const dueAt = new Date(vault.nextYieldAt).getTime();
const delay = Math.max(500, dueAt - Date.now() + 800);
const t = setTimeout(() => {
load().catch(() => {});
}, delay);
return () => clearTimeout(t);
}, [vault?.nextYieldAt, vault?.used]);
const vaultCountdownMs = useMemo(() => {
if (!(vault?.used > 0) || !vault?.nextYieldAt) return null;
return new Date(vault.nextYieldAt).getTime() - now;
}, [vault?.used, vault?.nextYieldAt, now]);
const vaultTickLabel = useMemo(() => {
const ms = vault?.tickMs ?? (vault?.mode === 'offline' ? 600_000 : 300_000);
return `${Math.round(ms / 60_000)} min`;
}, [vault?.tickMs, vault?.mode]);
const sorted = useMemo(() => {
const list = [...inventory];
const list = inventory.filter((inv) => !inv.staked);
if (sort === 'value') {
list.sort(
(a, b) =>
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
list.sort((a, b) =>
compareCents(
b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue,
a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue
)
);
} else {
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
@@ -93,28 +154,23 @@ export default function Dashboard() {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const selectedValue = useMemo(
() =>
inventory
.filter((inv) => selected.includes(inv.id))
.reduce((sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0), 0),
[inventory, selected]
);
const selectedValue = useMemo(() => {
let sum = 0n;
for (const inv of inventory) {
if (!selected.includes(inv.id)) continue;
sum += toCentsBigInt(inv.valueCents ?? inv.item?.valueCents);
}
return sum.toString();
}, [inventory, selected]);
const thresholdCents = useMemo(() => {
const credits = Number(maxValue);
if (!Number.isFinite(credits) || credits < 0) return null;
return Math.round(credits * 100);
}, [maxValue]);
const thresholdCents = useMemo(() => creditsToCentsString(maxValue), [maxValue]);
const belowThreshold = useMemo(() => {
if (thresholdCents == null) return [];
return inventory.filter(
(inv) =>
!inv.favorite &&
!inv.staked &&
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
);
return inventory.filter((inv) => {
if (inv.favorite || inv.staked) return false;
return compareCents(inv.valueCents ?? inv.item?.valueCents, thresholdCents) < 0;
});
}, [inventory, thresholdCents]);
const selectedItems = useMemo(
@@ -134,14 +190,13 @@ export default function Dashboard() {
const canStake = selectedUnstaked.length > 0 && selectedUnstaked.length <= vaultFree;
const canUnstake = selectedStaked.length > 0;
const belowValue = useMemo(
() =>
belowThreshold.reduce(
(sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0),
0
),
[belowThreshold]
);
const belowValue = useMemo(() => {
let sum = 0n;
for (const inv of belowThreshold) {
sum += toCentsBigInt(inv.valueCents ?? inv.item?.valueCents);
}
return sum.toString();
}, [belowThreshold]);
const toggle = (id) => {
setSelected((prev) =>
@@ -217,27 +272,6 @@ export default function Dashboard() {
}
};
const claimVault = async () => {
setBusy(true);
setError('');
setVaultMessage('');
try {
const data = await api.vaultClaim();
if (data.user) patchUser({ balance: data.user.balance });
if (data.vault) setVault(data.vault);
setVaultMessage(
data.credited > 0
? `Vault yield +${formatCredits(data.credited)} cr`
: 'No vault yield ready yet'
);
await load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const setFavorite = async (favorite) => {
if (!selected.length) return;
setBusy(true);
@@ -257,7 +291,8 @@ export default function Dashboard() {
if (!ids.length || thresholdCents == null) return;
const favSkipped = inventory.filter(
(inv) =>
inv.favorite && (inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
inv.favorite &&
compareCents(inv.valueCents ?? inv.item?.valueCents, thresholdCents) < 0
).length;
const ok = window.confirm(
`Sell ${ids.length} item${ids.length === 1 ? '' : 's'} below ${formatCredits(thresholdCents)} cr for ${formatCredits(belowValue)} cr?` +
@@ -282,19 +317,18 @@ export default function Dashboard() {
const onAutoSellToggle = async (enabled) => {
setAutoSellEnabled(enabled);
const credits = Number(maxValue);
if (!Number.isFinite(credits) || credits < 0) return;
await saveAutoSell(enabled, credits);
if (creditsToCentsString(maxValue) == null) return;
await saveAutoSell(enabled, maxValue);
};
const onThresholdBlur = async () => {
const credits = Number(maxValue);
if (!Number.isFinite(credits) || credits < 0) return;
const savedCents = user?.autoSellThresholdCents ?? 1000;
if (Math.round(credits * 100) === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
const cents = creditsToCentsString(maxValue);
if (cents == null) return;
const savedCents = String(user?.autoSellThresholdCents ?? 1000);
if (cents === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
return;
}
await saveAutoSell(autoSellEnabled, credits);
await saveAutoSell(autoSellEnabled, maxValue);
};
if (loading) return <p className="muted">Loading</p>;
@@ -303,15 +337,56 @@ export default function Dashboard() {
return (
<div>
<section className="section">
<div className="section-head">
<div>
<h1>Inventory</h1>
<p className="muted">Select items to sell at market value.</p>
</div>
</div>
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
<div className="inv-toolbar">
{stakedItems.length > 0 && (
<section className="section">
<div className="vault-panel admin-block">
<div className="section-head">
<div>
<h2>Vault</h2>
</div>
<div className="vault-timer" title={`Shared ${vaultTickLabel} tick (${vault?.mode || 'online'})`}>
<span className="muted">Next payment</span>
<strong className={vaultCountdownMs != null && vaultCountdownMs <= 0 ? 'gold' : ''}>
{vaultCountdownMs != null && vaultCountdownMs <= 0
? 'paying…'
: formatVaultCountdown(vaultCountdownMs)}
</strong>
</div>
</div>
<div className="vault-stats">
<span>
Slots <strong>{vault?.used ?? 0}/{vault?.slots ?? 3}</strong>
</span>
<span>
Online / tick{' '}
<strong className="gold">{formatCredits(vault?.onlinePerTick ?? 0)} cr</strong>
</span>
<span>
Offline / tick{' '}
<strong>{formatCredits(vault?.offlinePerTick ?? 0)} cr</strong>
</span>
</div>
<div className="grid vault-grid">
{stakedItems.map((inv) => (
<ItemTile
key={`vault-${inv.id}`}
item={inv.item}
favorite={inv.favorite}
staked
selected={selected.includes(inv.id)}
onClick={() => toggle(inv.id)}
/>
))}
</div>
</div>
</section>
)}
<section className="section">
<div className="section-head inv-section-head">
<h1>Inventory</h1>
<div className="inv-toolbar-left">
<div className="sort-toggle" role="group" aria-label="Sort inventory">
<button
@@ -335,87 +410,103 @@ export default function Dashboard() {
Most recent
</button>
</div>
{inventory.length > 0 && (
{sorted.length > 0 && (
<span className="muted inv-count">
{inventory.length} item{inventory.length === 1 ? '' : 's'}
{sorted.length} item{sorted.length === 1 ? '' : 's'}
</span>
)}
</div>
<div className="row-actions">
{selected.length > 0 && (
<>
<span className="muted">
{selected.length} selected · {formatCredits(selectedValue)} cr
{selectedHasFavorite ? ' · includes favorites' : ''}
</span>
<button
type="button"
className="btn ghost"
onClick={() => setSelected([])}
disabled={busy}
>
Clear
</button>
{!selectedAllFavorite && (
<button
type="button"
className="btn secondary"
onClick={() => setFavorite(true)}
disabled={busy}
>
Favorite
</button>
)}
{selectedItems.some((inv) => inv.favorite) && (
<button
type="button"
className="btn ghost"
onClick={() => setFavorite(false)}
disabled={busy}
>
Unfavorite
</button>
)}
<button
type="button"
className="btn"
onClick={sell}
disabled={busy || !selectedCanSell}
title={
selectedHasFavorite || selectedStaked.length
? 'Favorites and vault items cannot be sold'
: undefined
}
>
Sell
</button>
{canStake && (
<button
type="button"
className="btn secondary"
onClick={stakeSelected}
disabled={busy}
>
Stake ({selectedUnstaked.length})
</button>
)}
{canUnstake && (
<button
type="button"
className="btn secondary"
onClick={unstakeSelected}
disabled={busy}
>
Unstake ({selectedStaked.length})
</button>
)}
</>
)}
</div>
</div>
{selected.length > 0 && (
<div className="inv-toolbar">
<div className="row-actions">
<button
type="button"
className="btn danger"
onClick={sell}
disabled={busy || !selectedCanSell}
title={
selectedHasFavorite || selectedStaked.length
? 'Favorites and vault items cannot be sold'
: undefined
}
>
Sell
</button>
<span className="muted">
{selected.length} selected ·{' '}
<span className="item-price">
{formatCredits(selectedValue)} cr
</span>
{selectedHasFavorite ? ' · includes favorites' : ''}
</span>
<button
type="button"
className="btn ghost"
onClick={() => setSelected([])}
disabled={busy}
>
Clear
</button>
{!selectedAllFavorite && (
<button
type="button"
className="btn secondary"
onClick={() => setFavorite(true)}
disabled={busy}
>
<span className="btn-ico" aria-hidden="true">
</span>
Favorite
</button>
)}
{selectedItems.some((inv) => inv.favorite) && (
<button
type="button"
className="btn ghost"
onClick={() => setFavorite(false)}
disabled={busy}
>
<span className="btn-ico" aria-hidden="true">
</span>
Unfavorite
</button>
)}
{canStake && (
<button
type="button"
className="btn secondary"
onClick={stakeSelected}
disabled={busy}
>
<span className="btn-ico btn-ico-vault" aria-hidden="true">
V
</span>
Stake ({selectedUnstaked.length})
</button>
)}
{canUnstake && (
<button
type="button"
className="btn secondary"
onClick={unstakeSelected}
disabled={busy}
>
<span className="btn-ico btn-ico-vault" aria-hidden="true">
V
</span>
Unstake ({selectedStaked.length})
</button>
)}
</div>
</div>
)}
<div className="inv-bulk-sell">
{inventory.length > 0 && (
{sorted.length > 0 && (
<>
<button
type="button"
@@ -440,11 +531,13 @@ export default function Dashboard() {
</label>
<span className="muted inv-bulk-preview">
{belowThreshold.length} item{belowThreshold.length === 1 ? '' : 's'} ·{' '}
{formatCredits(belowValue)} cr
<span className="item-price">
{formatCredits(belowValue)} cr
</span>
</span>
</>
)}
{inventory.length === 0 && (
{sorted.length === 0 && (
<label className="inv-bulk-label">
Threshold
<input
@@ -471,76 +564,26 @@ export default function Dashboard() {
<span>Auto-sell</span>
</label>
<span className="inv-auto-sell-at muted">
at{' '}
{formatCredits(
Number.isFinite(Number(maxValue)) && Number(maxValue) >= 0
? Math.round(Number(maxValue) * 100)
: 0
)}{' '}
cr
sell below {formatCredits(creditsToCentsString(maxValue) ?? 0)} cr
</span>
</div>
</div>
{error && <div className="error">{error}</div>}
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
<div className="vault-panel admin-block">
<div className="section-head">
<div>
<h2>Vault</h2>
<p className="muted">
Stake up to {vault?.slots ?? 3} items. Online: 10% / 10 min · Offline: 1% / 10 min
(scales with wear &amp; rarity).
</p>
</div>
<button
type="button"
className="btn secondary"
onClick={claimVault}
disabled={busy || !(vault?.used > 0)}
>
Claim yield
</button>
</div>
<div className="vault-stats">
<span>
Slots <strong>{vault?.used ?? 0}/{vault?.slots ?? 3}</strong>
</span>
<span>
Online / tick{' '}
<strong className="gold">{formatCredits(vault?.onlinePerTick ?? 0)} cr</strong>
</span>
<span>
Offline / tick{' '}
<strong>{formatCredits(vault?.offlinePerTick ?? 0)} cr</strong>
</span>
</div>
{stakedItems.length > 0 ? (
<div className="grid vault-grid">
{stakedItems.map((inv) => (
<ItemTile
key={`vault-${inv.id}`}
item={inv.item}
favorite={inv.favorite}
staked
selected={selected.includes(inv.id)}
onClick={() => toggle(inv.id)}
/>
))}
</div>
) : (
<p className="muted">No items staked select items below and Stake.</p>
)}
</div>
{inventory.length === 0 ? (
{sorted.length === 0 ? (
<div className="empty">
Empty for now {' '}
<Link to="/cases" style={{ color: 'var(--accent)' }}>
open a case
</Link>
.
{stakedItems.length > 0 ? (
'All items are in the vault.'
) : (
<>
Empty for now {' '}
<Link to="/cases" style={{ color: 'var(--accent)' }}>
open a case
</Link>
.
</>
)}
</div>
) : (
<>
@@ -550,7 +593,6 @@ export default function Dashboard() {
key={inv.id}
item={inv.item}
favorite={inv.favorite}
staked={inv.staked}
selected={selected.includes(inv.id)}
onClick={() => toggle(inv.id)}
/>
-182
View File
@@ -1,182 +0,0 @@
import { useEffect, useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
import { getSocket } from '../socket';
const defaults = {
maxPlayers: 4,
maxItemsPerPlayer: 3,
minItemValue: 0,
minPlayersToStart: 2,
};
export default function DuelListPage() {
const { user, loading } = useAuth();
const navigate = useNavigate();
const [open, setOpen] = useState([]);
const [inProgress, setInProgress] = useState([]);
const [form, setForm] = useState(defaults);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
const list = await api.duels();
if (!cancelled) {
setOpen(list.open || []);
setInProgress(list.inProgress || []);
}
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
const socket = getSocket();
const onList = (list) => {
setOpen(list.open || []);
setInProgress(list.inProgress || []);
};
socket.emit('duels:subscribe');
socket.on('duels:list', onList);
socket.on('connect', () => socket.emit('duels:subscribe'));
return () => {
cancelled = true;
socket.emit('duels:unsubscribe');
socket.off('duels:list', onList);
};
}, [user]);
const create = async (e) => {
e.preventDefault();
setBusy(true);
setError('');
try {
const data = await api.createDuel({
maxPlayers: Number(form.maxPlayers),
maxItemsPerPlayer: Number(form.maxItemsPerPlayer),
minItemValue: Math.round(Number(form.minItemValue) * 100),
minPlayersToStart: Number(form.minPlayersToStart),
});
navigate(`/battle/1vx/${data.room.id}`);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<div>
<div className="section-head">
<div>
<h1>1vX Case</h1>
<p className="muted">Create a room or join an open duel.</p>
</div>
<Link to="/battle" className="btn secondary">
Modes
</Link>
</div>
{error && <div className="error">{error}</div>}
<section className="section admin-block">
<h2>Create room</h2>
<form className="form form-inline" onSubmit={create}>
<label>
Max players (210)
<input
type="number"
min={2}
max={10}
value={form.maxPlayers}
onChange={(e) => setForm({ ...form, maxPlayers: e.target.value })}
/>
</label>
<label>
Max items / player (110)
<input
type="number"
min={1}
max={10}
value={form.maxItemsPerPlayer}
onChange={(e) => setForm({ ...form, maxItemsPerPlayer: e.target.value })}
/>
</label>
<label>
Min item value (cr)
<input
type="number"
min={0}
step={0.01}
value={form.minItemValue}
onChange={(e) => setForm({ ...form, minItemValue: e.target.value })}
/>
</label>
<label>
Min players to start
<input
type="number"
min={2}
max={10}
value={form.minPlayersToStart}
onChange={(e) => setForm({ ...form, minPlayersToStart: e.target.value })}
/>
</label>
<button type="submit" className="btn" disabled={busy}>
Create
</button>
</form>
</section>
<section className="section">
<h2>Open rooms</h2>
{open.length === 0 ? (
<div className="empty">No open rooms.</div>
) : (
<div className="room-list">
{open.map((r) => (
<Link key={r.id} to={`/battle/1vx/${r.id}`} className="room-card">
<div className="room-title">Room #{r.id}</div>
<div className="muted">
by {r.creatorUsername} · {r.playerCount}/{r.maxPlayers} players ·{' '}
{r.maxItemsPerPlayer} items max · min {formatCredits(r.minItemValue)} cr
</div>
<div className="muted">Pot {formatCredits(r.totalValue)} cr</div>
</Link>
))}
</div>
)}
</section>
<section className="section">
<h2>In progress</h2>
{inProgress.length === 0 ? (
<div className="empty">No live rooms.</div>
) : (
<div className="room-list">
{inProgress.map((r) => (
<Link key={r.id} to={`/battle/1vx/${r.id}`} className="room-card live">
<div className="room-title">
Room #{r.id} <span className="badge">Live</span>
</div>
<div className="muted">
{r.playerCount} players · pot {formatCredits(r.totalValue)} cr · Spectate
</div>
</Link>
))}
</div>
)}
</section>
</div>
);
}
-354
View File
@@ -1,354 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { api, formatCredits, formatChance } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile, { itemValueCents } from '../components/ItemTile';
import CaseReel from '../components/CaseReel';
import { onInventoryChanged } from '../inventoryEvents';
import { getSocket } from '../socket';
export default function DuelRoomPage() {
const { id } = useParams();
const { user, loading, refresh } = useAuth();
const [room, setRoom] = useState(null);
const [inventory, setInventory] = useState([]);
const [selected, setSelected] = useState([]);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [spinning, setSpinning] = useState(false);
const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
const loadInventory = useCallback(async () => {
const inv = await api.inventory();
setInventory(inv.inventory);
}, []);
const inventoryByValue = useMemo(
() =>
[...inventory].sort(
(a, b) =>
itemValueCents(b.item) - itemValueCents(a.item) ||
(b.valueCents ?? 0) - (a.valueCents ?? 0)
),
[inventory]
);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
const [data] = await Promise.all([api.duel(id), loadInventory()]);
if (!cancelled) setRoom(data.room);
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
const socket = getSocket();
const roomId = Number(id);
const onState = (state) => {
if (Number(state.id) !== roomId) return;
setRoom(state);
if (state.status === 'open' || state.status === 'resolved') {
setSpinning(false);
loadInventory().catch(() => {});
}
};
const onSpin = (state) => {
if (Number(state.id) !== roomId) return;
setRoom(state);
setSpinning(true);
setReelWinnerItemId(state.reelWinnerItemId);
};
const onResult = (state) => {
if (Number(state.id) !== roomId) return;
setRoom(state);
setSpinning(false);
loadInventory().catch(() => {});
refresh().catch(() => {});
};
socket.emit('duel:subscribe', roomId);
socket.on('duel:state', onState);
socket.on('duel:spin', onSpin);
socket.on('duel:result', onResult);
socket.on('connect', () => socket.emit('duel:subscribe', roomId));
return () => {
cancelled = true;
socket.emit('duel:unsubscribe', roomId);
socket.off('duel:state', onState);
socket.off('duel:spin', onSpin);
socket.off('duel:result', onResult);
};
}, [user, id, loadInventory, refresh]);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
return onInventoryChanged(() => {
loadInventory().catch(() => {});
refresh().catch(() => {});
});
}, [user, loadInventory, refresh]);
const isSpectator =
room &&
(room.status === 'in_progress' || room.status === 'spinning' || room.status === 'resolved') &&
!(room.deposits || []).some((d) => d.userId === user?.id);
const myDeposits = useMemo(
() => (room?.deposits || []).filter((d) => d.userId === user?.id),
[room, user]
);
const slotsLeft = Math.max(0, (room?.maxItemsPerPlayer || 0) - myDeposits.length);
const potItems = useMemo(
() =>
(room?.deposits || [])
.filter((d) => d.item)
.map((d) => ({ ...d.item, id: d.id })),
[room]
);
const canBet = room?.status === 'open';
const isCreator = room?.creatorId === user?.id;
const toggleSelect = (invId) => {
setSelected((prev) => {
if (prev.includes(invId)) return prev.filter((x) => x !== invId);
if (prev.length >= slotsLeft) return prev;
return [...prev, invId];
});
};
const placeBet = async () => {
setBusy(true);
setError('');
try {
const data = await api.duelBet(id, selected);
setRoom(data.room);
setSelected([]);
await loadInventory();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const leave = async () => {
setBusy(true);
setError('');
try {
const data = await api.duelLeave(id);
setRoom(data.room);
setSelected([]);
await loadInventory();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const cancel = async () => {
setBusy(true);
setError('');
try {
const data = await api.duelCancel(id);
setRoom(data.room);
await loadInventory();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const start = async () => {
setBusy(true);
setError('');
try {
const data = await api.duelStart(id);
setRoom(data.room);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
if (!room && !error) return <p className="muted">Loading room</p>;
return (
<div className="bet-page">
<div className="section-head">
<div>
<h1>Room #{id}</h1>
<p className="muted">
{room?.maxPlayers} players max · {room?.maxItemsPerPlayer} items · min{' '}
{formatCredits(room?.minItemValue || 0)} cr
{isSpectator ? ' · Spectating' : ''}
</p>
</div>
<Link to="/battle/1vx" className="btn secondary">
All rooms
</Link>
</div>
{error && <div className="error">{error}</div>}
<div className="jackpot-status">
<div className="timer-block">
<span className="muted">Status</span>
<span className={`status-pill ${room?.status || ''}`}>{room?.status}</span>
</div>
<div className="timer-block">
<span className="muted">Pot</span>
<strong className="gold">{formatCredits(room?.totalValue || 0)} cr</strong>
</div>
<div className="timer-block">
<span className="muted">Players</span>
<strong>
{room?.playerCount || 0}/{room?.maxPlayers}
</strong>
</div>
</div>
{(spinning || room?.status === 'spinning') && potItems.length > 0 && (
<div className="section">
<CaseReel
items={potItems}
winnerId={reelWinnerItemId || potItems[0]?.id}
spinning={spinning || room?.status === 'spinning'}
onDone={() => {}}
/>
{room?.winnerUsername && (
<div className="won-banner" style={{ marginTop: '0.75rem' }}>
Winner: <strong>{room.winnerUsername}</strong>
</div>
)}
</div>
)}
{room?.status === 'resolved' && room.winnerUsername && (
<div className="won-banner section">
Winner: <strong>{room.winnerUsername}</strong> took the pot (
{formatCredits(room.totalValue)} cr)
</div>
)}
<section className="section">
<h2>Players & chances</h2>
{(room?.players || []).length === 0 ? (
<div className="empty">No deposits yet.</div>
) : (
<table className="table">
<thead>
<tr>
<th>Player</th>
<th>Value</th>
<th>Items</th>
<th>Chance</th>
</tr>
</thead>
<tbody>
{room.players.map((p) => (
<tr key={p.userId} className={p.userId === user.id ? 'row-me' : ''}>
<td>{p.username}</td>
<td>{formatCredits(p.valueCents)} cr</td>
<td>{p.itemCount}</td>
<td>{formatChance(p.chance)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="section">
<h2>Deposited items</h2>
{(room?.deposits || []).length === 0 ? (
<div className="empty">Empty.</div>
) : (
<div className="grid">
{room.deposits.map((d) => (
<div key={d.id} className="deposit-wrap">
<ItemTile item={d.item} />
<div className="deposit-owner muted">{d.username}</div>
</div>
))}
</div>
)}
</section>
{canBet && (
<section className="section">
<div className="section-head">
<div>
<h2>Your bets</h2>
<p className="muted">
{myDeposits.length}/{room.maxItemsPerPlayer} items · {slotsLeft} slot
{slotsLeft === 1 ? '' : 's'} left
</p>
</div>
<div className="row-actions">
{myDeposits.length > 0 && (
<button type="button" className="btn danger" disabled={busy} onClick={leave}>
Leave / withdraw
</button>
)}
{isCreator && (
<>
<button type="button" className="btn secondary" disabled={busy} onClick={cancel}>
Cancel room
</button>
<button type="button" className="btn" disabled={busy} onClick={start}>
Start now
</button>
</>
)}
</div>
</div>
<div className="section-head">
<p className="muted">Select items to bet</p>
<button
type="button"
className="btn"
disabled={busy || !selected.length || slotsLeft === 0}
onClick={placeBet}
>
Deposit
</button>
</div>
{inventoryByValue.length === 0 ? (
<div className="empty">
No free items <Link to="/cases">open a case</Link>.
</div>
) : (
<div className="grid">
{inventoryByValue.map((inv) => (
<ItemTile
key={inv.id}
item={inv.item}
favorite={inv.favorite}
selected={selected.includes(inv.id)}
disabled={
(slotsLeft === 0 && !selected.includes(inv.id)) ||
itemValueCents(inv.item) < (room.minItemValue || 0)
}
onClick={() => toggleSelect(inv.id)}
/>
))}
</div>
)}
</section>
)}
</div>
);
}
+13 -10
View File
@@ -1,4 +1,4 @@
import { api, formatCredits, formatLastConnection, formatPlayTime } from '../api';
import { api, formatCredits, formatLastConnection, formatPlayTime, compareCents } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile';
import ProfileCaseStats from '../components/ProfileCaseStats';
@@ -38,10 +38,11 @@ export default function PlayerProfilePage() {
const sorted = useMemo(() => {
const list = [...inventory];
if (sort === 'value') {
list.sort(
(a, b) =>
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
list.sort((a, b) =>
compareCents(
b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue,
a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue
)
);
} else {
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
@@ -161,10 +162,7 @@ export default function PlayerProfilePage() {
Cases opened <strong>{profile.casesOpened}</strong>
</li>
<li>
Jackpot wins <strong>{profile.jackpotWins}</strong>
</li>
<li>
Duel wins <strong>{profile.duelWins}</strong>
Battle wins <strong>{profile.jackpotWins}</strong>
</li>
</ul>
<ProfileAchievementBadges achievements={profile.achievements} />
@@ -223,7 +221,12 @@ export default function PlayerProfilePage() {
<>
<div className="grid">
{pageItems.map((inv) => (
<ItemTile key={inv.id} item={inv.item} favorite={inv.favorite} />
<ItemTile
key={inv.id}
item={inv.item}
favorite={inv.favorite}
staked={inv.staked}
/>
))}
</div>
{totalPages > 1 && (
+47 -21
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { api, formatCredits, toCentsBigInt } from '../api';
import { useAuth } from '../AuthContext';
function formatSkillValue(skill, value) {
@@ -67,7 +67,7 @@ export default function PrestigePage() {
patchUser(result.user);
await refresh();
setMessage(
`Prestige #${result.prestige.count} · +${result.prGranted} Pr. Balance, inventory, keys & catalog reset.`
`Prestige #${result.prestige.count} · +${result.prGranted} Pr. Balance, inventory & keys reset.`
);
await load();
} catch (err) {
@@ -77,14 +77,14 @@ export default function PrestigePage() {
}
};
const onUpgrade = async (skillId) => {
const onUpgrade = async (skillId, count = 1) => {
setBusy(true);
setError('');
setMessage('');
try {
const result = await api.prestigeUpgrade(skillId);
const result = await api.prestigeUpgrade(skillId, count);
patchUser(result.user);
setMessage(`Upgraded ${skillId}.`);
setMessage(count > 1 ? `Upgraded ${skillId} ×${count}.` : `Upgraded ${skillId}.`);
setData((prev) =>
prev
? {
@@ -101,7 +101,10 @@ export default function PrestigePage() {
}
};
const shortfall = Math.max(0, cost - (Number(user.balance) || 0));
const shortfall =
toCentsBigInt(cost) > toCentsBigInt(user.balance)
? toCentsBigInt(cost) - toCentsBigInt(user.balance)
: 0n;
const statusLabel = !data?.canAfford
? `Need ${formatCredits(shortfall)} cr more`
: data?.lockedItems > 0
@@ -178,8 +181,8 @@ export default function PrestigePage() {
<strong>+{p.openBonus || 0}</strong>
</div>
<div>
<span className="muted">Discount</span>
<strong>{p.caseDiscount || 0}%</strong>
<span className="muted">PR Farmer</span>
<strong>+{p.prBonus || 0}%</strong>
</div>
<div>
<span className="muted">Quality</span>
@@ -251,6 +254,7 @@ export default function PrestigePage() {
<li>All Pr already earned</li>
<li>Skill-tree bonuses</li>
<li>Account &amp; prestige count</li>
<li>Catalog progress</li>
</ul>
</div>
<div>
@@ -258,7 +262,7 @@ export default function PrestigePage() {
<ul>
<li>Balance</li>
<li>Inventory &amp; vault stakes</li>
<li>Case keys &amp; catalog progress</li>
<li>Case keys</li>
</ul>
</div>
</div>
@@ -308,21 +312,43 @@ export default function PrestigePage() {
<p className="muted">{skill.description}</p>
<div className="prestige-skill-meta">
<span>
Lv {skill.level} · now{' '}
Lv {skill.level}
{skill.maxLevel != null ? `/${skill.maxLevel}` : ''}
{' · now '}
<strong>{formatSkillValue(skill, skill.current)}</strong>
</span>
<span className="prestige-delta">
{formatSkillValue(skill, skill.nextValue)}
</span>
{!skill.atMax && (
<span className="prestige-delta">
{formatSkillValue(skill, skill.nextValue)}
</span>
)}
{skill.atMax && <span className="prestige-delta">MAX</span>}
</div>
<div className="prestige-skill-actions">
<button
type="button"
className="btn secondary"
disabled={busy || skill.atMax || !skill.canAfford}
onClick={() => onUpgrade(skill.id, 1)}
>
{skill.atMax ? 'Maxed' : `Buy · ${skill.cost} Pr`}
</button>
<button
type="button"
className="btn secondary"
disabled={busy || skill.atMax || !skill.canAfford10}
onClick={() => onUpgrade(skill.id, skill.buyCount10 || 10)}
title={
skill.atMax
? 'Max level reached'
: `${skill.buyCount10 || 10} levels → ${formatSkillValue(skill, skill.nextValue10)}`
}
>
{skill.atMax
? '—'
: `×${skill.buyCount10 || 10} · ${skill.cost10} Pr`}
</button>
</div>
<button
type="button"
className="btn secondary"
disabled={busy || !skill.canAfford}
onClick={() => onUpgrade(skill.id)}
>
Buy · {skill.cost} Pr
</button>
</article>
</div>
))}
+16 -7
View File
@@ -6,6 +6,7 @@ import ProfileAchievementBadges from '../components/ProfileAchievementBadges';
import ProfilePrestigeStats from '../components/ProfilePrestigeStats';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { onSocketSessionOk } from '../socket';
export default function ProfilePage() {
const { user, loading, refresh, logout } = useAuth();
@@ -21,7 +22,7 @@ export default function ProfilePage() {
useEffect(() => {
if (!user || user.role === 'admin') return;
let cancelled = false;
(async () => {
const loadProfile = async () => {
try {
const data = await api.profile();
if (!cancelled) {
@@ -31,9 +32,14 @@ export default function ProfilePage() {
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
};
loadProfile();
const offSession = onSocketSessionOk(() => {
loadProfile().catch(() => {});
});
return () => {
cancelled = true;
offSession();
};
}, [user]);
@@ -130,7 +136,13 @@ export default function ProfilePage() {
</div>
<label className="btn secondary file-btn">
Change photo
<input type="file" accept="image/*" hidden onChange={onAvatar} disabled={busy} />
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif"
hidden
onChange={onAvatar}
disabled={busy}
/>
</label>
<div className="muted" style={{ marginTop: '0.75rem' }}>
Balance {formatCredits(user.balance)} cr
@@ -178,10 +190,7 @@ export default function ProfilePage() {
Cases opened <strong>{profile.casesOpened}</strong>
</li>
<li>
Jackpot wins <strong>{profile.jackpotWins}</strong>
</li>
<li>
Duel wins <strong>{profile.duelWins}</strong>
Battle wins <strong>{profile.jackpotWins}</strong>
</li>
</ul>
<ProfileAchievementBadges
+160 -54
View File
@@ -23,20 +23,33 @@ function useAdCountdown(nextAdAt) {
return remaining;
}
function formatCountdown(sec) {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
export default function ShopPage() {
const { user, loading, patchUser } = useAuth();
const [packages, setPackages] = useState([]);
const [osuTopups, setOsuTopups] = useState([]);
const [osuTopupsEnabled, setOsuTopupsEnabled] = useState(true);
const [payments, setPayments] = useState(null);
const [ad, setAd] = useState(null);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [watching, setWatching] = useState(false);
const [confirmingId, setConfirmingId] = useState(null);
const remaining = useAdCountdown(ad?.ready ? null : ad?.nextAdAt);
const load = useCallback(async () => {
const data = await api.shop();
setPackages(data.packages || []);
setOsuTopups(data.osuTopups || []);
setOsuTopupsEnabled(data.osuTopupsEnabled !== false && (data.osuTopups || []).length > 0);
setPayments(data.payments || null);
setAd(data.ad);
patchUser({
balance: data.user.balance,
@@ -60,7 +73,7 @@ export default function ShopPage() {
};
}, [user?.id, user?.role, load]);
const buy = async (packageId) => {
const buyCredits = async (packageId) => {
setBusy(true);
setError('');
setMessage('');
@@ -70,7 +83,7 @@ export default function ShopPage() {
balance: data.user.balance,
osuBalance: data.user.osuBalance,
});
setMessage(`Bought ${data.package.name} — +${formatCredits(data.package.credits)} cr`);
setMessage(`Exchanged — +${formatCredits(data.package.credits)} cr`);
await load();
} catch (err) {
setError(err.message);
@@ -79,12 +92,41 @@ export default function ShopPage() {
}
};
const buyOsu = async (packId) => {
setBusy(true);
setConfirmingId(packId);
setError('');
setMessage('');
try {
const checkout = await api.shopOsuCheckout(packId);
if (checkout.mode === 'mock' && checkout.confirmable) {
const data = await api.shopOsuConfirm(checkout.order.id);
patchUser({
balance: data.user.balance,
osuBalance: data.user.osuBalance,
});
setMessage(
data.alreadyPaid
? 'Order already completed'
: `+${data.osuGranted} osu added to your wallet`
);
await load();
} else {
setError('Payment provider is not ready for live checkout yet');
}
} catch (err) {
setError(err.message);
} finally {
setConfirmingId(null);
setBusy(false);
}
};
const watchAd = async () => {
setBusy(true);
setError('');
setMessage('');
setWatching(true);
// Simulated short ad
await new Promise((r) => setTimeout(r, 1800));
try {
const data = await api.shopAd();
@@ -115,30 +157,122 @@ export default function ShopPage() {
if (user.role === 'admin') return <Navigate to="/admin" replace />;
const adReady = ad?.ready || (ad?.nextAdAt && remaining === 0);
const osuBal = user.osuBalance ?? 0;
return (
<div>
<section className="section">
<div className="section-head">
<div>
<h1>Shop</h1>
<p className="muted">Spend osu for credit packs, or watch an ad every minute.</p>
</div>
<div className="row-actions">
<span className="osu-pill">{user.osuBalance ?? 0} osu</span>
<span className="balance-pill">{formatCredits(user.balance)} cr</span>
</div>
</div>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
<div className="admin-block ad-panel">
<h2>Watch an ad</h2>
<p className="muted">
Earn between {formatCredits(ad?.rewardMin || 10000)} and{' '}
{formatCredits(ad?.rewardMax || 20000)} cr. Cooldown: 1 minute.
<div className="shop-page">
<header className="shop-hero">
<div className="shop-hero-copy">
<p className="shop-kicker">CaseOrion Shop</p>
<h1>Fuel your runs</h1>
<p className="shop-lede">
<strong>osu</strong> is the premium wallet bought with real money, spent on credit
packs. Credits are for opening cases, vaulting, and battling.
</p>
</div>
<div className="shop-wallet">
<div className="shop-wallet-row">
<span className="muted">osu</span>
<strong className="osu-pill shop-wallet-osu">{osuBal}</strong>
</div>
<div className="shop-wallet-row">
<span className="muted">Credits</span>
<strong className="balance-pill">{formatCredits(user.balance)} cr</strong>
</div>
{payments?.mock && (
<p className="shop-mock-note muted">
Dev payments: mock checkout credits osu instantly (Stripe-ready backend).
</p>
)}
</div>
</header>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
{osuTopupsEnabled && osuTopups.length > 0 && (
<section className="shop-section">
<div className="shop-section-head">
<h2>Get osu</h2>
<p className="muted">Premium top-ups · better rate on larger packs</p>
</div>
<div className="shop-osu-grid">
{osuTopups.map((pack) => (
<article
key={pack.id}
className={`shop-osu-card${pack.badge ? ' has-badge' : ''}${
pack.badge === 'Best value' ? ' highlight' : ''
}${pack.badge === 'Popular' ? ' popular' : ''}`}
>
{pack.badge ? <span className="shop-badge">{pack.badge}</span> : null}
<h3>{pack.name}</h3>
<p className="shop-osu-amount">
{pack.osu} <span>osu</span>
</p>
<p className="muted shop-osu-blurb">{pack.blurb}</p>
<p className="shop-osu-rate muted">{pack.perOsuLabel}</p>
<button
type="button"
className="btn shop-buy-btn"
disabled={busy}
onClick={() => buyOsu(pack.id)}
>
{confirmingId === pack.id
? 'Processing…'
: `Buy · ${pack.displayPrice || `${(pack.priceCents / 100).toFixed(2)}`}`}
</button>
</article>
))}
</div>
</section>
)}
<section className="shop-section">
<div className="shop-section-head">
<h2>Exchange for credits</h2>
<p className="muted">
Spend osu · payout scales with your credit balance (min floor early game)
</p>
</div>
<div className="shop-credit-grid">
{packages.map((p) => {
const canAfford = osuBal >= p.osuCost;
return (
<article key={p.id} className="shop-credit-card">
{p.tag ? <span className="shop-badge subtle">{p.tag}</span> : null}
<h3>{p.name}</h3>
<p className="pack-credits">+{formatCredits(p.credits)} cr</p>
<p className="muted shop-credit-scale">
{p.balancePct != null ? `${p.balancePct}% of balance` : null}
{p.scaled === false ? ' · floor' : ''}
</p>
<p className="shop-credit-cost">
<span className="osu-pill">{p.osuCost} osu</span>
</p>
<button
type="button"
className="btn secondary"
disabled={busy || !canAfford}
onClick={() => buyCredits(p.id)}
title={canAfford ? undefined : 'Not enough osu'}
>
{canAfford ? 'Exchange' : 'Need osu'}
</button>
</article>
);
})}
</div>
</section>
<section className="shop-section shop-free">
<div className="shop-ad-panel">
<div>
<h2>Free credits</h2>
<p className="muted">
Watch a short ad every minute for {formatCredits(ad?.rewardMin || 10000)}
{formatCredits(ad?.rewardMax || 20000)} cr.
</p>
</div>
{watching ? (
<div className="ad-sim">
<div className="ad-bar" />
@@ -147,43 +281,15 @@ export default function ShopPage() {
) : (
<button
type="button"
className="btn"
className="btn secondary"
disabled={busy || !adReady}
onClick={watchAd}
>
{adReady
? 'Watch ad'
: `Available in ${String(Math.floor(remaining / 60)).padStart(2, '0')}:${String(
remaining % 60
).padStart(2, '0')}`}
{adReady ? 'Watch ad' : `Ready in ${formatCountdown(remaining)}`}
</button>
)}
</div>
</section>
<section className="section">
<h2>Credit packs</h2>
<p className="muted" style={{ marginBottom: '1rem' }}>
Buy credits with osu (demo currency).
</p>
<div className="mode-grid">
{packages.map((p) => (
<div key={p.id} className="mode-card shop-pack">
<h2>{p.name}</h2>
<p className="pack-credits">{p.blurb}</p>
<p className="muted">{p.osuCost} osu</p>
<button
type="button"
className="btn"
disabled={busy || (user.osuBalance ?? 0) < p.osuCost}
onClick={() => buy(p.id)}
>
Buy
</button>
</div>
))}
</div>
</section>
</div>
);
}
+107 -3
View File
@@ -4,10 +4,14 @@ let socket = null;
let limited = false;
let limitPayload = null;
const limitListeners = new Set();
const sessionOkListeners = new Set();
let unloadBound = false;
let connectTimer = null;
let reconnectSeq = 0;
function notifyLimit(payload) {
limited = true;
limitPayload = payload || { max: 10 };
limitPayload = payload || { max: 2 };
for (const fn of limitListeners) {
try {
fn(limitPayload);
@@ -29,32 +33,125 @@ function notifyOk() {
}
}
function notifySessionOk(payload) {
for (const fn of sessionOkListeners) {
try {
fn(payload);
} catch {
/* ignore */
}
}
}
function clearConnectTimer() {
if (connectTimer != null) {
clearTimeout(connectTimer);
connectTimer = null;
}
}
function ensureConnected() {
if (!socket || limited) return;
socket.io.reconnection(true);
if (!socket.connected) {
clearConnectTimer();
connectTimer = setTimeout(() => {
connectTimer = null;
if (!socket || limited || socket.connected) return;
socket.connect();
}, 50);
}
}
function bindUnloadOnce() {
if (unloadBound || typeof window === 'undefined') return;
unloadBound = true;
const release = () => {
clearConnectTimer();
if (socket?.connected) {
// Manual disconnect frees the presence slot; pageshow/visibility will reconnect.
socket.disconnect();
}
};
// pagehide covers tab close, bfcache, and mobile backgrounding better than unload
window.addEventListener('pagehide', release);
window.addEventListener('pageshow', () => {
ensureConnected();
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') ensureConnected();
});
}
export function getSocket() {
if (!socket) {
// Connect only after auth is known (see refreshSocketSession) to avoid
// anonymous+auth double handshakes that stack slots on rapid refresh.
socket = io({
path: '/socket.io',
withCredentials: true,
autoConnect: true,
autoConnect: false,
reconnection: true,
reconnectionDelay: 500,
reconnectionAttempts: Infinity,
});
bindUnloadOnce();
socket.on('session:limit', (payload) => {
socket.io.reconnection(false);
notifyLimit(payload);
});
socket.on('session:ok', () => {
socket.on('session:replaced', () => {
// Another tab/refresh took our slot — stay quiet, allow a later reconnect
// without showing the hard limit overlay.
socket.io.reconnection(false);
});
socket.on('session:ok', (payload) => {
if (!socket.io.reconnection()) {
socket.io.reconnection(true);
}
notifyOk();
notifySessionOk(payload);
});
}
return socket;
}
/**
* Force a new Socket.IO handshake so express-session reloads the auth cookie.
* Needed after login/register/logout — otherwise an anonymous socket stays "offline".
* Serialized so spam refresh cannot open many overlapping sockets from this tab.
*/
export function refreshSocketSession() {
const s = getSocket();
if (limited) return s;
const seq = ++reconnectSeq;
clearConnectTimer();
s.io.reconnection(true);
const connectNow = () => {
if (seq !== reconnectSeq || limited) return;
if (!s.connected) s.connect();
};
if (s.connected) {
s.once('disconnect', () => {
if (seq !== reconnectSeq) return;
clearConnectTimer();
connectTimer = setTimeout(connectNow, 40);
});
s.disconnect();
} else {
clearConnectTimer();
connectTimer = setTimeout(connectNow, 0);
}
return s;
}
export function isSessionLimited() {
return limited;
}
@@ -70,10 +167,17 @@ export function onSessionLimitChange(fn) {
return () => limitListeners.delete(fn);
}
/** Fired when the server confirms an authenticated presence session. */
export function onSocketSessionOk(fn) {
sessionOkListeners.add(fn);
return () => sessionOkListeners.delete(fn);
}
/** Allow reconnect after the user closes other tabs and reloads. */
export function clearSessionLimit() {
notifyOk();
if (socket) {
socket.io.reconnection(true);
if (!socket.connected) socket.connect();
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
port: 5888,
proxy: {
'/api': {
target: 'http://localhost:3001',