nice update
This commit is contained in:
+3
-2
@@ -4,5 +4,6 @@ dist/
|
||||
*.db
|
||||
*.db-journal
|
||||
.DS_Store
|
||||
server/sessions.db
|
||||
client/dist/
|
||||
server/uploads/
|
||||
!server/uploads/.gitkeep
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# CaseForge
|
||||
|
||||
CS-style case opening site with virtual credits, inventory, leaderboard, and an admin panel for cases / drops / rates.
|
||||
CS-style case opening site with virtual credits, inventory, multiplayer item betting, shop, leaderboard, and an admin panel for cases / drops / rates.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Server**: Node.js, Express, Prisma, SQLite, express-session
|
||||
- **Client**: React (Vite), React Router
|
||||
- **Server**: Node.js, Express, Prisma, SQLite, express-session, Socket.IO
|
||||
- **Client**: React (Vite), React Router, Socket.IO client
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -18,6 +18,20 @@ npm run dev
|
||||
|
||||
- App: http://localhost:5173
|
||||
- API: http://localhost:3001
|
||||
- LAN: http://\<your-lan-ip\>:5173 (Vite binds to `0.0.0.0`)
|
||||
|
||||
Create `server/.env` if missing:
|
||||
|
||||
```
|
||||
DATABASE_URL="file:./dev.db"
|
||||
PORT=3001
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
SESSION_SECRET=dev-secret
|
||||
STARTING_BALANCE=10000
|
||||
STARTING_OSU=100
|
||||
JACKPOT_ROUND_SECONDS=60
|
||||
JACKPOT_MAX_ITEMS=3
|
||||
```
|
||||
|
||||
## Demo accounts
|
||||
|
||||
@@ -26,18 +40,28 @@ npm run dev
|
||||
| Admin | `admin` | `admin123` |
|
||||
| Player| `player` | `player123`|
|
||||
|
||||
New registrations receive **100.00** starting credits (`STARTING_BALANCE` in `server/.env`, stored in cents).
|
||||
New registrations receive **100.00** starting credits and **100 osu** (`STARTING_BALANCE` / `STARTING_OSU` in `server/.env`; credits stored in cents).
|
||||
|
||||
## Features
|
||||
|
||||
- Login / register (username + password)
|
||||
- Dashboard: inventory + available cases
|
||||
- Case page with CS-style reel open animation (result rolled server-side)
|
||||
- Inventory: sort by recent or value, multi-select and sell at market value
|
||||
- **Feed Drop**: toggle in nav — live left column of latest case unboxes + player
|
||||
- Cases page: browse and open crates (CS-style reel, result rolled server-side)
|
||||
- **Bet Item** hub:
|
||||
- **MultiPlayers Case** — live shared pot, up to 3 items, win chance by value, Socket.IO sync
|
||||
- **1vX Case** — custom rooms (player/item limits, min value), spectate in-progress
|
||||
- **Coinflip** — stub (coming soon)
|
||||
- **Shop**: spend osu on credit packs; watch an ad every 1 min for 100.00–200.00 cr
|
||||
- Profile (via username in the nav): avatar, bio, password, stats
|
||||
- Leaderboard: richest players (balance + inventory market value)
|
||||
- Admin: manage cases, items, drop weights, enable/disable cases
|
||||
- Admin: manage cases, items, images (URL or upload), drop weights, enable/disable cases
|
||||
|
||||
## Notes
|
||||
|
||||
- Balances and prices are integers in **cents**
|
||||
- `Transaction` types include `open_case` and `seed_credit` (stubs for future `deposit` / `withdrawal`)
|
||||
- Balances and prices are integers in **cents**; **osu** is a separate demo currency for the shop
|
||||
- Inventory items locked while deposited in a bet; unlocked or transferred on resolve
|
||||
- `Transaction` types include `open_case`, `seed_credit`, `seed_osu`, `sell_item`, `shop_purchase`, `ad_reward`, `jackpot_bet`, `jackpot_win`, `duel_bet`, `duel_win`
|
||||
- Avatars stored under `server/uploads/` and served at `/uploads`
|
||||
- Real-time state via Socket.IO (`jackpot`, `duel:{id}`, `duels` rooms)
|
||||
- Swap `DATABASE_URL` to PostgreSQL later without changing app logic
|
||||
|
||||
Generated
+110
-1
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
"react-router-dom": "^7.18.1",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
@@ -670,6 +671,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
@@ -747,6 +754,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -757,6 +781,28 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.6",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
|
||||
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.21.0",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1051,6 +1097,12 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
@@ -1273,6 +1325,34 @@
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.7",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
|
||||
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1385,6 +1465,35 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
"dependencies": {
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
"react-router-dom": "^7.18.1",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.17",
|
||||
|
||||
@@ -3,10 +3,18 @@ import Layout from './components/Layout';
|
||||
import { useAuth } from './AuthContext';
|
||||
import Admin from './pages/Admin';
|
||||
import CasePage from './pages/CasePage';
|
||||
import CasesPage from './pages/CasesPage';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Leaderboard from './pages/Leaderboard';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import BetHub from './pages/BetHub';
|
||||
import MultiPlayersPage from './pages/MultiPlayersPage';
|
||||
import DuelListPage from './pages/DuelListPage';
|
||||
import DuelRoomPage from './pages/DuelRoomPage';
|
||||
import CoinflipStub from './pages/CoinflipStub';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import ShopPage from './pages/ShopPage';
|
||||
|
||||
function HomeRedirect() {
|
||||
const { user, loading } = useAuth();
|
||||
@@ -25,7 +33,15 @@ export default function App() {
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<HomeRedirect />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/cases" element={<CasesPage />} />
|
||||
<Route path="/cases/:id" element={<CasePage />} />
|
||||
<Route path="/bet" element={<BetHub />} />
|
||||
<Route path="/bet/multi" element={<MultiPlayersPage />} />
|
||||
<Route path="/bet/1vx" element={<DuelListPage />} />
|
||||
<Route path="/bet/1vx/:id" element={<DuelRoomPage />} />
|
||||
<Route path="/bet/coinflip" element={<CoinflipStub />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/shop" element={<ShopPage />} />
|
||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -49,9 +49,23 @@ export function AuthProvider({ children }) {
|
||||
setUser((prev) => (prev ? { ...prev, balance } : prev));
|
||||
};
|
||||
|
||||
const patchUser = (partial) => {
|
||||
setUser((prev) => (prev ? { ...prev, ...partial } : prev));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ user, loading, login, register, adminLogin, logout, refresh, updateBalance }}
|
||||
value={{
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
adminLogin,
|
||||
logout,
|
||||
refresh,
|
||||
updateBalance,
|
||||
patchUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
|
||||
+64
-4
@@ -1,11 +1,13 @@
|
||||
async function request(path, options = {}) {
|
||||
const headers = { ...(options.headers || {}) };
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(path, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -32,7 +34,56 @@ export const api = {
|
||||
case: (id) => request(`/api/cases/${id}`),
|
||||
openCase: (id) => request(`/api/cases/${id}/open`, { method: 'POST' }),
|
||||
inventory: () => request('/api/inventory'),
|
||||
sellInventory: (inventoryItemIds) =>
|
||||
request('/api/inventory/sell', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
shop: () => request('/api/shop'),
|
||||
shopBuy: (packageId) =>
|
||||
request('/api/shop/buy', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ packageId }),
|
||||
}),
|
||||
shopAd: () => request('/api/shop/ad', { method: 'POST' }),
|
||||
feed: () => request('/api/feed'),
|
||||
leaderboard: () => request('/api/leaderboard'),
|
||||
jackpot: () => request('/api/jackpot'),
|
||||
jackpotBet: (inventoryItemIds) =>
|
||||
request('/api/jackpot/bet', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
jackpotWithdraw: (inventoryItemIds) =>
|
||||
request('/api/jackpot/withdraw', {
|
||||
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`, {
|
||||
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'),
|
||||
updateProfile: (body) =>
|
||||
request('/api/profile', { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
changePassword: (currentPassword, newPassword) =>
|
||||
request('/api/profile/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
}),
|
||||
uploadAvatar: (file) => {
|
||||
const form = new FormData();
|
||||
form.append('avatar', file);
|
||||
return request('/api/profile/avatar', { method: 'POST', body: form });
|
||||
},
|
||||
adminLogin: (username, password) =>
|
||||
request('/api/admin/login', {
|
||||
method: 'POST',
|
||||
@@ -63,8 +114,17 @@ export const api = {
|
||||
}),
|
||||
deleteCaseItem: (id) =>
|
||||
request(`/api/admin/case-items/${id}`, { method: 'DELETE' }),
|
||||
adminUpload: (file) => {
|
||||
const form = new FormData();
|
||||
form.append('image', file);
|
||||
return request('/api/admin/upload', { method: 'POST', body: form });
|
||||
},
|
||||
};
|
||||
|
||||
export function formatCredits(cents) {
|
||||
return (Number(cents) / 100).toFixed(2);
|
||||
}
|
||||
|
||||
export function formatChance(pct) {
|
||||
return `${Number(pct || 0).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ function buildTrack(items, winnerId, spins = 42) {
|
||||
return { track, winnerIndex: spins };
|
||||
}
|
||||
|
||||
export default function CaseReel({ items, winnerId, spinning, onDone }) {
|
||||
export default function CaseReel({ items, winnerId, spinning, onDone, showOwners = false }) {
|
||||
const trackRef = useRef(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const onDoneRef = useRef(onDone);
|
||||
@@ -55,7 +55,7 @@ export default function CaseReel({ items, winnerId, spinning, onDone }) {
|
||||
}, [spinning, winnerId, winnerIndex, items]);
|
||||
|
||||
return (
|
||||
<div className="reel-wrap" ref={trackRef}>
|
||||
<div className={`reel-wrap${showOwners ? ' reel-wrap-owners' : ''}`} ref={trackRef}>
|
||||
<div className="reel-marker" />
|
||||
<div
|
||||
className="reel-track"
|
||||
@@ -73,9 +73,25 @@ export default function CaseReel({ items, winnerId, spinning, onDone }) {
|
||||
style={{ '--rarity': rarityColor(item.rarity) }}
|
||||
>
|
||||
<div className="glyph" style={{ color: rarityColor(item.rarity) }}>
|
||||
{item.name.slice(0, 2).toUpperCase()}
|
||||
{item.imageUrl ? (
|
||||
<img src={item.imageUrl} alt="" className="reel-img" />
|
||||
) : (
|
||||
item.name.slice(0, 2).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="label">{item.name}</div>
|
||||
{showOwners && (item.ownerUsername || item.ownerAvatarUrl) ? (
|
||||
<div className="reel-owner">
|
||||
{item.ownerAvatarUrl ? (
|
||||
<img src={item.ownerAvatarUrl} alt="" className="reel-owner-avatar" />
|
||||
) : (
|
||||
<span className="reel-owner-fallback">
|
||||
{(item.ownerUsername || '?').slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="reel-owner-name">{item.ownerUsername}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { getSocket } from '../socket';
|
||||
import { rarityColor } from '../rarities';
|
||||
|
||||
const MAX_BUFFER = 40;
|
||||
const KEEP_FOREVER = 5;
|
||||
const EXPIRE_MS = 3 * 60 * 1000; // older than 3 min disappear (except top 5)
|
||||
|
||||
function formatDropTime(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString(undefined, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function pruneDrops(list, now = Date.now()) {
|
||||
return list.filter((d, index) => {
|
||||
if (index < KEEP_FOREVER) return true;
|
||||
const t = new Date(d.at).getTime();
|
||||
if (Number.isNaN(t)) return false;
|
||||
return now - t < EXPIRE_MS;
|
||||
});
|
||||
}
|
||||
|
||||
export default function DropFeed() {
|
||||
const [drops, setDrops] = useState([]);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.feed();
|
||||
if (!cancelled) setDrops(pruneDrops(data.drops || []));
|
||||
} catch {
|
||||
/* ignore — socket history may fill */
|
||||
}
|
||||
})();
|
||||
|
||||
const socket = getSocket();
|
||||
const onHistory = (list) => {
|
||||
setDrops(pruneDrops(Array.isArray(list) ? list.slice(0, MAX_BUFFER) : []));
|
||||
};
|
||||
const onDrop = (entry) => {
|
||||
setDrops((prev) => pruneDrops([entry, ...prev].slice(0, MAX_BUFFER)));
|
||||
};
|
||||
|
||||
socket.emit('feed:subscribe');
|
||||
socket.on('feed:history', onHistory);
|
||||
socket.on('feed:drop', onDrop);
|
||||
socket.on('connect', () => socket.emit('feed:subscribe'));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socket.emit('feed:unsubscribe');
|
||||
socket.off('feed:history', onHistory);
|
||||
socket.off('feed:drop', onDrop);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const t = Date.now();
|
||||
setNow(t);
|
||||
setDrops((prev) => pruneDrops(prev, t));
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const visible = useMemo(() => pruneDrops(drops, now), [drops, now]);
|
||||
|
||||
return (
|
||||
<aside className="drop-feed" aria-label="Drop feed">
|
||||
<div className="drop-feed-head">
|
||||
<h2>Feed Drop</h2>
|
||||
<p className="muted">Latest unboxes</p>
|
||||
</div>
|
||||
<div className="drop-feed-list">
|
||||
{visible.length === 0 ? (
|
||||
<div className="drop-feed-empty muted">No drops yet — open a case.</div>
|
||||
) : (
|
||||
visible.map((d) => {
|
||||
const color = rarityColor(d.item?.rarity);
|
||||
return (
|
||||
<div key={d.id} className="drop-feed-row" style={{ borderLeftColor: color }}>
|
||||
<div className="drop-feed-item-visual" style={{ color }}>
|
||||
{d.item?.imageUrl ? (
|
||||
<img src={d.item.imageUrl} alt="" />
|
||||
) : (
|
||||
(d.item?.name || '??').slice(0, 2).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="drop-feed-meta">
|
||||
<div className="drop-feed-item-name">{d.item?.name}</div>
|
||||
<div className="drop-feed-player">
|
||||
{d.avatarUrl ? (
|
||||
<img src={d.avatarUrl} alt="" className="drop-feed-avatar" />
|
||||
) : (
|
||||
<span className="drop-feed-avatar fallback">
|
||||
{(d.username || '?').slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span>{d.username}</span>
|
||||
</div>
|
||||
<div className="muted drop-feed-sub">
|
||||
{d.caseName ? `${d.caseName} · ` : ''}
|
||||
{formatCredits(d.item?.marketValue || 0)} cr
|
||||
</div>
|
||||
<div className="muted drop-feed-time">{formatDropTime(d.at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,43 @@
|
||||
import { formatCredits } from '../api';
|
||||
import { rarityColor } from '../rarities';
|
||||
|
||||
export default function ItemTile({ item, showValue = true }) {
|
||||
export default function ItemTile({
|
||||
item,
|
||||
showValue = true,
|
||||
selected = false,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}) {
|
||||
const color = rarityColor(item.rarity);
|
||||
const clickable = typeof onClick === 'function';
|
||||
const hasImage = Boolean(item.imageUrl);
|
||||
|
||||
return (
|
||||
<div className="item-tile" style={{ borderTopColor: color }}>
|
||||
<div className="item-visual" style={{ color }}>
|
||||
{item.name.slice(0, 2).toUpperCase()}
|
||||
<div
|
||||
className={`item-tile${selected ? ' selected' : ''}${clickable ? ' selectable' : ''}${
|
||||
disabled ? ' disabled' : ''
|
||||
}`}
|
||||
style={{ borderTopColor: color }}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable && !disabled ? 0 : undefined}
|
||||
onKeyDown={
|
||||
clickable && !disabled
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className={`item-visual${hasImage ? ' has-img' : ''}`} style={{ color }}>
|
||||
{hasImage ? (
|
||||
<img src={item.imageUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
item.name.slice(0, 2).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="item-name">{item.name}</div>
|
||||
<div className="item-meta" style={{ color }}>
|
||||
|
||||
@@ -1,18 +1,52 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { formatCredits } from '../api';
|
||||
import DropFeed from './DropFeed';
|
||||
|
||||
const FEED_KEY = 'caseforge-feed-drop';
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
const [feedOpen, setFeedOpen] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(FEED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(FEED_KEY, feedOpen ? '1' : '0');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [feedOpen]);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className={`app-shell${feedOpen ? ' feed-open' : ''}`}>
|
||||
<header className="topnav">
|
||||
<NavLink to="/dashboard" className="brand">
|
||||
CaseForge
|
||||
</NavLink>
|
||||
<nav className="nav-links">
|
||||
<NavLink to="/dashboard">Dashboard</NavLink>
|
||||
{user && user.role !== 'admin' && (
|
||||
<>
|
||||
<NavLink to="/dashboard">Inventory</NavLink>
|
||||
<NavLink to="/cases">Cases</NavLink>
|
||||
<NavLink to="/bet">Bet Item</NavLink>
|
||||
<NavLink to="/shop">Shop</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-toggle${feedOpen ? ' active' : ''}`}
|
||||
onClick={() => setFeedOpen((v) => !v)}
|
||||
aria-pressed={feedOpen}
|
||||
>
|
||||
Feed Drop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<NavLink to="/leaderboard">Leaderboard</NavLink>
|
||||
{user?.role === 'admin' && <NavLink to="/admin">Admin</NavLink>}
|
||||
{user && (
|
||||
@@ -20,7 +54,12 @@ export default function Layout() {
|
||||
)}
|
||||
{user ? (
|
||||
<>
|
||||
<NavLink to="/profile" className="nav-user" title="Profile">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="" className="nav-avatar" />
|
||||
) : null}
|
||||
<span className="muted">{user.username}</span>
|
||||
</NavLink>
|
||||
<button type="button" className="btn ghost" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
@@ -30,9 +69,12 @@ export default function Layout() {
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
<div className="app-body">
|
||||
{feedOpen && user && user.role !== 'admin' && <DropFeed />}
|
||||
<main className="main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useCountdown(endsAt) {
|
||||
const [remaining, setRemaining] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endsAt) {
|
||||
setRemaining(0);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const ms = new Date(endsAt).getTime() - Date.now();
|
||||
setRemaining(Math.max(0, Math.ceil(ms / 1000)));
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 250);
|
||||
return () => clearInterval(id);
|
||||
}, [endsAt]);
|
||||
|
||||
const mm = String(Math.floor(remaining / 60)).padStart(2, '0');
|
||||
const ss = String(remaining % 60).padStart(2, '0');
|
||||
return { remaining, label: `${mm}:${ss}` };
|
||||
}
|
||||
@@ -65,6 +65,18 @@ h3 {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-body .main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -104,6 +116,158 @@ h3 {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
background: transparent;
|
||||
border: 1px solid var(--stroke);
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.55rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-toggle:hover,
|
||||
.nav-toggle.active {
|
||||
color: var(--text);
|
||||
border-color: var(--accent-dim);
|
||||
background: rgba(61, 214, 198, 0.08);
|
||||
}
|
||||
|
||||
.drop-feed {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--stroke);
|
||||
background: rgba(10, 15, 19, 0.85);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 58px);
|
||||
position: sticky;
|
||||
top: 58px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.drop-feed-head {
|
||||
padding: 0.9rem 1rem 0.5rem;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.drop-feed-head h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.drop-feed-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.drop-feed-empty {
|
||||
padding: 1rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.drop-feed-row {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
padding: 0.45rem;
|
||||
border: 1px solid var(--stroke);
|
||||
border-left-width: 3px;
|
||||
background: var(--bg-panel);
|
||||
animation: rise-in 280ms ease both;
|
||||
}
|
||||
|
||||
.drop-feed-item-visual {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.85rem;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--stroke);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drop-feed-item-visual img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.drop-feed-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.drop-feed-item-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drop-feed-player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.drop-feed-avatar {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.drop-feed-avatar.fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.6rem;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--accent);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.drop-feed-sub {
|
||||
font-size: 0.72rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.drop-feed-time {
|
||||
font-size: 0.68rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.drop-feed {
|
||||
width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.app-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drop-feed {
|
||||
width: 100%;
|
||||
max-height: 220px;
|
||||
position: static;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
}
|
||||
}
|
||||
|
||||
.balance-pill {
|
||||
font-family: var(--font-display);
|
||||
color: var(--gold);
|
||||
@@ -343,6 +507,10 @@ h3 {
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.reel-wrap.reel-wrap-owners {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.reel-wrap::before,
|
||||
.reel-wrap::after {
|
||||
content: '';
|
||||
@@ -412,6 +580,44 @@ h3 {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reel-owner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
max-width: 100%;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.reel-owner-avatar,
|
||||
.reel-owner-fallback {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--stroke);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.reel-owner-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1rem;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--accent);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.reel-owner-name {
|
||||
font-size: 0.62rem;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.won-banner {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
@@ -426,6 +632,15 @@ h3 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.won-img {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--stroke);
|
||||
margin: 0 auto 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -542,3 +757,396 @@ h3 {
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
}
|
||||
|
||||
.item-tile.selectable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-tile.selected {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.item-tile.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mode-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mode-card {
|
||||
display: block;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--stroke);
|
||||
background: linear-gradient(160deg, var(--bg-elevated), var(--bg-panel));
|
||||
min-height: 160px;
|
||||
transition: transform 180ms ease, border-color 180ms ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
a.mode-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.mode-card.stub {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-display);
|
||||
color: var(--gold);
|
||||
border: 1px solid rgba(228, 174, 57, 0.4);
|
||||
padding: 0.15rem 0.45rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.jackpot-status {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.timer-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.timer-digits {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.6rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.gold {
|
||||
color: var(--gold);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-pill.open {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status-pill.spinning,
|
||||
.status-pill.in_progress {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.status-pill.resolved,
|
||||
.status-pill.cancelled {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.row-me {
|
||||
background: rgba(61, 214, 198, 0.06);
|
||||
}
|
||||
|
||||
.deposit-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.deposit-owner {
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.room-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.room-card {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg-panel);
|
||||
transition: border-color 160ms ease;
|
||||
}
|
||||
|
||||
.room-card:hover {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.room-card.live {
|
||||
border-color: rgba(228, 174, 57, 0.35);
|
||||
}
|
||||
|
||||
.room-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.form-inline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.profile-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg-deep);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-display);
|
||||
font-size: 2rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.avatar-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-btn {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.ok-msg {
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.nav-user {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.nav-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.stub-panel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-visual.has-img,
|
||||
.case-visual.has-img,
|
||||
.case-icon.has-img {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-visual img,
|
||||
.case-visual img,
|
||||
.case-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.case-icon.has-img {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.case-icon img {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.reel-img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reel-wrap-owners .reel-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.reel-wrap-owners .reel-item .glyph {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.image-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-field input[type='text'],
|
||||
.image-field > input:not([type]) {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--stroke);
|
||||
color: var(--text);
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
|
||||
.admin-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg-deep);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.65rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.admin-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.admin-thumb.empty {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
.drop-item-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.drop-thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.osu-pill {
|
||||
font-family: var(--font-display);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(61, 214, 198, 0.35);
|
||||
background: rgba(61, 214, 198, 0.08);
|
||||
padding: 0.35rem 0.7rem;
|
||||
}
|
||||
|
||||
.inv-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sort-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.sort-toggle button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
padding: 0.45rem 0.85rem;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.sort-toggle button.active {
|
||||
color: var(--text);
|
||||
background: rgba(61, 214, 198, 0.1);
|
||||
}
|
||||
|
||||
.shop-pack .pack-credits {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
color: var(--gold);
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
|
||||
.ad-panel {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ad-sim {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.ad-bar {
|
||||
height: 6px;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(90deg, var(--accent-dim), var(--accent), var(--accent-dim));
|
||||
background-size: 200% 100%;
|
||||
animation: ad-slide 1.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ad-slide {
|
||||
from {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
+287
-29
@@ -3,6 +3,55 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
const emptyCase = { name: '', price: 100, imageUrl: '', active: true };
|
||||
const emptyItem = { name: '', rarity: 'MilSpec', marketValue: 100, imageUrl: '' };
|
||||
|
||||
function ImageField({ value, onChange, disabled }) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const onFile = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const data = await api.adminUpload(file);
|
||||
onChange(data.url);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="image-field">
|
||||
{value ? (
|
||||
<div className="admin-thumb">
|
||||
<img src={value} alt="" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-thumb empty">No img</div>
|
||||
)}
|
||||
<input
|
||||
placeholder="Image URL or upload"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<label className="btn secondary file-btn">
|
||||
{uploading ? '…' : 'Upload'}
|
||||
<input type="file" accept="image/*" hidden onChange={onFile} disabled={disabled || uploading} />
|
||||
</label>
|
||||
{value ? (
|
||||
<button type="button" className="btn ghost" onClick={() => onChange('')} disabled={disabled}>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Admin() {
|
||||
const { user, loading, adminLogin, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -15,13 +64,12 @@ export default function Admin() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const [caseForm, setCaseForm] = useState({ name: '', price: 100, imageUrl: '', active: true });
|
||||
const [itemForm, setItemForm] = useState({
|
||||
name: '',
|
||||
rarity: 'MilSpec',
|
||||
marketValue: 100,
|
||||
imageUrl: '',
|
||||
});
|
||||
const [caseForm, setCaseForm] = useState(emptyCase);
|
||||
const [itemForm, setItemForm] = useState(emptyItem);
|
||||
const [editingCaseId, setEditingCaseId] = useState(null);
|
||||
const [editingItemId, setEditingItemId] = useState(null);
|
||||
const [caseEdit, setCaseEdit] = useState(null);
|
||||
const [itemEdit, setItemEdit] = useState(null);
|
||||
const [dropForm, setDropForm] = useState({ caseId: '', itemId: '', dropRate: 1 });
|
||||
|
||||
const load = async () => {
|
||||
@@ -39,8 +87,8 @@ export default function Admin() {
|
||||
if (!dropForm.itemId && i.items[0]) {
|
||||
setDropForm((f) => ({ ...f, itemId: String(i.items[0].id) }));
|
||||
}
|
||||
if (r.rarities.length && !itemForm.rarity) {
|
||||
setItemForm((f) => ({ ...f, rarity: r.rarities[0] }));
|
||||
if (r.rarities.length) {
|
||||
setItemForm((f) => ({ ...f, rarity: f.rarity || r.rarities[0] }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,6 +144,62 @@ export default function Admin() {
|
||||
);
|
||||
}
|
||||
|
||||
const startEditCase = (c) => {
|
||||
setEditingCaseId(c.id);
|
||||
setCaseEdit({
|
||||
name: c.name,
|
||||
price: c.price,
|
||||
imageUrl: c.imageUrl || '',
|
||||
active: c.active,
|
||||
});
|
||||
};
|
||||
|
||||
const saveCaseEdit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await api.updateCase(editingCaseId, {
|
||||
name: caseEdit.name,
|
||||
price: Number(caseEdit.price),
|
||||
imageUrl: caseEdit.imageUrl,
|
||||
active: caseEdit.active,
|
||||
});
|
||||
setEditingCaseId(null);
|
||||
setCaseEdit(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditItem = (item) => {
|
||||
setEditingItemId(item.id);
|
||||
setItemEdit({
|
||||
name: item.name,
|
||||
rarity: item.rarity,
|
||||
marketValue: item.marketValue,
|
||||
imageUrl: item.imageUrl || '',
|
||||
});
|
||||
};
|
||||
|
||||
const saveItemEdit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await api.updateItem(editingItemId, {
|
||||
name: itemEdit.name,
|
||||
rarity: itemEdit.rarity,
|
||||
marketValue: Number(itemEdit.marketValue),
|
||||
imageUrl: itemEdit.imageUrl,
|
||||
});
|
||||
setEditingItemId(null);
|
||||
setItemEdit(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main" style={{ paddingTop: '1.5rem' }}>
|
||||
<header className="topnav" style={{ margin: '-1.5rem -1.5rem 1.5rem', position: 'static' }}>
|
||||
@@ -117,7 +221,7 @@ export default function Admin() {
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Admin panel</h1>
|
||||
<p className="muted">Cases, loot pool, and drop weights.</p>
|
||||
<p className="muted">Cases, loot pool, images, and drop weights.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +245,7 @@ export default function Admin() {
|
||||
<div className="admin-block">
|
||||
<h3>Create case</h3>
|
||||
<form
|
||||
className="admin-row"
|
||||
className="form"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@@ -149,43 +253,110 @@ export default function Admin() {
|
||||
...caseForm,
|
||||
price: Number(caseForm.price),
|
||||
});
|
||||
setCaseForm({ name: '', price: 100, imageUrl: '', active: true });
|
||||
setCaseForm(emptyCase);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
placeholder="Name"
|
||||
value={caseForm.name}
|
||||
onChange={(e) => setCaseForm({ ...caseForm, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Price (cents)
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Price (cents)"
|
||||
value={caseForm.price}
|
||||
onChange={(e) => setCaseForm({ ...caseForm, price: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
placeholder="Image URL"
|
||||
</label>
|
||||
<label>
|
||||
Image
|
||||
<ImageField
|
||||
value={caseForm.imageUrl}
|
||||
onChange={(e) => setCaseForm({ ...caseForm, imageUrl: e.target.value })}
|
||||
onChange={(imageUrl) => setCaseForm({ ...caseForm, imageUrl })}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn" type="submit">
|
||||
Add
|
||||
Add case
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{cases.map((c) => (
|
||||
<div key={c.id} className="admin-block">
|
||||
{editingCaseId === c.id && caseEdit ? (
|
||||
<form className="form" onSubmit={saveCaseEdit}>
|
||||
<h3>Edit case #{c.id}</h3>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={caseEdit.name}
|
||||
onChange={(e) => setCaseEdit({ ...caseEdit, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Price (cents)
|
||||
<input
|
||||
type="number"
|
||||
value={caseEdit.price}
|
||||
onChange={(e) => setCaseEdit({ ...caseEdit, price: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Image
|
||||
<ImageField
|
||||
value={caseEdit.imageUrl}
|
||||
onChange={(imageUrl) => setCaseEdit({ ...caseEdit, imageUrl })}
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={caseEdit.active}
|
||||
onChange={(e) => setCaseEdit({ ...caseEdit, active: e.target.checked })}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
<div className="row-actions">
|
||||
<button className="btn" type="submit">
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setEditingCaseId(null);
|
||||
setCaseEdit(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-row">
|
||||
{c.imageUrl ? (
|
||||
<div className="admin-thumb">
|
||||
<img src={c.imageUrl} alt="" />
|
||||
</div>
|
||||
) : null}
|
||||
<strong>{c.name}</strong>
|
||||
<span className="muted">{formatCredits(c.price)} cr</span>
|
||||
<span className="muted">{c.active ? 'active' : 'inactive'}</span>
|
||||
<button type="button" className="btn secondary" onClick={() => startEditCase(c)}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
@@ -209,6 +380,8 @@ export default function Admin() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="muted">{c.items.length} drops configured</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -219,7 +392,7 @@ export default function Admin() {
|
||||
<div className="admin-block">
|
||||
<h3>Create item</h3>
|
||||
<form
|
||||
className="admin-row"
|
||||
className="form"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@@ -228,10 +401,8 @@ export default function Admin() {
|
||||
marketValue: Number(itemForm.marketValue),
|
||||
});
|
||||
setItemForm({
|
||||
name: '',
|
||||
...emptyItem,
|
||||
rarity: rarities[0] || 'MilSpec',
|
||||
marketValue: 100,
|
||||
imageUrl: '',
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -239,12 +410,16 @@ export default function Admin() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
placeholder="Name"
|
||||
value={itemForm.name}
|
||||
onChange={(e) => setItemForm({ ...itemForm, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Rarity
|
||||
<select
|
||||
value={itemForm.rarity}
|
||||
onChange={(e) => setItemForm({ ...itemForm, rarity: e.target.value })}
|
||||
@@ -255,25 +430,104 @@ export default function Admin() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Value (cents)
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Value (cents)"
|
||||
value={itemForm.marketValue}
|
||||
onChange={(e) => setItemForm({ ...itemForm, marketValue: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Image
|
||||
<ImageField
|
||||
value={itemForm.imageUrl}
|
||||
onChange={(imageUrl) => setItemForm({ ...itemForm, imageUrl })}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn" type="submit">
|
||||
Add
|
||||
Add item
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="admin-block">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="admin-row">
|
||||
<div key={item.id} className="admin-block">
|
||||
{editingItemId === item.id && itemEdit ? (
|
||||
<form className="form" onSubmit={saveItemEdit}>
|
||||
<h3>Edit item #{item.id}</h3>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={itemEdit.name}
|
||||
onChange={(e) => setItemEdit({ ...itemEdit, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Rarity
|
||||
<select
|
||||
value={itemEdit.rarity}
|
||||
onChange={(e) => setItemEdit({ ...itemEdit, rarity: e.target.value })}
|
||||
>
|
||||
{rarities.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Value (cents)
|
||||
<input
|
||||
type="number"
|
||||
value={itemEdit.marketValue}
|
||||
onChange={(e) => setItemEdit({ ...itemEdit, marketValue: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Image
|
||||
<ImageField
|
||||
value={itemEdit.imageUrl}
|
||||
onChange={(imageUrl) => setItemEdit({ ...itemEdit, imageUrl })}
|
||||
/>
|
||||
</label>
|
||||
<div className="row-actions">
|
||||
<button className="btn" type="submit">
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setEditingItemId(null);
|
||||
setItemEdit(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="admin-row">
|
||||
{item.imageUrl ? (
|
||||
<div className="admin-thumb">
|
||||
<img src={item.imageUrl} alt="" />
|
||||
</div>
|
||||
) : null}
|
||||
<span>
|
||||
{item.name} · {item.rarity} · {formatCredits(item.marketValue)} cr
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => startEditItem(item)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
@@ -286,8 +540,9 @@ export default function Admin() {
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -352,7 +607,10 @@ export default function Admin() {
|
||||
{c.items.length === 0 && <div className="muted">No drops yet.</div>}
|
||||
{c.items.map((ci) => (
|
||||
<div key={ci.id} className="drop-row">
|
||||
<span>
|
||||
<span className="drop-item-cell">
|
||||
{ci.item.imageUrl ? (
|
||||
<img src={ci.item.imageUrl} alt="" className="drop-thumb" />
|
||||
) : null}
|
||||
{ci.item.name} ({ci.item.rarity})
|
||||
</span>
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
const MODES = [
|
||||
{
|
||||
to: '/bet/multi',
|
||||
title: 'MultiPlayers Case',
|
||||
blurb: 'Join the live pot. Bet up to 3 items — chance scales with value. Winner takes all.',
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
to: '/bet/1vx',
|
||||
title: '1vX Case',
|
||||
blurb: 'Create or join custom rooms. Set player/item limits and min value. Spectate live duels.',
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
to: '/bet/coinflip',
|
||||
title: 'Coinflip',
|
||||
blurb: 'Classic 1v1 item flip. Coming soon.',
|
||||
ready: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default function BetHub() {
|
||||
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>Bet Item</h1>
|
||||
<p className="muted">Multiplayer item gambling modes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mode-grid">
|
||||
{MODES.map((m) =>
|
||||
m.ready ? (
|
||||
<Link key={m.to} to={m.to} className="mode-card">
|
||||
<h2>{m.title}</h2>
|
||||
<p className="muted">{m.blurb}</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div key={m.to} className="mode-card stub">
|
||||
<h2>{m.title}</h2>
|
||||
<p className="muted">{m.blurb}</p>
|
||||
<span className="badge">Soon</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -60,8 +60,8 @@ export default function CasePage() {
|
||||
|
||||
return (
|
||||
<div className="case-page">
|
||||
<Link to="/dashboard" className="muted">
|
||||
← Back to dashboard
|
||||
<Link to="/cases" className="muted">
|
||||
← Back to cases
|
||||
</Link>
|
||||
|
||||
{!crate && !error && <p className="muted">Loading case…</p>}
|
||||
@@ -76,12 +76,12 @@ export default function CasePage() {
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={`case-icon ${canOpen ? '' : 'disabled'}`}
|
||||
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}>
|
||||
{spinning ? 'Opening…' : `Open for ${formatCredits(crate.price)} cr`}
|
||||
@@ -105,6 +105,9 @@ export default function CasePage() {
|
||||
className="won-banner"
|
||||
style={{ '--rarity': rarityColor(wonItem.rarity) }}
|
||||
>
|
||||
{wonItem.imageUrl ? (
|
||||
<img src={wonItem.imageUrl} alt="" className="won-img" />
|
||||
) : null}
|
||||
<div className="muted">You unboxed</div>
|
||||
<strong>{wonItem.name}</strong>
|
||||
<div style={{ color: rarityColor(wonItem.rarity) }}>{wonItem.rarity}</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
export default function CasesPage() {
|
||||
const { user, loading } = useAuth();
|
||||
const [cases, setCases] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const cs = await api.cases();
|
||||
if (!cancelled) setCases(cs.cases);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
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>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) => (
|
||||
<Link key={c.id} to={`/cases/${c.id}`} className="case-tile">
|
||||
<div className={`case-visual${c.imageUrl ? ' has-img' : ''}`} style={{ color: 'var(--accent)' }}>
|
||||
{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">{c.items.length} possible drops</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{cases.length === 0 && !error && <div className="empty">No active cases.</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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="/bet" 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="/bet/multi" className="btn">
|
||||
MultiPlayers
|
||||
</Link>
|
||||
<Link to="/bet/1vx" className="btn secondary">
|
||||
1vX Case
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+106
-33
@@ -1,25 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, loading } = useAuth();
|
||||
const { user, loading, patchUser } = useAuth();
|
||||
const [inventory, setInventory] = useState([]);
|
||||
const [cases, setCases] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [sort, setSort] = useState('recent');
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const inv = await api.inventory();
|
||||
setInventory(inv.inventory);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [inv, cs] = await Promise.all([api.inventory(), api.cases()]);
|
||||
if (!cancelled) {
|
||||
setInventory(inv.inventory);
|
||||
setCases(cs.cases);
|
||||
}
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
@@ -29,6 +32,46 @@ export default function Dashboard() {
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const list = [...inventory];
|
||||
if (sort === 'value') {
|
||||
list.sort((a, b) => b.item.marketValue - a.item.marketValue);
|
||||
} else {
|
||||
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
|
||||
}
|
||||
return list;
|
||||
}, [inventory, sort]);
|
||||
|
||||
const selectedValue = useMemo(
|
||||
() =>
|
||||
inventory
|
||||
.filter((inv) => selected.includes(inv.id))
|
||||
.reduce((sum, inv) => sum + inv.item.marketValue, 0),
|
||||
[inventory, selected]
|
||||
);
|
||||
|
||||
const toggle = (id) => {
|
||||
setSelected((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const sell = async () => {
|
||||
if (!selected.length) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.sellInventory(selected);
|
||||
patchUser({ balance: data.user.balance });
|
||||
setSelected([]);
|
||||
await load();
|
||||
} 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 />;
|
||||
@@ -39,42 +82,72 @@ export default function Dashboard() {
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Inventory</h1>
|
||||
<p className="muted">Your drops and loot.</p>
|
||||
<p className="muted">Select items to sell at market value.</p>
|
||||
</div>
|
||||
<div className="balance-pill">{formatCredits(user.balance)} cr</div>
|
||||
</div>
|
||||
|
||||
<div className="inv-toolbar">
|
||||
<div className="sort-toggle" role="group" aria-label="Sort inventory">
|
||||
<button
|
||||
type="button"
|
||||
className={sort === 'recent' ? 'active' : ''}
|
||||
onClick={() => setSort('recent')}
|
||||
>
|
||||
Most recent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={sort === 'value' ? 'active' : ''}
|
||||
onClick={() => setSort('value')}
|
||||
>
|
||||
Highest value
|
||||
</button>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<span className="muted">
|
||||
{selected.length} selected · {formatCredits(selectedValue)} cr
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => setSelected([])}
|
||||
disabled={busy}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={sell} disabled={busy}>
|
||||
Sell
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{inventory.length === 0 ? (
|
||||
<div className="empty">Empty for now — open a case below.</div>
|
||||
<div className="empty">
|
||||
Empty for now —{' '}
|
||||
<Link to="/cases" style={{ color: 'var(--accent)' }}>
|
||||
open a case
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{inventory.map((inv) => (
|
||||
<ItemTile key={inv.id} item={inv.item} />
|
||||
{sorted.map((inv) => (
|
||||
<ItemTile
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
selected={selected.includes(inv.id)}
|
||||
onClick={() => toggle(inv.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Cases</h2>
|
||||
<p className="muted">Pick a crate to open.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid case-grid">
|
||||
{cases.map((c) => (
|
||||
<Link key={c.id} to={`/cases/${c.id}`} className="case-tile">
|
||||
<div className="case-visual" style={{ color: 'var(--accent)' }}>
|
||||
▣
|
||||
</div>
|
||||
<div className="case-name">{c.name}</div>
|
||||
<div className="case-meta">{formatCredits(c.price)} cr</div>
|
||||
<div className="case-meta">{c.items.length} possible drops</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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(`/bet/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="/bet" 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 (2–10)
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={10}
|
||||
value={form.maxPlayers}
|
||||
onChange={(e) => setForm({ ...form, maxPlayers: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Max items / player (1–10)
|
||||
<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={`/bet/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={`/bet/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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
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 from '../components/ItemTile';
|
||||
import CaseReel from '../components/CaseReel';
|
||||
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);
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
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="/bet/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>
|
||||
{inventory.length === 0 ? (
|
||||
<div className="empty">
|
||||
No free items — <Link to="/cases">open a case</Link>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{inventory.map((inv) => (
|
||||
<ItemTile
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
selected={selected.includes(inv.id)}
|
||||
disabled={
|
||||
(slotsLeft === 0 && !selected.includes(inv.id)) ||
|
||||
inv.item.marketValue < (room.minItemValue || 0)
|
||||
}
|
||||
onClick={() => toggleSelect(inv.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits, formatChance } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
import CaseReel from '../components/CaseReel';
|
||||
import { useCountdown } from '../hooks/useCountdown';
|
||||
import { getSocket } from '../socket';
|
||||
|
||||
export default function MultiPlayersPage() {
|
||||
const { user, loading, refresh } = useAuth();
|
||||
const [round, setRound] = 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 [lastWinner, setLastWinner] = useState(null);
|
||||
const [revealWinner, setRevealWinner] = useState(false);
|
||||
const [spinDeposits, setSpinDeposits] = useState([]);
|
||||
const [spinActive, setSpinActive] = useState(false);
|
||||
|
||||
const { label: timerLabel, remaining } = useCountdown(
|
||||
round?.status === 'open' ? round.endsAt : null
|
||||
);
|
||||
|
||||
const loadInventory = useCallback(async () => {
|
||||
const inv = await api.inventory();
|
||||
setInventory(inv.inventory);
|
||||
}, []);
|
||||
|
||||
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);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
|
||||
const socket = getSocket();
|
||||
const onState = (state) => {
|
||||
setRound(state);
|
||||
if (state?.status === 'open') {
|
||||
setSpinning(false);
|
||||
setReelWinnerItemId(null);
|
||||
loadInventory().catch(() => {});
|
||||
}
|
||||
};
|
||||
const onSpin = (state) => {
|
||||
setRound(state);
|
||||
setSpinDeposits(state.deposits || []);
|
||||
setSpinActive(true);
|
||||
setSpinning(true);
|
||||
setRevealWinner(false);
|
||||
setReelWinnerItemId(state.reelWinnerItemId);
|
||||
setLastWinner(state.winnerUsername);
|
||||
};
|
||||
const onResult = (state) => {
|
||||
setRound(state);
|
||||
setSpinning(false);
|
||||
loadInventory().catch(() => {});
|
||||
refresh().catch(() => {});
|
||||
};
|
||||
|
||||
socket.emit('jackpot:subscribe');
|
||||
socket.on('jackpot:state', onState);
|
||||
socket.on('jackpot:spin', onSpin);
|
||||
socket.on('jackpot:result', onResult);
|
||||
socket.on('connect', () => socket.emit('jackpot:subscribe'));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socket.emit('jackpot:unsubscribe');
|
||||
socket.off('jackpot:state', onState);
|
||||
socket.off('jackpot:spin', onSpin);
|
||||
socket.off('jackpot:result', onResult);
|
||||
};
|
||||
}, [user, loadInventory, refresh]);
|
||||
|
||||
const maxItems = round?.maxItemsPerPlayer || 3;
|
||||
const myDeposits = useMemo(
|
||||
() => (round?.deposits || []).filter((d) => d.userId === user?.id),
|
||||
[round, user]
|
||||
);
|
||||
const mySlotsLeft = Math.max(0, maxItems - myDeposits.length);
|
||||
|
||||
const potItems = useMemo(() => {
|
||||
const source = spinActive && spinDeposits.length ? spinDeposits : round?.deposits || [];
|
||||
return source
|
||||
.filter((d) => d.item)
|
||||
.map((d) => ({
|
||||
...d.item,
|
||||
id: d.id,
|
||||
ownerUsername: d.username,
|
||||
ownerAvatarUrl: d.avatarUrl || '',
|
||||
}));
|
||||
}, [round, spinDeposits, spinActive]);
|
||||
|
||||
const showReel = spinActive && potItems.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealWinner) return undefined;
|
||||
const t = setTimeout(() => setSpinActive(false), 5000);
|
||||
return () => clearTimeout(t);
|
||||
}, [revealWinner]);
|
||||
|
||||
const toggleSelect = (invId) => {
|
||||
setSelected((prev) => {
|
||||
if (prev.includes(invId)) return prev.filter((id) => id !== invId);
|
||||
if (prev.length >= mySlotsLeft) return prev;
|
||||
return [...prev, invId];
|
||||
});
|
||||
};
|
||||
|
||||
const placeBet = async () => {
|
||||
if (!selected.length) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.jackpotBet(selected);
|
||||
setRound(data.round);
|
||||
setSelected([]);
|
||||
await loadInventory();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const withdraw = async (inventoryItemIds) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.jackpotWithdraw(inventoryItemIds);
|
||||
setRound(data.round);
|
||||
await loadInventory();
|
||||
} 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 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>
|
||||
</div>
|
||||
<Link to="/bet" className="btn secondary">
|
||||
Modes
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<div className="jackpot-status">
|
||||
<div className="timer-block">
|
||||
<span className="muted">Round</span>
|
||||
<strong>#{round?.id || '—'}</strong>
|
||||
<span className={`status-pill ${round?.status || ''}`}>{round?.status || '…'}</span>
|
||||
</div>
|
||||
<div className="timer-block">
|
||||
<span className="muted">Timer</span>
|
||||
<strong className="timer-digits">
|
||||
{round?.status === 'open' ? timerLabel : round?.status === 'spinning' ? 'SPIN' : '—'}
|
||||
</strong>
|
||||
{round?.status === 'open' && remaining <= 5 && remaining > 0 && (
|
||||
<span className="muted">closing…</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="timer-block">
|
||||
<span className="muted">Pot</span>
|
||||
<strong className="gold">{formatCredits(round?.totalValue || 0)} cr</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showReel && (
|
||||
<div className="section">
|
||||
<CaseReel
|
||||
items={potItems}
|
||||
winnerId={reelWinnerItemId || potItems[0]?.id}
|
||||
spinning={spinning || round?.status === 'spinning'}
|
||||
showOwners
|
||||
onDone={() => {
|
||||
setSpinning(false);
|
||||
setRevealWinner(true);
|
||||
}}
|
||||
/>
|
||||
{revealWinner && lastWinner && (
|
||||
<div className="won-banner" style={{ marginTop: '0.75rem' }}>
|
||||
Winner: <strong>{lastWinner}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!showReel && revealWinner && lastWinner && (
|
||||
<div className="won-banner section">
|
||||
Winner: <strong>{lastWinner}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="section">
|
||||
<h2>Players</h2>
|
||||
{(round?.players || []).length === 0 ? (
|
||||
<div className="empty">No bets yet — be the first.</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Value</th>
|
||||
<th>Items</th>
|
||||
<th>Chance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{round.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>Pot items</h2>
|
||||
{(round?.deposits || []).length === 0 ? (
|
||||
<div className="empty">Empty pot.</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{round.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>
|
||||
|
||||
{myDeposits.length > 0 && round?.status === 'open' && (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<h2>Your bets ({myDeposits.length}/{maxItems})</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
disabled={busy}
|
||||
onClick={() => withdraw(myDeposits.map((d) => d.inventoryItemId))}
|
||||
>
|
||||
Withdraw all
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{round?.status === 'open' && (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Bet from inventory</h2>
|
||||
<p className="muted">
|
||||
Select up to {mySlotsLeft} more item{mySlotsLeft === 1 ? '' : 's'}.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy || !selected.length || mySlotsLeft === 0}
|
||||
onClick={placeBet}
|
||||
>
|
||||
Place bet
|
||||
</button>
|
||||
</div>
|
||||
{inventory.length === 0 ? (
|
||||
<div className="empty">
|
||||
No free items — <Link to="/cases">open a case</Link>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{inventory.map((inv) => (
|
||||
<ItemTile
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
selected={selected.includes(inv.id)}
|
||||
disabled={
|
||||
mySlotsLeft === 0 && !selected.includes(inv.id)
|
||||
}
|
||||
onClick={() => toggleSelect(inv.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, loading, refresh } = useAuth();
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [bio, setBio] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.profile();
|
||||
if (!cancelled) {
|
||||
setProfile(data.profile);
|
||||
setBio(data.user.bio || '');
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const saveBio = async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
await api.updateProfile({ bio });
|
||||
await refresh();
|
||||
setMessage('Profile updated.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
await api.changePassword(currentPassword, newPassword);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setMessage('Password changed.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onAvatar = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
await api.uploadAvatar(file);
|
||||
await refresh();
|
||||
setMessage('Avatar updated.');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Profile</h1>
|
||||
<p className="muted">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{message && <div className="ok-msg">{message}</div>}
|
||||
|
||||
<div className="profile-layout">
|
||||
<div className="profile-avatar-block admin-block">
|
||||
<div className="avatar-preview">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="" />
|
||||
) : (
|
||||
<span>{user.username.slice(0, 2).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="btn secondary file-btn">
|
||||
Change photo
|
||||
<input type="file" accept="image/*" hidden onChange={onAvatar} disabled={busy} />
|
||||
</label>
|
||||
<div className="muted" style={{ marginTop: '0.75rem' }}>
|
||||
Balance {formatCredits(user.balance)} cr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-stats admin-block">
|
||||
<h2>Stats</h2>
|
||||
{profile ? (
|
||||
<ul className="stat-list">
|
||||
<li>
|
||||
Joined{' '}
|
||||
<strong>{new Date(profile.createdAt).toLocaleDateString()}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Inventory{' '}
|
||||
<strong>
|
||||
{profile.inventoryCount} items · {formatCredits(profile.inventoryValue)} cr
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
Cases opened <strong>{profile.casesOpened}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Jackpot wins <strong>{profile.jackpotWins}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Duel wins <strong>{profile.duelWins}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<p className="muted">Loading stats…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section admin-block">
|
||||
<h2>Bio</h2>
|
||||
<form className="form" onSubmit={saveBio}>
|
||||
<label>
|
||||
About you
|
||||
<textarea
|
||||
rows={3}
|
||||
maxLength={280}
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Save bio
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="section admin-block">
|
||||
<h2>Change password</h2>
|
||||
<form className="form" onSubmit={changePassword}>
|
||||
<label>
|
||||
Current password
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
New password
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Update password
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
function useAdCountdown(nextAdAt) {
|
||||
const [remaining, setRemaining] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nextAdAt) {
|
||||
setRemaining(0);
|
||||
return undefined;
|
||||
}
|
||||
const tick = () => {
|
||||
const ms = new Date(nextAdAt).getTime() - Date.now();
|
||||
setRemaining(Math.max(0, Math.ceil(ms / 1000)));
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 250);
|
||||
return () => clearInterval(id);
|
||||
}, [nextAdAt]);
|
||||
|
||||
return remaining;
|
||||
}
|
||||
|
||||
export default function ShopPage() {
|
||||
const { user, loading, patchUser } = useAuth();
|
||||
const [packages, setPackages] = useState([]);
|
||||
const [ad, setAd] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [watching, setWatching] = useState(false);
|
||||
|
||||
const remaining = useAdCountdown(ad?.ready ? null : ad?.nextAdAt);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api.shop();
|
||||
setPackages(data.packages || []);
|
||||
setAd(data.ad);
|
||||
patchUser({
|
||||
balance: data.user.balance,
|
||||
osuBalance: data.user.osuBalance,
|
||||
lastAdAt: data.user.lastAdAt,
|
||||
});
|
||||
}, [patchUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [user, load]);
|
||||
|
||||
const buy = async (packageId) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
const data = await api.shopBuy(packageId);
|
||||
patchUser({
|
||||
balance: data.user.balance,
|
||||
osuBalance: data.user.osuBalance,
|
||||
});
|
||||
setMessage(`Bought ${data.package.name} — +${formatCredits(data.package.credits)} cr`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
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();
|
||||
patchUser({
|
||||
balance: data.user.balance,
|
||||
osuBalance: data.user.osuBalance,
|
||||
lastAdAt: data.user.lastAdAt,
|
||||
});
|
||||
setAd(data.ad);
|
||||
setMessage(`Ad reward: +${formatCredits(data.reward)} cr`);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
if (err.message?.includes('available in') || err.nextAdAt) {
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setWatching(false);
|
||||
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 />;
|
||||
|
||||
const adReady = ad?.ready || (ad?.nextAdAt && remaining === 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.
|
||||
</p>
|
||||
{watching ? (
|
||||
<div className="ad-sim">
|
||||
<div className="ad-bar" />
|
||||
<p className="muted">Playing ad…</p>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy || !adReady}
|
||||
onClick={watchAd}
|
||||
>
|
||||
{adReady
|
||||
? 'Watch ad'
|
||||
: `Available in ${String(Math.floor(remaining / 60)).padStart(2, '0')}:${String(
|
||||
remaining % 60
|
||||
).padStart(2, '0')}`}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
let socket = null;
|
||||
|
||||
export function getSocket() {
|
||||
if (!socket) {
|
||||
socket = io({
|
||||
path: '/socket.io',
|
||||
withCredentials: true,
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 500,
|
||||
reconnectionAttempts: Infinity,
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
@@ -4,12 +4,22 @@ import react from '@vitejs/plugin-react';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+151
-100
@@ -7,94 +7,130 @@
|
||||
"": {
|
||||
"name": "casegambling",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.3"
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz",
|
||||
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==",
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "5.6.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.4",
|
||||
"supports-color": "10.2.2",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/index.js",
|
||||
"concurrently": "dist/bin/index.js"
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.4",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
|
||||
"integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.9.0",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -118,17 +154,34 @@
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
@@ -142,9 +195,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
|
||||
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -155,47 +208,44 @@
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
@@ -219,18 +269,18 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
@@ -247,31 +297,32 @@
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^7.2.0",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+298
-1421
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -18,7 +18,9 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"express-session": "^1.18.1"
|
||||
"express-session": "^1.18.1",
|
||||
"multer": "^2.2.0",
|
||||
"socket.io": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.11.1"
|
||||
|
||||
@@ -12,10 +12,19 @@ model User {
|
||||
username String @unique
|
||||
passwordHash String
|
||||
balance Int @default(0)
|
||||
osuBalance Int @default(0)
|
||||
lastAdAt DateTime?
|
||||
role String @default("user")
|
||||
avatarUrl String @default("")
|
||||
bio String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
inventory InventoryItem[]
|
||||
transactions Transaction[]
|
||||
jackpotWins JackpotRound[] @relation("JackpotWinner")
|
||||
jackpotDeposits JackpotDeposit[]
|
||||
duelRoomsCreated DuelRoom[] @relation("DuelCreator")
|
||||
duelWins DuelRoom[] @relation("DuelWinner")
|
||||
duelDeposits DuelDeposit[]
|
||||
}
|
||||
|
||||
model Case {
|
||||
@@ -54,9 +63,12 @@ model InventoryItem {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
itemId Int
|
||||
locked Boolean @default(false)
|
||||
obtainedAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
jackpotDeposits JackpotDeposit[]
|
||||
duelDeposits DuelDeposit[]
|
||||
}
|
||||
|
||||
model Transaction {
|
||||
@@ -68,3 +80,59 @@ model Transaction {
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model JackpotRound {
|
||||
id Int @id @default(autoincrement())
|
||||
status String @default("open")
|
||||
endsAt DateTime
|
||||
winnerUserId Int?
|
||||
createdAt DateTime @default(now())
|
||||
resolvedAt DateTime?
|
||||
winner User? @relation("JackpotWinner", fields: [winnerUserId], references: [id])
|
||||
deposits JackpotDeposit[]
|
||||
}
|
||||
|
||||
model JackpotDeposit {
|
||||
id Int @id @default(autoincrement())
|
||||
roundId Int
|
||||
userId Int
|
||||
inventoryItemId Int
|
||||
valueCents Int
|
||||
createdAt DateTime @default(now())
|
||||
round JackpotRound @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
inventoryItem InventoryItem @relation(fields: [inventoryItemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([roundId, inventoryItemId])
|
||||
}
|
||||
|
||||
model DuelRoom {
|
||||
id Int @id @default(autoincrement())
|
||||
creatorId Int
|
||||
status String @default("open")
|
||||
maxPlayers Int
|
||||
maxItemsPerPlayer Int
|
||||
minItemValue Int @default(0)
|
||||
minPlayersToStart Int @default(2)
|
||||
winnerUserId Int?
|
||||
createdAt DateTime @default(now())
|
||||
startedAt DateTime?
|
||||
resolvedAt DateTime?
|
||||
creator User @relation("DuelCreator", fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
winner User? @relation("DuelWinner", fields: [winnerUserId], references: [id])
|
||||
deposits DuelDeposit[]
|
||||
}
|
||||
|
||||
model DuelDeposit {
|
||||
id Int @id @default(autoincrement())
|
||||
roomId Int
|
||||
userId Int
|
||||
inventoryItemId Int
|
||||
valueCents Int
|
||||
createdAt DateTime @default(now())
|
||||
room DuelRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
inventoryItem InventoryItem @relation(fields: [inventoryItemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([roomId, inventoryItemId])
|
||||
}
|
||||
|
||||
+11
-1
@@ -49,6 +49,7 @@ async function main() {
|
||||
username: 'player',
|
||||
passwordHash: userHash,
|
||||
balance: 10000,
|
||||
osuBalance: 100,
|
||||
role: 'user',
|
||||
},
|
||||
});
|
||||
@@ -62,6 +63,15 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.transaction.create({
|
||||
data: {
|
||||
userId: demo.id,
|
||||
type: 'seed_osu',
|
||||
amount: 100,
|
||||
meta: JSON.stringify({ reason: 'demo_osu' }),
|
||||
},
|
||||
});
|
||||
|
||||
const items = [];
|
||||
for (const data of ITEMS) {
|
||||
items.push(await prisma.item.create({ data }));
|
||||
@@ -135,7 +145,7 @@ async function main() {
|
||||
|
||||
console.log('Seed complete.');
|
||||
console.log(' admin / admin123');
|
||||
console.log(' player / player123 (balance: 100.00)');
|
||||
console.log(' player / player123 (balance: 100.00, osu: 100)');
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getIO } from './realtime.js';
|
||||
|
||||
const MAX_FEED = 40;
|
||||
const feed = [];
|
||||
|
||||
export function getDropFeed() {
|
||||
return [...feed];
|
||||
}
|
||||
|
||||
export function pushDrop(entry) {
|
||||
const event = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
at: new Date().toISOString(),
|
||||
...entry,
|
||||
};
|
||||
feed.unshift(event);
|
||||
if (feed.length > MAX_FEED) feed.length = MAX_FEED;
|
||||
|
||||
const io = getIO();
|
||||
if (io) io.to('feed').emit('feed:drop', event);
|
||||
return event;
|
||||
}
|
||||
+134
-13
@@ -1,27 +1,60 @@
|
||||
import http from 'http';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import session from 'express-session';
|
||||
import { Server } from 'socket.io';
|
||||
|
||||
import authRoutes from './routes/auth.js';
|
||||
import casesRoutes from './routes/cases.js';
|
||||
import inventoryRoutes from './routes/inventory.js';
|
||||
import leaderboardRoutes from './routes/leaderboard.js';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
import jackpotRoutes from './routes/jackpot.js';
|
||||
import duelsRoutes from './routes/duels.js';
|
||||
import profileRoutes from './routes/profile.js';
|
||||
import shopRoutes from './routes/shop.js';
|
||||
import feedRoutes from './routes/feed.js';
|
||||
import { setIO } from './realtime.js';
|
||||
import { getDropFeed } from './feed.js';
|
||||
import { ensureJackpotRound } from './services/jackpot.js';
|
||||
import { recoverDuelSpins, getDuelRoom, listDuelRooms } from './services/duel.js';
|
||||
import { getJackpotState } from './services/jackpot.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const uploadsDir = path.join(__dirname, '../uploads');
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const PORT = Number(process.env.PORT || 3001);
|
||||
const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173';
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: CLIENT_ORIGIN,
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use(express.json());
|
||||
function isAllowedOrigin(origin) {
|
||||
if (!origin) return true;
|
||||
if (origin === CLIENT_ORIGIN) return true;
|
||||
if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return true;
|
||||
// Private LAN ranges (dev access from phones / other PCs)
|
||||
if (/^http:\/\/192\.168\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(origin)) return true;
|
||||
if (/^http:\/\/10\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(origin)) return true;
|
||||
if (/^http:\/\/172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(origin)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
app.use(
|
||||
session({
|
||||
const corsOptions = {
|
||||
origin(origin, callback) {
|
||||
callback(null, isAllowedOrigin(origin));
|
||||
},
|
||||
credentials: true,
|
||||
};
|
||||
|
||||
const sessionMiddleware = session({
|
||||
secret: process.env.SESSION_SECRET || 'dev-secret',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
@@ -31,8 +64,12 @@ app.use(
|
||||
secure: false,
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
app.use(express.json());
|
||||
app.use(sessionMiddleware);
|
||||
app.use('/uploads', express.static(uploadsDir));
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
@@ -43,12 +80,96 @@ app.use('/api/cases', casesRoutes);
|
||||
app.use('/api/inventory', inventoryRoutes);
|
||||
app.use('/api/leaderboard', leaderboardRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/jackpot', jackpotRoutes);
|
||||
app.use('/api/duels', duelsRoutes);
|
||||
app.use('/api/profile', profileRoutes);
|
||||
app.use('/api/shop', shopRoutes);
|
||||
app.use('/api/feed', feedRoutes);
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`API listening on http://localhost:${PORT}`);
|
||||
const io = new Server(server, {
|
||||
cors: corsOptions,
|
||||
});
|
||||
setIO(io);
|
||||
|
||||
io.use((socket, next) => {
|
||||
sessionMiddleware(socket.request, {}, next);
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const userId = socket.request.session?.userId || null;
|
||||
socket.data.userId = userId;
|
||||
|
||||
if (userId) {
|
||||
socket.join(`user:${userId}`);
|
||||
}
|
||||
|
||||
socket.on('jackpot:subscribe', async () => {
|
||||
socket.join('jackpot');
|
||||
try {
|
||||
const state = await getJackpotState();
|
||||
socket.emit('jackpot:state', state);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('jackpot:unsubscribe', () => {
|
||||
socket.leave('jackpot');
|
||||
});
|
||||
|
||||
socket.on('duels:subscribe', async () => {
|
||||
socket.join('duels');
|
||||
try {
|
||||
const list = await listDuelRooms();
|
||||
socket.emit('duels:list', list);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('duels:unsubscribe', () => {
|
||||
socket.leave('duels');
|
||||
});
|
||||
|
||||
socket.on('duel:subscribe', async (roomId) => {
|
||||
const id = Number(roomId);
|
||||
if (!id) return;
|
||||
socket.join(`duel:${id}`);
|
||||
try {
|
||||
const room = await getDuelRoom(id);
|
||||
if (room) socket.emit('duel:state', room);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('duel:unsubscribe', (roomId) => {
|
||||
const id = Number(roomId);
|
||||
if (!id) return;
|
||||
socket.leave(`duel:${id}`);
|
||||
});
|
||||
|
||||
socket.on('feed:subscribe', () => {
|
||||
socket.join('feed');
|
||||
socket.emit('feed:history', getDropFeed());
|
||||
});
|
||||
|
||||
socket.on('feed:unsubscribe', () => {
|
||||
socket.leave('feed');
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, async () => {
|
||||
console.log(`API listening on http://localhost:${PORT}`);
|
||||
try {
|
||||
await ensureJackpotRound();
|
||||
await recoverDuelSpins();
|
||||
} catch (err) {
|
||||
console.error('Failed to bootstrap game services', err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@ export function publicUser(user) {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
balance: user.balance,
|
||||
osuBalance: user.osuBalance ?? 0,
|
||||
lastAdAt: user.lastAdAt || null,
|
||||
role: user.role,
|
||||
avatarUrl: user.avatarUrl || '',
|
||||
bio: user.bio || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
let io = null;
|
||||
|
||||
export function setIO(instance) {
|
||||
io = instance;
|
||||
}
|
||||
|
||||
export function getIO() {
|
||||
return io;
|
||||
}
|
||||
|
||||
export function emitJackpot(event, payload) {
|
||||
if (!io) return;
|
||||
io.to('jackpot').emit(event, payload);
|
||||
}
|
||||
|
||||
export function emitDuel(roomId, event, payload) {
|
||||
if (!io) return;
|
||||
io.to(`duel:${roomId}`).emit(event, payload);
|
||||
}
|
||||
|
||||
export function emitDuelsList(payload) {
|
||||
if (!io) return;
|
||||
io.to('duels').emit('duels:list', payload);
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import bcrypt from 'bcrypt';
|
||||
import { prisma } from '../db.js';
|
||||
import { requireAdmin, publicUser } from '../middleware.js';
|
||||
import { isValidRarity, RARITIES } from '../rarities.js';
|
||||
import { createUploader } from '../upload.js';
|
||||
|
||||
const router = Router();
|
||||
const upload = createUploader('asset');
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
@@ -35,6 +37,18 @@ router.get('/rarities', requireAdmin, (_req, res) => {
|
||||
res.json({ rarities: RARITIES });
|
||||
});
|
||||
|
||||
router.post('/upload', requireAdmin, (req, res) => {
|
||||
upload.single('image')(req, res, (err) => {
|
||||
if (err) {
|
||||
return res.status(400).json({ error: err.message || 'Upload failed' });
|
||||
}
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
res.status(201).json({ url: `/uploads/${req.file.filename}` });
|
||||
});
|
||||
});
|
||||
|
||||
// —— Cases ——
|
||||
router.get('/cases', requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { publicUser, requireAuth } from '../middleware.js';
|
||||
|
||||
const router = Router();
|
||||
const STARTING_BALANCE = Number(process.env.STARTING_BALANCE || 10000);
|
||||
const STARTING_OSU = Number(process.env.STARTING_OSU || 100);
|
||||
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
@@ -32,6 +33,7 @@ router.post('/register', async (req, res) => {
|
||||
username,
|
||||
passwordHash,
|
||||
balance: STARTING_BALANCE,
|
||||
osuBalance: STARTING_OSU,
|
||||
role: 'user',
|
||||
},
|
||||
});
|
||||
@@ -45,6 +47,15 @@ router.post('/register', async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'seed_osu',
|
||||
amount: STARTING_OSU,
|
||||
meta: JSON.stringify({ reason: 'starting_osu' }),
|
||||
},
|
||||
});
|
||||
|
||||
req.session.userId = user.id;
|
||||
req.session.role = user.role;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import { prisma } from '../db.js';
|
||||
import { requireAuth, publicUser } from '../middleware.js';
|
||||
import { normalizeRates, pickWeighted } from '../weighted.js';
|
||||
import { pushDrop } from '../feed.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -144,6 +145,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
|
||||
return {
|
||||
user: publicUser(updatedUser),
|
||||
caseName: c.name,
|
||||
item: {
|
||||
inventoryId: inventoryItem.id,
|
||||
id: item.id,
|
||||
@@ -155,7 +157,15 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
};
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
pushDrop({
|
||||
source: 'case',
|
||||
caseName: result.caseName,
|
||||
username: result.user.username,
|
||||
avatarUrl: result.user.avatarUrl || '',
|
||||
item: result.item,
|
||||
});
|
||||
|
||||
res.json({ user: result.user, item: result.item });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const status = err.status || 500;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../middleware.js';
|
||||
import {
|
||||
listDuelRooms,
|
||||
getDuelRoom,
|
||||
createDuelRoom,
|
||||
placeDuelBet,
|
||||
leaveDuelRoom,
|
||||
cancelDuelRoom,
|
||||
startDuelRoom,
|
||||
} from '../services/duel.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function handleError(res, err) {
|
||||
const status = err.status || 500;
|
||||
if (status >= 500) console.error(err);
|
||||
res.status(status).json({ error: err.message || 'Request failed' });
|
||||
}
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const list = await listDuelRooms();
|
||||
res.json(list);
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const room = await createDuelRoom(req.session.userId, req.body);
|
||||
res.status(201).json({ room });
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const room = await getDuelRoom(req.params.id);
|
||||
if (!room) return res.status(404).json({ error: 'Room not found' });
|
||||
res.json({ room });
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/bet', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const room = await placeDuelBet(
|
||||
req.session.userId,
|
||||
req.params.id,
|
||||
req.body.inventoryItemIds
|
||||
);
|
||||
res.json({ room });
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/leave', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const room = await leaveDuelRoom(req.session.userId, req.params.id);
|
||||
res.json({ room });
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/cancel', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const room = await cancelDuelRoom(req.session.userId, req.params.id);
|
||||
res.json({ room });
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/start', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const room = await startDuelRoom(req.session.userId, req.params.id);
|
||||
res.json({ room });
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { getDropFeed } from '../feed.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
res.json({ drops: getDropFeed() });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../db.js';
|
||||
import { requireAuth } from '../middleware.js';
|
||||
import { requireAuth, publicUser } from '../middleware.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const items = await prisma.inventoryItem.findMany({
|
||||
where: { userId: req.session.userId },
|
||||
where: { userId: req.session.userId, locked: false },
|
||||
include: { item: true },
|
||||
orderBy: { obtainedAt: 'desc' },
|
||||
});
|
||||
@@ -16,6 +16,7 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
inventory: items.map((inv) => ({
|
||||
id: inv.id,
|
||||
obtainedAt: inv.obtainedAt,
|
||||
locked: inv.locked,
|
||||
item: {
|
||||
id: inv.item.id,
|
||||
name: inv.item.name,
|
||||
@@ -31,4 +32,66 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sell', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const ids = [...new Set((req.body.inventoryItemIds || []).map(Number).filter(Boolean))];
|
||||
if (!ids.length) {
|
||||
return res.status(400).json({ error: 'Select at least one item to sell' });
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const invItems = await tx.inventoryItem.findMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
userId: req.session.userId,
|
||||
locked: false,
|
||||
},
|
||||
include: { item: true },
|
||||
});
|
||||
|
||||
if (invItems.length !== ids.length) {
|
||||
const err = new Error('Invalid or locked inventory items');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const total = invItems.reduce((sum, inv) => sum + inv.item.marketValue, 0);
|
||||
|
||||
for (const inv of invItems) {
|
||||
await tx.inventoryItem.delete({ where: { id: inv.id } });
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: req.session.userId,
|
||||
type: 'sell_item',
|
||||
amount: inv.item.marketValue,
|
||||
meta: JSON.stringify({
|
||||
inventoryItemId: inv.id,
|
||||
itemId: inv.itemId,
|
||||
itemName: inv.item.name,
|
||||
valueCents: inv.item.marketValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const user = await tx.user.update({
|
||||
where: { id: req.session.userId },
|
||||
data: { balance: { increment: total } },
|
||||
});
|
||||
|
||||
return { user, total, soldCount: invItems.length };
|
||||
});
|
||||
|
||||
res.json({
|
||||
user: publicUser(result.user),
|
||||
total: result.total,
|
||||
soldCount: result.soldCount,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
if (status >= 500) console.error(err);
|
||||
res.status(status).json({ error: err.message || 'Failed to sell items' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../middleware.js';
|
||||
import {
|
||||
getJackpotState,
|
||||
placeJackpotBet,
|
||||
withdrawJackpotBet,
|
||||
} from '../services/jackpot.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function handleError(res, err) {
|
||||
const status = err.status || 500;
|
||||
if (status >= 500) console.error(err);
|
||||
res.status(status).json({ error: err.message || 'Request failed' });
|
||||
}
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const state = await getJackpotState();
|
||||
res.json({ round: state });
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/bet', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const state = await placeJackpotBet(req.session.userId, req.body.inventoryItemIds);
|
||||
res.json({ round: state });
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/withdraw', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const state = await withdrawJackpotBet(req.session.userId, req.body.inventoryItemIds);
|
||||
res.json({ round: state });
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { prisma } from '../db.js';
|
||||
import { requireAuth, publicUser } from '../middleware.js';
|
||||
|
||||
const router = Router();
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const uploadsDir = path.join(__dirname, '../../uploads');
|
||||
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadsDir),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase() || '.png';
|
||||
const safe = ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext) ? ext : '.png';
|
||||
cb(null, `avatar-${req.session.userId}-${Date.now()}${safe}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!file.mimetype.startsWith('image/')) {
|
||||
return cb(new Error('Only images are allowed'));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.session.userId },
|
||||
include: {
|
||||
inventory: { include: { item: true } },
|
||||
},
|
||||
});
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const inventoryValue = user.inventory.reduce(
|
||||
(sum, inv) => sum + (inv.locked ? 0 : inv.item.marketValue),
|
||||
0
|
||||
);
|
||||
|
||||
const [jackpotWins, duelWins, casesOpened] = await Promise.all([
|
||||
prisma.transaction.count({
|
||||
where: { userId: user.id, type: 'jackpot_win' },
|
||||
}),
|
||||
prisma.transaction.count({
|
||||
where: { userId: user.id, type: 'duel_win' },
|
||||
}),
|
||||
prisma.transaction.count({
|
||||
where: { userId: user.id, type: 'open_case' },
|
||||
}),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
user: publicUser(user),
|
||||
profile: {
|
||||
createdAt: user.createdAt,
|
||||
inventoryValue,
|
||||
inventoryCount: user.inventory.filter((i) => !i.locked).length,
|
||||
jackpotWins,
|
||||
duelWins,
|
||||
casesOpened,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to load profile' });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const bio = String(req.body.bio ?? '').slice(0, 280);
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.session.userId },
|
||||
data: { bio },
|
||||
});
|
||||
res.json({ user: publicUser(user) });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to update profile' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/password', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body || {};
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: 'Current and new password required' });
|
||||
}
|
||||
if (String(newPassword).length < 6) {
|
||||
return res.status(400).json({ error: 'New password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const ok = await bcrypt.compare(String(currentPassword), user.passwordHash);
|
||||
if (!ok) return res.status(400).json({ error: 'Current password is incorrect' });
|
||||
|
||||
const passwordHash = await bcrypt.hash(String(newPassword), 10);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash },
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to change password' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/avatar', requireAuth, (req, res) => {
|
||||
upload.single('avatar')(req, res, async (err) => {
|
||||
if (err) {
|
||||
return res.status(400).json({ error: err.message || 'Upload failed' });
|
||||
}
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
try {
|
||||
const avatarUrl = `/uploads/${req.file.filename}`;
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.session.userId },
|
||||
data: { avatarUrl },
|
||||
});
|
||||
res.json({ user: publicUser(user) });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: 'Failed to save avatar' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../db.js';
|
||||
import { requireAuth, publicUser } from '../middleware.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Packages: spend osu → receive credits (cents). */
|
||||
export const SHOP_PACKAGES = [
|
||||
{ id: 'starter', name: 'Starter Pack', osuCost: 10, credits: 5000, blurb: '50.00 cr' },
|
||||
{ id: 'boost', name: 'Boost Pack', osuCost: 25, credits: 15000, blurb: '150.00 cr' },
|
||||
{ id: 'pro', name: 'Pro Pack', osuCost: 50, credits: 35000, blurb: '350.00 cr' },
|
||||
{ id: 'whale', name: 'Whale Pack', osuCost: 100, credits: 80000, blurb: '800.00 cr' },
|
||||
];
|
||||
|
||||
const AD_COOLDOWN_MS = 60 * 1000;
|
||||
const AD_REWARD_MIN = 10000; // 100.00 cr
|
||||
const AD_REWARD_MAX = 20000; // 200.00 cr
|
||||
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const lastAdAt = user.lastAdAt ? new Date(user.lastAdAt).getTime() : 0;
|
||||
const nextAdAt = lastAdAt + AD_COOLDOWN_MS;
|
||||
const adReady = Date.now() >= nextAdAt;
|
||||
|
||||
res.json({
|
||||
packages: SHOP_PACKAGES,
|
||||
user: publicUser(user),
|
||||
ad: {
|
||||
cooldownMs: AD_COOLDOWN_MS,
|
||||
rewardMin: AD_REWARD_MIN,
|
||||
rewardMax: AD_REWARD_MAX,
|
||||
ready: adReady,
|
||||
nextAdAt: adReady ? null : new Date(nextAdAt).toISOString(),
|
||||
lastAdAt: user.lastAdAt,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to load shop' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/buy', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const packageId = String(req.body.packageId || '');
|
||||
const pack = SHOP_PACKAGES.find((p) => p.id === packageId);
|
||||
if (!pack) {
|
||||
return res.status(400).json({ error: 'Unknown package' });
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
||||
if (!user) {
|
||||
const err = new Error('User not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
if (user.osuBalance < pack.osuCost) {
|
||||
const err = new Error('Not enough osu');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const updated = await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
osuBalance: { decrement: pack.osuCost },
|
||||
balance: { increment: pack.credits },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'shop_purchase',
|
||||
amount: pack.credits,
|
||||
meta: JSON.stringify({
|
||||
packageId: pack.id,
|
||||
osuCost: pack.osuCost,
|
||||
credits: pack.credits,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
res.json({ user: publicUser(result), package: pack });
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
if (status >= 500) console.error(err);
|
||||
res.status(status).json({ error: err.message || 'Purchase failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/ad', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
||||
if (!user) {
|
||||
const err = new Error('User not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const lastAdAt = user.lastAdAt ? new Date(user.lastAdAt).getTime() : 0;
|
||||
const elapsed = Date.now() - lastAdAt;
|
||||
if (elapsed < AD_COOLDOWN_MS) {
|
||||
const waitSec = Math.ceil((AD_COOLDOWN_MS - elapsed) / 1000);
|
||||
const err = new Error(`Ad available in ${waitSec}s`);
|
||||
err.status = 429;
|
||||
err.nextAdAt = new Date(lastAdAt + AD_COOLDOWN_MS).toISOString();
|
||||
throw err;
|
||||
}
|
||||
|
||||
const reward =
|
||||
AD_REWARD_MIN + Math.floor(Math.random() * (AD_REWARD_MAX - AD_REWARD_MIN + 1));
|
||||
const now = new Date();
|
||||
|
||||
const updated = await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
balance: { increment: reward },
|
||||
lastAdAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'ad_reward',
|
||||
amount: reward,
|
||||
meta: JSON.stringify({ reward }),
|
||||
},
|
||||
});
|
||||
|
||||
return { user: updated, reward };
|
||||
});
|
||||
|
||||
res.json({
|
||||
user: publicUser(result.user),
|
||||
reward: result.reward,
|
||||
ad: {
|
||||
ready: false,
|
||||
nextAdAt: new Date(Date.now() + AD_COOLDOWN_MS).toISOString(),
|
||||
cooldownMs: AD_COOLDOWN_MS,
|
||||
rewardMin: AD_REWARD_MIN,
|
||||
rewardMax: AD_REWARD_MAX,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
if (status >= 500) console.error(err);
|
||||
res.status(status).json({
|
||||
error: err.message || 'Ad reward failed',
|
||||
nextAdAt: err.nextAdAt || null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,455 @@
|
||||
import { prisma } from '../db.js';
|
||||
import { pickWeighted } from '../weighted.js';
|
||||
import { emitDuel, emitDuelsList } from '../realtime.js';
|
||||
|
||||
const SPIN_MS = 5500;
|
||||
const pendingSpins = new Map();
|
||||
|
||||
function depositInclude() {
|
||||
return {
|
||||
user: { select: { id: true, username: true, avatarUrl: true } },
|
||||
inventoryItem: { include: { item: true } },
|
||||
};
|
||||
}
|
||||
|
||||
function roomInclude() {
|
||||
return {
|
||||
creator: { select: { id: true, username: true, avatarUrl: true } },
|
||||
winner: { select: { id: true, username: true, avatarUrl: true } },
|
||||
deposits: { include: depositInclude() },
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeDuelRoom(room) {
|
||||
if (!room) return null;
|
||||
|
||||
const deposits = (room.deposits || []).map((d) => ({
|
||||
id: d.id,
|
||||
userId: d.userId,
|
||||
username: d.user?.username || '',
|
||||
avatarUrl: d.user?.avatarUrl || '',
|
||||
valueCents: d.valueCents,
|
||||
inventoryItemId: d.inventoryItemId,
|
||||
item: d.inventoryItem
|
||||
? {
|
||||
id: d.inventoryItem.item.id,
|
||||
name: d.inventoryItem.item.name,
|
||||
imageUrl: d.inventoryItem.item.imageUrl,
|
||||
rarity: d.inventoryItem.item.rarity,
|
||||
marketValue: d.inventoryItem.item.marketValue,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
const byUser = new Map();
|
||||
let totalValue = 0;
|
||||
for (const d of deposits) {
|
||||
totalValue += d.valueCents;
|
||||
const prev = byUser.get(d.userId) || {
|
||||
userId: d.userId,
|
||||
username: d.username,
|
||||
avatarUrl: d.avatarUrl,
|
||||
valueCents: 0,
|
||||
itemCount: 0,
|
||||
};
|
||||
prev.valueCents += d.valueCents;
|
||||
prev.itemCount += 1;
|
||||
byUser.set(d.userId, prev);
|
||||
}
|
||||
|
||||
const players = [...byUser.values()].map((p) => ({
|
||||
...p,
|
||||
chance: totalValue > 0 ? (p.valueCents / totalValue) * 100 : 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
status: room.status,
|
||||
creatorId: room.creatorId,
|
||||
creatorUsername: room.creator?.username || '',
|
||||
maxPlayers: room.maxPlayers,
|
||||
maxItemsPerPlayer: room.maxItemsPerPlayer,
|
||||
minItemValue: room.minItemValue,
|
||||
minPlayersToStart: room.minPlayersToStart,
|
||||
winnerUserId: room.winnerUserId || null,
|
||||
winnerUsername: room.winner?.username || null,
|
||||
createdAt: room.createdAt,
|
||||
startedAt: room.startedAt || null,
|
||||
resolvedAt: room.resolvedAt || null,
|
||||
totalValue,
|
||||
playerCount: players.length,
|
||||
players,
|
||||
deposits,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRoom(id) {
|
||||
return prisma.duelRoom.findUnique({
|
||||
where: { id: Number(id) },
|
||||
include: roomInclude(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDuelRoom(id) {
|
||||
return serializeDuelRoom(await loadRoom(id));
|
||||
}
|
||||
|
||||
export async function listDuelRooms() {
|
||||
const rooms = await prisma.duelRoom.findMany({
|
||||
where: { status: { in: ['open', 'in_progress', 'spinning'] } },
|
||||
include: roomInclude(),
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const serialized = rooms.map(serializeDuelRoom);
|
||||
return {
|
||||
open: serialized.filter((r) => r.status === 'open'),
|
||||
inProgress: serialized.filter((r) => r.status === 'in_progress' || r.status === 'spinning'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function broadcastDuelsList() {
|
||||
const list = await listDuelRooms();
|
||||
emitDuelsList(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
function broadcastRoom(room, extra = {}) {
|
||||
const state = serializeDuelRoom(room);
|
||||
emitDuel(room.id, 'duel:state', { ...state, ...extra });
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function createDuelRoom(userId, opts = {}) {
|
||||
const maxPlayers = Math.min(10, Math.max(2, Number(opts.maxPlayers) || 2));
|
||||
const maxItemsPerPlayer = Math.min(10, Math.max(1, Number(opts.maxItemsPerPlayer) || 3));
|
||||
const minItemValue = Math.max(0, Number(opts.minItemValue) || 0);
|
||||
const minPlayersToStart = Math.min(
|
||||
maxPlayers,
|
||||
Math.max(2, Number(opts.minPlayersToStart) || 2)
|
||||
);
|
||||
|
||||
const room = await prisma.duelRoom.create({
|
||||
data: {
|
||||
creatorId: userId,
|
||||
status: 'open',
|
||||
maxPlayers,
|
||||
maxItemsPerPlayer,
|
||||
minItemValue,
|
||||
minPlayersToStart,
|
||||
},
|
||||
include: roomInclude(),
|
||||
});
|
||||
|
||||
await broadcastDuelsList();
|
||||
return serializeDuelRoom(room);
|
||||
}
|
||||
|
||||
export async function placeDuelBet(userId, roomId, inventoryItemIds) {
|
||||
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
|
||||
if (!ids.length) {
|
||||
throw Object.assign(new Error('Select at least one item'), { status: 400 });
|
||||
}
|
||||
|
||||
const room = await prisma.$transaction(async (tx) => {
|
||||
const current = await tx.duelRoom.findUnique({
|
||||
where: { id: Number(roomId) },
|
||||
include: { deposits: true },
|
||||
});
|
||||
if (!current || current.status !== 'open') {
|
||||
throw Object.assign(new Error('Room is not open for bets'), { status: 409 });
|
||||
}
|
||||
if (ids.length > current.maxItemsPerPlayer) {
|
||||
throw Object.assign(
|
||||
new Error(`Max ${current.maxItemsPerPlayer} items per player`),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const existingCount = current.deposits.filter((d) => d.userId === userId).length;
|
||||
if (existingCount + ids.length > current.maxItemsPerPlayer) {
|
||||
throw Object.assign(
|
||||
new Error(`You can bet at most ${current.maxItemsPerPlayer} items`),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const uniquePlayers = new Set(current.deposits.map((d) => d.userId));
|
||||
const isNewPlayer = !uniquePlayers.has(userId);
|
||||
if (isNewPlayer && uniquePlayers.size >= current.maxPlayers) {
|
||||
throw Object.assign(new Error('Room is full'), { status: 409 });
|
||||
}
|
||||
|
||||
const invItems = await tx.inventoryItem.findMany({
|
||||
where: { id: { in: ids }, userId, locked: false },
|
||||
include: { item: true },
|
||||
});
|
||||
if (invItems.length !== ids.length) {
|
||||
throw Object.assign(new Error('Invalid or locked inventory items'), { status: 400 });
|
||||
}
|
||||
|
||||
for (const inv of invItems) {
|
||||
if (inv.item.marketValue < current.minItemValue) {
|
||||
throw Object.assign(
|
||||
new Error(`Items must be worth at least ${current.minItemValue} cents`),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
await tx.inventoryItem.update({
|
||||
where: { id: inv.id },
|
||||
data: { locked: true },
|
||||
});
|
||||
await tx.duelDeposit.create({
|
||||
data: {
|
||||
roomId: current.id,
|
||||
userId,
|
||||
inventoryItemId: inv.id,
|
||||
valueCents: inv.item.marketValue,
|
||||
},
|
||||
});
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId,
|
||||
type: 'duel_bet',
|
||||
amount: 0,
|
||||
meta: JSON.stringify({
|
||||
roomId: current.id,
|
||||
inventoryItemId: inv.id,
|
||||
itemId: inv.itemId,
|
||||
valueCents: inv.item.marketValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const refreshed = await tx.duelRoom.findUnique({
|
||||
where: { id: current.id },
|
||||
include: roomInclude(),
|
||||
});
|
||||
|
||||
const players = new Set(refreshed.deposits.map((d) => d.userId));
|
||||
const allHaveItems = [...players].every(
|
||||
(pid) => refreshed.deposits.some((d) => d.userId === pid)
|
||||
);
|
||||
|
||||
if (players.size >= refreshed.maxPlayers && allHaveItems) {
|
||||
return startDuelInTx(tx, refreshed.id);
|
||||
}
|
||||
|
||||
return refreshed;
|
||||
});
|
||||
|
||||
const state = broadcastRoom(room);
|
||||
await broadcastDuelsList();
|
||||
|
||||
if (room.status === 'spinning') {
|
||||
scheduleDuelFinish(room.id);
|
||||
const reelItemId =
|
||||
room.deposits.find((d) => d.userId === room.winnerUserId)?.id || null;
|
||||
emitDuel(room.id, 'duel:spin', { ...state, reelWinnerItemId: reelItemId });
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function leaveDuelRoom(userId, roomId) {
|
||||
const room = await prisma.$transaction(async (tx) => {
|
||||
const current = await tx.duelRoom.findUnique({
|
||||
where: { id: Number(roomId) },
|
||||
include: { deposits: true },
|
||||
});
|
||||
if (!current || current.status !== 'open') {
|
||||
throw Object.assign(new Error('Cannot leave this room'), { status: 409 });
|
||||
}
|
||||
|
||||
const deposits = current.deposits.filter((d) => d.userId === userId);
|
||||
for (const d of deposits) {
|
||||
await tx.duelDeposit.delete({ where: { id: d.id } });
|
||||
await tx.inventoryItem.update({
|
||||
where: { id: d.inventoryItemId },
|
||||
data: { locked: false },
|
||||
});
|
||||
}
|
||||
|
||||
return tx.duelRoom.findUnique({
|
||||
where: { id: current.id },
|
||||
include: roomInclude(),
|
||||
});
|
||||
});
|
||||
|
||||
const state = broadcastRoom(room);
|
||||
await broadcastDuelsList();
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function cancelDuelRoom(userId, roomId) {
|
||||
const room = await prisma.$transaction(async (tx) => {
|
||||
const current = await tx.duelRoom.findUnique({
|
||||
where: { id: Number(roomId) },
|
||||
include: { deposits: true },
|
||||
});
|
||||
if (!current) {
|
||||
throw Object.assign(new Error('Room not found'), { status: 404 });
|
||||
}
|
||||
if (current.creatorId !== userId) {
|
||||
throw Object.assign(new Error('Only the creator can cancel'), { status: 403 });
|
||||
}
|
||||
if (current.status !== 'open') {
|
||||
throw Object.assign(new Error('Room cannot be cancelled'), { status: 409 });
|
||||
}
|
||||
|
||||
for (const d of current.deposits) {
|
||||
await tx.duelDeposit.delete({ where: { id: d.id } });
|
||||
await tx.inventoryItem.update({
|
||||
where: { id: d.inventoryItemId },
|
||||
data: { locked: false },
|
||||
});
|
||||
}
|
||||
|
||||
return tx.duelRoom.update({
|
||||
where: { id: current.id },
|
||||
data: { status: 'cancelled', resolvedAt: new Date() },
|
||||
include: roomInclude(),
|
||||
});
|
||||
});
|
||||
|
||||
const state = broadcastRoom(room);
|
||||
await broadcastDuelsList();
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function startDuelRoom(userId, roomId) {
|
||||
const room = await prisma.$transaction(async (tx) => {
|
||||
const current = await tx.duelRoom.findUnique({
|
||||
where: { id: Number(roomId) },
|
||||
include: roomInclude(),
|
||||
});
|
||||
if (!current) {
|
||||
throw Object.assign(new Error('Room not found'), { status: 404 });
|
||||
}
|
||||
if (current.creatorId !== userId) {
|
||||
throw Object.assign(new Error('Only the creator can start'), { status: 403 });
|
||||
}
|
||||
if (current.status !== 'open') {
|
||||
throw Object.assign(new Error('Room is not open'), { status: 409 });
|
||||
}
|
||||
|
||||
const players = new Set(current.deposits.map((d) => d.userId));
|
||||
if (players.size < current.minPlayersToStart) {
|
||||
throw Object.assign(
|
||||
new Error(`Need at least ${current.minPlayersToStart} players`),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return startDuelInTx(tx, current.id);
|
||||
});
|
||||
|
||||
const state = broadcastRoom(room);
|
||||
await broadcastDuelsList();
|
||||
|
||||
if (room.status === 'spinning') {
|
||||
scheduleDuelFinish(room.id);
|
||||
const reelItemId =
|
||||
room.deposits.find((d) => d.userId === room.winnerUserId)?.id || null;
|
||||
emitDuel(room.id, 'duel:spin', { ...state, reelWinnerItemId: reelItemId });
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async function startDuelInTx(tx, roomId) {
|
||||
const room = await tx.duelRoom.findUnique({
|
||||
where: { id: roomId },
|
||||
include: roomInclude(),
|
||||
});
|
||||
if (!room || room.status !== 'open') return room;
|
||||
|
||||
const byUser = new Map();
|
||||
for (const d of room.deposits) {
|
||||
byUser.set(d.userId, (byUser.get(d.userId) || 0) + d.valueCents);
|
||||
}
|
||||
if (byUser.size < 2) {
|
||||
throw Object.assign(new Error('Need at least 2 players with items'), { status: 400 });
|
||||
}
|
||||
|
||||
const entries = [...byUser.entries()].map(([userId, dropRate]) => ({
|
||||
userId,
|
||||
dropRate,
|
||||
}));
|
||||
const winner = pickWeighted(entries);
|
||||
|
||||
return tx.duelRoom.update({
|
||||
where: { id: roomId },
|
||||
data: {
|
||||
status: 'spinning',
|
||||
winnerUserId: winner.userId,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
include: roomInclude(),
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleDuelFinish(roomId) {
|
||||
if (pendingSpins.has(roomId)) {
|
||||
clearTimeout(pendingSpins.get(roomId));
|
||||
}
|
||||
const t = setTimeout(() => {
|
||||
finishDuelSpin(roomId).catch((err) => console.error('duel finish', err));
|
||||
}, SPIN_MS + 200);
|
||||
pendingSpins.set(roomId, t);
|
||||
}
|
||||
|
||||
async function finishDuelSpin(roomId) {
|
||||
pendingSpins.delete(roomId);
|
||||
|
||||
const room = await prisma.$transaction(async (tx) => {
|
||||
const current = await tx.duelRoom.findUnique({
|
||||
where: { id: Number(roomId) },
|
||||
include: { deposits: true },
|
||||
});
|
||||
if (!current || current.status !== 'spinning') return null;
|
||||
|
||||
const winnerId = current.winnerUserId;
|
||||
for (const d of current.deposits) {
|
||||
await tx.inventoryItem.update({
|
||||
where: { id: d.inventoryItemId },
|
||||
data: { userId: winnerId, locked: false },
|
||||
});
|
||||
}
|
||||
|
||||
const totalValue = current.deposits.reduce((s, d) => s + d.valueCents, 0);
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: winnerId,
|
||||
type: 'duel_win',
|
||||
amount: totalValue,
|
||||
meta: JSON.stringify({
|
||||
roomId: current.id,
|
||||
depositIds: current.deposits.map((d) => d.id),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return tx.duelRoom.update({
|
||||
where: { id: current.id },
|
||||
data: { status: 'resolved', resolvedAt: new Date() },
|
||||
include: roomInclude(),
|
||||
});
|
||||
});
|
||||
|
||||
if (!room) return;
|
||||
|
||||
const state = serializeDuelRoom(room);
|
||||
emitDuel(room.id, 'duel:result', state);
|
||||
emitDuel(room.id, 'duel:state', state);
|
||||
await broadcastDuelsList();
|
||||
}
|
||||
|
||||
export async function recoverDuelSpins() {
|
||||
const spinning = await prisma.duelRoom.findMany({
|
||||
where: { status: 'spinning' },
|
||||
});
|
||||
for (const room of spinning) {
|
||||
scheduleDuelFinish(room.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { prisma } from '../db.js';
|
||||
import { pickWeighted } from '../weighted.js';
|
||||
import { emitJackpot } from '../realtime.js';
|
||||
|
||||
const ROUND_SECONDS = Number(process.env.JACKPOT_ROUND_SECONDS || 60);
|
||||
const MAX_ITEMS = Number(process.env.JACKPOT_MAX_ITEMS || 3);
|
||||
const SPIN_MS = 5500;
|
||||
|
||||
let resolveTimer = null;
|
||||
let spinFollowUp = null;
|
||||
|
||||
function depositInclude() {
|
||||
return {
|
||||
user: { select: { id: true, username: true, avatarUrl: true } },
|
||||
inventoryItem: { include: { item: true } },
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeJackpotRound(round) {
|
||||
if (!round) return null;
|
||||
|
||||
const deposits = (round.deposits || []).map((d) => ({
|
||||
id: d.id,
|
||||
userId: d.userId,
|
||||
username: d.user?.username || '',
|
||||
avatarUrl: d.user?.avatarUrl || '',
|
||||
valueCents: d.valueCents,
|
||||
inventoryItemId: d.inventoryItemId,
|
||||
item: d.inventoryItem
|
||||
? {
|
||||
id: d.inventoryItem.item.id,
|
||||
name: d.inventoryItem.item.name,
|
||||
imageUrl: d.inventoryItem.item.imageUrl,
|
||||
rarity: d.inventoryItem.item.rarity,
|
||||
marketValue: d.inventoryItem.item.marketValue,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
const byUser = new Map();
|
||||
let totalValue = 0;
|
||||
for (const d of deposits) {
|
||||
totalValue += d.valueCents;
|
||||
const prev = byUser.get(d.userId) || {
|
||||
userId: d.userId,
|
||||
username: d.username,
|
||||
avatarUrl: d.avatarUrl,
|
||||
valueCents: 0,
|
||||
itemCount: 0,
|
||||
};
|
||||
prev.valueCents += d.valueCents;
|
||||
prev.itemCount += 1;
|
||||
byUser.set(d.userId, prev);
|
||||
}
|
||||
|
||||
const players = [...byUser.values()].map((p) => ({
|
||||
...p,
|
||||
chance: totalValue > 0 ? (p.valueCents / totalValue) * 100 : 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: round.id,
|
||||
status: round.status,
|
||||
endsAt: round.endsAt,
|
||||
createdAt: round.createdAt,
|
||||
resolvedAt: round.resolvedAt || null,
|
||||
winnerUserId: round.winnerUserId || null,
|
||||
winnerUsername: round.winner?.username || null,
|
||||
totalValue,
|
||||
maxItemsPerPlayer: MAX_ITEMS,
|
||||
players,
|
||||
deposits,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOpenOrLatest() {
|
||||
const open = await prisma.jackpotRound.findFirst({
|
||||
where: { status: { in: ['open', 'spinning'] } },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
if (open) return open;
|
||||
|
||||
return prisma.jackpotRound.findFirst({
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getJackpotState() {
|
||||
const round = await loadOpenOrLatest();
|
||||
return serializeJackpotRound(round);
|
||||
}
|
||||
|
||||
export async function broadcastJackpot(extra = {}) {
|
||||
const state = await getJackpotState();
|
||||
emitJackpot('jackpot:state', { ...state, ...extra });
|
||||
return state;
|
||||
}
|
||||
|
||||
async function createOpenRound() {
|
||||
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
|
||||
return prisma.jackpotRound.create({
|
||||
data: { status: 'open', endsAt },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
if (resolveTimer) {
|
||||
clearTimeout(resolveTimer);
|
||||
resolveTimer = null;
|
||||
}
|
||||
if (spinFollowUp) {
|
||||
clearTimeout(spinFollowUp);
|
||||
spinFollowUp = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleResolve(endsAt) {
|
||||
clearTimers();
|
||||
const delay = Math.max(0, new Date(endsAt).getTime() - Date.now());
|
||||
resolveTimer = setTimeout(() => {
|
||||
resolveJackpotRound().catch((err) => console.error('jackpot resolve', err));
|
||||
}, delay);
|
||||
}
|
||||
|
||||
export async function ensureJackpotRound() {
|
||||
let round = await prisma.jackpotRound.findFirst({
|
||||
where: { status: 'open' },
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
|
||||
if (!round) {
|
||||
const spinning = await prisma.jackpotRound.findFirst({
|
||||
where: { status: 'spinning' },
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
if (spinning) {
|
||||
// Server restarted mid-spin — finish after a short delay
|
||||
spinFollowUp = setTimeout(() => {
|
||||
finishJackpotSpin(spinning.id).catch((err) => console.error(err));
|
||||
}, 1000);
|
||||
return getJackpotState();
|
||||
}
|
||||
round = await createOpenRound();
|
||||
} else if (new Date(round.endsAt).getTime() <= Date.now()) {
|
||||
await resolveJackpotRound();
|
||||
return getJackpotState();
|
||||
}
|
||||
|
||||
scheduleResolve(round.endsAt);
|
||||
return getJackpotState();
|
||||
}
|
||||
|
||||
export async function placeJackpotBet(userId, inventoryItemIds) {
|
||||
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
|
||||
if (!ids.length) {
|
||||
throw Object.assign(new Error('Select at least one item'), { status: 400 });
|
||||
}
|
||||
if (ids.length > MAX_ITEMS) {
|
||||
throw Object.assign(new Error(`Max ${MAX_ITEMS} items per bet`), { status: 400 });
|
||||
}
|
||||
|
||||
const round = await prisma.$transaction(async (tx) => {
|
||||
const open = await tx.jackpotRound.findFirst({
|
||||
where: { status: 'open' },
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
if (!open) {
|
||||
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
|
||||
}
|
||||
if (new Date(open.endsAt).getTime() <= Date.now()) {
|
||||
throw Object.assign(new Error('Round is closing'), { status: 409 });
|
||||
}
|
||||
|
||||
const existingCount = await tx.jackpotDeposit.count({
|
||||
where: { roundId: open.id, userId },
|
||||
});
|
||||
if (existingCount + ids.length > MAX_ITEMS) {
|
||||
throw Object.assign(
|
||||
new Error(`You can bet at most ${MAX_ITEMS} items this round`),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const invItems = await tx.inventoryItem.findMany({
|
||||
where: { id: { in: ids }, userId, locked: false },
|
||||
include: { item: true },
|
||||
});
|
||||
if (invItems.length !== ids.length) {
|
||||
throw Object.assign(new Error('Invalid or locked inventory items'), { status: 400 });
|
||||
}
|
||||
|
||||
for (const inv of invItems) {
|
||||
await tx.inventoryItem.update({
|
||||
where: { id: inv.id },
|
||||
data: { locked: true },
|
||||
});
|
||||
await tx.jackpotDeposit.create({
|
||||
data: {
|
||||
roundId: open.id,
|
||||
userId,
|
||||
inventoryItemId: inv.id,
|
||||
valueCents: inv.item.marketValue,
|
||||
},
|
||||
});
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId,
|
||||
type: 'jackpot_bet',
|
||||
amount: 0,
|
||||
meta: JSON.stringify({
|
||||
roundId: open.id,
|
||||
inventoryItemId: inv.id,
|
||||
itemId: inv.itemId,
|
||||
valueCents: inv.item.marketValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tx.jackpotRound.findUnique({
|
||||
where: { id: open.id },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const state = serializeJackpotRound(round);
|
||||
emitJackpot('jackpot:state', state);
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function withdrawJackpotBet(userId, inventoryItemIds) {
|
||||
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
|
||||
if (!ids.length) {
|
||||
throw Object.assign(new Error('Select items to withdraw'), { status: 400 });
|
||||
}
|
||||
|
||||
const round = await prisma.$transaction(async (tx) => {
|
||||
const open = await tx.jackpotRound.findFirst({
|
||||
where: { status: 'open' },
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
if (!open) {
|
||||
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
|
||||
}
|
||||
if (new Date(open.endsAt).getTime() - Date.now() < 3000) {
|
||||
throw Object.assign(new Error('Too late to withdraw'), { status: 409 });
|
||||
}
|
||||
|
||||
const deposits = await tx.jackpotDeposit.findMany({
|
||||
where: {
|
||||
roundId: open.id,
|
||||
userId,
|
||||
inventoryItemId: { in: ids },
|
||||
},
|
||||
});
|
||||
if (deposits.length !== ids.length) {
|
||||
throw Object.assign(new Error('Deposit not found'), { status: 400 });
|
||||
}
|
||||
|
||||
for (const d of deposits) {
|
||||
await tx.jackpotDeposit.delete({ where: { id: d.id } });
|
||||
await tx.inventoryItem.update({
|
||||
where: { id: d.inventoryItemId },
|
||||
data: { locked: false },
|
||||
});
|
||||
}
|
||||
|
||||
return tx.jackpotRound.findUnique({
|
||||
where: { id: open.id },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const state = serializeJackpotRound(round);
|
||||
emitJackpot('jackpot:state', state);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function finishJackpotSpin(roundId) {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const round = await tx.jackpotRound.findUnique({
|
||||
where: { id: roundId },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
},
|
||||
});
|
||||
if (!round || round.status !== 'spinning') return null;
|
||||
|
||||
const winnerId = round.winnerUserId;
|
||||
for (const d of round.deposits) {
|
||||
await tx.inventoryItem.update({
|
||||
where: { id: d.inventoryItemId },
|
||||
data: { userId: winnerId, locked: false },
|
||||
});
|
||||
}
|
||||
|
||||
const totalValue = round.deposits.reduce((s, d) => s + d.valueCents, 0);
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: winnerId,
|
||||
type: 'jackpot_win',
|
||||
amount: totalValue,
|
||||
meta: JSON.stringify({
|
||||
roundId: round.id,
|
||||
depositIds: round.deposits.map((d) => d.id),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.jackpotRound.update({
|
||||
where: { id: round.id },
|
||||
data: { status: 'resolved', resolvedAt: new Date() },
|
||||
});
|
||||
|
||||
return createOpenRoundInTx(tx);
|
||||
});
|
||||
|
||||
if (!result) return;
|
||||
|
||||
const state = serializeJackpotRound(result);
|
||||
scheduleResolve(result.endsAt);
|
||||
emitJackpot('jackpot:result', {
|
||||
previousRoundId: roundId,
|
||||
...state,
|
||||
});
|
||||
emitJackpot('jackpot:state', state);
|
||||
}
|
||||
|
||||
async function createOpenRoundInTx(tx) {
|
||||
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
|
||||
return tx.jackpotRound.create({
|
||||
data: { status: 'open', endsAt },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveJackpotRound() {
|
||||
clearTimers();
|
||||
|
||||
const spun = await prisma.$transaction(async (tx) => {
|
||||
const open = await tx.jackpotRound.findFirst({
|
||||
where: { status: 'open' },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
if (!open) return null;
|
||||
|
||||
if (!open.deposits.length) {
|
||||
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
|
||||
const extended = await tx.jackpotRound.update({
|
||||
where: { id: open.id },
|
||||
data: { endsAt },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
});
|
||||
return { kind: 'extend', updated: extended };
|
||||
}
|
||||
|
||||
const byUser = new Map();
|
||||
for (const d of open.deposits) {
|
||||
byUser.set(d.userId, (byUser.get(d.userId) || 0) + d.valueCents);
|
||||
}
|
||||
const entries = [...byUser.entries()].map(([userId, dropRate]) => ({
|
||||
userId,
|
||||
dropRate,
|
||||
}));
|
||||
const winner = pickWeighted(entries);
|
||||
|
||||
const winnerDeposits = open.deposits.filter((d) => d.userId === winner.userId);
|
||||
const reelDepositId = winnerDeposits[0]?.id || null;
|
||||
|
||||
const updated = await tx.jackpotRound.update({
|
||||
where: { id: open.id },
|
||||
data: { status: 'spinning', winnerUserId: winner.userId },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return { kind: 'spin', updated, reelItemId: reelDepositId };
|
||||
});
|
||||
|
||||
if (!spun) return;
|
||||
|
||||
if (spun.kind === 'extend') {
|
||||
const state = serializeJackpotRound(spun.updated);
|
||||
scheduleResolve(state.endsAt);
|
||||
emitJackpot('jackpot:state', state);
|
||||
return state;
|
||||
}
|
||||
|
||||
const { updated, reelItemId } = spun;
|
||||
const state = serializeJackpotRound(updated);
|
||||
emitJackpot('jackpot:spin', {
|
||||
...state,
|
||||
reelWinnerItemId: reelItemId,
|
||||
});
|
||||
|
||||
spinFollowUp = setTimeout(() => {
|
||||
finishJackpotSpin(updated.id).catch((err) => console.error('jackpot finish', err));
|
||||
}, SPIN_MS + 200);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const uploadsDir = path.join(__dirname, '../uploads');
|
||||
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
export function createUploader(prefix) {
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadsDir),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase() || '.png';
|
||||
const safe = ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext) ? ext : '.png';
|
||||
cb(null, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${safe}`);
|
||||
},
|
||||
});
|
||||
|
||||
return multer({
|
||||
storage,
|
||||
limits: { fileSize: 3 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!file.mimetype.startsWith('image/')) {
|
||||
return cb(new Error('Only images are allowed'));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user