diff --git a/.env.prod.example b/.env.prod.example index 3951b8d..1a1a52b 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -18,8 +18,16 @@ ADMIN_USERNAME=admin ADMIN_PASSWORD=change-me-strong-password # Starting balances for new accounts (credits in cents, osu as units) +# In production, keep STARTING_OSU low/0 — osu is the real-money currency. STARTING_BALANCE=10000 -STARTING_OSU=100 +STARTING_OSU=25 + +# Payments (osu top-ups) +# mock = instant confirm for local/dev (default) +# stripe = requires STRIPE_SECRET_KEY (+ webhook wiring) +PAYMENTS_PROVIDER=mock +# STRIPE_SECRET_KEY=sk_live_... +# STRIPE_WEBHOOK_SECRET=whsec_... # Jackpot JACKPOT_ROUND_SECONDS=30 diff --git a/README.md b/README.md index 923473d..febf050 100644 --- a/README.md +++ b/README.md @@ -16,16 +16,16 @@ npm run db:seed npm run dev ``` -- App: http://localhost:5173 +- App: http://localhost:5888 - API: http://localhost:3001 -- LAN: http://\:5173 (Vite binds to `0.0.0.0`) +- LAN: http://\:5888 (Vite binds to `0.0.0.0`) Create `server/.env` if missing: ``` DATABASE_URL="file:./dev.db" PORT=3001 -CLIENT_ORIGIN=http://localhost:5173 +CLIENT_ORIGIN=http://localhost:5888 SESSION_SECRET=dev-secret STARTING_BALANCE=10000 STARTING_OSU=100 @@ -47,21 +47,20 @@ New registrations receive **100.00** starting credits and **100 osu** (`STARTING ## Features - Login / register (username + password) -- **Inventory**: sort by recent or value, multi-select sell at **instance** value (float/wear), bulk **Sell below** threshold, **favorites** (★ — cannot be sold or auto-sold; can still be bet in battles), **Vault** staking (up to **3** slots by default; +1 per Vault Capacity skill) — staked items earn dividends every **10 min** (10% value online / 1% offline, scaled by float² wear and rarity) +- **Inventory**: sort by recent or value, multi-select sell at **instance** value (float/wear), bulk **Sell below** threshold, **favorites** (★ — cannot be sold or auto-sold; can still be bet in battles), **Vault** staking (up to **3** slots by default; +1 per Vault Capacity skill) — shared timer; dividends auto-pay **every 5 min online** (10% value) / **10 min offline** (1%), scaled by float² wear and rarity (cap **500%** value / tick) - **Auto-sell**: optional threshold (shared with bulk sell); on **case opens**, credits are applied after the reel finishes; on **battle wins**, items taken from other players below the threshold are sold immediately - **Wear (float)**: each case drop gets a random float `0–1` and paint seed; grades FN / MW / FT / WW / BS (CS ranges). Catalog `marketValue` is the **Field-Tested** reference; instance `valueCents` scales by float within the wear band (FN up to +50%, MW +15–30%, FT mid ≈ catalog, WW down to −40%, BS lower) - **Feed Drop**: toggle in nav — live left column of latest case unboxes + MultiPlayers Battle winners - Cases page: browse and open crates (CS-style reel, result rolled server-side) - **Case keys** (per crate): **1 key** → **x10**; each **100 opens** of that case earns +1 key up to **x20** (keys 1→11, +1 open per key: x11…x20); from **x20** onward each next key costs **200, 300, 400…** opens (100 × tier) and adds +1 max open (x21, x22…) — no cap - **Battle** hub (`/battle`): - - **MultiPlayers Case** — live shared pot preview on the hub (top items, pot total, players), 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** (nav balance pill → `/shop`): spend osu on credit packs; watch an ad every 1 min for 100.00–200.00 cr + - Create / join MultiPlayers pots — live shared pot, up to 3 items, win chance by value, Socket.IO sync + - Spectate in-progress battles from the hub +- **Shop** (`/shop`): buy **osu** with real money (mock checkout in dev, Stripe-ready), exchange osu for credit packs, or watch an ad every 1 min for 100.00–200.00 cr - Profile (username in nav): avatar, bio, password, stats, logout - **Presence**: online status (Socket.IO), total play time, last connection — shown on own and public profiles -- **Achievements** (`/achievements`, also from profile): 10 starter badges unlocked by play (cases, battles, catalog, prestige, shop, playtime); earned badges appear on the profile -- **Prestige**: pay **1 000 000 000 000.00** cr to reset; earn **Pr** (1000 base + ~1000 per **100 000 000 000.00** cr excess); spend Pr on a skill tree (Fortune, Yield, Polish, Bulk, Discount, Quality, noTime, Refund, Respin, Vault Capacity, Offline/Online Yield) +- **Achievements** (`/achievements`, also from profile): starter badges unlocked by play (cases, battles, catalog, prestige, shop, playtime); earned badges appear on the profile +- **Prestige**: pay **1 000 000 000 000.00** cr to reset; earn **Pr** (1000 base + ~1000 per **250 000 000 000.00** cr excess); spend Pr on a skill tree (Fortune, Yield, Polish, Bulk, PR Farmer, Quality, noTime, Refund, Respin, Vault Capacity, Offline/Online Yield) - **Leaderboard**: richest players (balance + inventory value); click a row to open a public **player profile** (`/player/:username`) with stats and inventory - Admin: overview dashboard, players (credit adjust), cases / items / drops (split pages, search filters) @@ -114,12 +113,12 @@ Typical **prod → local** flow: `docker-db-export.sh` on the server → `scp` t ## Notes -- Balances and prices are integers in **cents**; **osu** is a separate demo currency for the shop +- Balances and prices are integers in **cents**; **osu** is the premium (real-money) currency — top-ups create `PaymentOrder` rows (`PAYMENTS_PROVIDER=mock|stripe`) - Catalog item price = Field-Tested reference; each inventory row stores `floatValue`, `wear`, `paintSeed`, `valueCents`, `favorite`, optional vault `staked` / `stakedAt` / `lastYieldAt` - Per-user auto-sell settings: `autoSellEnabled`, `autoSellThresholdCents` (default threshold **10.00** cr) - Case key progress stored in `CaseKeyProgress` (per user + case); backfill: `npm run db:backfill-case-keys --prefix server` - Inventory items locked while deposited in a bet; unlocked or transferred on resolve; staked vault items cannot be sold, auto-sold, or bet - Avatars / uploads under `server/uploads/` (volume in Docker) - Real-time via Socket.IO; Docker Hub image name is lowercase: `foufure/casegambling` -- Max **10** simultaneous browser windows/tabs per account (Socket.IO); override with `MAX_SOCKETS_PER_USER` +- Max **2** simultaneous browser windows/tabs per account (Socket.IO); override with `MAX_SOCKETS_PER_USER` - Swap `DATABASE_URL` to PostgreSQL later without changing app logic diff --git a/amelioration.md b/amelioration.md new file mode 100644 index 0000000..e4001bc --- /dev/null +++ b/amelioration.md @@ -0,0 +1,255 @@ +# CaseOrion — Analyse & améliorations + +**Date :** 2026-07-25 +**Périmètre :** analyse complète (server + client + Docker), **sans modification de code**. +**Stack :** Express / Prisma / SQLite / Socket.IO · React (Vite) · ~16k LOC client+server. + +--- + +## Synthèse + +Le projet est une API jeu cohérente en **mono-process**, avec un effort sérieux sur l’économie haute (crédits en **strings + BigInt** via `cents.js`). +Les risques les plus graves sont : **odds duel cassées**, **paiements mock en prod**, **`db push --accept-data-loss` au boot**, **comparaisons argent via `Number()`**, et **état in-memory** (sessions, timers battles, présence). + +| Zone | État | +|------|------| +| Cases / wear / keys | Solide (roll serveur, BigInt value) | +| Auto-sell | Logique OK (`value < seuil`) ; UX/confusion catalogue vs instance | +| Jackpot / Battle | Agrégation BigInt correcte | +| Duels 1vX | **Supprimé** (2026-07-25) | +| Vault / prestige | Fonctionnel ; settle concurrent risqué | +| Shop | Mock OK en dev ; dangereux si prod | +| Docker | Marche en single-node ; pas scale-ready | + +--- + +## P0 — Critique (corriger en premier) + +### ~~1. Odds des duels faussées (multi-items)~~ — **résolu par suppression** +Le mode **1vX Case** (rooms custom / `duel.js`) a été **retiré** (2026-07-25). Il ne reste que le Battle MultiPlayers (`jackpot.js`). Les anciennes routes `/battle/1vx` redirigent vers `/battle`. + +### 1. Paiements mock utilisables en production +**Fichiers :** `server/src/payments.js`, `routes/shop.js`, `.env.prod.example` + +`PAYMENTS_PROVIDER=mock` + `POST /api/shop/osu/confirm` crédite l’osu **sans paiement**. + +| Proposition | Détail | +|-------------|--------| +| **A** | Refuser mock si `NODE_ENV=production` sauf `ALLOW_MOCK_PAYMENTS=1` | +| B | Brancher Stripe (webhook raw body + signature) | +| C | Désactiver toute la section osu en prod tant que non prêt | + +--- + +### 3. `prisma db push --accept-data-loss` à chaque démarrage Docker +**Fichiers :** `Dockerfile`, `docker-compose.yml` + +Peut **détruire / tronquer** des colonnes au restart. Inadapté à une vraie prod. + +| Proposition | Détail | +|-------------|--------| +| **A (recommandé)** | Prisma Migrate (`migrate deploy`) ; push manuel hors boot | +| B | Garder push **sans** `--accept-data-loss` + fail loud | +| C | Job one-shot de migration séparé du `CMD` app | + +--- + +### ~~4. `db.js` coerce tous les BigInt → `Number`~~ — **corrigé** +`reviveMoney` **supprimé**. Choix retenu : argent en **strings** + `toCentsBigInt` (pas de plafond `Number`), BigInt Prisma éventuels laissés intacts jusqu’au JSON (`toString` dans `index.js`). Pas de conversion globale silencieuse. + +--- + +### ~~5. Uploads SVG → XSS stocké~~ — **corrigé** +Allowlist **PNG / JPEG / WebP / GIF** (MIME + extension + vérif `sharp` du format réel). SVG et autres `image/*` refusés (admin + avatar). + +--- + +## P1 — Économie & précision (haute priorité) + +### ~~6. Finir la migration argent BigInt-safe (bout en bout)~~ — **corrigé (A)** +Helpers client dans `api.js` : `toCentsBigInt`, `compareCents`, `centsJson` (miroir serveur). Branchés sur ItemTile, CasePage, Dashboard, Battle, Prestige shortfall, Admin sorts/inputs. Affichage via les mêmes helpers. + +--- + +### ~~7. Courses sur le solde (TOCTOU)~~ — **corrigé (A)** +`spendUserBalance` / `creditUserBalance` / `adjustUserBalance` dans `cents.js` : update SQL atomique `balance = balance ± x WHERE balance >= cost` (INTEGER SQLite) ou CAS string pour montants > Int64. Branché open case, prestige, sell/auto-sell, vault, shop, jackpot. + +Même famille restante : double settle vault (login + socket + intervalle) — le crédit est atomique, mais le calcul de ticks peut encore se chevaucher. + +--- + +### ~~8. Auto-sell — clarté & robustesse~~ — **partiel (A)** +Won banner case : **toujours** wear + valeur d’instance ; si auto-sold, ajoute aussi le payout. + +Reste ouvert : B immédiat serveur · C TTL claim · D confirm prestige. + +--- + +### ~~9. Colonnes prestige mortes / non branchées~~ — **corrigé (B)** +Supprimées du schema + reset server-prestige : `prestigeKeyOpenReduction`, `prestigeStartBonus`, `prestigeAdBonus`, `prestigeAdCooldownReduction`, `prestigeShopDiscount`, `prestigeStartOsu`, `prestigeDropValueBonus`, `prestigeCaseDiscount`. + +--- + +## P2 — Sécurité & prod + +### 10. Sessions MemoryStore +Perdues au restart ; incompatible multi-replicas. + +| Proposition | A : SQLite/Redis store · B : sticky single node documenté · C : JWT (changement plus large) | + +### 11. CORS trop permissif +`index.js` autorise toujours localhost + LAN HTTP, même “prod”. + +| Proposition | A : en prod, **uniquement** `CLIENT_ORIGIN` + `CORS_ORIGINS` | + +### 12. Rate limiting absent +Login / register / open / ad / shop confirm non limités. + +| Proposition | A : `express-rate-limit` par IP+user · B : captcha register · C : cooldowns serveur déjà partiels (ad) à généraliser | + +### 13. Admin via `session.role` sans re-check DB +Élévation stale possible. + +| Proposition | A : `requireAdmin` recharge le user · B : claim `roleVersion` en session | + +### 14. Secrets & config +`SESSION_SECRET=dev-secret` par défaut ; pas de `server/.env.example` local (seulement README + `.env.prod.example`). + +| Proposition | A : fail boot si secret faible en prod · B : ajouter `.env.example` | + +### 15. Mots de passe faibles +Min 6 caractères seulement. + +| Proposition | A : min 8 + zxcvbn soft · B : garder simple (jeu privé) mais documenter | + +--- + +## P3 — Qualité code / refactor + +### 16. Duplication jackpot ↔ duel +Serialize, finish spin, auto-sell winner : quasi copy-paste. + +| Proposition | A : module `services/battleCommon.js` · B : unifier modèle “room” (plus gros refactor) | + +### 17. `serializeInv` dupliqué +`inventory.js` / `profile.js` (et variantes). + +| Proposition | A : `serializeInventoryItem` partagé | + +### 18. Client — listeners `socket.on('connect')` jamais retirés +Layout, BattleHub, BattleRoom, Duel*, DropFeed → fuite de handlers au remount. + +| Proposition | A : cleanup `socket.off` dans chaque `useEffect` | + +### 19. Battle hub incomplet +Routes 1vX existent ; hub n’expose que MultiPlayers + Coinflip stub. Copy “MultiPlayers” / redirects legacy. + +| Proposition | A : cartes hub 1vX + Coinflip disabled clair · B : retirer Coinflip jusqu’à implémentation | + +### 20. Guards auth copiés partout +Chaque page : loading / !user / admin redirect. + +| Proposition | A : `` / `` · B : loader React Router | + +### 21. CasePage — complexité +State machine respin/auto-sell/announce via refs : difficile à maintenir, mais globalement correcte. + +| Proposition | A : extraire hook `useCaseOpenFlow` · B : machine XState · C : laisser tel quel + tests d’intégration | + +### 22. Admin UX +- Inputs prix en cents bruts + `Number` +- Pas d’édition packs crédits / ads (seulement osu packs) +- Adjust player sans confirm +- Pas de lien “voir le site joueur” + +| Proposition | A : champs crédits + confirm adjust · B : admin shop packs crédits | + +### 23. Tests absents +Aucun test automatisé visible sur la logique critique (odds, cents, auto-sell, vault). + +| Proposition | A : Vitest unitaire `cents` / `wear` / `autoSell` / duel weights · B : smoke API Playwright | + +### 24. Observabilité +`console.error` local ; pas de métriques / tracing. + +| Proposition | A : structured logs (pino) + request id · B : health DB ready | + +--- + +## P4 — Produit / design (nice to have) + +| # | Sujet | Proposition | +|---|--------|-------------| +| 25 | Prestige sans confirm | Dialog “tu perds inventaire + keys” | +| 26 | Catalog conservé au prestige | Documenter (déjà le cas) ou option reset | +| 27 | Favoris bettables | Clarifier UI ou bloquer en battle | +| 28 | Feed montre parfois FT | Afficher valeur d’instance | +| 29 | Lazy routes / code-split | `React.lazy` admin vs player | +| 30 | SQLite scale | Postgres si charge / multi-instance | +| 31 | Achievements / shop ads hardcodés | Config admin | +| 32 | Animation CaseReel perf | Lazy images ; réduire slots si `animReduction` | + +--- + +## Vérification logique (résumé métier) + +| Système | Verdict | +|---------|---------| +| **Drop case** | Serveur fait foi ; luck = catalog chapters + Fortune | +| **Wear → prix** | Correct (FT = ref) ; Polish/Quality biaisent vers le haut | +| **Auto-sell** | `instance < seuil` ; Yield après coup ; pas de bug “35T vendu sous 15T” si prestige max + vrai 35T instance | +| **Keys** | Progression documentée ; `keyOpenReduction` non branché | +| **Vault** | Timer partagé ; risque double settle | +| **Jackpot** | Poids valeur OK | +| **Duel** | **Poids multi-items KO** | +| **Prestige joueur** | Wipe bal/inv/keys ; keep skills/Pr/**catalog** | +| **Server prestige** | Wipe économie globale ; keep account/achievements/stats | + +--- + +## Roadmap proposée (ordre d’exécution) + +``` +Semaine 1 (P0) + ├─ Fix duel toCentsBigInt + test + ├─ Gate mock payments en prod + ├─ Retirer reviveMoney Number(bigint) + ├─ Allowlist uploads (no SVG) + └─ Plan migration Docker (stop accept-data-loss au boot) + +Semaine 2 (P1) + ├─ BigInt-safe client (ItemTile, sorts, prestige shortfall, admin inputs) + ├─ Balance updates atomiques + vault settle idempotent + └─ Brancher ou supprimer prestige fields morts + +Semaine 3 (P2–P3) + ├─ Rate limit + CORS prod + session store + ├─ Refactor battle common + socket cleanup + ├─ Hub 1vX + tests unitaires cents/odds + └─ Confirms UX (prestige, admin adjust) +``` + +--- + +## Alternatives d’architecture (si le projet grossit) + +| Option | Quand | Coût | +|--------|-------|------| +| **Rester mono SQLite** | LAN / petits effectifs | Faible — documenter single-node | +| **Postgres + Redis** (sessions, presence, timers) | Multi-instances / uptime | Moyen | +| **Queue économie** (BullMQ) | Anti race balance/vault | Moyen | +| **Séparer worker** (spins, vault ticks) | Charge Socket.IO | Élevé | +| **Stripe live** | Monétisation réelle | Moyen + conformité | + +--- + +## Hors scope de cette analyse + +- Audit pentest complet / dépendances CVE détaillées +- Refonte UI/UX visuelle +- Implémentation Coinflip +- Contenu (balancing drops / prix items) + +--- + +*Document généré pour pilotage : aucune modification code associée. Prioriser P0 avant tout déploiement Docker “réel”.* diff --git a/client/src/App.jsx b/client/src/App.jsx index 66f64d6..4ad2938 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,4 +1,4 @@ -import { Navigate, Route, Routes, useParams } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; import Layout from './components/Layout'; import { useAuth } from './AuthContext'; import Admin from './pages/Admin'; @@ -9,10 +9,7 @@ import Leaderboard from './pages/Leaderboard'; import Login from './pages/Login'; import Register from './pages/Register'; import BattleHub from './pages/BattleHub'; -import MultiPlayersPage from './pages/MultiPlayersPage'; -import DuelListPage from './pages/DuelListPage'; -import DuelRoomPage from './pages/DuelRoomPage'; -import CoinflipStub from './pages/CoinflipStub'; +import BattleRoomPage from './pages/BattleRoomPage'; import ProfilePage from './pages/ProfilePage'; import PlayerProfilePage from './pages/PlayerProfilePage'; import CatalogPage from './pages/CatalogPage'; @@ -28,11 +25,6 @@ function HomeRedirect() { return ; } -function BetDuelRedirect() { - const { id } = useParams(); - return ; -} - export default function App() { return ( @@ -48,15 +40,17 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/client/src/AuthContext.jsx b/client/src/AuthContext.jsx index 67fbe4b..2e7b5f3 100644 --- a/client/src/AuthContext.jsx +++ b/client/src/AuthContext.jsx @@ -1,5 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { api } from './api'; +import { getSocket, refreshSocketSession } from './socket'; const AuthContext = createContext(null); @@ -22,6 +23,23 @@ export function AuthProvider({ children }) { refresh(); }, [refresh]); + // Keep Socket.IO presence in sync with HTTP session (login / logout / boot). + useEffect(() => { + if (loading) return; + refreshSocketSession(); + }, [loading, user?.id]); + + useEffect(() => { + const socket = getSocket(); + const onReset = () => { + refresh(); + }; + socket.on('server:prestige-reset', onReset); + return () => { + socket.off('server:prestige-reset', onReset); + }; + }, [refresh]); + const login = async (username, password) => { const data = await api.login(username, password); setUser(data.user); diff --git a/client/src/admin/AdminCases.jsx b/client/src/admin/AdminCases.jsx index b58cfec..78a6410 100644 --- a/client/src/admin/AdminCases.jsx +++ b/client/src/admin/AdminCases.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { api, formatCredits } from '../api'; +import { api, formatCredits, centsJson, toCentsBigInt } from '../api'; import ImageField from './ImageField'; const emptyCase = { name: '', price: 100, imageUrl: '', active: true }; @@ -62,7 +62,7 @@ export default function AdminCases() { e.preventDefault(); setError(''); try { - await api.createCase({ ...caseForm, price: Number(caseForm.price) }); + await api.createCase({ ...caseForm, price: centsJson(toCentsBigInt(caseForm.price)) }); setCaseForm(emptyCase); await load(); } catch (err) { @@ -112,7 +112,7 @@ export default function AdminCases() { try { await api.updateCase(editingId, { name: edit.name, - price: Number(edit.price), + price: centsJson(toCentsBigInt(edit.price)), imageUrl: edit.imageUrl, active: edit.active, }); diff --git a/client/src/admin/AdminDashboard.jsx b/client/src/admin/AdminDashboard.jsx index 27b1e90..607c792 100644 --- a/client/src/admin/AdminDashboard.jsx +++ b/client/src/admin/AdminDashboard.jsx @@ -78,6 +78,12 @@ export default function AdminDashboard() { Drop rates + + Shop osu packs + + + Prestige Server +
diff --git a/client/src/admin/AdminDrops.jsx b/client/src/admin/AdminDrops.jsx index f15afb7..c0839a0 100644 --- a/client/src/admin/AdminDrops.jsx +++ b/client/src/admin/AdminDrops.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { api, formatCredits } from '../api'; +import { api, formatCredits, compareCents } from '../api'; function dropPercents(items) { const total = items.reduce((s, ci) => s + Number(ci.dropRate || 0), 0); @@ -7,12 +7,54 @@ function dropPercents(items) { return items.map((ci) => (Number(ci.dropRate) / total) * 100); } +function sortCatalog(list, sort) { + const next = [...list]; + next.sort((a, b) => { + if (sort === 'value-asc') return compareCents(a.marketValue, b.marketValue); + if (sort === 'name') return a.name.localeCompare(b.name); + if (sort === 'rarity') { + const r = a.rarity.localeCompare(b.rarity); + if (r !== 0) return r; + return compareCents(b.marketValue, a.marketValue); + } + // value-desc (default) + return compareCents(b.marketValue, a.marketValue); + }); + return next; +} + +function sortPool(list, sort) { + const next = [...list]; + next.sort((a, b) => { + if (sort === 'value-desc') { + return compareCents(b.item.marketValue, a.item.marketValue); + } + if (sort === 'value-asc') { + return compareCents(a.item.marketValue, b.item.marketValue); + } + if (sort === 'name') return a.item.name.localeCompare(b.item.name); + if (sort === 'rarity') { + const r = a.item.rarity.localeCompare(b.item.rarity); + if (r !== 0) return r; + return compareCents(b.item.marketValue, a.item.marketValue); + } + if (sort === 'rate-desc') return Number(b.dropRate) - Number(a.dropRate); + // rate-asc (default) + return Number(a.dropRate) - Number(b.dropRate); + }); + return next; +} + export default function AdminDrops() { const [cases, setCases] = useState([]); const [items, setItems] = useState([]); const [error, setError] = useState(''); const [selectedCaseId, setSelectedCaseId] = useState(''); const [dropForm, setDropForm] = useState({ itemId: '', dropRate: 1 }); + const [itemSort, setItemSort] = useState('value-desc'); + const [itemQuery, setItemQuery] = useState(''); + const [categoryFilter, setCategoryFilter] = useState('all'); + const [poolSort, setPoolSort] = useState('rate-asc'); const load = useCallback(async () => { const [c, i] = await Promise.all([api.adminCases(), api.adminItems()]); @@ -22,10 +64,6 @@ export default function AdminDrops() { if (prev && c.cases.some((x) => String(x.id) === String(prev))) return prev; return c.cases[0] ? String(c.cases[0].id) : ''; }); - setDropForm((f) => ({ - ...f, - itemId: f.itemId || (i.items[0] ? String(i.items[0].id) : ''), - })); }, []); useEffect(() => { @@ -37,10 +75,43 @@ export default function AdminDrops() { [cases, selectedCaseId] ); + const usedCategories = useMemo(() => { + const set = new Set(items.map((item) => item.category || 'Misc')); + return [...set].sort((a, b) => a.localeCompare(b)); + }, [items]); + + const catalogItems = useMemo(() => { + const q = itemQuery.trim().toLowerCase(); + const filtered = items.filter((item) => { + const cat = item.category || 'Misc'; + if (categoryFilter !== 'all' && cat !== categoryFilter) return false; + if (!q) return true; + return ( + item.name.toLowerCase().includes(q) || + item.rarity.toLowerCase().includes(q) || + cat.toLowerCase().includes(q) + ); + }); + return sortCatalog(filtered, itemSort); + }, [items, itemQuery, itemSort, categoryFilter]); + + useEffect(() => { + if (!catalogItems.length) { + setDropForm((f) => (f.itemId ? { ...f, itemId: '' } : f)); + return; + } + setDropForm((f) => { + if (f.itemId && catalogItems.some((item) => String(item.id) === String(f.itemId))) { + return f; + } + return { ...f, itemId: String(catalogItems[0].id) }; + }); + }, [catalogItems]); + const poolItems = useMemo(() => { if (!selected) return []; - return [...selected.items].sort((a, b) => Number(a.dropRate) - Number(b.dropRate)); - }, [selected]); + return sortPool(selected.items, poolSort); + }, [selected, poolSort]); const percents = useMemo(() => dropPercents(poolItems), [poolItems]); @@ -76,6 +147,37 @@ export default function AdminDrops() { Add drop · {selected.name}{' '} ({formatCredits(selected.price)} cr) +
+ + + setItemQuery(e.target.value)} + aria-label="Search catalog items" + /> +
{ @@ -95,12 +197,18 @@ export default function AdminDrops() { -
-

Pool ({poolItems.length})

+
+

Pool ({poolItems.length})

+ +
{poolItems.length === 0 ? (
No drops yet.
) : ( @@ -130,7 +252,9 @@ export default function AdminDrops() { ) : null} {ci.item.name}{' '} - ({ci.item.rarity}) + + ({ci.item.rarity}) · {formatCredits(ci.item.marketValue)} cr + - onChange(e.target.value)} - placeholder="e.g. Rifle, Knife…" - required - /> - - {categories.map((c) => ( - - + ); } @@ -51,16 +49,23 @@ export default function AdminItems() { ]); setItems(i.items); setRarities(r.rarities); - setCategories( - (c.names || c.categories || []).map((entry) => - typeof entry === 'string' ? entry : entry.name - ) + const names = (c.names || c.categories || []).map((entry) => + typeof entry === 'string' ? entry : entry.name ); - setItemForm((f) => ({ - ...f, - rarity: f.rarity || r.rarities[0] || 'MilSpec', - category: f.category || 'Misc', - })); + setCategories(names); + setItemForm((f) => { + const nextCategory = + f.category && names.includes(f.category) + ? f.category + : names.includes('Misc') + ? 'Misc' + : names[0] || ''; + return { + ...f, + rarity: f.rarity || r.rarities[0] || 'MilSpec', + category: nextCategory, + }; + }); }, []); useEffect(() => { @@ -89,7 +94,7 @@ export default function AdminItems() { list = [...list]; list.sort((a, b) => { if (sort === 'name') return a.name.localeCompare(b.name); - if (sort === 'value') return Number(b.marketValue) - Number(a.marketValue); + if (sort === 'value') return compareCents(b.marketValue, a.marketValue); if (sort === 'rarity') return a.rarity.localeCompare(b.rarity); // category (default): group by category, then name const ca = (a.category || 'Misc').localeCompare(b.category || 'Misc'); @@ -164,12 +169,14 @@ export default function AdminItems() { try { await api.createItem({ ...itemForm, - marketValue: Number(itemForm.marketValue), + marketValue: centsJson(toCentsBigInt(itemForm.marketValue)), }); setItemForm({ ...emptyItem, rarity: rarities[0] || 'MilSpec', - category: 'Misc', + category: categories.includes('Misc') + ? 'Misc' + : categories[0] || '', }); await load(); } catch (err) { @@ -188,7 +195,6 @@ export default function AdminItems() {