update nice visuel

This commit is contained in:
gpatruno
2026-07-25 20:04:34 +02:00
parent 3a8871cbae
commit 810e4ac922
78 changed files with 6742 additions and 3116 deletions
+9 -1
View File
@@ -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
+11 -12
View File
@@ -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://\<your-lan-ip\>:5173 (Vite binds to `0.0.0.0`)
- LAN: http://\<your-lan-ip\>: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 `01` 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 +1530%, 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.00200.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.00200.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 **1000000000000.00** cr to reset; earn **Pr** (1000 base + ~1000 per **100000000000.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 **1000000000000.00** cr to reset; earn **Pr** (1000 base + ~1000 per **250000000000.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
+255
View File
@@ -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 losu **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 jusquau 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 dinstance ; 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 nexpose 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 : `<RequireAuth>` / `<RequirePlayer>` · 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 dinté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 dinstance |
| 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 dexé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 (P2P3)
├─ Rate limit + CORS prod + session store
├─ Refactor battle common + socket cleanup
├─ Hub 1vX + tests unitaires cents/odds
└─ Confirms UX (prestige, admin adjust)
```
---
## Alternatives darchitecture (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”.*
+12 -18
View File
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
import { Navigate, Route, Routes } from 'react-router-dom';
import Layout from './components/Layout';
import { useAuth } from './AuthContext';
import Admin from './pages/Admin';
@@ -9,10 +9,7 @@ import Leaderboard from './pages/Leaderboard';
import Login from './pages/Login';
import Register from './pages/Register';
import BattleHub from './pages/BattleHub';
import MultiPlayersPage from './pages/MultiPlayersPage';
import DuelListPage from './pages/DuelListPage';
import DuelRoomPage from './pages/DuelRoomPage';
import CoinflipStub from './pages/CoinflipStub';
import BattleRoomPage from './pages/BattleRoomPage';
import ProfilePage from './pages/ProfilePage';
import PlayerProfilePage from './pages/PlayerProfilePage';
import CatalogPage from './pages/CatalogPage';
@@ -28,11 +25,6 @@ function HomeRedirect() {
return <Navigate to="/dashboard" replace />;
}
function BetDuelRedirect() {
const { id } = useParams();
return <Navigate to={`/battle/1vx/${id}`} replace />;
}
export default function App() {
return (
<Routes>
@@ -48,15 +40,17 @@ export default function App() {
<Route path="/prestige" element={<PrestigePage />} />
<Route path="/achievements" element={<AchievementsPage />} />
<Route path="/battle" element={<BattleHub />} />
<Route path="/battle/multi" element={<MultiPlayersPage />} />
<Route path="/battle/1vx" element={<DuelListPage />} />
<Route path="/battle/1vx/:id" element={<DuelRoomPage />} />
<Route path="/battle/coinflip" element={<CoinflipStub />} />
<Route path="/battle/create" element={<BattleRoomPage create />} />
<Route path="/battle/multi" element={<Navigate to="/battle/create" replace />} />
<Route path="/battle/1vx" element={<Navigate to="/battle" replace />} />
<Route path="/battle/1vx/:id" element={<Navigate to="/battle" replace />} />
<Route path="/battle/coinflip" element={<Navigate to="/battle" replace />} />
<Route path="/battle/:id" element={<BattleRoomPage />} />
<Route path="/bet" element={<Navigate to="/battle" replace />} />
<Route path="/bet/multi" element={<Navigate to="/battle/multi" replace />} />
<Route path="/bet/1vx" element={<Navigate to="/battle/1vx" replace />} />
<Route path="/bet/1vx/:id" element={<BetDuelRedirect />} />
<Route path="/bet/coinflip" element={<Navigate to="/battle/coinflip" replace />} />
<Route path="/bet/multi" element={<Navigate to="/battle/create" replace />} />
<Route path="/bet/1vx" element={<Navigate to="/battle" replace />} />
<Route path="/bet/1vx/:id" element={<Navigate to="/battle" replace />} />
<Route path="/bet/coinflip" element={<Navigate to="/battle" replace />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/player/:username" element={<PlayerProfilePage />} />
<Route path="/shop" element={<ShopPage />} />
+18
View File
@@ -1,5 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { api } from './api';
import { getSocket, refreshSocketSession } from './socket';
const AuthContext = createContext(null);
@@ -22,6 +23,23 @@ export function AuthProvider({ children }) {
refresh();
}, [refresh]);
// Keep Socket.IO presence in sync with HTTP session (login / logout / boot).
useEffect(() => {
if (loading) return;
refreshSocketSession();
}, [loading, user?.id]);
useEffect(() => {
const socket = getSocket();
const onReset = () => {
refresh();
};
socket.on('server:prestige-reset', onReset);
return () => {
socket.off('server:prestige-reset', onReset);
};
}, [refresh]);
const login = async (username, password) => {
const data = await api.login(username, password);
setUser(data.user);
+3 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, formatCredits } from '../api';
import { api, formatCredits, centsJson, toCentsBigInt } from '../api';
import ImageField from './ImageField';
const emptyCase = { name: '', price: 100, imageUrl: '', active: true };
@@ -62,7 +62,7 @@ export default function AdminCases() {
e.preventDefault();
setError('');
try {
await api.createCase({ ...caseForm, price: Number(caseForm.price) });
await api.createCase({ ...caseForm, price: centsJson(toCentsBigInt(caseForm.price)) });
setCaseForm(emptyCase);
await load();
} catch (err) {
@@ -112,7 +112,7 @@ export default function AdminCases() {
try {
await api.updateCase(editingId, {
name: edit.name,
price: Number(edit.price),
price: centsJson(toCentsBigInt(edit.price)),
imageUrl: edit.imageUrl,
active: edit.active,
});
+6
View File
@@ -78,6 +78,12 @@ export default function AdminDashboard() {
<Link to="/admin/drops" className="btn secondary">
Drop rates
</Link>
<Link to="/admin/shop" className="btn secondary">
Shop osu packs
</Link>
<Link to="/admin/prestige-server" className="btn secondary">
Prestige Server
</Link>
</div>
<section className="admin-block" style={{ marginTop: '1.25rem' }}>
+139 -15
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, formatCredits } from '../api';
import { api, formatCredits, compareCents } from '../api';
function dropPercents(items) {
const total = items.reduce((s, ci) => s + Number(ci.dropRate || 0), 0);
@@ -7,12 +7,54 @@ function dropPercents(items) {
return items.map((ci) => (Number(ci.dropRate) / total) * 100);
}
function sortCatalog(list, sort) {
const next = [...list];
next.sort((a, b) => {
if (sort === 'value-asc') return compareCents(a.marketValue, b.marketValue);
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'rarity') {
const r = a.rarity.localeCompare(b.rarity);
if (r !== 0) return r;
return compareCents(b.marketValue, a.marketValue);
}
// value-desc (default)
return compareCents(b.marketValue, a.marketValue);
});
return next;
}
function sortPool(list, sort) {
const next = [...list];
next.sort((a, b) => {
if (sort === 'value-desc') {
return compareCents(b.item.marketValue, a.item.marketValue);
}
if (sort === 'value-asc') {
return compareCents(a.item.marketValue, b.item.marketValue);
}
if (sort === 'name') return a.item.name.localeCompare(b.item.name);
if (sort === 'rarity') {
const r = a.item.rarity.localeCompare(b.item.rarity);
if (r !== 0) return r;
return compareCents(b.item.marketValue, a.item.marketValue);
}
if (sort === 'rate-desc') return Number(b.dropRate) - Number(a.dropRate);
// rate-asc (default)
return Number(a.dropRate) - Number(b.dropRate);
});
return next;
}
export default function AdminDrops() {
const [cases, setCases] = useState([]);
const [items, setItems] = useState([]);
const [error, setError] = useState('');
const [selectedCaseId, setSelectedCaseId] = useState('');
const [dropForm, setDropForm] = useState({ itemId: '', dropRate: 1 });
const [itemSort, setItemSort] = useState('value-desc');
const [itemQuery, setItemQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
const [poolSort, setPoolSort] = useState('rate-asc');
const load = useCallback(async () => {
const [c, i] = await Promise.all([api.adminCases(), api.adminItems()]);
@@ -22,10 +64,6 @@ export default function AdminDrops() {
if (prev && c.cases.some((x) => String(x.id) === String(prev))) return prev;
return c.cases[0] ? String(c.cases[0].id) : '';
});
setDropForm((f) => ({
...f,
itemId: f.itemId || (i.items[0] ? String(i.items[0].id) : ''),
}));
}, []);
useEffect(() => {
@@ -37,10 +75,43 @@ export default function AdminDrops() {
[cases, selectedCaseId]
);
const usedCategories = useMemo(() => {
const set = new Set(items.map((item) => item.category || 'Misc'));
return [...set].sort((a, b) => a.localeCompare(b));
}, [items]);
const catalogItems = useMemo(() => {
const q = itemQuery.trim().toLowerCase();
const filtered = items.filter((item) => {
const cat = item.category || 'Misc';
if (categoryFilter !== 'all' && cat !== categoryFilter) return false;
if (!q) return true;
return (
item.name.toLowerCase().includes(q) ||
item.rarity.toLowerCase().includes(q) ||
cat.toLowerCase().includes(q)
);
});
return sortCatalog(filtered, itemSort);
}, [items, itemQuery, itemSort, categoryFilter]);
useEffect(() => {
if (!catalogItems.length) {
setDropForm((f) => (f.itemId ? { ...f, itemId: '' } : f));
return;
}
setDropForm((f) => {
if (f.itemId && catalogItems.some((item) => String(item.id) === String(f.itemId))) {
return f;
}
return { ...f, itemId: String(catalogItems[0].id) };
});
}, [catalogItems]);
const poolItems = useMemo(() => {
if (!selected) return [];
return [...selected.items].sort((a, b) => Number(a.dropRate) - Number(b.dropRate));
}, [selected]);
return sortPool(selected.items, poolSort);
}, [selected, poolSort]);
const percents = useMemo(() => dropPercents(poolItems), [poolItems]);
@@ -76,6 +147,37 @@ export default function AdminDrops() {
Add drop · {selected.name}{' '}
<span className="muted">({formatCredits(selected.price)} cr)</span>
</h3>
<div className="admin-toolbar" style={{ marginBottom: '0.75rem' }}>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
aria-label="Filter category"
>
<option value="all">All categories</option>
{usedCategories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<select
value={itemSort}
onChange={(e) => setItemSort(e.target.value)}
aria-label="Sort catalog items"
>
<option value="value-desc">Sort: value </option>
<option value="value-asc">Sort: value </option>
<option value="name">Sort: name</option>
<option value="rarity">Sort: rarity</option>
</select>
<input
className="admin-search"
placeholder="Search items…"
value={itemQuery}
onChange={(e) => setItemQuery(e.target.value)}
aria-label="Search catalog items"
/>
</div>
<form
className="admin-row"
onSubmit={async (e) => {
@@ -95,12 +197,18 @@ export default function AdminDrops() {
<select
value={dropForm.itemId}
onChange={(e) => setDropForm({ ...dropForm, itemId: e.target.value })}
disabled={!catalogItems.length}
aria-label="Select item to add"
>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.rarity})
</option>
))}
{catalogItems.length === 0 ? (
<option value="">No matching items</option>
) : (
catalogItems.map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.rarity}) · {formatCredits(item.marketValue)} cr
</option>
))
)}
</select>
<input
type="number"
@@ -111,14 +219,28 @@ export default function AdminDrops() {
placeholder="Weight"
required
/>
<button className="btn" type="submit">
<button className="btn" type="submit" disabled={!dropForm.itemId}>
Add drop
</button>
</form>
</div>
<div className="admin-block">
<h3>Pool ({poolItems.length})</h3>
<div className="section-head" style={{ marginBottom: '0.75rem' }}>
<h3 style={{ margin: 0 }}>Pool ({poolItems.length})</h3>
<select
value={poolSort}
onChange={(e) => setPoolSort(e.target.value)}
aria-label="Sort pool"
>
<option value="rate-asc">Sort: weight </option>
<option value="rate-desc">Sort: weight </option>
<option value="value-desc">Sort: value </option>
<option value="value-asc">Sort: value </option>
<option value="name">Sort: name</option>
<option value="rarity">Sort: rarity</option>
</select>
</div>
{poolItems.length === 0 ? (
<div className="muted">No drops yet.</div>
) : (
@@ -130,7 +252,9 @@ export default function AdminDrops() {
<img src={ci.item.imageUrl} alt="" className="drop-thumb" />
) : null}
{ci.item.name}{' '}
<span className="muted">({ci.item.rarity})</span>
<span className="muted">
({ci.item.rarity}) · {formatCredits(ci.item.marketValue)} cr
</span>
</span>
<input
type="number"
+37 -32
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, formatCredits } from '../api';
import { api, formatCredits, compareCents, centsJson, toCentsBigInt } from '../api';
import ImageField from './ImageField';
const emptyItem = {
@@ -10,23 +10,21 @@ const emptyItem = {
imageUrl: '',
};
function CategoryField({ value, onChange, categories, id }) {
const listId = id || 'item-categories';
function CategoryField({ value, onChange, categories }) {
const options =
value && !categories.includes(value) ? [...categories, value] : categories;
return (
<>
<input
list={listId}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="e.g. Rifle, Knife…"
required
/>
<datalist id={listId}>
{categories.map((c) => (
<option key={c} value={c} />
))}
</datalist>
</>
<select value={value} onChange={(e) => onChange(e.target.value)} required>
{options.length === 0 ? (
<option value="">No categories create one first</option>
) : (
options.map((c) => (
<option key={c} value={c}>
{c}
</option>
))
)}
</select>
);
}
@@ -51,16 +49,23 @@ export default function AdminItems() {
]);
setItems(i.items);
setRarities(r.rarities);
setCategories(
(c.names || c.categories || []).map((entry) =>
typeof entry === 'string' ? entry : entry.name
)
const names = (c.names || c.categories || []).map((entry) =>
typeof entry === 'string' ? entry : entry.name
);
setItemForm((f) => ({
...f,
rarity: f.rarity || r.rarities[0] || 'MilSpec',
category: f.category || 'Misc',
}));
setCategories(names);
setItemForm((f) => {
const nextCategory =
f.category && names.includes(f.category)
? f.category
: names.includes('Misc')
? 'Misc'
: names[0] || '';
return {
...f,
rarity: f.rarity || r.rarities[0] || 'MilSpec',
category: nextCategory,
};
});
}, []);
useEffect(() => {
@@ -89,7 +94,7 @@ export default function AdminItems() {
list = [...list];
list.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'value') return Number(b.marketValue) - Number(a.marketValue);
if (sort === 'value') return compareCents(b.marketValue, a.marketValue);
if (sort === 'rarity') return a.rarity.localeCompare(b.rarity);
// category (default): group by category, then name
const ca = (a.category || 'Misc').localeCompare(b.category || 'Misc');
@@ -164,12 +169,14 @@ export default function AdminItems() {
try {
await api.createItem({
...itemForm,
marketValue: Number(itemForm.marketValue),
marketValue: centsJson(toCentsBigInt(itemForm.marketValue)),
});
setItemForm({
...emptyItem,
rarity: rarities[0] || 'MilSpec',
category: 'Misc',
category: categories.includes('Misc')
? 'Misc'
: categories[0] || '',
});
await load();
} catch (err) {
@@ -188,7 +195,6 @@ export default function AdminItems() {
<label>
Category
<CategoryField
id="create-item-categories"
value={itemForm.category}
categories={categories}
onChange={(category) => setItemForm({ ...itemForm, category })}
@@ -247,7 +253,7 @@ export default function AdminItems() {
name: edit.name,
rarity: edit.rarity,
category: edit.category,
marketValue: Number(edit.marketValue),
marketValue: centsJson(toCentsBigInt(edit.marketValue)),
imageUrl: edit.imageUrl,
});
setEditingId(null);
@@ -270,7 +276,6 @@ export default function AdminItems() {
<label>
Category
<CategoryField
id={`edit-item-categories-${item.id}`}
value={edit.category}
categories={categories}
onChange={(category) => setEdit({ ...edit, category })}
+2
View File
@@ -8,6 +8,8 @@ const NAV = [
{ to: '/admin/items', label: 'Items' },
{ to: '/admin/categories', label: 'Categories' },
{ to: '/admin/drops', label: 'Drops' },
{ to: '/admin/shop', label: 'Shop' },
{ to: '/admin/prestige-server', label: 'Prestige Server' },
];
export default function AdminLayout() {
+200 -51
View File
@@ -4,8 +4,105 @@ import {
formatCredits,
formatLastConnection,
formatPlayTime,
compareCents,
creditsToCentsString,
toCentsBigInt,
} from '../api';
/** Credits delta (e.g. "15.5" or "-1000") → signed cents string. */
function parseDeltaCents(raw) {
const s = String(raw ?? '').trim().replace(',', '.');
if (!s || s === '0' || s === '+0' || s === '-0') return null;
const neg = s.startsWith('-');
const body = s.startsWith('+') || s.startsWith('-') ? s.slice(1) : s;
const cents = creditsToCentsString(body);
if (cents == null || cents === '0') return null;
return neg ? `-${cents}` : cents;
}
/** Whole-unit delta (Pr / osu) — integers only. */
function parseDeltaUnits(raw) {
const s = String(raw ?? '').trim();
if (!/^-?\d+$/.test(s) || s === '0' || s === '-0') return null;
try {
return BigInt(s);
} catch {
return null;
}
}
function balanceAfterDelta(balance, delta) {
try {
const next = toCentsBigInt(balance) + toCentsBigInt(delta);
return (next < 0n ? 0n : next).toString();
} catch {
return toCentsBigInt(balance).toString();
}
}
/** Compact display for whole units (Pr / osu) via credit formatter. */
function formatUnits(n) {
try {
return formatCredits((BigInt(n) * 100n).toString());
} catch {
return String(n);
}
}
function previewClass(delta) {
if (delta == null) return 'admin-adjust-preview muted';
const n = typeof delta === 'bigint' ? delta : BigInt(delta);
return n > 0n ? 'admin-adjust-preview pos' : 'admin-adjust-preview neg';
}
function formatSignedPreview(delta, suffix, isCents = false) {
if (delta == null) return '—';
const n = typeof delta === 'bigint' ? delta : BigInt(delta);
const abs = n < 0n ? -n : n;
const formatted = isCents ? formatCredits(abs.toString()) : formatUnits(abs);
return `${n > 0n ? '+' : ''}${formatted} ${suffix}`;
}
function AdjustRow({
label,
value,
onChange,
step = '1',
delta,
preview,
result,
onSubmit,
busy,
}) {
return (
<form
className="admin-adjust-row"
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<label>
{label}
<span className="admin-adjust-input-wrap">
<input
type="number"
step={step}
value={value}
onChange={(e) => onChange(e.target.value)}
required
/>
<span className={previewClass(delta)}>{preview}</span>
</span>
</label>
{result != null && <span className="admin-adjust-result muted">{result}</span>}
<button className="btn" type="submit" disabled={busy || delta == null}>
Apply
</button>
</form>
);
}
export default function AdminPlayers() {
const [users, setUsers] = useState([]);
const [error, setError] = useState('');
@@ -13,8 +110,14 @@ export default function AdminPlayers() {
const [sort, setSort] = useState('wealth');
const [adjustId, setAdjustId] = useState(null);
const [deltaCredits, setDeltaCredits] = useState('100');
const [deltaPr, setDeltaPr] = useState('1000');
const [deltaOsu, setDeltaOsu] = useState('100');
const [busy, setBusy] = useState(false);
const deltaCents = useMemo(() => parseDeltaCents(deltaCredits), [deltaCredits]);
const prDelta = useMemo(() => parseDeltaUnits(deltaPr), [deltaPr]);
const osuDelta = useMemo(() => parseDeltaUnits(deltaOsu), [deltaOsu]);
const load = useCallback(async () => {
const data = await api.adminUsers();
setUsers(data.users);
@@ -39,23 +142,24 @@ export default function AdminPlayers() {
new Date(a.lastConnection || 0).getTime()
);
}
return Number(b.totalWealth) - Number(a.totalWealth);
return compareCents(b.totalWealth, a.totalWealth);
});
return list;
}, [users, query, sort]);
const applyDelta = async (userId, credits) => {
const cents = Math.round(Number(credits) * 100);
if (!Number.isInteger(cents) || cents === 0) {
setError('Enter a non-zero credit amount (e.g. 100 or -50)');
return;
}
const resetDeltas = () => {
setDeltaCredits('100');
setDeltaPr('1000');
setDeltaOsu('100');
};
const applyPatch = async (userId, body) => {
setBusy(true);
setError('');
try {
await api.adminUpdateUser(userId, { balanceDelta: cents });
await api.adminUpdateUser(userId, body);
setAdjustId(null);
setDeltaCredits('100');
resetDeltas();
await load();
} catch (err) {
setError(err.message);
@@ -64,12 +168,44 @@ export default function AdminPlayers() {
}
};
const applyCredits = async (userId) => {
const cents = parseDeltaCents(deltaCredits);
if (cents == null) {
setError('Enter a non-zero credit amount (e.g. 100 or -50)');
return;
}
await applyPatch(userId, { balanceDelta: cents });
};
const applyPr = async (userId) => {
const delta = parseDeltaUnits(deltaPr);
if (delta == null) {
setError('Enter a non-zero Pr amount (e.g. 1000 or -100)');
return;
}
await applyPatch(userId, { prBalanceDelta: delta.toString() });
};
const applyOsu = async (userId) => {
const delta = parseDeltaUnits(deltaOsu);
if (delta == null) {
setError('Enter a non-zero osu amount (e.g. 100 or -50)');
return;
}
// osuBalance is Int — stay within safe integer range for the JSON number API
if (delta > BigInt(Number.MAX_SAFE_INTEGER) || delta < BigInt(Number.MIN_SAFE_INTEGER)) {
setError('osu delta is too large');
return;
}
await applyPatch(userId, { osuBalanceDelta: Number(delta) });
};
return (
<div>
<div className="section-head">
<div>
<h1>Players</h1>
<p className="muted">{users.length} accounts · adjust credits when needed</p>
<p className="muted">{users.length} accounts · adjust credits, Pr & osu</p>
</div>
<div className="admin-toolbar">
<select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">
@@ -108,8 +244,9 @@ export default function AdminPlayers() {
{u.inventoryCount})
</div>
<div className="muted">
Opens {u.casesOpened} · prestige {u.prestigeCount} · achievements{' '}
{u.achievements} · play {formatPlayTime(u.playTimeSeconds)} · last{' '}
{formatUnits(u.prBalance ?? 0)} Pr · {formatUnits(u.osuBalance ?? 0)} osu · opens{' '}
{u.casesOpened} · prestige {u.prestigeCount} · achievements {u.achievements} · play{' '}
{formatPlayTime(u.playTimeSeconds)} · last{' '}
{formatLastConnection(u.lastConnection)}
</div>
</div>
@@ -118,50 +255,62 @@ export default function AdminPlayers() {
className="btn secondary"
onClick={() => {
setAdjustId(adjustId === u.id ? null : u.id);
setDeltaCredits('100');
resetDeltas();
}}
>
Adjust credits
Adjust
</button>
</div>
{adjustId === u.id && (
<form
className="admin-adjust-row"
onSubmit={(e) => {
e.preventDefault();
applyDelta(u.id, deltaCredits);
}}
>
<label>
Δ credits (e.g. 100 or -50)
<input
type="number"
step="0.01"
value={deltaCredits}
onChange={(e) => setDeltaCredits(e.target.value)}
required
/>
</label>
<button className="btn" type="submit" disabled={busy}>
Apply
</button>
<button
type="button"
className="btn ghost"
onClick={() => applyDelta(u.id, '1000')}
disabled={busy}
>
+1000
</button>
<button
type="button"
className="btn ghost"
onClick={() => applyDelta(u.id, '-100')}
disabled={busy}
>
100
</button>
</form>
<div className="admin-adjust-panel">
<AdjustRow
label="Δ credits (e.g. 100 or -50)"
value={deltaCredits}
onChange={setDeltaCredits}
step="0.01"
delta={deltaCents == null ? null : BigInt(deltaCents)}
preview={formatSignedPreview(
deltaCents == null ? null : BigInt(deltaCents),
'cr',
true
)}
result={
deltaCents == null
? null
: `→ bal ${formatCredits(balanceAfterDelta(u.balance, deltaCents))} cr`
}
onSubmit={() => applyCredits(u.id)}
busy={busy}
/>
<AdjustRow
label="Δ Pr (e.g. 1000 or -100)"
value={deltaPr}
onChange={setDeltaPr}
delta={prDelta}
preview={formatSignedPreview(prDelta, 'Pr')}
result={
prDelta == null
? null
: `${formatUnits(balanceAfterDelta(u.prBalance ?? 0, prDelta))} Pr`
}
onSubmit={() => applyPr(u.id)}
busy={busy}
/>
<AdjustRow
label="Δ osu (e.g. 100 or -50)"
value={deltaOsu}
onChange={setDeltaOsu}
delta={osuDelta}
preview={formatSignedPreview(osuDelta, 'osu')}
result={
osuDelta == null
? null
: `${formatUnits(balanceAfterDelta(u.osuBalance ?? 0, osuDelta))} osu`
}
onSubmit={() => applyOsu(u.id)}
busy={busy}
/>
</div>
)}
</div>
))}
+216
View File
@@ -0,0 +1,216 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api';
function slugifyKey(raw) {
return String(raw || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_|_$/g, '')
.slice(0, 48);
}
export default function AdminPrestigeServer() {
const [preview, setPreview] = useState(null);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [achName, setAchName] = useState('Server Prestige');
const [achKey, setAchKey] = useState('server_prestige');
const [achKeyTouched, setAchKeyTouched] = useState(false);
const [achDescription, setAchDescription] = useState(
'Survived a Server Prestige wipe. Economy & prestige reset; stats & achievements kept.'
);
const [achIcon, setAchIcon] = useState('♛');
const [password, setPassword] = useState('');
const [confirmText, setConfirmText] = useState('');
const loadPreview = async () => {
const data = await api.adminPrestigeServerPreview();
setPreview(data.preview);
};
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.adminPrestigeServerPreview();
if (!cancelled) setPreview(data.preview);
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
return () => {
cancelled = true;
};
}, []);
const derivedKey = useMemo(() => {
if (achKeyTouched) return achKey;
const fromName = slugifyKey(achName);
return fromName ? `server_${fromName}`.replace(/^server_server_/, 'server_') : 'server_prestige';
}, [achName, achKey, achKeyTouched]);
const canSubmit =
!busy &&
achName.trim() &&
achDescription.trim() &&
password.length >= 1 &&
confirmText.trim().toUpperCase() === 'PRESTIGE SERVER';
const onSubmit = async (e) => {
e.preventDefault();
if (!canSubmit) return;
setError('');
setMessage('');
setBusy(true);
try {
const result = await api.adminPrestigeServer({
password,
achievement: {
key: derivedKey,
name: achName.trim(),
description: achDescription.trim(),
icon: achIcon.trim() || '♛',
},
});
setPassword('');
setConfirmText('');
setMessage(
`Server Prestige done · ${result.playersReset} players reset · achievement “${result.achievement.name}” granted`
);
await loadPreview();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<div>
<div className="section-head">
<div>
<h1>Prestige Server</h1>
<p className="muted">
Wipe economy &amp; prestige progress for every player. Kept: account (username, password,
avatar, bio), <strong>Joined</strong>, <strong>play time</strong>,{' '}
<strong>last connection</strong>, <strong>cases opened</strong>,{' '}
<strong>jackpot / battle wins</strong>, and <strong>achievements</strong>. Then grants the
wipe achievement configured below.
</p>
</div>
</div>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
{preview && (
<div className="admin-stat-grid" style={{ marginBottom: '1.25rem' }}>
<div className="admin-stat">
<span className="admin-stat-label">Players to reset</span>
<strong>{preview.players}</strong>
</div>
<div className="admin-stat">
<span className="admin-stat-label">Inventory wiped</span>
<strong>{preview.inventoryItems}</strong>
</div>
<div className="admin-stat">
<span className="admin-stat-label">Prestige logs wiped</span>
<strong>{preview.prestigeLogs}</strong>
</div>
<div className="admin-stat">
<span className="admin-stat-label">Stats txs kept</span>
<strong>{preview.statsTransactionsKept}</strong>
<span className="muted">opens / battle wins</span>
</div>
</div>
)}
<form className="admin-block form" onSubmit={onSubmit}>
<h2>Wipe achievement</h2>
<p className="muted">
Configure this before activating. Every reset player receives it (existing achievements
stay).
</p>
<div className="admin-form-grid">
<label>
Name
<input
value={achName}
onChange={(e) => setAchName(e.target.value)}
required
maxLength={80}
placeholder="Server Prestige"
/>
</label>
<label>
Icon
<input
value={achIcon}
onChange={(e) => setAchIcon(e.target.value)}
maxLength={8}
placeholder="♛"
/>
</label>
<label className="admin-form-span">
Key (unique id)
<input
value={derivedKey}
onChange={(e) => {
setAchKeyTouched(true);
setAchKey(slugifyKey(e.target.value));
}}
maxLength={48}
placeholder="server_prestige"
/>
</label>
<label className="admin-form-span">
Description
<textarea
value={achDescription}
onChange={(e) => setAchDescription(e.target.value)}
required
maxLength={240}
rows={3}
/>
</label>
</div>
<h2 style={{ marginTop: '1.5rem' }}>Confirm activation</h2>
<p className="muted">
Type <strong>PRESTIGE SERVER</strong> and enter the admin password. This cannot be undone.
</p>
<div className="admin-form-grid">
<label className="admin-form-span">
Confirmation phrase
<input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="PRESTIGE SERVER"
autoComplete="off"
/>
</label>
<label className="admin-form-span">
Admin password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</label>
</div>
<div className="row-actions" style={{ marginTop: '1rem' }}>
<button type="submit" className="btn" disabled={!canSubmit}>
{busy ? 'Wiping…' : 'Activate Prestige Server'}
</button>
</div>
</form>
</div>
);
}
+276
View File
@@ -0,0 +1,276 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '../api';
function emptyEdit(pack) {
return {
name: pack.name || '',
osu: pack.osu ?? 0,
priceCents: pack.priceCents ?? 0,
currency: pack.currency || 'eur',
badge: pack.badge || '',
blurb: pack.blurb || '',
perOsuLabel: pack.perOsuLabel || '',
enabled: Boolean(pack.enabled),
sortOrder: pack.sortOrder ?? 0,
};
}
export default function AdminShop() {
const [sectionEnabled, setSectionEnabled] = useState(true);
const [packs, setPacks] = useState([]);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [editingId, setEditingId] = useState(null);
const [edit, setEdit] = useState(null);
const load = useCallback(async () => {
const data = await api.adminShopOsuPacks();
setSectionEnabled(Boolean(data.sectionEnabled));
setPacks(data.packs || []);
}, []);
useEffect(() => {
load().catch((err) => setError(err.message));
}, [load]);
const toggleSection = async () => {
setBusy(true);
setError('');
setMessage('');
try {
const data = await api.adminShopOsuSection(!sectionEnabled);
setSectionEnabled(Boolean(data.sectionEnabled));
setPacks(data.packs || []);
setMessage(
data.sectionEnabled
? 'Get osu section enabled on the shop'
: 'Get osu section disabled on the shop'
);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const togglePack = async (pack) => {
setBusy(true);
setError('');
try {
const data = await api.adminUpdateShopOsuPack(pack.id, { enabled: !pack.enabled });
setPacks((prev) => prev.map((p) => (p.id === pack.id ? data.pack : p)));
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const saveEdit = async (e) => {
e.preventDefault();
if (!editingId || !edit) return;
setBusy(true);
setError('');
setMessage('');
try {
const data = await api.adminUpdateShopOsuPack(editingId, {
name: edit.name,
osu: Number(edit.osu),
priceCents: Number(edit.priceCents),
currency: edit.currency,
badge: edit.badge || null,
blurb: edit.blurb,
perOsuLabel: edit.perOsuLabel,
enabled: Boolean(edit.enabled),
sortOrder: Number(edit.sortOrder),
});
setPacks((prev) => prev.map((p) => (p.id === editingId ? data.pack : p)));
setEditingId(null);
setEdit(null);
setMessage(`Saved ${data.pack.name}`);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<div>
<div className="section-head">
<div>
<h1>Shop · osu packs</h1>
<p className="muted">
Configure real-money osu top-ups shown on /shop. Disable the whole section or
individual packs.
</p>
</div>
<button
type="button"
className={`btn${sectionEnabled ? ' danger' : ''}`}
disabled={busy}
onClick={toggleSection}
>
{sectionEnabled ? 'Disable Get osu section' : 'Enable Get osu section'}
</button>
</div>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
{!sectionEnabled && (
<div className="admin-block">
<p className="muted" style={{ margin: 0 }}>
The <strong>Get osu</strong> grid is hidden for players. Packs below can still be
edited.
</p>
</div>
)}
<div className="admin-card-list">
{packs.map((pack) => (
<div key={pack.id} className="admin-block">
{editingId === pack.id && edit ? (
<form className="form admin-form-grid" onSubmit={saveEdit}>
<h3 className="admin-form-span">
Edit {pack.id}
</h3>
<label>
Name
<input
value={edit.name}
onChange={(e) => setEdit({ ...edit, name: e.target.value })}
required
/>
</label>
<label>
Osu amount
<input
type="number"
min="1"
step="1"
value={edit.osu}
onChange={(e) => setEdit({ ...edit, osu: e.target.value })}
required
/>
</label>
<label>
Price (EUR cents)
<input
type="number"
min="0"
step="1"
value={edit.priceCents}
onChange={(e) => setEdit({ ...edit, priceCents: e.target.value })}
required
/>
</label>
<label>
Currency
<input
value={edit.currency}
onChange={(e) => setEdit({ ...edit, currency: e.target.value })}
required
/>
</label>
<label>
Badge
<input
value={edit.badge}
onChange={(e) => setEdit({ ...edit, badge: e.target.value })}
placeholder="Popular, Best value…"
/>
</label>
<label>
Sort order
<input
type="number"
step="1"
value={edit.sortOrder}
onChange={(e) => setEdit({ ...edit, sortOrder: e.target.value })}
/>
</label>
<label className="admin-form-span">
Blurb
<input
value={edit.blurb}
onChange={(e) => setEdit({ ...edit, blurb: e.target.value })}
/>
</label>
<label className="admin-form-span">
Per-osu label
<input
value={edit.perOsuLabel}
onChange={(e) => setEdit({ ...edit, perOsuLabel: e.target.value })}
placeholder="€0.10 / osu"
/>
</label>
<label className="admin-form-span" style={{ flexDirection: 'row', gap: '0.5rem' }}>
<input
type="checkbox"
checked={edit.enabled}
onChange={(e) => setEdit({ ...edit, enabled: e.target.checked })}
/>
Pack enabled
</label>
<div className="row-actions admin-form-span">
<button className="btn" type="submit" disabled={busy}>
Save
</button>
<button
type="button"
className="btn secondary"
onClick={() => {
setEditingId(null);
setEdit(null);
}}
>
Cancel
</button>
</div>
</form>
) : (
<div className="admin-row">
<div className="admin-row-main">
<strong>
{pack.name}{' '}
<span className="muted">({pack.id})</span>
</strong>
<div className="muted">
{pack.osu} osu · {pack.displayPrice} · sort {pack.sortOrder}
{pack.badge ? ` · ${pack.badge}` : ''}
{' · '}
<span className={pack.enabled ? 'admin-pill-on' : 'admin-pill-off'}>
{pack.enabled ? 'enabled' : 'disabled'}
</span>
</div>
{pack.blurb ? <div className="muted">{pack.blurb}</div> : null}
</div>
<button
type="button"
className="btn secondary"
disabled={busy}
onClick={() => {
setEditingId(pack.id);
setEdit(emptyEdit(pack));
}}
>
Edit
</button>
<button
type="button"
className={`btn${pack.enabled ? ' ghost' : ''}`}
disabled={busy}
onClick={() => togglePack(pack)}
>
{pack.enabled ? 'Disable' : 'Enable'}
</button>
</div>
)}
</div>
))}
</div>
</div>
);
}
+1 -1
View File
@@ -38,7 +38,7 @@ export default function ImageField({ value, onChange, disabled }) {
{uploading ? '…' : 'Upload'}
<input
type="file"
accept="image/*"
accept="image/png,image/jpeg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif"
hidden
onChange={onFile}
disabled={disabled || uploading}
+145 -39
View File
@@ -35,10 +35,10 @@ export const api = {
catalog: () => request('/api/catalog'),
prestige: () => request('/api/prestige'),
doPrestige: () => request('/api/prestige', { method: 'POST', body: JSON.stringify({}) }),
prestigeUpgrade: (skillId) =>
prestigeUpgrade: (skillId, count = 1) =>
request('/api/prestige/upgrade', {
method: 'POST',
body: JSON.stringify({ skillId }),
body: JSON.stringify({ skillId, count }),
}),
openCase: (id, count = 1) =>
request(`/api/cases/${id}/open`, {
@@ -56,7 +56,6 @@ export const api = {
method: 'POST',
body: JSON.stringify({ inventoryItemIds }),
}),
vaultClaim: () => request('/api/vault/claim', { method: 'POST' }),
sellInventory: (inventoryItemIds) =>
request('/api/inventory/sell', {
method: 'POST',
@@ -83,41 +82,65 @@ export const api = {
method: 'POST',
body: JSON.stringify({ packageId }),
}),
shopOsuCheckout: (packId) =>
request('/api/shop/osu/checkout', {
method: 'POST',
body: JSON.stringify({ packId }),
}),
shopOsuConfirm: (orderId) =>
request('/api/shop/osu/confirm', {
method: 'POST',
body: JSON.stringify({ orderId }),
}),
shopAd: () => request('/api/shop/ad', { method: 'POST' }),
feed: () => request('/api/feed'),
feedAnnounce: (inventoryItemIds, caseName) => {
feedAnnounce: (inventoryItemIds, caseName, opts = {}) => {
const ids = Array.isArray(inventoryItemIds)
? inventoryItemIds
: [inventoryItemIds];
: inventoryItemIds != null
? [inventoryItemIds]
: [];
const body = {
inventoryItemIds: ids.filter(Boolean),
caseName,
respin: Boolean(opts.respin),
};
if (opts.snapshotBest) {
body.snapshotBest = opts.snapshotBest;
body.openCount = opts.openCount;
body.count = opts.count;
} else if (opts.snapshotItems) {
body.snapshotItems = opts.snapshotItems;
}
return request('/api/feed/announce', {
method: 'POST',
body: JSON.stringify({ inventoryItemIds: ids, caseName }),
body: JSON.stringify(body),
});
},
leaderboard: () => request('/api/leaderboard'),
jackpot: () => request('/api/jackpot'),
jackpotBet: (inventoryItemIds) =>
jackpotRound: (id) => request(`/api/jackpot/${id}`),
jackpotCreateBet: (inventoryItemIds) =>
request('/api/jackpot/bet', {
method: 'POST',
body: JSON.stringify({ inventoryItemIds }),
}),
jackpotWithdraw: (inventoryItemIds) =>
request('/api/jackpot/withdraw', {
jackpotBet: (id, inventoryItemIds) =>
request(`/api/jackpot/${id}/bet`, {
method: 'POST',
body: JSON.stringify({ inventoryItemIds }),
}),
duels: () => request('/api/duels'),
duel: (id) => request(`/api/duels/${id}`),
createDuel: (body) =>
request('/api/duels', { method: 'POST', body: JSON.stringify(body) }),
duelBet: (id, inventoryItemIds) =>
request(`/api/duels/${id}/bet`, {
jackpotWithdraw: (id, inventoryItemIds) =>
request(`/api/jackpot/${id}/withdraw`, {
method: 'POST',
body: JSON.stringify({ inventoryItemIds }),
}),
// legacy alias
jackpotCreate: (inventoryItemIds) =>
request('/api/jackpot/bet', {
method: 'POST',
body: JSON.stringify({ inventoryItemIds }),
}),
duelLeave: (id) => request(`/api/duels/${id}/leave`, { method: 'POST' }),
duelCancel: (id) => request(`/api/duels/${id}/cancel`, { method: 'POST' }),
duelStart: (id) => request(`/api/duels/${id}/start`, { method: 'POST' }),
profile: () => request('/api/profile'),
playerProfile: (username) =>
request(`/api/profile/player/${encodeURIComponent(username)}`),
@@ -140,6 +163,12 @@ export const api = {
body: JSON.stringify({ username, password }),
}),
adminStats: () => request('/api/admin/stats'),
adminPrestigeServerPreview: () => request('/api/admin/prestige-server'),
adminPrestigeServer: (body) =>
request('/api/admin/prestige-server', {
method: 'POST',
body: JSON.stringify(body),
}),
adminUsers: () => request('/api/admin/users'),
adminUpdateUser: (id, body) =>
request(`/api/admin/users/${id}`, {
@@ -150,6 +179,17 @@ export const api = {
adminItems: () => request('/api/admin/items'),
adminRarities: () => request('/api/admin/rarities'),
adminCategories: () => request('/api/admin/categories'),
adminShopOsuPacks: () => request('/api/admin/shop/osu-packs'),
adminShopOsuSection: (enabled) =>
request('/api/admin/shop/osu-section', {
method: 'PUT',
body: JSON.stringify({ enabled }),
}),
adminUpdateShopOsuPack: (id, body) =>
request(`/api/admin/shop/osu-packs/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
createCategory: (body) =>
request('/api/admin/categories', { method: 'POST', body: JSON.stringify(body) }),
updateCategory: (id, body) =>
@@ -186,17 +226,77 @@ export const api = {
};
/**
* Exact credit display (cents → "1234.56"), for tooltips / confirmations.
* Parse money amounts (cents) to BigInt — mirror of server `cents.js`.
* Accepts bigint, number, or decimal/int string. Truncates toward zero.
*/
export function toCentsBigInt(value) {
if (typeof value === 'bigint') return value;
if (value == null || value === '') return 0n;
if (typeof value === 'number') {
if (!Number.isFinite(value)) return 0n;
return BigInt(Math.trunc(value));
}
const s = String(value).trim();
if (!s) return 0n;
const m = s.match(/^([+-]?\d+)/);
if (!m) return 0n;
try {
return BigInt(m[1]);
} catch {
return 0n;
}
}
/** JSON-safe cents string. */
export function centsJson(value) {
return toCentsBigInt(value).toString();
}
/** Sort/compare helper: negative if a < b, 0 if equal, positive if a > b. */
export function compareCents(a, b) {
const aa = toCentsBigInt(a);
const bb = toCentsBigInt(b);
if (aa < bb) return -1;
if (aa > bb) return 1;
return 0;
}
/** Exact credit display (cents → "1234.56"), for tooltips / confirmations. */
export function formatCreditsExact(cents) {
const n = Number(cents);
if (!Number.isFinite(n)) return '0.00';
const fixed = (n / 100).toFixed(2);
const neg = fixed.startsWith('-');
const body = neg ? fixed.slice(1) : fixed;
const [intPart, dec] = body.split('.');
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f');
return `${neg ? '-' : ''}${grouped}.${dec}`;
let bi = toCentsBigInt(cents);
const neg = bi < 0n;
if (neg) bi = -bi;
const whole = bi / 100n;
const frac = (bi % 100n).toString().padStart(2, '0');
const grouped = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f');
return `${neg ? '-' : ''}${grouped}.${frac}`;
}
/** Parse credits input (decimal string) → integer cents string. Null if invalid. */
export function creditsToCentsString(raw) {
if (raw == null || raw === '') return null;
const s = String(raw).trim().replace(',', '.');
const m = s.match(/^(\d+)(?:\.(\d{1,2})\d*)?$/);
if (!m) return null;
try {
const whole = BigInt(m[1]);
const frac = m[2] ? m[2].padEnd(2, '0') : '00';
return (whole * 100n + BigInt(frac)).toString();
} catch {
return null;
}
}
/** Cents → credits decimal string without Number precision loss. */
export function centsToCreditsString(cents) {
let bi = toCentsBigInt(cents);
const neg = bi < 0n;
const abs = neg ? -bi : bi;
const whole = abs / 100n;
const frac = (abs % 100n).toString().padStart(2, '0');
const body =
frac === '00' ? whole.toString() : `${whole}.${frac}`.replace(/0+$/, '').replace(/\.$/, '');
return `${neg ? '-' : ''}${body}`;
}
/** Alphabet suffixes after T: aa, ab, … az, ba, … (each step ×1000). */
@@ -225,25 +325,31 @@ function tierSuffix(tier) {
* K / M / B / T, then aa, ab, … (×1000 each). Input is cents.
*/
export function formatCredits(cents) {
const n = Number(cents);
if (!Number.isFinite(n)) return '0.00';
const neg = n < 0;
const credits = Math.abs(n) / 100;
let bi = toCentsBigInt(cents);
const neg = bi < 0n;
if (neg) bi = -bi;
if (credits < 1000) {
return `${neg ? '-' : ''}${credits.toFixed(2)}`;
const creditsWhole = bi / 100n;
const creditsFrac = bi % 100n;
if (creditsWhole < 1000n) {
return `${neg ? '-' : ''}${creditsWhole}.${creditsFrac.toString().padStart(2, '0')}`;
}
let tier = 0;
let scaled = credits;
while (scaled >= 1000 && tier < 1000) {
scaled /= 1000;
let scaled = creditsWhole;
let rem = 0n;
while (scaled >= 1000n) {
rem = scaled % 1000n;
scaled /= 1000n;
tier += 1;
}
const suffix = tierSuffix(tier);
const shown = String(Number(scaled.toFixed(2)));
return `${neg ? '-' : ''}${shown}${suffix}`;
// scaled < 1000 → safe as Number; rem gives ~3 decimal digits of precision
const approx = Number(scaled) + Number(rem) / 1000;
if (!Number.isFinite(approx)) return `${neg ? '-' : ''}0${tierSuffix(tier)}`;
const shown = String(Number(approx.toFixed(2)));
return `${neg ? '-' : ''}${shown}${tierSuffix(tier)}`;
}
export function formatChance(pct) {
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { formatCredits, compareCents } from '../api';
import CaseReel from './CaseReel';
import { useCountdown } from '../hooks/useCountdown';
import { rarityColor } from '../rarities';
const TOP_ITEMS = 5;
function depositsToReelItems(deposits) {
return (deposits || [])
.filter((d) => d.item)
.map((d) => ({
...d.item,
id: d.id,
ownerUsername: d.username,
ownerAvatarUrl: d.avatarUrl || '',
}));
}
/**
* Live battle preview card for the hub (mode-card-live).
*/
export default function BattleLiveCard({ battle, spinEvent }) {
const [spinning, setSpinning] = useState(false);
const [spinActive, setSpinActive] = useState(false);
const [spinDeposits, setSpinDeposits] = useState([]);
const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
const [lastWinner, setLastWinner] = useState(null);
const [revealWinner, setRevealWinner] = useState(false);
useEffect(() => {
if (!spinEvent || spinEvent.id !== battle?.id) return;
setSpinDeposits(spinEvent.deposits || []);
setSpinActive(true);
setSpinning(true);
setRevealWinner(false);
setReelWinnerItemId(spinEvent.reelWinnerItemId);
setLastWinner(spinEvent.winnerUsername);
}, [spinEvent, battle?.id]);
useEffect(() => {
if (battle?.status === 'spinning' && !spinActive) {
setSpinDeposits(battle.deposits || []);
setSpinActive(true);
setSpinning(true);
setLastWinner(battle.winnerUsername || null);
}
}, [battle, spinActive]);
useEffect(() => {
if (!revealWinner) return undefined;
const t = setTimeout(() => {
setSpinActive(false);
setRevealWinner(false);
}, 4000);
return () => clearTimeout(t);
}, [revealWinner]);
const topItems = useMemo(() => {
const deposits = [...(battle?.deposits || [])].filter((d) => d.item);
deposits.sort((a, b) => compareCents(b.valueCents, a.valueCents));
return deposits.slice(0, TOP_ITEMS);
}, [battle]);
const potItems = useMemo(
() =>
depositsToReelItems(
spinActive && spinDeposits.length ? spinDeposits : battle?.deposits
),
[battle, spinDeposits, spinActive]
);
const showReel = spinActive && potItems.length > 0;
const liveTimerEndsAt =
battle?.status === 'open' &&
battle?.endsAt &&
!battle?.waitingForPlayers &&
!spinning
? battle.endsAt
: null;
const { label: liveTimerLabel } = useCountdown(liveTimerEndsAt);
const playerCount = battle?.players?.length ?? 0;
const potValue = battle?.totalValue ?? 0;
const itemCount = battle?.deposits?.length ?? 0;
const statusLabel =
battle?.status === 'spinning' || spinning
? 'Spinning'
: battle?.waitingForPlayers
? 'Waiting for players'
: battle?.status === 'open'
? liveTimerEndsAt
? `Live ${liveTimerLabel}`
: 'Live'
: battle?.status || '…';
if (!battle) return null;
return (
<Link to={`/battle/${battle.id}`} className="mode-card mode-card-live">
<div className="mode-card-top">
<h2>{battle.name || `Battle ${battle.id}`}</h2>
<span
className={`mode-status${
battle.status === 'spinning' || spinning ? ' spinning' : ''
}`}
>
{statusLabel}
</span>
</div>
<p className="muted mode-blurb">
Join the pot. Bet up to {battle.maxItemsPerPlayer || 3} items chance scales with value.
</p>
<div className="mode-pot-stats">
<div>
<span className="muted">Pot</span>
<strong>{formatCredits(potValue)} cr</strong>
</div>
<div>
<span className="muted">Players</span>
<strong>{playerCount}</strong>
</div>
<div>
<span className="muted">Items</span>
<strong>{itemCount}</strong>
</div>
</div>
{showReel ? (
<div className="mode-card-reel">
<CaseReel
items={potItems}
winnerId={reelWinnerItemId || potItems[0]?.id}
spinning={spinning || battle.status === 'spinning'}
showOwners
compact
onDone={() => {
setSpinning(false);
setRevealWinner(true);
}}
/>
{revealWinner && lastWinner && (
<p className="mode-card-winner">
Winner: <strong>{lastWinner}</strong>
</p>
)}
</div>
) : topItems.length > 0 ? (
<div className="mode-pot-items" aria-label="Top items in pot">
{topItems.map((d) => (
<div
key={d.id}
className="mode-pot-thumb"
style={{ borderColor: rarityColor(d.item.rarity) }}
title={`${d.item.name} · ${formatCredits(d.valueCents)} cr`}
>
{d.item.imageUrl ? (
<img src={d.item.imageUrl} alt="" loading="lazy" />
) : (
<span>{d.item.name.slice(0, 2).toUpperCase()}</span>
)}
</div>
))}
{itemCount > topItems.length && (
<span className="mode-pot-more muted">+{itemCount - topItems.length}</span>
)}
</div>
) : (
<p className="muted mode-pot-empty">No items in the pot yet be the first.</p>
)}
</Link>
);
}
+14 -9
View File
@@ -55,13 +55,13 @@ export default function DropFeed() {
if (!entry?.id) return;
setDrops((prev) => {
if (prev.some((d) => d.id === entry.id)) return prev;
// Also skip near-identical batch (same user + item + count) within a few seconds
const nearDup = prev.find(
(d) =>
d.username === entry.username &&
d.item?.id === entry.item?.id &&
(d.count || 1) === (entry.count || 1) &&
(d.openCount || 1) === (entry.openCount || 1) &&
Boolean(d.respin) === Boolean(entry.respin) &&
Math.abs(new Date(d.at) - new Date(entry.at)) < 15000
);
if (nearDup) return prev;
@@ -69,7 +69,6 @@ export default function DropFeed() {
});
};
// Prefer socket history; HTTP is fallback if socket is slow
socket.emit('feed:subscribe');
socket.on('feed:history', onHistory);
socket.on('feed:drop', onDrop);
@@ -117,7 +116,11 @@ export default function DropFeed() {
visible.map((d) => {
const color = rarityColor(d.item?.rarity);
return (
<div key={d.id} className="drop-feed-row" style={{ borderLeftColor: color }}>
<div
key={d.id}
className={`drop-feed-row${d.respin ? ' is-respin' : ''}`}
style={{ borderLeftColor: color }}
>
<div className="drop-feed-item-visual" style={{ color }}>
{d.item?.imageUrl ? (
<img src={d.item.imageUrl} alt="" />
@@ -148,16 +151,18 @@ export default function DropFeed() {
{d.username}
</>
) : (
d.username
<>
{d.respin ? (
<span className="drop-feed-respin-label">Respin</span>
) : null}
{d.respin ? ' ' : null}
{d.username}
</>
)}
</span>
</div>
<div className="muted drop-feed-sub">
{d.source === 'jackpot'
? 'MultiPlayers Case · '
: d.caseName
? `${d.caseName} · `
: ''}
{d.caseName ? `${d.caseName} · ` : ''}
{formatCredits(d.item?.marketValue || 0)} cr
{d.openCount > 1
? d.source === 'jackpot'
+8 -10
View File
@@ -1,13 +1,13 @@
import { formatCredits, formatCreditsExact } from '../api';
import { formatCredits, formatCreditsExact, toCentsBigInt } from '../api';
import { rarityColor } from '../rarities';
/** Instance sell/bet price, else catalog FT reference */
/** Instance sell/bet price, else catalog FT reference (BigInt cents). */
export function itemValueCents(item) {
if (!item) return 0;
if (item.valueCents != null && Number.isFinite(Number(item.valueCents))) {
return Number(item.valueCents);
if (!item) return 0n;
if (item.valueCents != null && item.valueCents !== '') {
return toCentsBigInt(item.valueCents);
}
return Number(item.marketValue) || 0;
return toCentsBigInt(item.marketValue);
}
export function formatFloat(f) {
@@ -41,6 +41,7 @@ export default function ItemTile({
const titleParts = [];
if (staked) titleParts.push('Vault');
if (favorite) titleParts.push('Favorite');
if (item.rarity) titleParts.push(item.rarity);
if (floatTitle) titleParts.push(floatTitle);
else titleParts.push(item.name);
if (showValue) titleParts.push(`${formatCreditsExact(value)} cr`);
@@ -76,16 +77,13 @@ export default function ItemTile({
)}
</div>
<div className="item-name">{item.name}</div>
<div className="item-meta" style={{ color }}>
{item.rarity}
</div>
{showWear && wear ? (
<div className="item-wear" title={floatTitle}>
{wear}
</div>
) : null}
{showValue && (
<div className="item-meta" title={`${formatCreditsExact(value)} cr`}>
<div className="item-meta item-price" title={`${formatCreditsExact(value)} cr`}>
{formatCredits(value)} cr
</div>
)}
+74 -49
View File
@@ -5,7 +5,7 @@ import { api, formatCredits, formatCreditsExact } from '../api';
import DropFeed from './DropFeed';
import SessionLimitOverlay from './SessionLimitOverlay';
import { onInventoryChanged } from '../inventoryEvents';
import { getSocket, onSessionLimitChange } from '../socket';
import { getSocket, onSessionLimitChange, getSessionLimitPayload } from '../socket';
const FEED_KEY = 'caseorion-feed-drop';
@@ -17,27 +17,20 @@ function ScrollToTop() {
return null;
}
function countWaitingBattles(jackpotRound, duelOpen) {
function countWaitingBattles(jackpotBattles) {
let n = 0;
// MultiPlayers: only if someone has already bet and round still waits for more players
const jpPlayers = jackpotRound?.players?.length ?? 0;
const jpDeposits = jackpotRound?.deposits?.length ?? 0;
if (
jackpotRound?.waitingForPlayers &&
jackpotRound.status !== 'spinning' &&
(jpPlayers > 0 || jpDeposits > 0)
) {
n += 1;
}
// 1vX: each open room that already has at least one player with a bet
for (const room of duelOpen || []) {
if (room.status !== 'open') continue;
const hasBet =
(room.playerCount ?? room.players?.length ?? 0) > 0 ||
(room.deposits?.length ?? 0) > 0;
if (hasBet) n += 1;
for (const battle of jackpotBattles || []) {
if (battle.status === 'spinning') continue;
const jpPlayers = battle?.players?.length ?? 0;
const jpDeposits = battle?.deposits?.length ?? 0;
if (
battle?.waitingForPlayers &&
battle.status === 'open' &&
(jpPlayers > 0 || jpDeposits > 0)
) {
n += 1;
}
}
return n;
@@ -45,6 +38,7 @@ function countWaitingBattles(jackpotRound, duelOpen) {
export default function Layout() {
const { user } = useAuth();
const { pathname } = useLocation();
const [feedOpen, setFeedOpen] = useState(() => {
try {
return localStorage.getItem(FEED_KEY) === '1';
@@ -54,7 +48,12 @@ export default function Layout() {
});
const [waitingBattles, setWaitingBattles] = useState(0);
const [prestigeVisible, setPrestigeVisible] = useState(false);
const [sessionLimit, setSessionLimit] = useState(null);
const [sessionLimit, setSessionLimit] = useState(() => getSessionLimitPayload());
const [navHidden, setNavHidden] = useState(false);
useEffect(() => {
setNavHidden(false);
}, [pathname]);
useEffect(() => {
try {
@@ -64,6 +63,47 @@ export default function Layout() {
}
}, [feedOpen]);
// Mobile: hide topnav while scrolling down, reveal on scroll up / near top
useEffect(() => {
const mq = window.matchMedia('(max-width: 700px)');
let lastY = window.scrollY || 0;
let hidden = false;
const apply = (next) => {
if (hidden === next) return;
hidden = next;
setNavHidden(next);
};
const onScroll = () => {
if (!mq.matches) {
apply(false);
lastY = window.scrollY || 0;
return;
}
const y = window.scrollY || 0;
const delta = y - lastY;
lastY = y;
if (y < 48) {
apply(false);
return;
}
if (delta > 6) apply(true);
else if (delta < -6) apply(false);
};
const onMq = () => {
if (!mq.matches) apply(false);
};
window.addEventListener('scroll', onScroll, { passive: true });
mq.addEventListener?.('change', onMq);
return () => {
window.removeEventListener('scroll', onScroll);
mq.removeEventListener?.('change', onMq);
};
}, []);
useEffect(() => {
if (!user || user.role === 'admin') {
setPrestigeVisible(false);
@@ -97,24 +137,22 @@ export default function Layout() {
return undefined;
}
let jackpotRound = null;
let duelOpen = [];
let jackpotBattles = [];
let cancelled = false;
const sync = () => {
if (cancelled) return;
setWaitingBattles((prev) => {
const next = countWaitingBattles(jackpotRound, duelOpen);
const next = countWaitingBattles(jackpotBattles);
return prev === next ? prev : next;
});
};
(async () => {
try {
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
const jp = await api.jackpot();
if (cancelled) return;
jackpotRound = jp.round;
duelOpen = duels.open || [];
jackpotBattles = jp.battles || [];
sync();
} catch {
/* nav badge is optional */
@@ -125,35 +163,22 @@ export default function Layout() {
const offLimitChange = onSessionLimitChange((payload) => {
setSessionLimit(payload);
});
const onJackpot = (state) => {
jackpotRound = state;
sync();
};
const onDuels = (list) => {
duelOpen = list.open || [];
const onJackpots = (payload) => {
jackpotBattles = payload.battles || [];
sync();
};
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
socket.on('jackpot:state', onJackpot);
socket.on('jackpot:spin', onJackpot);
socket.on('jackpot:result', onJackpot);
socket.on('duels:list', onDuels);
socket.emit('jackpots:subscribe');
socket.on('jackpots:list', onJackpots);
socket.on('connect', () => {
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
socket.emit('jackpots:subscribe');
});
return () => {
cancelled = true;
offLimitChange();
socket.emit('jackpot:unsubscribe');
socket.emit('duels:unsubscribe');
socket.off('jackpot:state', onJackpot);
socket.off('jackpot:spin', onJackpot);
socket.off('jackpot:result', onJackpot);
socket.off('duels:list', onDuels);
socket.emit('jackpots:unsubscribe');
socket.off('jackpots:list', onJackpots);
};
}, [user?.id, user?.role]);
@@ -164,8 +189,8 @@ export default function Layout() {
className={`app-shell${feedOpen ? ' feed-open' : ''}${sessionLimit ? ' session-locked' : ''}`}
>
<ScrollToTop />
{sessionLimit && <SessionLimitOverlay max={sessionLimit.max || 10} />}
<header className="topnav">
{sessionLimit && <SessionLimitOverlay max={sessionLimit.max || 2} />}
<header className={`topnav${navHidden ? ' topnav--hidden' : ''}`}>
<div className="topnav-left">
<NavLink to="/dashboard" className="brand">
CaseOrion
+11 -5
View File
@@ -2,8 +2,14 @@ export default function ProfilePrestigeStats({ prestige }) {
if (!prestige || !prestige.count) return null;
const rows = [
typeof prestige.prBalance === 'number' && prestige.prBalance > 0
? { label: 'Pr', value: <strong>{prestige.prBalance}</strong> }
(() => {
try {
return BigInt(String(prestige.prBalance ?? 0)) > 0n;
} catch {
return false;
}
})()
? { label: 'Pr', value: <strong>{String(prestige.prBalance)}</strong> }
: null,
{ label: 'Fortune', value: <strong>+{prestige.chanceBonus}%</strong> },
{ label: 'Yield', value: <strong>+{prestige.gainBonus}%</strong> },
@@ -15,9 +21,9 @@ export default function ProfilePrestigeStats({ prestige }) {
label: 'Bulk',
value: <strong>+{prestige.openBonus} opens</strong>,
},
(prestige.caseDiscount || 0) > 0 && {
label: 'Discount',
value: <strong>{prestige.caseDiscount}%</strong>,
(prestige.prBonus || 0) > 0 && {
label: 'PR Farmer',
value: <strong>+{prestige.prBonus}%</strong>,
},
(prestige.qualityBonus || 0) > 0 && {
label: 'Quality',
@@ -18,7 +18,7 @@ const FAKE_ADS = [
},
];
export default function SessionLimitOverlay({ max = 10 }) {
export default function SessionLimitOverlay({ max = 2 }) {
const [ad] = useState(
() => FAKE_ADS[Math.floor(Math.random() * FAKE_ADS.length)]
);
+493 -16
View File
@@ -89,6 +89,20 @@ h3 {
position: sticky;
top: 0;
z-index: 20;
transition: transform 280ms ease;
}
@media (max-width: 700px) {
.topnav.topnav--hidden {
transform: translateY(-110%);
pointer-events: none;
}
}
@media (prefers-reduced-motion: reduce) {
.topnav {
transition: none;
}
}
.brand {
@@ -321,11 +335,23 @@ h3 {
.drop-feed {
width: 100%;
max-height: 220px;
max-height: none;
position: static;
border-right: none;
border-bottom: 1px solid var(--stroke);
}
.drop-feed-head {
display: none;
}
/* One drop row visible; scroll for the rest */
.drop-feed-list {
flex: none;
max-height: calc(1rem + 2 * 0.45rem + 5.75rem);
overflow-y: auto;
overscroll-behavior: contain;
}
}
.balance-pill {
@@ -384,9 +410,13 @@ a.balance-pill-link.active {
}
.btn.danger {
background: transparent;
border-color: rgba(235, 75, 75, 0.5);
color: var(--danger);
background: var(--danger);
border-color: transparent;
color: #1a0606;
}
.btn.danger:hover:not(:disabled) {
background: #f06565;
}
.btn.ghost {
@@ -668,7 +698,7 @@ a.balance-pill-link.active {
}
.item-wear {
color: var(--gold);
color: var(--accent);
font-size: 0.72rem;
font-family: var(--font-display);
letter-spacing: 0.02em;
@@ -693,6 +723,39 @@ a.balance-pill-link.active {
line-height: 1.2;
}
.case-hero-wrap {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 420ms ease;
}
.case-hero-wrap > .case-hero {
min-height: 0;
overflow: hidden;
opacity: 1;
transform: translateY(0);
transition:
opacity 320ms ease,
transform 420ms ease;
}
.case-hero-wrap--hidden {
grid-template-rows: 0fr;
pointer-events: none;
}
.case-hero-wrap--hidden > .case-hero {
opacity: 0;
transform: translateY(-1.25rem);
}
@media (prefers-reduced-motion: reduce) {
.case-hero-wrap,
.case-hero-wrap > .case-hero {
transition: none;
}
}
.case-hero {
display: flex;
flex-direction: column;
@@ -734,7 +797,8 @@ a.balance-pill-link.active {
transition: width 300ms ease;
}
.case-key-badge {
.case-price,
.item-price {
color: var(--gold);
font-family: var(--font-display);
}
@@ -768,6 +832,11 @@ a.balance-pill-link.active {
margin-bottom: 1rem;
}
.multi-reel-hint {
margin: 0;
font-size: 0.85rem;
}
.multi-reels.many {
gap: 1.25rem;
}
@@ -1703,6 +1772,15 @@ a.balance-pill-link.active {
border-top: 1px solid var(--stroke);
}
.admin-adjust-panel .admin-adjust-row:first-child {
margin-top: 0.75rem;
}
.admin-adjust-panel .admin-adjust-row + .admin-adjust-row {
margin-top: 0.35rem;
padding-top: 0.55rem;
}
.admin-adjust-row label {
display: flex;
flex-direction: column;
@@ -1719,6 +1797,35 @@ a.balance-pill-link.active {
min-width: 8rem;
}
.admin-adjust-input-wrap {
display: flex;
align-items: center;
gap: 0.55rem;
}
.admin-adjust-preview {
font-size: 0.9rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
min-width: 4.5rem;
}
.admin-adjust-preview.pos {
color: var(--accent);
}
.admin-adjust-preview.neg {
color: var(--danger);
}
.admin-adjust-result {
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
align-self: end;
padding-bottom: 0.55rem;
white-space: nowrap;
}
.admin-card-list {
display: flex;
flex-direction: column;
@@ -1843,18 +1950,24 @@ a.balance-pill-link.active {
.item-vault-badge {
position: absolute;
top: 0.35rem;
left: 0.35rem;
z-index: 1;
width: 1.15rem;
height: 1.15rem;
top: 0.3rem;
left: 0.3rem;
z-index: 2;
min-width: 1.35rem;
height: 1.35rem;
padding: 0 0.2rem;
display: grid;
place-items: center;
border-radius: 0.2rem;
background: rgba(80, 160, 220, 0.35);
color: #9fd4ff;
font-size: 0.7rem;
font-weight: 700;
border-radius: 0.25rem;
background: #0a121c;
color: #e8f6ff;
border: 1.5px solid #7ec8ff;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.85),
0 2px 6px rgba(0, 0, 0, 0.75);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.02em;
line-height: 1;
pointer-events: none;
}
@@ -1869,6 +1982,24 @@ a.balance-pill-link.active {
.vault-panel .section-head {
margin-bottom: 0.75rem;
align-items: flex-start;
}
.vault-timer {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.15rem;
min-width: 7.5rem;
font-family: var(--font-display);
font-size: 0.95rem;
line-height: 1.2;
}
.vault-timer strong {
font-size: 1.35rem;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.vault-stats {
@@ -1881,8 +2012,60 @@ a.balance-pill-link.active {
.vault-grid {
margin-bottom: 0.5rem;
grid-template-columns: repeat(auto-fill, minmax(96px, 112px));
gap: 0.55rem;
justify-content: start;
}
.vault-grid .item-tile {
padding: 0.45rem;
min-height: 0;
gap: 0.2rem;
}
.vault-grid .item-visual {
min-height: 56px;
font-size: 1.1rem;
}
.vault-grid .item-visual.has-img {
min-height: 56px;
}
.vault-grid .item-visual img {
max-height: 52px;
width: auto;
max-width: 100%;
object-fit: contain;
}
.vault-grid .item-name {
font-size: 0.78rem;
line-height: 1.15;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.vault-grid .item-meta,
.vault-grid .item-wear {
font-size: 0.7rem;
line-height: 1.15;
}
.vault-grid .item-vault-badge {
min-width: 1.15rem;
height: 1.15rem;
font-size: 0.68rem;
}
.vault-grid .item-favorite-badge {
font-size: 0.65rem;
padding: 0.1rem 0.28rem;
}
.item-tile.disabled {
opacity: 0.4;
pointer-events: none;
@@ -1909,6 +2092,10 @@ a.balance-pill-link.active {
min-height: 220px;
}
.mode-card-wide {
grid-column: 1 / -1;
}
.mode-card-top {
display: flex;
align-items: flex-start;
@@ -2148,6 +2335,32 @@ a.mode-card:hover {
align-items: center;
}
.row-actions .btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.btn-ico {
color: var(--gold);
font-size: 0.95em;
line-height: 1;
}
.btn-ico-vault {
display: inline-grid;
place-items: center;
min-width: 1.1em;
height: 1.1em;
padding: 0 0.15em;
border-radius: 0.2rem;
background: #0a121c;
color: #e8f6ff;
border: 1px solid #7ec8ff;
font-size: 0.72em;
font-weight: 800;
}
.room-list {
display: flex;
flex-direction: column;
@@ -2658,6 +2871,238 @@ a.achievement-badge:hover .achievement-badge-name {
padding: 0.35rem 0.7rem;
}
.shop-page {
display: flex;
flex-direction: column;
gap: 2rem;
max-width: 1100px;
}
.shop-hero {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(220px, 0.7fr);
gap: 1.5rem 2rem;
align-items: end;
padding: 0.25rem 0 0.5rem;
border-bottom: 1px solid var(--stroke);
}
.shop-kicker {
margin: 0 0 0.35rem;
font-family: var(--font-display);
font-size: 0.8rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-dim);
}
.shop-hero h1 {
margin: 0 0 0.5rem;
font-size: clamp(1.75rem, 3vw, 2.4rem);
}
.shop-lede {
margin: 0;
max-width: 38rem;
color: var(--muted);
line-height: 1.45;
}
.shop-lede strong {
color: var(--accent);
font-weight: 600;
}
.shop-wallet {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 0.85rem 1rem;
background:
linear-gradient(145deg, rgba(61, 214, 198, 0.08), transparent 55%),
var(--bg-panel);
border: 1px solid var(--stroke);
}
.shop-wallet-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.shop-wallet-osu {
font-size: 1.05rem;
}
.shop-mock-note {
margin: 0.25rem 0 0;
font-size: 0.78rem;
line-height: 1.3;
}
.shop-section {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.shop-section-head h2 {
margin: 0 0 0.2rem;
}
.shop-section-head .muted {
margin: 0;
}
.shop-osu-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.85rem;
}
.shop-osu-card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 1rem 0.9rem 0.9rem;
background: linear-gradient(180deg, var(--bg-elevated), var(--bg-panel));
border: 1px solid var(--stroke);
min-height: 100%;
}
.shop-osu-card.popular {
border-color: rgba(61, 214, 198, 0.45);
}
.shop-osu-card.highlight {
border-color: rgba(228, 174, 57, 0.55);
background:
linear-gradient(160deg, rgba(228, 174, 57, 0.1), transparent 45%),
linear-gradient(180deg, var(--bg-elevated), var(--bg-panel));
}
.shop-badge {
position: absolute;
top: 0.55rem;
right: 0.55rem;
font-family: var(--font-display);
font-size: 0.68rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--bg-deep);
background: var(--accent);
padding: 0.15rem 0.4rem;
}
.shop-osu-card.highlight .shop-badge {
background: var(--gold);
}
.shop-badge.subtle {
background: transparent;
color: var(--muted);
border: 1px solid var(--stroke);
position: static;
align-self: flex-start;
margin-bottom: 0.15rem;
}
.shop-osu-card h3,
.shop-credit-card h3 {
margin: 0;
font-family: var(--font-display);
font-size: 1rem;
}
.shop-osu-amount {
margin: 0.15rem 0;
font-family: var(--font-display);
font-size: 1.65rem;
color: var(--accent);
line-height: 1.1;
}
.shop-osu-amount span {
font-size: 0.85rem;
color: var(--muted);
}
.shop-osu-blurb,
.shop-osu-rate {
margin: 0;
font-size: 0.82rem;
line-height: 1.3;
flex: 1;
}
.shop-buy-btn {
width: 100%;
margin-top: 0.5rem;
}
.shop-credit-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.75rem;
}
.shop-credit-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.85rem;
background: var(--bg-panel);
border: 1px solid var(--stroke);
}
.shop-credit-card .pack-credits {
margin: 0.1rem 0;
font-size: 1.15rem;
}
.shop-credit-cost {
margin: 0.15rem 0 0.35rem;
}
.shop-credit-scale {
margin: 0;
font-size: 0.78rem;
}
.shop-credit-card .btn {
width: 100%;
margin-top: auto;
}
.shop-ad-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
border: 1px dashed var(--stroke);
background: rgba(61, 214, 198, 0.04);
}
.shop-ad-panel h2 {
margin: 0 0 0.25rem;
}
.shop-ad-panel .muted {
margin: 0;
}
@media (max-width: 720px) {
.shop-hero {
grid-template-columns: 1fr;
align-items: start;
}
}
.inv-toolbar {
display: flex;
flex-wrap: wrap;
@@ -2667,6 +3112,17 @@ a.achievement-badge:hover .achievement-badge-name {
margin-bottom: 1rem;
}
.inv-section-head {
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.75rem 1rem;
}
.inv-section-head h1 {
margin: 0;
}
.inv-toolbar-left {
display: flex;
flex-wrap: wrap;
@@ -2786,6 +3242,13 @@ a.achievement-badge:hover .achievement-badge-name {
margin: 0.35rem 0;
}
.pack-credits {
font-family: var(--font-display);
font-size: 1.4rem;
color: var(--gold);
margin: 0.35rem 0;
}
.ad-panel {
margin-top: 0.5rem;
}
@@ -3289,6 +3752,20 @@ a.achievement-badge:hover .achievement-badge-name {
font-family: var(--font-display);
}
.prestige-skill-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
justify-content: center;
}
.prestige-skill-actions .btn {
flex: 1 1 auto;
min-width: 0;
padding: 0.45rem 0.55rem;
font-size: 0.8rem;
}
.prestige-history {
margin-top: 1.5rem;
padding-top: 1rem;
+4
View File
@@ -8,6 +8,8 @@ import AdminItems from '../admin/AdminItems';
import AdminCategories from '../admin/AdminCategories';
import AdminDrops from '../admin/AdminDrops';
import AdminPlayers from '../admin/AdminPlayers';
import AdminPrestigeServer from '../admin/AdminPrestigeServer';
import AdminShop from '../admin/AdminShop';
function AdminLogin() {
const { adminLogin } = useAuth();
@@ -74,6 +76,8 @@ export default function Admin() {
<Route path="items" element={<AdminItems />} />
<Route path="categories" element={<AdminCategories />} />
<Route path="drops" element={<AdminDrops />} />
<Route path="prestige-server" element={<AdminPrestigeServer />} />
<Route path="shop" element={<AdminShop />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
</Routes>
+47 -204
View File
@@ -1,43 +1,14 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { api } from '../api';
import { useAuth } from '../AuthContext';
import CaseReel from '../components/CaseReel';
import { useCountdown } from '../hooks/useCountdown';
import { rarityColor } from '../rarities';
import BattleLiveCard from '../components/BattleLiveCard';
import { getSocket } from '../socket';
const TOP_ITEMS = 5;
function waitingDuelRooms(open) {
return (open || []).filter(
(r) =>
r.status === 'open' &&
((r.playerCount ?? r.players?.length ?? 0) > 0 || (r.deposits?.length ?? 0) > 0)
);
}
function depositsToReelItems(deposits) {
return (deposits || [])
.filter((d) => d.item)
.map((d) => ({
...d.item,
id: d.id,
ownerUsername: d.username,
ownerAvatarUrl: d.avatarUrl || '',
}));
}
export default function BattleHub() {
const { user, loading } = useAuth();
const [round, setRound] = useState(null);
const [duelOpen, setDuelOpen] = useState([]);
const [spinning, setSpinning] = useState(false);
const [spinActive, setSpinActive] = useState(false);
const [spinDeposits, setSpinDeposits] = useState([]);
const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
const [lastWinner, setLastWinner] = useState(null);
const [revealWinner, setRevealWinner] = useState(false);
const [battles, setBattles] = useState([]);
const [spinById, setSpinById] = useState({});
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
@@ -45,103 +16,53 @@ export default function BattleHub() {
(async () => {
try {
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
if (cancelled) return;
setRound(jp.round);
setDuelOpen(duels.open || []);
if (jp.round?.status === 'spinning') {
setSpinDeposits(jp.round.deposits || []);
setSpinActive(true);
setSpinning(true);
setReelWinnerItemId(jp.round.reelWinnerItemId || null);
setLastWinner(jp.round.winnerUsername || null);
}
const data = await api.jackpot();
if (!cancelled) setBattles(data.battles || []);
} catch {
/* hub still usable without live preview */
/* hub still usable */
}
})();
const socket = getSocket();
const onState = (state) => setRound(state);
const onSpin = (state) => {
setRound(state);
setSpinDeposits(state.deposits || []);
setSpinActive(true);
setSpinning(true);
setRevealWinner(false);
setReelWinnerItemId(state.reelWinnerItemId);
setLastWinner(state.winnerUsername);
const onList = (payload) => {
setBattles(payload.battles || []);
};
const onSpin = (state) => {
if (!state?.id) return;
setSpinById((prev) => ({ ...prev, [state.id]: state }));
setBattles((prev) => {
const idx = prev.findIndex((b) => b.id === state.id);
if (idx < 0) return [...prev, state];
const next = [...prev];
next[idx] = state;
return next;
});
};
const onResult = (state) => {
const id = state?.previousRoundId || state?.id;
if (!id) return;
setSpinById((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
};
const onResult = (state) => setRound(state);
const onDuels = (list) => setDuelOpen(list.open || []);
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
socket.on('jackpot:state', onState);
socket.emit('jackpots:subscribe');
socket.on('jackpots:list', onList);
socket.on('jackpot:spin', onSpin);
socket.on('jackpot:result', onResult);
socket.on('duels:list', onDuels);
socket.on('connect', () => {
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
});
socket.on('connect', () => socket.emit('jackpots:subscribe'));
return () => {
cancelled = true;
socket.emit('jackpot:unsubscribe');
socket.emit('duels:unsubscribe');
socket.off('jackpot:state', onState);
socket.emit('jackpots:unsubscribe');
socket.off('jackpots:list', onList);
socket.off('jackpot:spin', onSpin);
socket.off('jackpot:result', onResult);
socket.off('duels:list', onDuels);
};
}, [user]);
useEffect(() => {
if (!revealWinner) return undefined;
const t = setTimeout(() => {
setSpinActive(false);
setRevealWinner(false);
}, 4000);
return () => clearTimeout(t);
}, [revealWinner]);
const topItems = useMemo(() => {
const deposits = [...(round?.deposits || [])].filter((d) => d.item);
deposits.sort((a, b) => (b.valueCents || 0) - (a.valueCents || 0));
return deposits.slice(0, TOP_ITEMS);
}, [round]);
const potItems = useMemo(
() => depositsToReelItems(spinActive && spinDeposits.length ? spinDeposits : round?.deposits),
[round, spinDeposits, spinActive]
);
const showReel = spinActive && potItems.length > 0;
const waitingRooms = useMemo(() => waitingDuelRooms(duelOpen), [duelOpen]);
const waitingRoomCount = waitingRooms.length;
const liveTimerEndsAt =
round?.status === 'open' && round?.endsAt && !round?.waitingForPlayers && !spinning
? round.endsAt
: null;
const { label: liveTimerLabel } = useCountdown(liveTimerEndsAt);
const playerCount = round?.players?.length ?? 0;
const potValue = round?.totalValue ?? 0;
const itemCount = round?.deposits?.length ?? 0;
const statusLabel = !round
? '…'
: round.status === 'spinning' || spinning
? 'Spinning'
: round.waitingForPlayers
? 'Waiting for players'
: round.status === 'open'
? liveTimerEndsAt
? `Live ${liveTimerLabel}`
: 'Live'
: round.status;
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
@@ -151,106 +72,28 @@ export default function BattleHub() {
<div className="section-head">
<div>
<h1>Battle</h1>
<p className="muted">Multiplayer item gambling modes.</p>
</div>
</div>
<div className="mode-grid">
<Link to="/battle/multi" className="mode-card mode-card-live">
<Link to="/battle/create" className="mode-card mode-card-wide">
<div className="mode-card-top">
<h2>MultiPlayers Case</h2>
<span className={`mode-status${round?.status === 'spinning' || spinning ? ' spinning' : ''}`}>
{statusLabel}
</span>
<h2>Create a Battle</h2>
</div>
<p className="muted mode-blurb">
Join the live pot. Bet up to {round?.maxItemsPerPlayer || 3} items chance scales with
value.
Bet items to open a new pot. Others can join chance scales with value. Timer starts at
2 players.
</p>
<div className="mode-pot-stats">
<div>
<span className="muted">Pot</span>
<strong>{formatCredits(potValue)} cr</strong>
</div>
<div>
<span className="muted">Players</span>
<strong>{playerCount}</strong>
</div>
<div>
<span className="muted">Items</span>
<strong>{itemCount}</strong>
</div>
</div>
{showReel ? (
<div className="mode-card-reel">
<CaseReel
items={potItems}
winnerId={reelWinnerItemId || potItems[0]?.id}
spinning={spinning || round?.status === 'spinning'}
showOwners
compact
onDone={() => {
setSpinning(false);
setRevealWinner(true);
}}
/>
{revealWinner && lastWinner && (
<p className="mode-card-winner">
Winner: <strong>{lastWinner}</strong>
</p>
)}
</div>
) : topItems.length > 0 ? (
<div className="mode-pot-items" aria-label="Top items in pot">
{topItems.map((d) => (
<div
key={d.id}
className="mode-pot-thumb"
style={{ borderColor: rarityColor(d.item.rarity) }}
title={`${d.item.name} · ${formatCredits(d.valueCents)} cr`}
>
{d.item.imageUrl ? (
<img src={d.item.imageUrl} alt="" loading="lazy" />
) : (
<span>{d.item.name.slice(0, 2).toUpperCase()}</span>
)}
</div>
))}
{itemCount > topItems.length && (
<span className="mode-pot-more muted">+{itemCount - topItems.length}</span>
)}
</div>
) : (
<p className="muted mode-pot-empty">No items in the pot yet be the first.</p>
)}
</Link>
<Link to="/battle/1vx" className="mode-card">
<div className="mode-card-top">
<h2>1vX Case</h2>
{waitingRoomCount > 0 && (
<span className="mode-status">
{waitingRoomCount} waiting
</span>
)}
</div>
<p className="muted mode-blurb">
Create or join custom rooms. Set player/item limits and min value. Spectate live duels.
</p>
<div className="mode-pot-stats">
<div>
<span className="muted">Open rooms</span>
<strong>{waitingRoomCount}</strong>
</div>
</div>
</Link>
{battles.map((b) => (
<BattleLiveCard key={b.id} battle={b} spinEvent={spinById[b.id]} />
))}
<div className="mode-card stub">
<h2>Coinflip</h2>
<p className="muted">Classic 1v1 item flip. Coming soon.</p>
<span className="badge">Soon</span>
</div>
{battles.length === 0 && (
<p className="muted mode-pot-empty" style={{ gridColumn: '1 / -1' }}>
No open battles create one to get started.
</p>
)}
</div>
</section>
);
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits, formatChance } from '../api';
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom';
import { api, formatCredits, formatChance, compareCents } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile, { itemValueCents } from '../components/ItemTile';
import CaseReel from '../components/CaseReel';
@@ -8,7 +8,13 @@ import { useCountdown } from '../hooks/useCountdown';
import { onInventoryChanged } from '../inventoryEvents';
import { getSocket } from '../socket';
export default function MultiPlayersPage() {
/**
* Create mode (`/battle/create`) or join/spectate (`/battle/:id`).
*/
export default function BattleRoomPage({ create = false }) {
const { id: paramId } = useParams();
const battleId = create ? null : Number(paramId);
const navigate = useNavigate();
const { user, loading, refresh } = useAuth();
const [round, setRound] = useState(null);
const [inventory, setInventory] = useState([]);
@@ -37,8 +43,8 @@ export default function MultiPlayersPage() {
() =>
[...inventory].sort(
(a, b) =>
itemValueCents(b.item) - itemValueCents(a.item) ||
(b.valueCents ?? 0) - (a.valueCents ?? 0)
compareCents(itemValueCents(b.item), itemValueCents(a.item)) ||
compareCents(b.valueCents, a.valueCents)
),
[inventory]
);
@@ -46,23 +52,56 @@ export default function MultiPlayersPage() {
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
const [jp] = await Promise.all([api.jackpot(), loadInventory()]);
if (!cancelled) setRound(jp.round);
await loadInventory();
if (create) {
if (!cancelled) setRound(null);
return;
}
if (!battleId) {
if (!cancelled) setError('Invalid battle');
return;
}
const data = await api.jackpotRound(battleId);
if (cancelled) return;
setRound(data.round);
if (data.round?.status === 'spinning') {
setSpinDeposits(data.round.deposits || []);
setSpinActive(true);
setSpinning(true);
setLastWinner(data.round.winnerUsername || null);
}
if (data.round?.status === 'resolved' || data.round?.status === 'cancelled') {
setLastWinner(data.round.winnerUsername || null);
if (data.round.status === 'resolved') setRevealWinner(true);
}
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
if (create || !battleId) {
return () => {
cancelled = true;
};
}
const socket = getSocket();
const onState = (state) => {
if (state?.id && state.id !== battleId) return;
setRound(state);
if (state?.status === 'cancelled') {
navigate('/battle', { replace: true });
return;
}
if (state?.status === 'open') {
loadInventory().catch(() => {});
}
};
const onSpin = (state) => {
if (state?.id && state.id !== battleId) return;
setRound(state);
setSpinDeposits(state.deposits || []);
setSpinActive(true);
@@ -72,25 +111,30 @@ export default function MultiPlayersPage() {
setLastWinner(state.winnerUsername);
};
const onResult = (state) => {
if (state?.previousRoundId && state.previousRoundId !== battleId) return;
if (state?.id && state.id !== battleId && !state.previousRoundId) return;
setRound(state);
if (state.winnerUsername) setLastWinner(state.winnerUsername);
setRevealWinner(true);
setSpinning(false);
loadInventory().catch(() => {});
refresh().catch(() => {});
};
socket.emit('jackpot:subscribe');
socket.emit('jackpot:subscribe', battleId);
socket.on('jackpot:state', onState);
socket.on('jackpot:spin', onSpin);
socket.on('jackpot:result', onResult);
socket.on('connect', () => socket.emit('jackpot:subscribe'));
socket.on('connect', () => socket.emit('jackpot:subscribe', battleId));
return () => {
cancelled = true;
socket.emit('jackpot:unsubscribe');
socket.emit('jackpot:unsubscribe', battleId);
socket.off('jackpot:state', onState);
socket.off('jackpot:spin', onSpin);
socket.off('jackpot:result', onResult);
};
}, [user, loadInventory, refresh]);
}, [user, create, battleId, loadInventory, refresh, navigate]);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
@@ -106,6 +150,8 @@ export default function MultiPlayersPage() {
[round, user]
);
const mySlotsLeft = Math.max(0, maxItems - myDeposits.length);
const canBet =
create || round?.status === 'open' || (!round && create);
const potItems = useMemo(() => {
const source = spinActive && spinDeposits.length ? spinDeposits : round?.deposits || [];
@@ -140,7 +186,14 @@ export default function MultiPlayersPage() {
setBusy(true);
setError('');
try {
const data = await api.jackpotBet(selected);
if (create && !round) {
const data = await api.jackpotCreateBet(selected);
setSelected([]);
await loadInventory();
navigate(`/battle/${data.round.id}`, { replace: true });
return;
}
const data = await api.jackpotBet(round.id, selected);
setRound(data.round);
setSelected([]);
await loadInventory();
@@ -152,10 +205,15 @@ export default function MultiPlayersPage() {
};
const withdraw = async (inventoryItemIds) => {
if (!round?.id) return;
setBusy(true);
setError('');
try {
const data = await api.jackpotWithdraw(inventoryItemIds);
const data = await api.jackpotWithdraw(round.id, inventoryItemIds);
if (data.round?.status === 'cancelled') {
navigate('/battle', { replace: true });
return;
}
setRound(data.round);
await loadInventory();
} catch (err) {
@@ -169,15 +227,22 @@ export default function MultiPlayersPage() {
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
const title = create && !round ? 'Create a Battle' : round?.name || `Battle ${battleId || ''}`;
const isOpen = create || round?.status === 'open';
return (
<div className="bet-page">
<div className="section-head">
<div>
<h1>MultiPlayers Case</h1>
<p className="muted">Live shared pot win chance = your value / total pot.</p>
<h1>{title}</h1>
<p className="muted">
{create && !round
? 'Bet items to open a new battle. Chance = your value / total pot.'
: 'Win chance = your value / total pot.'}
</p>
</div>
<Link to="/battle" className="btn secondary">
Modes
Back
</Link>
</div>
@@ -185,25 +250,27 @@ export default function MultiPlayersPage() {
<div className="jackpot-status">
<div className="timer-block">
<span className="muted">Round</span>
<span className="muted">Battle</span>
<strong>#{round?.id || '—'}</strong>
<span className={`status-pill ${round?.status || ''}`}>{round?.status || '…'}</span>
<span className={`status-pill ${round?.status || ''}`}>
{round?.status || (create ? 'new' : '…')}
</span>
</div>
<div className="timer-block">
<span className="muted">Timer</span>
<strong className="timer-digits">
{round?.status === 'spinning' || spinning
? 'SPIN'
: round?.waitingForPlayers
: round?.waitingForPlayers || (create && !round)
? 'WAIT'
: round?.status === 'open' && round?.endsAt
? timerLabel
: '—'}
</strong>
{round?.waitingForPlayers && round?.status === 'open' && (
{((round?.waitingForPlayers && round?.status === 'open') || (create && !round)) && (
<span className="muted">
Need {round.minPlayers || 2} players ({round.players?.length || 0}/
{round.minPlayers || 2})
Need {round?.minPlayers || 2} players ({round?.players?.length || 0}/
{round?.minPlayers || 2})
</span>
)}
{round?.status === 'open' &&
@@ -289,7 +356,9 @@ export default function MultiPlayersPage() {
{myDeposits.length > 0 && round?.status === 'open' && (
<section className="section">
<div className="section-head">
<h2>Your bets ({myDeposits.length}/{maxItems})</h2>
<h2>
Your bets ({myDeposits.length}/{maxItems})
</h2>
<button
type="button"
className="btn danger"
@@ -302,11 +371,11 @@ export default function MultiPlayersPage() {
</section>
)}
{round?.status === 'open' && (
{isOpen && canBet && (
<section className="section">
<div className="section-head">
<div>
<h2>Bet from inventory</h2>
<h2>{create && !round ? 'Bet to create' : 'Bet from inventory'}</h2>
<p className="muted">
Select up to {mySlotsLeft} more item{mySlotsLeft === 1 ? '' : 's'}.
</p>
@@ -317,7 +386,7 @@ export default function MultiPlayersPage() {
disabled={busy || !selected.length || mySlotsLeft === 0}
onClick={placeBet}
>
Place bet
{create && !round ? 'Create battle' : 'Place bet'}
</button>
</div>
{inventoryByValue.length === 0 ? (
@@ -331,8 +400,12 @@ export default function MultiPlayersPage() {
key={inv.id}
item={inv.item}
favorite={inv.favorite}
staked={inv.staked}
selected={selected.includes(inv.id)}
disabled={mySlotsLeft === 0 && !selected.includes(inv.id)}
disabled={
inv.staked ||
(mySlotsLeft === 0 && !selected.includes(inv.id))
}
onClick={() => toggleSelect(inv.id)}
/>
))}
+415 -167
View File
@@ -1,14 +1,16 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { Navigate, useParams } from 'react-router-dom';
import { api, formatCredits, compareCents, toCentsBigInt } from '../api';
import { useAuth } from '../AuthContext';
import CaseReel from '../components/CaseReel';
import ItemTile from '../components/ItemTile';
import ItemTile, { itemValueCents } from '../components/ItemTile';
import { notifyInventoryChanged } from '../inventoryEvents';
import { rarityColor } from '../rarities';
import { loadCaseOpenQty, resolveOpenQty, saveCaseOpenQty } from '../userPrefs';
const DEFAULT_MAX_OPENS = 10;
/** Cap simultaneous reel animations to avoid lag on large multi-opens. */
const MAX_VISIBLE_REELS = 5;
/** 15, then steps of 5 (10, 15…), plus max if not already listed. */
function buildOpenQuantityOptions(maxOpens) {
@@ -20,26 +22,69 @@ function buildOpenQuantityOptions(maxOpens) {
return options;
}
function dropValue(item) {
return itemValueCents(item);
}
/** Best drops first (by instance value), capped for reel display. */
function pickShowcaseDrops(items, limit = MAX_VISIBLE_REELS) {
return [...items]
.sort((a, b) => compareCents(dropValue(b), dropValue(a)))
.slice(0, Math.max(1, limit));
}
/** Best drop by valueCents (BigInt-safe). */
function pickBestDrop(items) {
if (!items?.length) return null;
let best = items[0];
let bestVal = dropValue(best);
for (let i = 1; i < items.length; i += 1) {
const it = items[i];
const v = dropValue(it);
if (v > bestVal) {
best = it;
bestVal = v;
}
}
return best;
}
export default function CasePage() {
const { id } = useParams();
const { user, loading, updateBalance } = useAuth();
const [crate, setCrate] = useState(null);
const [error, setError] = useState('');
const [keyMessage, setKeyMessage] = useState('');
const [catalogMessage, setCatalogMessage] = useState('');
const [openBonusMessage, setOpenBonusMessage] = useState('');
const [animReduction, setAnimReduction] = useState(0);
const [quantity, setQuantity] = useState(1);
const [opening, setOpening] = useState(false);
const [spins, setSpins] = useState([]);
const [allDrops, setAllDrops] = useState([]);
const [caseName, setCaseName] = useState('');
/** True while paused on discarded winners before the respin reels restart. */
const [respinRestarting, setRespinRestarting] = useState(false);
const announcedRef = useRef(false);
const preAnnouncedRef = useRef(false);
const finishingRef = useRef(false);
const pendingAnnounceRef = useRef({ ids: [], caseName: '' });
const pendingAnnounceRef = useRef({ ids: [], caseName: '', respin: false });
const pendingAutoSellRef = useRef([]);
/** Auto-sell ids from the discarded first roll — applied when that reel finishes. */
const pendingPreRespinAutoSellRef = useRef([]);
/** When set, first reel pass is a discarded roll; then we restart with these final items. */
const pendingRespinFinalRef = useRef(null);
/** Snapshot of first-roll drops for feed (best item announced when first reels finish). */
const pendingPreRespinAnnounceRef = useRef(null);
/**
* Respin animation phase (kept out of setState updaters — must be Strict-Mode safe):
* null | 'pre' | 'restarting' | 'final'
*/
const respinPhaseRef = useRef(null);
const quantityRef = useRef(1);
const caseIdRef = useRef(null);
const prefReadyRef = useRef(false);
/** Tracks case maxOpens so we can bump qty when a key unlocks. */
const prevMaxOpensRef = useRef(null);
const keyProgress = crate?.keyProgress ?? null;
const maxOpens = keyProgress?.maxOpens ?? DEFAULT_MAX_OPENS;
@@ -61,10 +106,15 @@ export default function CasePage() {
const announcePendingDrop = async (useKeepalive = false) => {
const ids = pendingAnnounceRef.current.ids.filter(Boolean);
const announceCaseName = pendingAnnounceRef.current.caseName || caseName || crate?.name || '';
const respin = Boolean(pendingAnnounceRef.current.respin);
if (!ids.length || announcedRef.current) return;
announcedRef.current = true;
const body = JSON.stringify({ inventoryItemIds: ids, caseName: announceCaseName });
const body = JSON.stringify({
inventoryItemIds: ids,
caseName: announceCaseName,
respin,
});
if (useKeepalive) {
fetch('/api/feed/announce', {
@@ -80,15 +130,51 @@ export default function CasePage() {
}
try {
await api.feedAnnounce(ids, announceCaseName);
await api.feedAnnounce(ids, announceCaseName, { respin });
} catch {
announcedRef.current = false;
}
};
const announcePreRespinDrop = async () => {
const pending = pendingPreRespinAnnounceRef.current;
if (!pending?.items?.length || preAnnouncedRef.current) return;
preAnnouncedRef.current = true;
pendingPreRespinAnnounceRef.current = null;
const best = pickBestDrop(pending.items);
if (!best) {
preAnnouncedRef.current = false;
return;
}
const count = pending.items.filter((it) => it.id === best.id).length;
try {
await api.feedAnnounce([], pending.caseName || caseName || crate?.name || '', {
snapshotBest: {
id: best.id,
name: best.name,
imageUrl: best.imageUrl,
rarity: best.rarity,
valueCents: best.valueCents ?? best.marketValue,
wear: best.wear,
floatValue: best.floatValue,
paintSeed: best.paintSeed,
},
openCount: pending.items.length,
count,
respin: false,
});
} catch {
preAnnouncedRef.current = false;
}
};
const applyPendingAutoSellKeepalive = () => {
const ids = pendingAutoSellRef.current.filter(Boolean);
const ids = [
...pendingPreRespinAutoSellRef.current,
...pendingAutoSellRef.current,
].filter(Boolean);
if (!ids.length) return;
pendingPreRespinAutoSellRef.current = [];
pendingAutoSellRef.current = [];
fetch('/api/inventory/auto-sell/apply', {
method: 'POST',
@@ -99,42 +185,79 @@ export default function CasePage() {
}).catch(() => {});
};
const finalizeOpens = async (completedSpins, announceCaseName) => {
const markSpinsAutoSold = (soldIds, soldDetails = []) => {
const soldSet = new Set(soldIds);
const payoutById = new Map(
(soldDetails || []).map((s) => [s.id, s.payoutCents ?? s.valueCents])
);
setSpins((curr) =>
curr.map((s) =>
soldSet.has(s.item.inventoryId)
? {
...s,
item: {
...s.item,
autoSold: true,
soldForCents:
payoutById.get(s.item.inventoryId) ?? s.item.valueCents,
},
}
: s
)
);
setAllDrops((curr) =>
curr.map((item) =>
soldSet.has(item.inventoryId)
? {
...item,
autoSold: true,
soldForCents: payoutById.get(item.inventoryId) ?? item.valueCents,
}
: item
)
);
};
/** Credit auto-sell for the discarded first roll once its reels finish. */
const applyPreRespinAutoSell = async () => {
const toSell = [...pendingPreRespinAutoSellRef.current];
pendingPreRespinAutoSellRef.current = [];
if (!toSell.length) return;
try {
const data = await api.applyPendingAutoSell(toSell);
updateBalance(data.user.balance);
notifyInventoryChanged();
if (data.soldIds?.length) markSpinsAutoSold(data.soldIds, data.sold);
} catch (err) {
// Keep ids so unload keepalive can still flush if the request failed mid-respin.
pendingPreRespinAutoSellRef.current = toSell;
setError(err.message);
}
};
const finalizeOpens = async (announceCaseName) => {
if (finishingRef.current) return;
finishingRef.current = true;
const ids = completedSpins.map((s) => s.item?.inventoryId).filter(Boolean);
pendingAnnounceRef.current = { ids, caseName: announceCaseName };
// Keep full-batch ids already stored in pending refs (not only showcase reels)
if (announceCaseName) {
pendingAnnounceRef.current = {
...pendingAnnounceRef.current,
caseName: announceCaseName,
};
}
try {
await announcePendingDrop(false);
const toSell = completedSpins
.filter((s) => s.item?.willAutoSell)
.map((s) => s.item.inventoryId)
.filter(Boolean);
const toSell = [...pendingAutoSellRef.current];
pendingAutoSellRef.current = [];
if (toSell.length) {
const data = await api.applyPendingAutoSell(toSell);
updateBalance(data.user.balance);
notifyInventoryChanged();
const soldSet = new Set(data.soldIds);
setSpins((curr) =>
curr.map((s) =>
soldSet.has(s.item.inventoryId)
? {
...s,
item: {
...s.item,
autoSold: true,
soldForCents: s.item.valueCents,
},
}
: s
)
);
if (data.soldIds?.length) markSpinsAutoSold(data.soldIds, data.sold);
}
} catch (err) {
setError(err.message);
@@ -147,6 +270,7 @@ export default function CasePage() {
useEffect(() => {
let cancelled = false;
prefReadyRef.current = false;
prevMaxOpensRef.current = null;
caseIdRef.current = null;
setCrate(null);
(async () => {
@@ -167,9 +291,16 @@ export default function CasePage() {
const maxAffordable = useMemo(() => {
if (!crate || !user) return 1;
if (crate.price <= 0) return maxOpens;
const affordable = Math.floor(user.balance / crate.price);
return Math.min(maxOpens, Math.max(affordable, 0));
try {
const price = BigInt(String(crate.price ?? 0).split('.')[0] || '0');
if (price <= 0n) return maxOpens;
const bal = BigInt(String(user.balance ?? 0).split('.')[0] || '0');
const affordable = bal / price;
const n = affordable > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(affordable);
return Math.min(maxOpens, Math.max(n, 0));
} catch {
return 1;
}
}, [crate, user, maxOpens]);
const openOptions = useMemo(() => buildOpenQuantityOptions(maxOpens), [maxOpens]);
@@ -182,18 +313,28 @@ export default function CasePage() {
if (!prefReadyRef.current) {
prefReadyRef.current = true;
prevMaxOpensRef.current = maxOpens;
setQuantity(resolved);
return;
}
const prevMax = prevMaxOpensRef.current;
prevMaxOpensRef.current = maxOpens;
// New key unlocked → jump select to highest affordable tier (new max if credits allow).
const unlockedHigher = prevMax != null && maxOpens > prevMax;
setQuantity((q) => {
const allowed = openOptions.filter((n) => n <= Math.max(1, maxAffordable));
if (!allowed.length) return 1;
// Jump to newly unlocked max only when the balance can cover it.
if (unlockedHigher && maxOpens <= Math.max(1, maxAffordable)) {
return maxOpens;
}
if (allowed.includes(q)) return q;
const below = allowed.filter((n) => n <= q);
return below.length ? below[below.length - 1] : allowed[0];
});
}, [crate, maxAffordable, openOptions, user?.id]);
}, [crate, maxAffordable, openOptions, user?.id, maxOpens]);
useEffect(() => {
if (!prefReadyRef.current || !user?.id || crate?.id == null) return;
@@ -202,6 +343,37 @@ export default function CasePage() {
useEffect(() => {
return () => {
// Flush first-spin feed if page closes mid-respin
const pre = pendingPreRespinAnnounceRef.current;
if (pre?.items?.length && !preAnnouncedRef.current) {
preAnnouncedRef.current = true;
const best = pickBestDrop(pre.items);
if (best) {
const count = pre.items.filter((it) => it.id === best.id).length;
fetch('/api/feed/announce', {
method: 'POST',
credentials: 'include',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
snapshotBest: {
id: best.id,
name: best.name,
imageUrl: best.imageUrl,
rarity: best.rarity,
valueCents: best.valueCents ?? best.marketValue,
wear: best.wear,
floatValue: best.floatValue,
paintSeed: best.paintSeed,
},
openCount: pre.items.length,
count,
caseName: pre.caseName || '',
respin: false,
}),
}).catch(() => {});
}
}
announcePendingDrop(true);
applyPendingAutoSellKeepalive();
};
@@ -213,7 +385,7 @@ export default function CasePage() {
const ca = Number(a.chance ?? a.dropRate ?? 0);
const cb = Number(b.chance ?? b.dropRate ?? 0);
if (ca !== cb) return ca - cb;
return (b.marketValue || 0) - (a.marketValue || 0);
return compareCents(b.marketValue, a.marketValue);
});
}, [crate]);
@@ -225,31 +397,57 @@ export default function CasePage() {
);
}, [keyProgress]);
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
const totalCost = useMemo(() => {
if (!crate) return '0';
return (toCentsBigInt(crate.price) * BigInt(quantity)).toString();
}, [crate, quantity]);
const totalCost = crate ? crate.price * quantity : 0;
const canOpen =
crate &&
!opening &&
crate.items.length > 0 &&
quantity >= 1 &&
quantity <= maxOpens &&
user.balance >= totalCost;
const canOpen = useMemo(() => {
if (!user || !crate || opening || !crate.items?.length) return false;
if (quantity < 1 || quantity > maxOpens) return false;
return toCentsBigInt(user.balance) >= toCentsBigInt(totalCost);
}, [user, crate, opening, quantity, maxOpens, totalCost]);
const allDone = spins.length > 0 && spins.every((s) => s.done);
const allDone =
spins.length > 0 &&
spins.every((s) => s.done) &&
!respinRestarting &&
respinPhaseRef.current !== 'pre' &&
respinPhaseRef.current !== 'restarting';
const heroHidden = opening || (spins.length > 0 && !allDone);
const beginReelSpins = (items) => {
setAllDrops(items);
const showcase = pickShowcaseDrops(items, MAX_VISIBLE_REELS);
const next = showcase.map((item, idx) => ({
key: `${item.inventoryId || item.id || 'x'}-${idx}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
item,
spinning: false,
done: false,
}));
setSpins(next);
requestAnimationFrame(() => {
setSpins((prev) => prev.map((s) => ({ ...s, spinning: true })));
});
};
const openCase = async () => {
if (!canOpen) return;
if (!canOpen || !crate) return;
setError('');
setKeyMessage('');
setCatalogMessage('');
setOpenBonusMessage('');
setSpins([]);
setAllDrops([]);
setOpening(true);
setRespinRestarting(false);
announcedRef.current = false;
preAnnouncedRef.current = false;
finishingRef.current = false;
pendingRespinFinalRef.current = null;
pendingPreRespinAnnounceRef.current = null;
pendingPreRespinAutoSellRef.current = [];
respinPhaseRef.current = null;
try {
const result = await api.openCase(crate.id, quantity);
@@ -259,10 +457,9 @@ export default function CasePage() {
if (result.animReduction != null) {
setAnimReduction(result.animReduction);
}
const bonusParts = [];
if (result.refunded) bonusParts.push('Refund! Case cost returned');
if (result.respin) bonusParts.push('Respin! All drops re-rolled');
if (bonusParts.length) setOpenBonusMessage(bonusParts.join(' · '));
if (result.refunded) {
setOpenBonusMessage('Refund! Case cost returned');
}
if (result.keyProgress) {
setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
}
@@ -272,19 +469,6 @@ export default function CasePage() {
);
}
if (result.catalog) {
const parts = [];
if (result.catalog.newlyFilled?.length) {
const n = result.catalog.newlyFilled.length;
parts.push(
`${n} catalog slot${n === 1 ? '' : 's'} filled`
);
}
if (result.catalog.chapterJustCompleted) {
parts.push('chapter complete · +1% luck');
} else if (result.catalog.luckPercent > 0 && result.catalog.newlyFilled?.length) {
parts.push(`luck +${result.catalog.luckPercent}%`);
}
if (parts.length) setCatalogMessage(parts.join(' · '));
if (result.catalog.luckPercent != null) {
setCrate((prev) =>
prev ? { ...prev, catalogLuckPercent: result.catalog.luckPercent } : prev
@@ -308,24 +492,34 @@ export default function CasePage() {
setCaseName(resolvedCaseName);
const items = result.items?.length ? result.items : result.item ? [result.item] : [];
const inventoryIds = items.map((item) => item?.inventoryId).filter(Boolean);
const pre = result.respin && result.preRespinItems?.length ? result.preRespinItems : null;
pendingAnnounceRef.current = {
ids: inventoryIds,
caseName: resolvedCaseName || '',
respin: Boolean(pre),
};
pendingAutoSellRef.current = items
.filter((item) => item?.willAutoSell)
.map((item) => item.inventoryId)
.filter(Boolean);
const next = items.map((item, idx) => ({
key: `${item.inventoryId || item.id}-${idx}-${Date.now()}`,
item,
spinning: false,
done: false,
}));
setSpins(next);
requestAnimationFrame(() => {
setSpins((prev) => prev.map((s) => ({ ...s, spinning: true })));
});
if (pre) {
pendingPreRespinAutoSellRef.current = pre
.filter((item) => item?.willAutoSell && item?.inventoryId)
.map((item) => item.inventoryId);
pendingPreRespinAnnounceRef.current = {
items: pre,
caseName: resolvedCaseName || '',
};
pendingRespinFinalRef.current = items;
respinPhaseRef.current = 'pre';
beginReelSpins(pre);
} else {
pendingPreRespinAutoSellRef.current = [];
respinPhaseRef.current = null;
beginReelSpins(items);
}
} catch (err) {
setError(err.message);
setOpening(false);
@@ -336,109 +530,149 @@ export default function CasePage() {
setSpins((prev) => {
const alreadyDone = prev.find((s) => s.key === key)?.done;
if (alreadyDone) return prev;
const next = prev.map((s) =>
return prev.map((s) =>
s.key === key ? { ...s, spinning: false, done: true } : s
);
const wasComplete = prev.length > 0 && prev.every((s) => s.done);
const nowComplete = next.length > 0 && next.every((s) => s.done);
if (nowComplete && !wasComplete) {
setOpening(false);
void finalizeOpens(next, caseName || crate?.name || '');
}
return next;
});
};
// 1) When first spin reels finish → feed line #1, start respin pause
// 2) When final reels finish → feed line #2 (respin) via finalizeOpens
useEffect(() => {
if (!spins.length || spins.some((s) => !s.done)) return;
const phase = respinPhaseRef.current;
if (phase === 'pre') {
if (!pendingRespinFinalRef.current) return;
// Block re-entry while we credit first-roll auto-sell, then pause before restart.
respinPhaseRef.current = 'restarting';
void (async () => {
await announcePreRespinDrop();
await applyPreRespinAutoSell();
setRespinRestarting(true);
})();
return;
}
if (phase === 'restarting') return;
if ((phase === 'final' || phase === null) && opening && !finishingRef.current) {
respinPhaseRef.current = null;
setOpening(false);
void finalizeOpens(caseName || crate?.name || '');
}
}, [spins, opening, caseName, crate?.name]);
// After pause, restart reels with real loot (respin feed waits until those finish)
useEffect(() => {
if (!respinRestarting) return;
const finalItems = pendingRespinFinalRef.current;
if (!finalItems) return;
const t = setTimeout(() => {
pendingRespinFinalRef.current = null;
respinPhaseRef.current = 'final';
setOpenBonusMessage((msg) =>
msg?.includes('Respin')
? msg
: msg
? `${msg} · Respin! Re-rolling…`
: 'Respin! Re-rolling all drops…'
);
beginReelSpins(finalItems);
setRespinRestarting(false);
}, 550);
return () => clearTimeout(t);
}, [respinRestarting]);
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<div className="case-page">
<Link to="/cases" className="muted">
Back to cases
</Link>
{!crate && !error && <p className="muted">Loading case</p>}
{error && <div className="error">{error}</div>}
{keyMessage && <div className="ok-msg">{keyMessage}</div>}
{catalogMessage && <div className="ok-msg">{catalogMessage}</div>}
{openBonusMessage && <div className="ok-msg">{openBonusMessage}</div>}
{crate && (
<>
<div className="case-hero">
<h1>{crate.name}</h1>
<p className="muted">
Cost {formatCredits(crate.price)} cr each
{crate.basePrice != null && Number(crate.basePrice) !== Number(crate.price)
? ` (was ${formatCredits(crate.basePrice)})`
: ''}{' '}
· Balance {formatCredits(user.balance)} cr
{crate.catalogLuckPercent > 0
? ` · Catalog luck +${crate.catalogLuckPercent}%`
: ''}
</p>
<div
className={`case-hero-wrap${heroHidden ? ' case-hero-wrap--hidden' : ''}`}
aria-hidden={heroHidden}
>
<div className="case-hero">
<h1>{crate.name}</h1>
{keyProgress && (
<div className="case-key-progress">
<div className="case-key-head">
<span>
Keys <strong>{keyProgress.keys}</strong> · Max{' '}
<strong>x{keyProgress.maxOpens}</strong>
</span>
<span className="muted">
Next key: {keyProgress.opensSinceLastKey} / {keyProgress.opensRequired} opens
</span>
</div>
<div className="case-key-bar" role="progressbar" aria-valuenow={progressPct}>
<div className="case-key-bar-fill" style={{ width: `${progressPct}%` }} />
{keyProgress && (
<div className="case-key-progress">
<div className="case-key-head">
<span>
Keys <strong>{keyProgress.keys}</strong> · Max{' '}
<strong>x{keyProgress.maxOpens}</strong>
</span>
<span className="muted">
Next key: {keyProgress.opensSinceLastKey} / {keyProgress.opensRequired} opens
</span>
</div>
<div className="case-key-bar" role="progressbar" aria-valuenow={progressPct}>
<div className="case-key-bar-fill" style={{ width: `${progressPct}%` }} />
</div>
</div>
)}
<div className="open-qty">
<label className="muted">
Open
<select
value={quantity}
disabled={opening}
onChange={(e) => {
const next = Number(e.target.value);
setQuantity(next);
persistQty(crate.id, next);
}}
>
{openOptions.map((n) => (
<option key={n} value={n} disabled={n > maxAffordable && maxAffordable > 0}>
{n}×
</option>
))}
</select>
</label>
<span className="muted">
Total {formatCredits(totalCost)} cr
{maxAffordable < quantity ? ' · insufficient balance' : ''}
</span>
</div>
)}
<div className="open-qty">
<label className="muted">
Open
<select
value={quantity}
disabled={opening}
onChange={(e) => {
const next = Number(e.target.value);
setQuantity(next);
persistQty(crate.id, next);
}}
>
{openOptions.map((n) => (
<option key={n} value={n} disabled={n > maxAffordable && maxAffordable > 0}>
{n}×
</option>
))}
</select>
</label>
<span className="muted">
Total {formatCredits(totalCost)} cr
{maxAffordable < quantity ? ' · insufficient balance' : ''}
</span>
<button
type="button"
className={`case-icon ${canOpen ? '' : 'disabled'}${crate.imageUrl ? ' has-img' : ''}`}
onClick={openCase}
disabled={!canOpen}
title="Open case"
>
{crate.imageUrl ? <img src={crate.imageUrl} alt="" /> : '▣'}
</button>
<button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
{opening
? `Opening ${quantity}×…`
: `Open ${quantity}× for ${formatCredits(totalCost)} cr`}
</button>
</div>
<button
type="button"
className={`case-icon ${canOpen ? '' : 'disabled'}${crate.imageUrl ? ' has-img' : ''}`}
onClick={openCase}
disabled={!canOpen}
title="Open case"
>
{crate.imageUrl ? <img src={crate.imageUrl} alt="" /> : '▣'}
</button>
<button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
{opening
? `Opening ${quantity}×…`
: `Open ${quantity}× for ${formatCredits(totalCost)} cr`}
</button>
</div>
{spins.length > 0 && (
<div className={`multi-reels${spins.length > 1 ? ' many' : ''}`}>
{allDrops.length > spins.length && (
<p className="muted multi-reel-hint">
Showing top {spins.length} of {allDrops.length} drops
</p>
)}
{spins.map((spin, idx) => (
<div key={spin.key} className="multi-reel-slot">
{spins.length > 1 && (
@@ -464,14 +698,18 @@ export default function CasePage() {
{spin.item.rarity}
</div>
<div className="muted">
{[
spin.item.wear || null,
`${formatCredits(spin.item.valueCents ?? spin.item.marketValue)} cr`,
]
.filter(Boolean)
.join(' · ')}
{spin.item.autoSold ? (
<>Auto-sold for {formatCredits(spin.item.soldForCents)} cr</>
) : (
<>
{formatCredits(spin.item.valueCents ?? spin.item.marketValue)} cr
{spin.item.wear ? ` · ${spin.item.wear}` : ''}
{' '}
· Auto-sold for {formatCredits(spin.item.soldForCents)} cr
</>
)}
) : null}
</div>
</div>
)}
@@ -480,13 +718,23 @@ export default function CasePage() {
</div>
)}
{allDone && spins.length > 1 && (
{allDone && allDrops.length > 1 && (
<div className="won-banner section">
<div className="muted">You unboxed {spins.length} items</div>
<div className="muted">
You unboxed {allDrops.length} items
{allDrops.length > spins.length
? ` · reels showed the top ${spins.length} by value`
: ''}
</div>
<div className="grid" style={{ marginTop: '0.75rem' }}>
{spins.map((s) => (
<ItemTile key={s.key} item={s.item} />
))}
{[...allDrops]
.sort((a, b) => compareCents(dropValue(b), dropValue(a)))
.map((item) => (
<ItemTile
key={item.inventoryId || `${item.id}-${item.floatValue}`}
item={item}
/>
))}
</div>
</div>
)}
+1 -7
View File
@@ -30,12 +30,6 @@ export default function CasesPage() {
return (
<section className="section">
<div className="section-head">
<div>
<h1>Cases</h1>
<p className="muted">Pick a crate to open.</p>
</div>
</div>
{error && <div className="error">{error}</div>}
<div className="grid case-grid">
{cases.map((c) => (
@@ -44,7 +38,7 @@ export default function CasesPage() {
{c.imageUrl ? <img src={c.imageUrl} alt="" loading="lazy" /> : '▣'}
</div>
<div className="case-name">{c.name}</div>
<div className="case-meta">{formatCredits(c.price)} cr</div>
<div className="case-meta case-price">{formatCredits(c.price)} cr</div>
<div className="case-meta">{c.items.length} possible drops</div>
{c.keyProgress && (
<div className="case-meta case-key-badge">
-35
View File
@@ -1,35 +0,0 @@
import { Navigate, Link } from 'react-router-dom';
import { useAuth } from '../AuthContext';
export default function CoinflipStub() {
const { user, loading } = useAuth();
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<section className="section">
<div className="section-head">
<div>
<h1>Coinflip</h1>
<p className="muted">1v1 item flip coming soon.</p>
</div>
<Link to="/battle" className="btn secondary">
Back
</Link>
</div>
<div className="empty stub-panel">
<p>This mode is not playable yet. Use MultiPlayers Case or 1vX Case in the meantime.</p>
<div className="row-actions" style={{ marginTop: '1rem' }}>
<Link to="/battle/multi" className="btn">
MultiPlayers
</Link>
<Link to="/battle/1vx" className="btn secondary">
1vX Case
</Link>
</div>
</div>
</section>
);
}
+253 -211
View File
@@ -1,13 +1,22 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { api, formatCredits, creditsToCentsString, centsToCreditsString, compareCents, toCentsBigInt } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile';
import { onInventoryChanged } from '../inventoryEvents';
import { getSocket, onSocketSessionOk } from '../socket';
import { loadInventorySort, saveInventorySort } from '../userPrefs';
const PAGE_SIZE = 50;
function formatVaultCountdown(ms) {
if (ms == null || !Number.isFinite(ms)) return '—';
const totalSec = Math.max(0, Math.ceil(ms / 1000));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
export default function Dashboard() {
const { user, loading, patchUser } = useAuth();
const [inventory, setInventory] = useState([]);
@@ -21,6 +30,7 @@ export default function Dashboard() {
const [maxValue, setMaxValue] = useState('10');
const [autoSellEnabled, setAutoSellEnabled] = useState(false);
const [settingsBusy, setSettingsBusy] = useState(false);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!user?.id) return;
@@ -32,7 +42,7 @@ export default function Dashboard() {
if (!user) return;
setAutoSellEnabled(Boolean(user.autoSellEnabled));
if (user.autoSellThresholdCents != null) {
setMaxValue((user.autoSellThresholdCents / 100).toFixed(2).replace(/\.?0+$/, '') || '0');
setMaxValue(centsToCreditsString(user.autoSellThresholdCents));
}
}, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]);
@@ -64,13 +74,64 @@ export default function Dashboard() {
});
}, [user]);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
const socket = getSocket();
const onYield = (payload) => {
if (payload?.user) patchUser(payload.user);
else if (payload?.balance != null) patchUser({ balance: payload.balance });
if (payload?.vault) setVault(payload.vault);
const credited = toCentsBigInt(payload?.credited);
if (credited > 0n) {
setVaultMessage(`Vault yield +${formatCredits(payload.credited)} cr`);
}
load().catch(() => {});
};
const onSession = () => {
load().catch(() => {});
};
socket.on('vault:yield', onYield);
const offSession = onSocketSessionOk(onSession);
return () => {
socket.off('vault:yield', onYield);
offSession();
};
}, [user, patchUser]);
useEffect(() => {
if (!(vault?.used > 0)) return undefined;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [vault?.used]);
useEffect(() => {
if (!(vault?.used > 0) || !vault?.nextYieldAt) return undefined;
const dueAt = new Date(vault.nextYieldAt).getTime();
const delay = Math.max(500, dueAt - Date.now() + 800);
const t = setTimeout(() => {
load().catch(() => {});
}, delay);
return () => clearTimeout(t);
}, [vault?.nextYieldAt, vault?.used]);
const vaultCountdownMs = useMemo(() => {
if (!(vault?.used > 0) || !vault?.nextYieldAt) return null;
return new Date(vault.nextYieldAt).getTime() - now;
}, [vault?.used, vault?.nextYieldAt, now]);
const vaultTickLabel = useMemo(() => {
const ms = vault?.tickMs ?? (vault?.mode === 'offline' ? 600_000 : 300_000);
return `${Math.round(ms / 60_000)} min`;
}, [vault?.tickMs, vault?.mode]);
const sorted = useMemo(() => {
const list = [...inventory];
const list = inventory.filter((inv) => !inv.staked);
if (sort === 'value') {
list.sort(
(a, b) =>
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
list.sort((a, b) =>
compareCents(
b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue,
a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue
)
);
} else {
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
@@ -93,28 +154,23 @@ export default function Dashboard() {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const selectedValue = useMemo(
() =>
inventory
.filter((inv) => selected.includes(inv.id))
.reduce((sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0), 0),
[inventory, selected]
);
const selectedValue = useMemo(() => {
let sum = 0n;
for (const inv of inventory) {
if (!selected.includes(inv.id)) continue;
sum += toCentsBigInt(inv.valueCents ?? inv.item?.valueCents);
}
return sum.toString();
}, [inventory, selected]);
const thresholdCents = useMemo(() => {
const credits = Number(maxValue);
if (!Number.isFinite(credits) || credits < 0) return null;
return Math.round(credits * 100);
}, [maxValue]);
const thresholdCents = useMemo(() => creditsToCentsString(maxValue), [maxValue]);
const belowThreshold = useMemo(() => {
if (thresholdCents == null) return [];
return inventory.filter(
(inv) =>
!inv.favorite &&
!inv.staked &&
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
);
return inventory.filter((inv) => {
if (inv.favorite || inv.staked) return false;
return compareCents(inv.valueCents ?? inv.item?.valueCents, thresholdCents) < 0;
});
}, [inventory, thresholdCents]);
const selectedItems = useMemo(
@@ -134,14 +190,13 @@ export default function Dashboard() {
const canStake = selectedUnstaked.length > 0 && selectedUnstaked.length <= vaultFree;
const canUnstake = selectedStaked.length > 0;
const belowValue = useMemo(
() =>
belowThreshold.reduce(
(sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0),
0
),
[belowThreshold]
);
const belowValue = useMemo(() => {
let sum = 0n;
for (const inv of belowThreshold) {
sum += toCentsBigInt(inv.valueCents ?? inv.item?.valueCents);
}
return sum.toString();
}, [belowThreshold]);
const toggle = (id) => {
setSelected((prev) =>
@@ -217,27 +272,6 @@ export default function Dashboard() {
}
};
const claimVault = async () => {
setBusy(true);
setError('');
setVaultMessage('');
try {
const data = await api.vaultClaim();
if (data.user) patchUser({ balance: data.user.balance });
if (data.vault) setVault(data.vault);
setVaultMessage(
data.credited > 0
? `Vault yield +${formatCredits(data.credited)} cr`
: 'No vault yield ready yet'
);
await load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const setFavorite = async (favorite) => {
if (!selected.length) return;
setBusy(true);
@@ -257,7 +291,8 @@ export default function Dashboard() {
if (!ids.length || thresholdCents == null) return;
const favSkipped = inventory.filter(
(inv) =>
inv.favorite && (inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
inv.favorite &&
compareCents(inv.valueCents ?? inv.item?.valueCents, thresholdCents) < 0
).length;
const ok = window.confirm(
`Sell ${ids.length} item${ids.length === 1 ? '' : 's'} below ${formatCredits(thresholdCents)} cr for ${formatCredits(belowValue)} cr?` +
@@ -282,19 +317,18 @@ export default function Dashboard() {
const onAutoSellToggle = async (enabled) => {
setAutoSellEnabled(enabled);
const credits = Number(maxValue);
if (!Number.isFinite(credits) || credits < 0) return;
await saveAutoSell(enabled, credits);
if (creditsToCentsString(maxValue) == null) return;
await saveAutoSell(enabled, maxValue);
};
const onThresholdBlur = async () => {
const credits = Number(maxValue);
if (!Number.isFinite(credits) || credits < 0) return;
const savedCents = user?.autoSellThresholdCents ?? 1000;
if (Math.round(credits * 100) === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
const cents = creditsToCentsString(maxValue);
if (cents == null) return;
const savedCents = String(user?.autoSellThresholdCents ?? 1000);
if (cents === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
return;
}
await saveAutoSell(autoSellEnabled, credits);
await saveAutoSell(autoSellEnabled, maxValue);
};
if (loading) return <p className="muted">Loading</p>;
@@ -303,15 +337,56 @@ export default function Dashboard() {
return (
<div>
<section className="section">
<div className="section-head">
<div>
<h1>Inventory</h1>
<p className="muted">Select items to sell at market value.</p>
</div>
</div>
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
<div className="inv-toolbar">
{stakedItems.length > 0 && (
<section className="section">
<div className="vault-panel admin-block">
<div className="section-head">
<div>
<h2>Vault</h2>
</div>
<div className="vault-timer" title={`Shared ${vaultTickLabel} tick (${vault?.mode || 'online'})`}>
<span className="muted">Next payment</span>
<strong className={vaultCountdownMs != null && vaultCountdownMs <= 0 ? 'gold' : ''}>
{vaultCountdownMs != null && vaultCountdownMs <= 0
? 'paying…'
: formatVaultCountdown(vaultCountdownMs)}
</strong>
</div>
</div>
<div className="vault-stats">
<span>
Slots <strong>{vault?.used ?? 0}/{vault?.slots ?? 3}</strong>
</span>
<span>
Online / tick{' '}
<strong className="gold">{formatCredits(vault?.onlinePerTick ?? 0)} cr</strong>
</span>
<span>
Offline / tick{' '}
<strong>{formatCredits(vault?.offlinePerTick ?? 0)} cr</strong>
</span>
</div>
<div className="grid vault-grid">
{stakedItems.map((inv) => (
<ItemTile
key={`vault-${inv.id}`}
item={inv.item}
favorite={inv.favorite}
staked
selected={selected.includes(inv.id)}
onClick={() => toggle(inv.id)}
/>
))}
</div>
</div>
</section>
)}
<section className="section">
<div className="section-head inv-section-head">
<h1>Inventory</h1>
<div className="inv-toolbar-left">
<div className="sort-toggle" role="group" aria-label="Sort inventory">
<button
@@ -335,87 +410,103 @@ export default function Dashboard() {
Most recent
</button>
</div>
{inventory.length > 0 && (
{sorted.length > 0 && (
<span className="muted inv-count">
{inventory.length} item{inventory.length === 1 ? '' : 's'}
{sorted.length} item{sorted.length === 1 ? '' : 's'}
</span>
)}
</div>
<div className="row-actions">
{selected.length > 0 && (
<>
<span className="muted">
{selected.length} selected · {formatCredits(selectedValue)} cr
{selectedHasFavorite ? ' · includes favorites' : ''}
</span>
<button
type="button"
className="btn ghost"
onClick={() => setSelected([])}
disabled={busy}
>
Clear
</button>
{!selectedAllFavorite && (
<button
type="button"
className="btn secondary"
onClick={() => setFavorite(true)}
disabled={busy}
>
Favorite
</button>
)}
{selectedItems.some((inv) => inv.favorite) && (
<button
type="button"
className="btn ghost"
onClick={() => setFavorite(false)}
disabled={busy}
>
Unfavorite
</button>
)}
<button
type="button"
className="btn"
onClick={sell}
disabled={busy || !selectedCanSell}
title={
selectedHasFavorite || selectedStaked.length
? 'Favorites and vault items cannot be sold'
: undefined
}
>
Sell
</button>
{canStake && (
<button
type="button"
className="btn secondary"
onClick={stakeSelected}
disabled={busy}
>
Stake ({selectedUnstaked.length})
</button>
)}
{canUnstake && (
<button
type="button"
className="btn secondary"
onClick={unstakeSelected}
disabled={busy}
>
Unstake ({selectedStaked.length})
</button>
)}
</>
)}
</div>
</div>
{selected.length > 0 && (
<div className="inv-toolbar">
<div className="row-actions">
<button
type="button"
className="btn danger"
onClick={sell}
disabled={busy || !selectedCanSell}
title={
selectedHasFavorite || selectedStaked.length
? 'Favorites and vault items cannot be sold'
: undefined
}
>
Sell
</button>
<span className="muted">
{selected.length} selected ·{' '}
<span className="item-price">
{formatCredits(selectedValue)} cr
</span>
{selectedHasFavorite ? ' · includes favorites' : ''}
</span>
<button
type="button"
className="btn ghost"
onClick={() => setSelected([])}
disabled={busy}
>
Clear
</button>
{!selectedAllFavorite && (
<button
type="button"
className="btn secondary"
onClick={() => setFavorite(true)}
disabled={busy}
>
<span className="btn-ico" aria-hidden="true">
</span>
Favorite
</button>
)}
{selectedItems.some((inv) => inv.favorite) && (
<button
type="button"
className="btn ghost"
onClick={() => setFavorite(false)}
disabled={busy}
>
<span className="btn-ico" aria-hidden="true">
</span>
Unfavorite
</button>
)}
{canStake && (
<button
type="button"
className="btn secondary"
onClick={stakeSelected}
disabled={busy}
>
<span className="btn-ico btn-ico-vault" aria-hidden="true">
V
</span>
Stake ({selectedUnstaked.length})
</button>
)}
{canUnstake && (
<button
type="button"
className="btn secondary"
onClick={unstakeSelected}
disabled={busy}
>
<span className="btn-ico btn-ico-vault" aria-hidden="true">
V
</span>
Unstake ({selectedStaked.length})
</button>
)}
</div>
</div>
)}
<div className="inv-bulk-sell">
{inventory.length > 0 && (
{sorted.length > 0 && (
<>
<button
type="button"
@@ -440,11 +531,13 @@ export default function Dashboard() {
</label>
<span className="muted inv-bulk-preview">
{belowThreshold.length} item{belowThreshold.length === 1 ? '' : 's'} ·{' '}
{formatCredits(belowValue)} cr
<span className="item-price">
{formatCredits(belowValue)} cr
</span>
</span>
</>
)}
{inventory.length === 0 && (
{sorted.length === 0 && (
<label className="inv-bulk-label">
Threshold
<input
@@ -471,76 +564,26 @@ export default function Dashboard() {
<span>Auto-sell</span>
</label>
<span className="inv-auto-sell-at muted">
at{' '}
{formatCredits(
Number.isFinite(Number(maxValue)) && Number(maxValue) >= 0
? Math.round(Number(maxValue) * 100)
: 0
)}{' '}
cr
sell below {formatCredits(creditsToCentsString(maxValue) ?? 0)} cr
</span>
</div>
</div>
{error && <div className="error">{error}</div>}
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
<div className="vault-panel admin-block">
<div className="section-head">
<div>
<h2>Vault</h2>
<p className="muted">
Stake up to {vault?.slots ?? 3} items. Online: 10% / 10 min · Offline: 1% / 10 min
(scales with wear &amp; rarity).
</p>
</div>
<button
type="button"
className="btn secondary"
onClick={claimVault}
disabled={busy || !(vault?.used > 0)}
>
Claim yield
</button>
</div>
<div className="vault-stats">
<span>
Slots <strong>{vault?.used ?? 0}/{vault?.slots ?? 3}</strong>
</span>
<span>
Online / tick{' '}
<strong className="gold">{formatCredits(vault?.onlinePerTick ?? 0)} cr</strong>
</span>
<span>
Offline / tick{' '}
<strong>{formatCredits(vault?.offlinePerTick ?? 0)} cr</strong>
</span>
</div>
{stakedItems.length > 0 ? (
<div className="grid vault-grid">
{stakedItems.map((inv) => (
<ItemTile
key={`vault-${inv.id}`}
item={inv.item}
favorite={inv.favorite}
staked
selected={selected.includes(inv.id)}
onClick={() => toggle(inv.id)}
/>
))}
</div>
) : (
<p className="muted">No items staked select items below and Stake.</p>
)}
</div>
{inventory.length === 0 ? (
{sorted.length === 0 ? (
<div className="empty">
Empty for now {' '}
<Link to="/cases" style={{ color: 'var(--accent)' }}>
open a case
</Link>
.
{stakedItems.length > 0 ? (
'All items are in the vault.'
) : (
<>
Empty for now {' '}
<Link to="/cases" style={{ color: 'var(--accent)' }}>
open a case
</Link>
.
</>
)}
</div>
) : (
<>
@@ -550,7 +593,6 @@ export default function Dashboard() {
key={inv.id}
item={inv.item}
favorite={inv.favorite}
staked={inv.staked}
selected={selected.includes(inv.id)}
onClick={() => toggle(inv.id)}
/>
-182
View File
@@ -1,182 +0,0 @@
import { useEffect, useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
import { getSocket } from '../socket';
const defaults = {
maxPlayers: 4,
maxItemsPerPlayer: 3,
minItemValue: 0,
minPlayersToStart: 2,
};
export default function DuelListPage() {
const { user, loading } = useAuth();
const navigate = useNavigate();
const [open, setOpen] = useState([]);
const [inProgress, setInProgress] = useState([]);
const [form, setForm] = useState(defaults);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
const list = await api.duels();
if (!cancelled) {
setOpen(list.open || []);
setInProgress(list.inProgress || []);
}
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
const socket = getSocket();
const onList = (list) => {
setOpen(list.open || []);
setInProgress(list.inProgress || []);
};
socket.emit('duels:subscribe');
socket.on('duels:list', onList);
socket.on('connect', () => socket.emit('duels:subscribe'));
return () => {
cancelled = true;
socket.emit('duels:unsubscribe');
socket.off('duels:list', onList);
};
}, [user]);
const create = async (e) => {
e.preventDefault();
setBusy(true);
setError('');
try {
const data = await api.createDuel({
maxPlayers: Number(form.maxPlayers),
maxItemsPerPlayer: Number(form.maxItemsPerPlayer),
minItemValue: Math.round(Number(form.minItemValue) * 100),
minPlayersToStart: Number(form.minPlayersToStart),
});
navigate(`/battle/1vx/${data.room.id}`);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<div>
<div className="section-head">
<div>
<h1>1vX Case</h1>
<p className="muted">Create a room or join an open duel.</p>
</div>
<Link to="/battle" className="btn secondary">
Modes
</Link>
</div>
{error && <div className="error">{error}</div>}
<section className="section admin-block">
<h2>Create room</h2>
<form className="form form-inline" onSubmit={create}>
<label>
Max players (210)
<input
type="number"
min={2}
max={10}
value={form.maxPlayers}
onChange={(e) => setForm({ ...form, maxPlayers: e.target.value })}
/>
</label>
<label>
Max items / player (110)
<input
type="number"
min={1}
max={10}
value={form.maxItemsPerPlayer}
onChange={(e) => setForm({ ...form, maxItemsPerPlayer: e.target.value })}
/>
</label>
<label>
Min item value (cr)
<input
type="number"
min={0}
step={0.01}
value={form.minItemValue}
onChange={(e) => setForm({ ...form, minItemValue: e.target.value })}
/>
</label>
<label>
Min players to start
<input
type="number"
min={2}
max={10}
value={form.minPlayersToStart}
onChange={(e) => setForm({ ...form, minPlayersToStart: e.target.value })}
/>
</label>
<button type="submit" className="btn" disabled={busy}>
Create
</button>
</form>
</section>
<section className="section">
<h2>Open rooms</h2>
{open.length === 0 ? (
<div className="empty">No open rooms.</div>
) : (
<div className="room-list">
{open.map((r) => (
<Link key={r.id} to={`/battle/1vx/${r.id}`} className="room-card">
<div className="room-title">Room #{r.id}</div>
<div className="muted">
by {r.creatorUsername} · {r.playerCount}/{r.maxPlayers} players ·{' '}
{r.maxItemsPerPlayer} items max · min {formatCredits(r.minItemValue)} cr
</div>
<div className="muted">Pot {formatCredits(r.totalValue)} cr</div>
</Link>
))}
</div>
)}
</section>
<section className="section">
<h2>In progress</h2>
{inProgress.length === 0 ? (
<div className="empty">No live rooms.</div>
) : (
<div className="room-list">
{inProgress.map((r) => (
<Link key={r.id} to={`/battle/1vx/${r.id}`} className="room-card live">
<div className="room-title">
Room #{r.id} <span className="badge">Live</span>
</div>
<div className="muted">
{r.playerCount} players · pot {formatCredits(r.totalValue)} cr · Spectate
</div>
</Link>
))}
</div>
)}
</section>
</div>
);
}
-354
View File
@@ -1,354 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { api, formatCredits, formatChance } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile, { itemValueCents } from '../components/ItemTile';
import CaseReel from '../components/CaseReel';
import { onInventoryChanged } from '../inventoryEvents';
import { getSocket } from '../socket';
export default function DuelRoomPage() {
const { id } = useParams();
const { user, loading, refresh } = useAuth();
const [room, setRoom] = useState(null);
const [inventory, setInventory] = useState([]);
const [selected, setSelected] = useState([]);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [spinning, setSpinning] = useState(false);
const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
const loadInventory = useCallback(async () => {
const inv = await api.inventory();
setInventory(inv.inventory);
}, []);
const inventoryByValue = useMemo(
() =>
[...inventory].sort(
(a, b) =>
itemValueCents(b.item) - itemValueCents(a.item) ||
(b.valueCents ?? 0) - (a.valueCents ?? 0)
),
[inventory]
);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
const [data] = await Promise.all([api.duel(id), loadInventory()]);
if (!cancelled) setRoom(data.room);
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
const socket = getSocket();
const roomId = Number(id);
const onState = (state) => {
if (Number(state.id) !== roomId) return;
setRoom(state);
if (state.status === 'open' || state.status === 'resolved') {
setSpinning(false);
loadInventory().catch(() => {});
}
};
const onSpin = (state) => {
if (Number(state.id) !== roomId) return;
setRoom(state);
setSpinning(true);
setReelWinnerItemId(state.reelWinnerItemId);
};
const onResult = (state) => {
if (Number(state.id) !== roomId) return;
setRoom(state);
setSpinning(false);
loadInventory().catch(() => {});
refresh().catch(() => {});
};
socket.emit('duel:subscribe', roomId);
socket.on('duel:state', onState);
socket.on('duel:spin', onSpin);
socket.on('duel:result', onResult);
socket.on('connect', () => socket.emit('duel:subscribe', roomId));
return () => {
cancelled = true;
socket.emit('duel:unsubscribe', roomId);
socket.off('duel:state', onState);
socket.off('duel:spin', onSpin);
socket.off('duel:result', onResult);
};
}, [user, id, loadInventory, refresh]);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
return onInventoryChanged(() => {
loadInventory().catch(() => {});
refresh().catch(() => {});
});
}, [user, loadInventory, refresh]);
const isSpectator =
room &&
(room.status === 'in_progress' || room.status === 'spinning' || room.status === 'resolved') &&
!(room.deposits || []).some((d) => d.userId === user?.id);
const myDeposits = useMemo(
() => (room?.deposits || []).filter((d) => d.userId === user?.id),
[room, user]
);
const slotsLeft = Math.max(0, (room?.maxItemsPerPlayer || 0) - myDeposits.length);
const potItems = useMemo(
() =>
(room?.deposits || [])
.filter((d) => d.item)
.map((d) => ({ ...d.item, id: d.id })),
[room]
);
const canBet = room?.status === 'open';
const isCreator = room?.creatorId === user?.id;
const toggleSelect = (invId) => {
setSelected((prev) => {
if (prev.includes(invId)) return prev.filter((x) => x !== invId);
if (prev.length >= slotsLeft) return prev;
return [...prev, invId];
});
};
const placeBet = async () => {
setBusy(true);
setError('');
try {
const data = await api.duelBet(id, selected);
setRoom(data.room);
setSelected([]);
await loadInventory();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const leave = async () => {
setBusy(true);
setError('');
try {
const data = await api.duelLeave(id);
setRoom(data.room);
setSelected([]);
await loadInventory();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const cancel = async () => {
setBusy(true);
setError('');
try {
const data = await api.duelCancel(id);
setRoom(data.room);
await loadInventory();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const start = async () => {
setBusy(true);
setError('');
try {
const data = await api.duelStart(id);
setRoom(data.room);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
if (!room && !error) return <p className="muted">Loading room</p>;
return (
<div className="bet-page">
<div className="section-head">
<div>
<h1>Room #{id}</h1>
<p className="muted">
{room?.maxPlayers} players max · {room?.maxItemsPerPlayer} items · min{' '}
{formatCredits(room?.minItemValue || 0)} cr
{isSpectator ? ' · Spectating' : ''}
</p>
</div>
<Link to="/battle/1vx" className="btn secondary">
All rooms
</Link>
</div>
{error && <div className="error">{error}</div>}
<div className="jackpot-status">
<div className="timer-block">
<span className="muted">Status</span>
<span className={`status-pill ${room?.status || ''}`}>{room?.status}</span>
</div>
<div className="timer-block">
<span className="muted">Pot</span>
<strong className="gold">{formatCredits(room?.totalValue || 0)} cr</strong>
</div>
<div className="timer-block">
<span className="muted">Players</span>
<strong>
{room?.playerCount || 0}/{room?.maxPlayers}
</strong>
</div>
</div>
{(spinning || room?.status === 'spinning') && potItems.length > 0 && (
<div className="section">
<CaseReel
items={potItems}
winnerId={reelWinnerItemId || potItems[0]?.id}
spinning={spinning || room?.status === 'spinning'}
onDone={() => {}}
/>
{room?.winnerUsername && (
<div className="won-banner" style={{ marginTop: '0.75rem' }}>
Winner: <strong>{room.winnerUsername}</strong>
</div>
)}
</div>
)}
{room?.status === 'resolved' && room.winnerUsername && (
<div className="won-banner section">
Winner: <strong>{room.winnerUsername}</strong> took the pot (
{formatCredits(room.totalValue)} cr)
</div>
)}
<section className="section">
<h2>Players & chances</h2>
{(room?.players || []).length === 0 ? (
<div className="empty">No deposits yet.</div>
) : (
<table className="table">
<thead>
<tr>
<th>Player</th>
<th>Value</th>
<th>Items</th>
<th>Chance</th>
</tr>
</thead>
<tbody>
{room.players.map((p) => (
<tr key={p.userId} className={p.userId === user.id ? 'row-me' : ''}>
<td>{p.username}</td>
<td>{formatCredits(p.valueCents)} cr</td>
<td>{p.itemCount}</td>
<td>{formatChance(p.chance)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="section">
<h2>Deposited items</h2>
{(room?.deposits || []).length === 0 ? (
<div className="empty">Empty.</div>
) : (
<div className="grid">
{room.deposits.map((d) => (
<div key={d.id} className="deposit-wrap">
<ItemTile item={d.item} />
<div className="deposit-owner muted">{d.username}</div>
</div>
))}
</div>
)}
</section>
{canBet && (
<section className="section">
<div className="section-head">
<div>
<h2>Your bets</h2>
<p className="muted">
{myDeposits.length}/{room.maxItemsPerPlayer} items · {slotsLeft} slot
{slotsLeft === 1 ? '' : 's'} left
</p>
</div>
<div className="row-actions">
{myDeposits.length > 0 && (
<button type="button" className="btn danger" disabled={busy} onClick={leave}>
Leave / withdraw
</button>
)}
{isCreator && (
<>
<button type="button" className="btn secondary" disabled={busy} onClick={cancel}>
Cancel room
</button>
<button type="button" className="btn" disabled={busy} onClick={start}>
Start now
</button>
</>
)}
</div>
</div>
<div className="section-head">
<p className="muted">Select items to bet</p>
<button
type="button"
className="btn"
disabled={busy || !selected.length || slotsLeft === 0}
onClick={placeBet}
>
Deposit
</button>
</div>
{inventoryByValue.length === 0 ? (
<div className="empty">
No free items <Link to="/cases">open a case</Link>.
</div>
) : (
<div className="grid">
{inventoryByValue.map((inv) => (
<ItemTile
key={inv.id}
item={inv.item}
favorite={inv.favorite}
selected={selected.includes(inv.id)}
disabled={
(slotsLeft === 0 && !selected.includes(inv.id)) ||
itemValueCents(inv.item) < (room.minItemValue || 0)
}
onClick={() => toggleSelect(inv.id)}
/>
))}
</div>
)}
</section>
)}
</div>
);
}
+13 -10
View File
@@ -1,4 +1,4 @@
import { api, formatCredits, formatLastConnection, formatPlayTime } from '../api';
import { api, formatCredits, formatLastConnection, formatPlayTime, compareCents } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile';
import ProfileCaseStats from '../components/ProfileCaseStats';
@@ -38,10 +38,11 @@ export default function PlayerProfilePage() {
const sorted = useMemo(() => {
const list = [...inventory];
if (sort === 'value') {
list.sort(
(a, b) =>
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
list.sort((a, b) =>
compareCents(
b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue,
a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue
)
);
} else {
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
@@ -161,10 +162,7 @@ export default function PlayerProfilePage() {
Cases opened <strong>{profile.casesOpened}</strong>
</li>
<li>
Jackpot wins <strong>{profile.jackpotWins}</strong>
</li>
<li>
Duel wins <strong>{profile.duelWins}</strong>
Battle wins <strong>{profile.jackpotWins}</strong>
</li>
</ul>
<ProfileAchievementBadges achievements={profile.achievements} />
@@ -223,7 +221,12 @@ export default function PlayerProfilePage() {
<>
<div className="grid">
{pageItems.map((inv) => (
<ItemTile key={inv.id} item={inv.item} favorite={inv.favorite} />
<ItemTile
key={inv.id}
item={inv.item}
favorite={inv.favorite}
staked={inv.staked}
/>
))}
</div>
{totalPages > 1 && (
+47 -21
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { api, formatCredits, toCentsBigInt } from '../api';
import { useAuth } from '../AuthContext';
function formatSkillValue(skill, value) {
@@ -67,7 +67,7 @@ export default function PrestigePage() {
patchUser(result.user);
await refresh();
setMessage(
`Prestige #${result.prestige.count} · +${result.prGranted} Pr. Balance, inventory, keys & catalog reset.`
`Prestige #${result.prestige.count} · +${result.prGranted} Pr. Balance, inventory & keys reset.`
);
await load();
} catch (err) {
@@ -77,14 +77,14 @@ export default function PrestigePage() {
}
};
const onUpgrade = async (skillId) => {
const onUpgrade = async (skillId, count = 1) => {
setBusy(true);
setError('');
setMessage('');
try {
const result = await api.prestigeUpgrade(skillId);
const result = await api.prestigeUpgrade(skillId, count);
patchUser(result.user);
setMessage(`Upgraded ${skillId}.`);
setMessage(count > 1 ? `Upgraded ${skillId} ×${count}.` : `Upgraded ${skillId}.`);
setData((prev) =>
prev
? {
@@ -101,7 +101,10 @@ export default function PrestigePage() {
}
};
const shortfall = Math.max(0, cost - (Number(user.balance) || 0));
const shortfall =
toCentsBigInt(cost) > toCentsBigInt(user.balance)
? toCentsBigInt(cost) - toCentsBigInt(user.balance)
: 0n;
const statusLabel = !data?.canAfford
? `Need ${formatCredits(shortfall)} cr more`
: data?.lockedItems > 0
@@ -178,8 +181,8 @@ export default function PrestigePage() {
<strong>+{p.openBonus || 0}</strong>
</div>
<div>
<span className="muted">Discount</span>
<strong>{p.caseDiscount || 0}%</strong>
<span className="muted">PR Farmer</span>
<strong>+{p.prBonus || 0}%</strong>
</div>
<div>
<span className="muted">Quality</span>
@@ -251,6 +254,7 @@ export default function PrestigePage() {
<li>All Pr already earned</li>
<li>Skill-tree bonuses</li>
<li>Account &amp; prestige count</li>
<li>Catalog progress</li>
</ul>
</div>
<div>
@@ -258,7 +262,7 @@ export default function PrestigePage() {
<ul>
<li>Balance</li>
<li>Inventory &amp; vault stakes</li>
<li>Case keys &amp; catalog progress</li>
<li>Case keys</li>
</ul>
</div>
</div>
@@ -308,21 +312,43 @@ export default function PrestigePage() {
<p className="muted">{skill.description}</p>
<div className="prestige-skill-meta">
<span>
Lv {skill.level} · now{' '}
Lv {skill.level}
{skill.maxLevel != null ? `/${skill.maxLevel}` : ''}
{' · now '}
<strong>{formatSkillValue(skill, skill.current)}</strong>
</span>
<span className="prestige-delta">
{formatSkillValue(skill, skill.nextValue)}
</span>
{!skill.atMax && (
<span className="prestige-delta">
{formatSkillValue(skill, skill.nextValue)}
</span>
)}
{skill.atMax && <span className="prestige-delta">MAX</span>}
</div>
<div className="prestige-skill-actions">
<button
type="button"
className="btn secondary"
disabled={busy || skill.atMax || !skill.canAfford}
onClick={() => onUpgrade(skill.id, 1)}
>
{skill.atMax ? 'Maxed' : `Buy · ${skill.cost} Pr`}
</button>
<button
type="button"
className="btn secondary"
disabled={busy || skill.atMax || !skill.canAfford10}
onClick={() => onUpgrade(skill.id, skill.buyCount10 || 10)}
title={
skill.atMax
? 'Max level reached'
: `${skill.buyCount10 || 10} levels → ${formatSkillValue(skill, skill.nextValue10)}`
}
>
{skill.atMax
? '—'
: `×${skill.buyCount10 || 10} · ${skill.cost10} Pr`}
</button>
</div>
<button
type="button"
className="btn secondary"
disabled={busy || !skill.canAfford}
onClick={() => onUpgrade(skill.id)}
>
Buy · {skill.cost} Pr
</button>
</article>
</div>
))}
+16 -7
View File
@@ -6,6 +6,7 @@ import ProfileAchievementBadges from '../components/ProfileAchievementBadges';
import ProfilePrestigeStats from '../components/ProfilePrestigeStats';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { onSocketSessionOk } from '../socket';
export default function ProfilePage() {
const { user, loading, refresh, logout } = useAuth();
@@ -21,7 +22,7 @@ export default function ProfilePage() {
useEffect(() => {
if (!user || user.role === 'admin') return;
let cancelled = false;
(async () => {
const loadProfile = async () => {
try {
const data = await api.profile();
if (!cancelled) {
@@ -31,9 +32,14 @@ export default function ProfilePage() {
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
};
loadProfile();
const offSession = onSocketSessionOk(() => {
loadProfile().catch(() => {});
});
return () => {
cancelled = true;
offSession();
};
}, [user]);
@@ -130,7 +136,13 @@ export default function ProfilePage() {
</div>
<label className="btn secondary file-btn">
Change photo
<input type="file" accept="image/*" hidden onChange={onAvatar} disabled={busy} />
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif"
hidden
onChange={onAvatar}
disabled={busy}
/>
</label>
<div className="muted" style={{ marginTop: '0.75rem' }}>
Balance {formatCredits(user.balance)} cr
@@ -178,10 +190,7 @@ export default function ProfilePage() {
Cases opened <strong>{profile.casesOpened}</strong>
</li>
<li>
Jackpot wins <strong>{profile.jackpotWins}</strong>
</li>
<li>
Duel wins <strong>{profile.duelWins}</strong>
Battle wins <strong>{profile.jackpotWins}</strong>
</li>
</ul>
<ProfileAchievementBadges
+160 -54
View File
@@ -23,20 +23,33 @@ function useAdCountdown(nextAdAt) {
return remaining;
}
function formatCountdown(sec) {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
export default function ShopPage() {
const { user, loading, patchUser } = useAuth();
const [packages, setPackages] = useState([]);
const [osuTopups, setOsuTopups] = useState([]);
const [osuTopupsEnabled, setOsuTopupsEnabled] = useState(true);
const [payments, setPayments] = useState(null);
const [ad, setAd] = useState(null);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [watching, setWatching] = useState(false);
const [confirmingId, setConfirmingId] = useState(null);
const remaining = useAdCountdown(ad?.ready ? null : ad?.nextAdAt);
const load = useCallback(async () => {
const data = await api.shop();
setPackages(data.packages || []);
setOsuTopups(data.osuTopups || []);
setOsuTopupsEnabled(data.osuTopupsEnabled !== false && (data.osuTopups || []).length > 0);
setPayments(data.payments || null);
setAd(data.ad);
patchUser({
balance: data.user.balance,
@@ -60,7 +73,7 @@ export default function ShopPage() {
};
}, [user?.id, user?.role, load]);
const buy = async (packageId) => {
const buyCredits = async (packageId) => {
setBusy(true);
setError('');
setMessage('');
@@ -70,7 +83,7 @@ export default function ShopPage() {
balance: data.user.balance,
osuBalance: data.user.osuBalance,
});
setMessage(`Bought ${data.package.name} — +${formatCredits(data.package.credits)} cr`);
setMessage(`Exchanged — +${formatCredits(data.package.credits)} cr`);
await load();
} catch (err) {
setError(err.message);
@@ -79,12 +92,41 @@ export default function ShopPage() {
}
};
const buyOsu = async (packId) => {
setBusy(true);
setConfirmingId(packId);
setError('');
setMessage('');
try {
const checkout = await api.shopOsuCheckout(packId);
if (checkout.mode === 'mock' && checkout.confirmable) {
const data = await api.shopOsuConfirm(checkout.order.id);
patchUser({
balance: data.user.balance,
osuBalance: data.user.osuBalance,
});
setMessage(
data.alreadyPaid
? 'Order already completed'
: `+${data.osuGranted} osu added to your wallet`
);
await load();
} else {
setError('Payment provider is not ready for live checkout yet');
}
} catch (err) {
setError(err.message);
} finally {
setConfirmingId(null);
setBusy(false);
}
};
const watchAd = async () => {
setBusy(true);
setError('');
setMessage('');
setWatching(true);
// Simulated short ad
await new Promise((r) => setTimeout(r, 1800));
try {
const data = await api.shopAd();
@@ -115,30 +157,122 @@ export default function ShopPage() {
if (user.role === 'admin') return <Navigate to="/admin" replace />;
const adReady = ad?.ready || (ad?.nextAdAt && remaining === 0);
const osuBal = user.osuBalance ?? 0;
return (
<div>
<section className="section">
<div className="section-head">
<div>
<h1>Shop</h1>
<p className="muted">Spend osu for credit packs, or watch an ad every minute.</p>
</div>
<div className="row-actions">
<span className="osu-pill">{user.osuBalance ?? 0} osu</span>
<span className="balance-pill">{formatCredits(user.balance)} cr</span>
</div>
</div>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
<div className="admin-block ad-panel">
<h2>Watch an ad</h2>
<p className="muted">
Earn between {formatCredits(ad?.rewardMin || 10000)} and{' '}
{formatCredits(ad?.rewardMax || 20000)} cr. Cooldown: 1 minute.
<div className="shop-page">
<header className="shop-hero">
<div className="shop-hero-copy">
<p className="shop-kicker">CaseOrion Shop</p>
<h1>Fuel your runs</h1>
<p className="shop-lede">
<strong>osu</strong> is the premium wallet bought with real money, spent on credit
packs. Credits are for opening cases, vaulting, and battling.
</p>
</div>
<div className="shop-wallet">
<div className="shop-wallet-row">
<span className="muted">osu</span>
<strong className="osu-pill shop-wallet-osu">{osuBal}</strong>
</div>
<div className="shop-wallet-row">
<span className="muted">Credits</span>
<strong className="balance-pill">{formatCredits(user.balance)} cr</strong>
</div>
{payments?.mock && (
<p className="shop-mock-note muted">
Dev payments: mock checkout credits osu instantly (Stripe-ready backend).
</p>
)}
</div>
</header>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
{osuTopupsEnabled && osuTopups.length > 0 && (
<section className="shop-section">
<div className="shop-section-head">
<h2>Get osu</h2>
<p className="muted">Premium top-ups · better rate on larger packs</p>
</div>
<div className="shop-osu-grid">
{osuTopups.map((pack) => (
<article
key={pack.id}
className={`shop-osu-card${pack.badge ? ' has-badge' : ''}${
pack.badge === 'Best value' ? ' highlight' : ''
}${pack.badge === 'Popular' ? ' popular' : ''}`}
>
{pack.badge ? <span className="shop-badge">{pack.badge}</span> : null}
<h3>{pack.name}</h3>
<p className="shop-osu-amount">
{pack.osu} <span>osu</span>
</p>
<p className="muted shop-osu-blurb">{pack.blurb}</p>
<p className="shop-osu-rate muted">{pack.perOsuLabel}</p>
<button
type="button"
className="btn shop-buy-btn"
disabled={busy}
onClick={() => buyOsu(pack.id)}
>
{confirmingId === pack.id
? 'Processing…'
: `Buy · ${pack.displayPrice || `${(pack.priceCents / 100).toFixed(2)}`}`}
</button>
</article>
))}
</div>
</section>
)}
<section className="shop-section">
<div className="shop-section-head">
<h2>Exchange for credits</h2>
<p className="muted">
Spend osu · payout scales with your credit balance (min floor early game)
</p>
</div>
<div className="shop-credit-grid">
{packages.map((p) => {
const canAfford = osuBal >= p.osuCost;
return (
<article key={p.id} className="shop-credit-card">
{p.tag ? <span className="shop-badge subtle">{p.tag}</span> : null}
<h3>{p.name}</h3>
<p className="pack-credits">+{formatCredits(p.credits)} cr</p>
<p className="muted shop-credit-scale">
{p.balancePct != null ? `${p.balancePct}% of balance` : null}
{p.scaled === false ? ' · floor' : ''}
</p>
<p className="shop-credit-cost">
<span className="osu-pill">{p.osuCost} osu</span>
</p>
<button
type="button"
className="btn secondary"
disabled={busy || !canAfford}
onClick={() => buyCredits(p.id)}
title={canAfford ? undefined : 'Not enough osu'}
>
{canAfford ? 'Exchange' : 'Need osu'}
</button>
</article>
);
})}
</div>
</section>
<section className="shop-section shop-free">
<div className="shop-ad-panel">
<div>
<h2>Free credits</h2>
<p className="muted">
Watch a short ad every minute for {formatCredits(ad?.rewardMin || 10000)}
{formatCredits(ad?.rewardMax || 20000)} cr.
</p>
</div>
{watching ? (
<div className="ad-sim">
<div className="ad-bar" />
@@ -147,43 +281,15 @@ export default function ShopPage() {
) : (
<button
type="button"
className="btn"
className="btn secondary"
disabled={busy || !adReady}
onClick={watchAd}
>
{adReady
? 'Watch ad'
: `Available in ${String(Math.floor(remaining / 60)).padStart(2, '0')}:${String(
remaining % 60
).padStart(2, '0')}`}
{adReady ? 'Watch ad' : `Ready in ${formatCountdown(remaining)}`}
</button>
)}
</div>
</section>
<section className="section">
<h2>Credit packs</h2>
<p className="muted" style={{ marginBottom: '1rem' }}>
Buy credits with osu (demo currency).
</p>
<div className="mode-grid">
{packages.map((p) => (
<div key={p.id} className="mode-card shop-pack">
<h2>{p.name}</h2>
<p className="pack-credits">{p.blurb}</p>
<p className="muted">{p.osuCost} osu</p>
<button
type="button"
className="btn"
disabled={busy || (user.osuBalance ?? 0) < p.osuCost}
onClick={() => buy(p.id)}
>
Buy
</button>
</div>
))}
</div>
</section>
</div>
);
}
+107 -3
View File
@@ -4,10 +4,14 @@ let socket = null;
let limited = false;
let limitPayload = null;
const limitListeners = new Set();
const sessionOkListeners = new Set();
let unloadBound = false;
let connectTimer = null;
let reconnectSeq = 0;
function notifyLimit(payload) {
limited = true;
limitPayload = payload || { max: 10 };
limitPayload = payload || { max: 2 };
for (const fn of limitListeners) {
try {
fn(limitPayload);
@@ -29,32 +33,125 @@ function notifyOk() {
}
}
function notifySessionOk(payload) {
for (const fn of sessionOkListeners) {
try {
fn(payload);
} catch {
/* ignore */
}
}
}
function clearConnectTimer() {
if (connectTimer != null) {
clearTimeout(connectTimer);
connectTimer = null;
}
}
function ensureConnected() {
if (!socket || limited) return;
socket.io.reconnection(true);
if (!socket.connected) {
clearConnectTimer();
connectTimer = setTimeout(() => {
connectTimer = null;
if (!socket || limited || socket.connected) return;
socket.connect();
}, 50);
}
}
function bindUnloadOnce() {
if (unloadBound || typeof window === 'undefined') return;
unloadBound = true;
const release = () => {
clearConnectTimer();
if (socket?.connected) {
// Manual disconnect frees the presence slot; pageshow/visibility will reconnect.
socket.disconnect();
}
};
// pagehide covers tab close, bfcache, and mobile backgrounding better than unload
window.addEventListener('pagehide', release);
window.addEventListener('pageshow', () => {
ensureConnected();
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') ensureConnected();
});
}
export function getSocket() {
if (!socket) {
// Connect only after auth is known (see refreshSocketSession) to avoid
// anonymous+auth double handshakes that stack slots on rapid refresh.
socket = io({
path: '/socket.io',
withCredentials: true,
autoConnect: true,
autoConnect: false,
reconnection: true,
reconnectionDelay: 500,
reconnectionAttempts: Infinity,
});
bindUnloadOnce();
socket.on('session:limit', (payload) => {
socket.io.reconnection(false);
notifyLimit(payload);
});
socket.on('session:ok', () => {
socket.on('session:replaced', () => {
// Another tab/refresh took our slot — stay quiet, allow a later reconnect
// without showing the hard limit overlay.
socket.io.reconnection(false);
});
socket.on('session:ok', (payload) => {
if (!socket.io.reconnection()) {
socket.io.reconnection(true);
}
notifyOk();
notifySessionOk(payload);
});
}
return socket;
}
/**
* Force a new Socket.IO handshake so express-session reloads the auth cookie.
* Needed after login/register/logout — otherwise an anonymous socket stays "offline".
* Serialized so spam refresh cannot open many overlapping sockets from this tab.
*/
export function refreshSocketSession() {
const s = getSocket();
if (limited) return s;
const seq = ++reconnectSeq;
clearConnectTimer();
s.io.reconnection(true);
const connectNow = () => {
if (seq !== reconnectSeq || limited) return;
if (!s.connected) s.connect();
};
if (s.connected) {
s.once('disconnect', () => {
if (seq !== reconnectSeq) return;
clearConnectTimer();
connectTimer = setTimeout(connectNow, 40);
});
s.disconnect();
} else {
clearConnectTimer();
connectTimer = setTimeout(connectNow, 0);
}
return s;
}
export function isSessionLimited() {
return limited;
}
@@ -70,10 +167,17 @@ export function onSessionLimitChange(fn) {
return () => limitListeners.delete(fn);
}
/** Fired when the server confirms an authenticated presence session. */
export function onSocketSessionOk(fn) {
sessionOkListeners.add(fn);
return () => sessionOkListeners.delete(fn);
}
/** Allow reconnect after the user closes other tabs and reloads. */
export function clearSessionLimit() {
notifyOk();
if (socket) {
socket.io.reconnection(true);
if (!socket.connected) socket.connect();
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
port: 5888,
proxy: {
'/api': {
target: 'http://localhost:3001',
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
# Deprecated alias — use docker-db-export.sh on prod, or local-db-export.sh on your laptop.
# This wrapper keeps the old name for existing docs / muscle memory.
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
if [[ -f "$DIR/../docker-compose.yml" ]] && command -v docker >/dev/null 2>&1 \
&& docker compose -f "$DIR/../docker-compose.yml" ps -q casegambling >/dev/null 2>&1; then
echo "Note: forwarding to docker-db-export.sh (Docker detected)." >&2
exec "$DIR/docker-db-export.sh" "$@"
fi
echo "Note: forwarding to local-db-export.sh." >&2
exec "$DIR/local-db-export.sh" "$@"
-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
# Deprecated alias — use docker-db-import.sh (Docker) or local-db-import.sh (laptop).
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
if [[ -f "$DIR/../docker-compose.yml" ]] && command -v docker >/dev/null 2>&1 \
&& docker compose -f "$DIR/../docker-compose.yml" ps -aq casegambling >/dev/null 2>&1; then
echo "Note: forwarding to docker-db-import.sh (Docker detected)." >&2
exec "$DIR/docker-db-import.sh" "$@"
fi
echo "Note: forwarding to local-db-import.sh." >&2
exec "$DIR/local-db-import.sh" "$@"
+61 -5
View File
@@ -5,30 +5,38 @@
# Usage:
# ./scripts/local-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS
# ./scripts/local-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
# ./scripts/local-db-import.sh ./backups/….tar.gz <username> # player-only merge
#
# Typical flow (prod → laptop):
# # on prod:
# ./scripts/docker-db-export.sh
# # copy the .tar.gz here, then:
# ./scripts/local-db-import.sh ./backups/casegambling-….tar.gz
# # or only one player into the existing local DB:
# ./scripts/local-db-import.sh ./backups/casegambling-….tar.gz SomePlayer
#
# Stop `npm run dev` before importing so SQLite is not locked.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MERGE_PLAYER_SCRIPT="$ROOT/scripts/merge-player-db.sh"
if [[ -z "${1:-}" ]]; then
echo "Usage: $0 <backup-dir-or-tar.gz>" >&2
if [[ -z "${1:-}" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo "Usage: $0 <backup-dir-or-tar.gz> [username]" >&2
echo "Example: $0 ./backups/casegambling-20260116-203000.tar.gz" >&2
echo " $0 ./backups/casegambling-20260116-203000.tar.gz SomePlayer" >&2
echo "Create a backup from Docker: ./scripts/docker-db-export.sh" >&2
exit 1
fi
BACKUP_ARG="$1"
USERNAME="${2:-}"
# Resolve backup path from the caller's cwd
if [[ "$1" = /* ]]; then
SRC="$1"
if [[ "$BACKUP_ARG" = /* ]]; then
SRC="$BACKUP_ARG"
else
SRC="$(pwd)/$1"
SRC="$(pwd)/$BACKUP_ARG"
fi
LOCAL_DB="${LOCAL_DB:-$ROOT/server/prisma/dev.db}"
@@ -49,6 +57,25 @@ is_archive() {
esac
}
copy_player_avatar() {
local avatar_url="$1"
local uploads_src="$2"
local uploads_dst="$3"
[[ -z "$avatar_url" || ! -d "$uploads_src" ]] && return 0
local rel="${avatar_url#/}"
rel="${rel#uploads/}"
rel="${rel#./}"
case "$rel" in
''|http:*|https:*|*..*) return 0 ;;
esac
local src_file="$uploads_src/$rel"
if [[ -f "$src_file" ]]; then
mkdir -p "$uploads_dst/$(dirname "$rel")"
cp -f "$src_file" "$uploads_dst/$rel"
echo "==> Copied avatar upload: $rel"
fi
}
if [[ -f "$SRC" ]] && is_archive "$SRC"; then
echo "==> Extracting $(basename "$SRC")"
TMP="$(mktemp -d)"
@@ -84,6 +111,35 @@ if [[ -z "$DB" ]]; then
exit 1
fi
# ── Player-only merge into existing local DB ─────────────────────────────────
if [[ -n "$USERNAME" ]]; then
if [[ ! -f "$LOCAL_DB" ]]; then
echo "Error: local DB not found: $LOCAL_DB" >&2
echo "Tip: run a full import first (without username), or start the app once." >&2
exit 1
fi
if [[ ! -x "$MERGE_PLAYER_SCRIPT" ]]; then
echo "Error: merge helper missing or not executable: $MERGE_PLAYER_SCRIPT" >&2
exit 1
fi
# Drop WAL/SHM so we merge against the real file contents
rm -f "${LOCAL_DB}-wal" "${LOCAL_DB}-shm"
"$MERGE_PLAYER_SCRIPT" "$DB" "$LOCAL_DB" "$USERNAME"
AVATAR_URL="$(sqlite3 "$DB" "SELECT avatarUrl FROM User WHERE username = '${USERNAME//\'/\'\'}' LIMIT 1;" 2>/dev/null || true)"
if [[ -d "$SRC/uploads" ]]; then
copy_player_avatar "${AVATAR_URL:-}" "$SRC/uploads" "$LOCAL_UPLOADS"
fi
echo "==> Done (player-only)."
echo " db: $LOCAL_DB"
echo " Tip: stop npm run dev before import if the DB was locked; then restart:"
echo " npm run dev"
exit 0
fi
# ── Full import (replace local DB + uploads) ─────────────────────────────────
mkdir -p "$(dirname "$LOCAL_DB")"
echo "==> Importing database → $LOCAL_DB"
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env bash
# Merge one player's rows from a source SQLite DB into a target SQLite DB.
#
# Usage (sourced or executed):
# ./scripts/merge-player-db.sh <source.db> <target.db> <username>
#
# Replaces any existing target user with the same username.
# Copies (when present in both DBs): User, InventoryItem, Transaction,
# PaymentOrder, CaseKeyProgress, CatalogEntry, PrestigeLog, UserAchievement.
# Skips multiplayer state (Jackpot*/Duel*).
# Tolerates older backups missing newer tables/columns (e.g. PaymentOrder).
#
# Requires matching Item / Case / Achievement ids in both DBs (same catalog lineage).
# Requires sqlite3.
set -euo pipefail
SRC_DB="${1:-}"
DST_DB="${2:-}"
USERNAME="${3:-}"
if [[ -z "$SRC_DB" || -z "$DST_DB" || -z "$USERNAME" ]]; then
echo "Usage: $0 <source.db> <target.db> <username>" >&2
exit 1
fi
if [[ ! -f "$SRC_DB" ]]; then
echo "Error: source DB not found: $SRC_DB" >&2
exit 1
fi
if [[ ! -f "$DST_DB" ]]; then
echo "Error: target DB not found: $DST_DB" >&2
echo "Tip: run a full import once first, or start the app so the DB exists." >&2
exit 1
fi
if ! command -v sqlite3 >/dev/null 2>&1; then
echo "Error: sqlite3 is required for player-only import" >&2
exit 1
fi
sql_quote() {
local s="$1"
s="${s//\'/\'\'}"
printf "'%s'" "$s"
}
table_exists() {
local db="$1"
local table="$2"
local n
n="$(sqlite3 "$db" "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=$(sql_quote "$table");")"
[[ "$n" -gt 0 ]]
}
# Column names for a table (space-separated), excluding names passed after $2
table_cols() {
local db="$1"
local table="$2"
shift 2
local -A skip=()
local e col
for e in "$@"; do
skip["$e"]=1
done
while IFS='|' read -r _ col _; do
[[ -n "$col" && -z "${skip[$col]:-}" ]] && printf '%s\n' "$col"
done < <(sqlite3 -separator '|' "$db" "PRAGMA table_info($(sql_quote "$table"));")
}
# Intersection of columns present in BOTH DBs, preserving destination order.
common_cols() {
local table="$1"
shift
local -A src_set=()
local col
while IFS= read -r col; do
[[ -n "$col" ]] && src_set["$col"]=1
done < <(table_cols "$SRC_DB" "$table" "$@")
local -a out=()
while IFS= read -r col; do
[[ -n "$col" && -n "${src_set[$col]:-}" ]] && out+=("$col")
done < <(table_cols "$DST_DB" "$table" "$@")
local IFS=', '
echo "${out[*]}"
}
count_rows() {
local table="$1"
local qtable="$1"
[[ "$table" == "Transaction" ]] && qtable='"Transaction"'
if table_exists "$SRC_DB" "$table"; then
sqlite3 "$SRC_DB" "SELECT COUNT(*) FROM $qtable WHERE userId = $SRC_UID;"
else
echo "skip"
fi
}
SRC_UID="$(sqlite3 "$SRC_DB" "SELECT id FROM User WHERE username = $(sql_quote "$USERNAME") LIMIT 1;")"
if [[ -z "$SRC_UID" ]]; then
echo "Error: no user with username '$USERNAME' in source DB" >&2
exit 2
fi
echo "==> Merging player '$USERNAME' (source id=$SRC_UID)"
echo " source: $SRC_DB"
echo " target: $DST_DB"
N_INV="$(count_rows InventoryItem)"
N_TX="$(count_rows Transaction)"
N_PAY="$(count_rows PaymentOrder)"
N_KEY="$(count_rows CaseKeyProgress)"
N_CAT="$(count_rows CatalogEntry)"
N_PRE="$(count_rows PrestigeLog)"
N_ACH="$(count_rows UserAchievement)"
echo " rows: inventory=$N_INV tx=$N_TX payments=$N_PAY keys=$N_KEY catalog=$N_CAT prestige=$N_PRE achievements=$N_ACH"
USER_COLS="$(common_cols User id)"
if [[ -z "$USER_COLS" ]]; then
echo "Error: no common User columns between source and target" >&2
exit 1
fi
INV_COLS="$(common_cols InventoryItem id userId)"
TX_COLS="$(common_cols Transaction id userId)"
if [[ -z "$INV_COLS" || -z "$TX_COLS" ]]; then
echo "Error: InventoryItem / Transaction schema mismatch" >&2
exit 1
fi
PAY_COLS=""
if table_exists "$SRC_DB" PaymentOrder && table_exists "$DST_DB" PaymentOrder; then
PAY_COLS="$(common_cols PaymentOrder userId)"
fi
KEY_COLS=""
table_exists "$SRC_DB" CaseKeyProgress && table_exists "$DST_DB" CaseKeyProgress && KEY_COLS="$(common_cols CaseKeyProgress id userId)"
CAT_COLS=""
table_exists "$SRC_DB" CatalogEntry && table_exists "$DST_DB" CatalogEntry && CAT_COLS="$(common_cols CatalogEntry id userId)"
PRE_COLS=""
table_exists "$SRC_DB" PrestigeLog && table_exists "$DST_DB" PrestigeLog && PRE_COLS="$(common_cols PrestigeLog id userId)"
ACH_COLS=""
table_exists "$SRC_DB" UserAchievement && table_exists "$DST_DB" UserAchievement && ACH_COLS="$(common_cols UserAchievement id userId)"
SQL_EXTRA_DELETE=""
SQL_EXTRA_INSERT=""
if [[ -n "$PAY_COLS" ]]; then
SQL_EXTRA_DELETE+="
DELETE FROM PaymentOrder
WHERE id IN (SELECT id FROM src.PaymentOrder WHERE userId = $SRC_UID);
"
SQL_EXTRA_INSERT+="
INSERT INTO PaymentOrder ($PAY_COLS, userId)
SELECT $PAY_COLS, (SELECT newId FROM _merge_uid)
FROM src.PaymentOrder WHERE userId = $SRC_UID;
"
else
echo " note: PaymentOrder absent in backup — skipped (osu top-up orders)"
fi
if [[ -n "$KEY_COLS" ]]; then
SQL_EXTRA_INSERT+="
INSERT INTO CaseKeyProgress (userId, $KEY_COLS)
SELECT (SELECT newId FROM _merge_uid), $KEY_COLS
FROM src.CaseKeyProgress WHERE userId = $SRC_UID;
"
fi
if [[ -n "$CAT_COLS" ]]; then
SQL_EXTRA_INSERT+="
INSERT INTO CatalogEntry (userId, $CAT_COLS)
SELECT (SELECT newId FROM _merge_uid), $CAT_COLS
FROM src.CatalogEntry WHERE userId = $SRC_UID;
"
fi
if [[ -n "$PRE_COLS" ]]; then
SQL_EXTRA_INSERT+="
INSERT INTO PrestigeLog (userId, $PRE_COLS)
SELECT (SELECT newId FROM _merge_uid), $PRE_COLS
FROM src.PrestigeLog WHERE userId = $SRC_UID;
"
fi
if [[ -n "$ACH_COLS" ]]; then
SQL_EXTRA_INSERT+="
INSERT INTO UserAchievement (userId, $ACH_COLS)
SELECT (SELECT newId FROM _merge_uid), $ACH_COLS
FROM src.UserAchievement WHERE userId = $SRC_UID;
"
fi
SQL_CLEANUP=""
table_exists "$DST_DB" JackpotRound && SQL_CLEANUP+="
UPDATE JackpotRound SET winnerUserId = NULL
WHERE winnerUserId IN (SELECT id FROM User WHERE username = $(sql_quote "$USERNAME"));
"
table_exists "$DST_DB" DuelRoom && SQL_CLEANUP+="
UPDATE DuelRoom SET winnerUserId = NULL
WHERE winnerUserId IN (SELECT id FROM User WHERE username = $(sql_quote "$USERNAME"));
DELETE FROM DuelRoom
WHERE creatorId IN (SELECT id FROM User WHERE username = $(sql_quote "$USERNAME"));
"
table_exists "$DST_DB" JackpotDeposit && SQL_CLEANUP+="
DELETE FROM JackpotDeposit
WHERE userId IN (SELECT id FROM User WHERE username = $(sql_quote "$USERNAME"));
"
table_exists "$DST_DB" DuelDeposit && SQL_CLEANUP+="
DELETE FROM DuelDeposit
WHERE userId IN (SELECT id FROM User WHERE username = $(sql_quote "$USERNAME"));
"
sqlite3 "$DST_DB" <<SQL
PRAGMA foreign_keys = ON;
ATTACH DATABASE $(sql_quote "$SRC_DB") AS src;
BEGIN;
$SQL_CLEANUP
DELETE FROM User WHERE username = $(sql_quote "$USERNAME");
$SQL_EXTRA_DELETE
INSERT INTO User ($USER_COLS)
SELECT $USER_COLS
FROM src.User WHERE id = $SRC_UID;
CREATE TEMP TABLE _merge_uid AS
SELECT id AS newId FROM User WHERE username = $(sql_quote "$USERNAME") LIMIT 1;
INSERT INTO InventoryItem (userId, $INV_COLS)
SELECT (SELECT newId FROM _merge_uid), $INV_COLS
FROM src.InventoryItem WHERE userId = $SRC_UID;
INSERT INTO "Transaction" (userId, $TX_COLS)
SELECT (SELECT newId FROM _merge_uid), $TX_COLS
FROM src."Transaction" WHERE userId = $SRC_UID;
$SQL_EXTRA_INSERT
DROP TABLE _merge_uid;
COMMIT;
DETACH DATABASE src;
SQL
NEW_UID="$(sqlite3 "$DST_DB" "SELECT id FROM User WHERE username = $(sql_quote "$USERNAME") LIMIT 1;")"
echo "==> Player merged as target id=$NEW_UID"
echo " (jackpot/duel deposits & rooms not imported)"
+89 -7
View File
@@ -3,8 +3,9 @@
# Run this on the machine that runs docker compose.
#
# Usage:
# ./scripts/docker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS
# ./scripts/docker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
# ./scripts/xdocker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS
# ./scripts/xdocker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
# ./scripts/xdocker-db-import.sh ./backups/….tar.gz <username> # player-only merge
#
# Expects a backup from docker-db-export.sh or local-db-export.sh
# (prod.db or dev.db + uploads/).
@@ -24,18 +25,24 @@ else
exit 1
fi
if [[ -z "${1:-}" ]]; then
echo "Usage: $0 <backup-dir-or-tar.gz>" >&2
MERGE_PLAYER_SCRIPT="$ROOT/scripts/merge-player-db.sh"
if [[ -z "${1:-}" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo "Usage: $0 <backup-dir-or-tar.gz> [username]" >&2
echo "Example: $0 ./backups/casegambling-20260116-203000.tar.gz" >&2
echo " $0 ./backups/casegambling-20260116-203000.tar.gz SomePlayer" >&2
echo "Create a backup first: ./scripts/docker-db-export.sh (or local-db-export.sh)" >&2
exit 1
fi
BACKUP_ARG="$1"
USERNAME="${2:-}"
# Resolve backup path from the caller's cwd (before any cd)
if [[ "$1" = /* ]]; then
SRC="$1"
if [[ "$BACKUP_ARG" = /* ]]; then
SRC="$BACKUP_ARG"
else
SRC="$(pwd)/$1"
SRC="$(pwd)/$BACKUP_ARG"
fi
cd "$ROOT"
@@ -52,10 +59,14 @@ fi
SERVICE="${SERVICE:-casegambling}"
TMP=""
WORK=""
cleanup() {
if [[ -n "$TMP" && -d "$TMP" ]]; then
rm -rf "$TMP"
fi
if [[ -n "$WORK" && -d "$WORK" ]]; then
rm -rf "$WORK"
fi
}
trap cleanup EXIT
@@ -66,6 +77,25 @@ is_archive() {
esac
}
copy_player_avatar_docker() {
local avatar_url="$1"
local uploads_src="$2"
[[ -z "$avatar_url" || ! -d "$uploads_src" ]] && return 0
local rel="${avatar_url#/}"
rel="${rel#uploads/}"
rel="${rel#./}"
case "$rel" in
''|http:*|https:*|*..*) return 0 ;;
esac
local src_file="$uploads_src/$rel"
if [[ -f "$src_file" ]]; then
"${COMPOSE[@]}" run --rm --no-deps --entrypoint sh "$SERVICE" -c \
"mkdir -p /app/uploads/$(dirname "$rel")" >/dev/null
"${COMPOSE[@]}" cp "$src_file" "$SERVICE:/app/uploads/$rel"
echo "==> Copied avatar upload: $rel"
fi
}
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker not found" >&2
exit 1
@@ -109,6 +139,58 @@ fi
echo "==> Ensuring compose service exists"
"${COMPOSE[@]}" up -d --no-start "$SERVICE" >/dev/null
# ── Player-only merge into existing Docker DB ────────────────────────────────
if [[ -n "$USERNAME" ]]; then
if [[ ! -x "$MERGE_PLAYER_SCRIPT" ]]; then
echo "Error: merge helper missing or not executable: $MERGE_PLAYER_SCRIPT" >&2
exit 1
fi
if ! command -v sqlite3 >/dev/null 2>&1; then
echo "Error: sqlite3 is required on the host for player-only import" >&2
exit 1
fi
echo "==> Stopping $SERVICE (safe SQLite merge)"
"${COMPOSE[@]}" stop "$SERVICE" >/dev/null 2>&1 || true
WORK="$(mktemp -d)"
TARGET_DB="$WORK/prod.db"
echo "==> Fetching current /data/prod.db"
if ! "${COMPOSE[@]}" cp "$SERVICE:/data/prod.db" "$TARGET_DB" 2>/dev/null; then
echo "Error: could not copy /data/prod.db from $SERVICE" >&2
echo "Tip: run a full import once first so the volume has a database." >&2
exit 1
fi
"${COMPOSE[@]}" run --rm --no-deps --entrypoint sh "$SERVICE" -c \
'rm -f /data/prod.db-wal /data/prod.db-shm' >/dev/null
"$MERGE_PLAYER_SCRIPT" "$DB" "$TARGET_DB" "$USERNAME"
echo "==> Writing merged database → /data/prod.db"
"${COMPOSE[@]}" cp "$TARGET_DB" "$SERVICE:/data/prod.db"
AVATAR_URL="$(sqlite3 "$DB" "SELECT avatarUrl FROM User WHERE username = '${USERNAME//\'/\'\'}' LIMIT 1;" 2>/dev/null || true)"
if [[ -d "$SRC/uploads" ]]; then
copy_player_avatar_docker "${AVATAR_URL:-}" "$SRC/uploads"
fi
echo "==> Starting $SERVICE"
"${COMPOSE[@]}" start "$SERVICE" 2>/dev/null || "${COMPOSE[@]}" up -d "$SERVICE"
HOST_PORT_VAL="${HOST_PORT:-}"
if [[ -z "$HOST_PORT_VAL" && -f "$ENV_FILE" ]]; then
HOST_PORT_VAL="$(grep -E '^HOST_PORT=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- | tr -d '"' || true)"
fi
HOST_PORT_VAL="${HOST_PORT_VAL:-3009}"
echo "==> Done (player-only)."
echo " App: http://localhost:${HOST_PORT_VAL}"
echo " Logs: ${COMPOSE[*]} logs -f --tail=50"
exit 0
fi
# ── Full import (replace Docker DB + uploads) ────────────────────────────────
echo "==> Stopping $SERVICE (safe SQLite replace)"
"${COMPOSE[@]}" stop "$SERVICE" >/dev/null 2>&1 || true
+508
View File
@@ -15,10 +15,448 @@
"express-session": "^1.18.1",
"multer": "^2.2.0",
"prisma": "^6.11.1",
"sharp": "^0.34.4",
"socket.io": "^4.8.3"
},
"devDependencies": {}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz",
"integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.3"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz",
"integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.3"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz",
"integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz",
"integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz",
"integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz",
"integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz",
"integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz",
"integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz",
"integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz",
"integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz",
"integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz",
"integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.3"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz",
"integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.3"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz",
"integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.3"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz",
"integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.3"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz",
"integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.3"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz",
"integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.3"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz",
"integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.3"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz",
"integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.5.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz",
"integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz",
"integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz",
"integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@prisma/client": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
@@ -457,6 +895,15 @@
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -1420,6 +1867,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@@ -1471,6 +1930,48 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.34.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz",
"integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.0",
"semver": "^7.7.2"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.4",
"@img/sharp-darwin-x64": "0.34.4",
"@img/sharp-libvips-darwin-arm64": "1.2.3",
"@img/sharp-libvips-darwin-x64": "1.2.3",
"@img/sharp-libvips-linux-arm": "1.2.3",
"@img/sharp-libvips-linux-arm64": "1.2.3",
"@img/sharp-libvips-linux-ppc64": "1.2.3",
"@img/sharp-libvips-linux-s390x": "1.2.3",
"@img/sharp-libvips-linux-x64": "1.2.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.3",
"@img/sharp-libvips-linuxmusl-x64": "1.2.3",
"@img/sharp-linux-arm": "0.34.4",
"@img/sharp-linux-arm64": "0.34.4",
"@img/sharp-linux-ppc64": "0.34.4",
"@img/sharp-linux-s390x": "0.34.4",
"@img/sharp-linux-x64": "0.34.4",
"@img/sharp-linuxmusl-arm64": "0.34.4",
"@img/sharp-linuxmusl-x64": "0.34.4",
"@img/sharp-wasm32": "0.34.4",
"@img/sharp-win32-arm64": "0.34.4",
"@img/sharp-win32-ia32": "0.34.4",
"@img/sharp-win32-x64": "0.34.4"
}
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@@ -1671,6 +2172,13 @@
"node": ">=0.6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+2 -2
View File
@@ -24,7 +24,7 @@
"express-session": "^1.18.1",
"multer": "^2.2.0",
"prisma": "^6.11.1",
"sharp": "^0.34.4",
"socket.io": "^4.8.3"
},
"devDependencies": {}
}
}
+58 -52
View File
@@ -11,29 +11,21 @@ model User {
id Int @id @default(autoincrement())
username String @unique
passwordHash String
balance BigInt @default(0)
balance String @default("0")
osuBalance Int @default(0)
prBalance Int @default(0)
prBalance String @default("0")
lastAdAt DateTime?
role String @default("user")
avatarUrl String @default("")
bio String @default("")
autoSellEnabled Boolean @default(false)
autoSellThresholdCents BigInt @default(1000)
autoSellThresholdCents String @default("1000")
prestigeCount Int @default(0)
prestigeChanceBonus Int @default(0)
prestigeGainBonus Int @default(0)
prestigeKeyOpenReduction Int @default(0)
prestigeStartBonus Int @default(0)
prestigeAdBonus Int @default(0)
prestigeAdCooldownReduction Int @default(0)
prestigeWearBonus Int @default(0)
prestigePrBonus Int @default(0)
prestigeShopDiscount Int @default(0)
prestigeStartOsu Int @default(0)
prestigeDropValueBonus Int @default(0)
prestigeOpenBonus Int @default(0)
prestigeCaseDiscount Int @default(0)
prestigeQualityBonus Int @default(0)
prestigeAnimReduction Int @default(0)
prestigeRefundChance Int @default(0)
@@ -41,27 +33,46 @@ model User {
prestigeVaultSlots Int @default(0)
prestigeOfflineYield Int @default(0)
prestigeOnlineYield Int @default(0)
vaultYieldAt DateTime?
lastConnection DateTime?
playTimeSeconds BigInt @default(0)
createdAt DateTime @default(now())
inventory InventoryItem[]
transactions Transaction[]
jackpotWins JackpotRound[] @relation("JackpotWinner")
jackpotCreated JackpotRound[] @relation("JackpotCreator")
jackpotDeposits JackpotDeposit[]
duelRoomsCreated DuelRoom[] @relation("DuelCreator")
duelWins DuelRoom[] @relation("DuelWinner")
duelDeposits DuelDeposit[]
caseKeyProgress CaseKeyProgress[]
catalogEntries CatalogEntry[]
prestigeLogs PrestigeLog[]
achievements UserAchievement[]
paymentOrders PaymentOrder[]
}
model PaymentOrder {
id String @id @default(cuid())
userId Int
packId String
osuAmount Int
priceCents Int
currency String @default("eur")
status String @default("pending")
provider String @default("mock")
providerRef String @default("")
meta String @default("{}")
createdAt DateTime @default(now())
paidAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status])
@@index([providerRef])
}
model Case {
id Int @id @default(autoincrement())
name String
imageUrl String @default("")
price BigInt
price String @default("0")
active Boolean @default(true)
createdAt DateTime @default(now())
items CaseItem[]
@@ -81,7 +92,7 @@ model Item {
imageUrl String @default("")
rarity String
category String @default("Misc")
marketValue BigInt
marketValue String @default("0")
createdAt DateTime @default(now())
cases CaseItem[]
inventory InventoryItem[]
@@ -110,20 +121,19 @@ model InventoryItem {
floatValue Float @default(0.265)
wear String @default("Field-Tested")
paintSeed Int @default(0)
valueCents BigInt @default(0)
valueCents String @default("0")
favorite 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 {
id Int @id @default(autoincrement())
userId Int
type String
amount BigInt
amount String
meta String @default("{}")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -133,11 +143,15 @@ model JackpotRound {
id Int @id @default(autoincrement())
status String @default("open")
endsAt DateTime
creatorId Int?
winnerUserId Int?
createdAt DateTime @default(now())
resolvedAt DateTime?
creator User? @relation("JackpotCreator", fields: [creatorId], references: [id])
winner User? @relation("JackpotWinner", fields: [winnerUserId], references: [id])
deposits JackpotDeposit[]
@@index([status])
}
model JackpotDeposit {
@@ -145,7 +159,7 @@ model JackpotDeposit {
roundId Int
userId Int
inventoryItemId Int
valueCents BigInt
valueCents String
createdAt DateTime @default(now())
round JackpotRound @relation(fields: [roundId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -154,37 +168,6 @@ model JackpotDeposit {
@@unique([roundId, inventoryItemId])
}
model DuelRoom {
id Int @id @default(autoincrement())
creatorId Int
status String @default("open")
maxPlayers Int
maxItemsPerPlayer Int
minItemValue BigInt @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 BigInt
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])
}
model CaseKeyProgress {
id Int @id @default(autoincrement())
userId Int
@@ -215,7 +198,7 @@ model PrestigeLog {
id Int @id @default(autoincrement())
userId Int
choice String
prGranted Int @default(0)
prGranted String @default("0")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
@@ -241,3 +224,26 @@ model UserAchievement {
@@unique([userId, achievementId])
}
/** Key/value app settings (e.g. shop.osuTopupsEnabled). */
model AppSetting {
key String @id
value String
updatedAt DateTime @updatedAt
}
/** Configurable real-money → osu packs for the shop. */
model ShopOsuPack {
id String @id
name String
osu Int
priceCents Int
currency String @default("eur")
badge String @default("")
blurb String @default("")
perOsuLabel String @default("")
enabled Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+23 -16
View File
@@ -1,6 +1,7 @@
import { prisma } from './db.js';
import { countCompletedChapters } from './catalog.js';
import { livePlayTimeSeconds } from './presence.js';
import { isPrestigeSkillTreeMaxed } from './prestige.js';
/** First batch of achievements — upserted on seed / boot. */
export const ACHIEVEMENT_DEFS = [
@@ -34,44 +35,44 @@ export const ACHIEVEMENT_DEFS = [
},
{
key: 'jackpot_win',
name: 'Pot Lucky',
description: 'Win a MultiPlayers Case round.',
name: 'Battle Winner',
description: 'Win a Battle pot.',
icon: '♠',
sortOrder: 5,
},
{
key: 'duel_win',
name: 'Duel Victor',
description: 'Win a 1vX Case battle.',
icon: '⚔',
sortOrder: 6,
},
{
key: 'catalog_chapter',
name: 'Chapter Complete',
description: 'Finish a full catalog chapter.',
icon: '◆',
sortOrder: 7,
sortOrder: 6,
},
{
key: 'first_prestige',
name: 'Prestige Bound',
description: 'Prestige for the first time.',
icon: '✦',
sortOrder: 8,
sortOrder: 7,
},
{
key: 'shop_customer',
name: 'Shop Regular',
description: 'Buy a shop pack or claim an ad reward.',
icon: '◈',
sortOrder: 9,
sortOrder: 8,
},
{
key: 'playtime_hour',
name: 'Hourglass',
description: 'Spend 1 hour playing on CaseOrion.',
icon: '⌛',
sortOrder: 9,
},
{
key: 'prestige_skills_max',
name: 'Skill Absolute',
description: 'Reach max level on every Prestige skill tree branch.',
icon: '♛',
sortOrder: 10,
},
];
@@ -89,6 +90,11 @@ export async function ensureAchievements() {
},
});
}
// Removed game modes (e.g. 1vX duels) — drop obsolete achievement defs
await prisma.userAchievement.deleteMany({
where: { achievement: { key: 'duel_win' } },
});
await prisma.achievement.deleteMany({ where: { key: 'duel_win' } });
}
function serializeAchievement(row, unlockedAt = null) {
@@ -160,16 +166,15 @@ export async function checkAchievements(userId) {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user || user.role === 'admin') return [];
const [casesOpened, jackpotWins, duelWins, chapters, shopOrAd, owned] =
const [casesOpened, jackpotWins, chapters, shopOrAd, owned] =
await Promise.all([
prisma.transaction.count({ where: { userId, type: 'open_case' } }),
prisma.transaction.count({ where: { userId, type: 'jackpot_win' } }),
prisma.transaction.count({ where: { userId, type: 'duel_win' } }),
countCompletedChapters(prisma, userId),
prisma.transaction.count({
where: {
userId,
type: { in: ['shop_purchase', 'ad_reward'] },
type: { in: ['shop_purchase', 'ad_reward', 'osu_purchase'] },
},
}),
prisma.userAchievement.findMany({
@@ -186,7 +191,6 @@ export async function checkAchievements(userId) {
if (casesOpened >= 25 && !have.has('case_25')) candidates.push('case_25');
if (casesOpened >= 100 && !have.has('case_100')) candidates.push('case_100');
if (jackpotWins >= 1 && !have.has('jackpot_win')) candidates.push('jackpot_win');
if (duelWins >= 1 && !have.has('duel_win')) candidates.push('duel_win');
if (chapters >= 1 && !have.has('catalog_chapter')) candidates.push('catalog_chapter');
if ((user.prestigeCount ?? 0) >= 1 && !have.has('first_prestige')) {
candidates.push('first_prestige');
@@ -195,6 +199,9 @@ export async function checkAchievements(userId) {
if (livePlayTimeSeconds(user) >= 3600 && !have.has('playtime_hour')) {
candidates.push('playtime_hour');
}
if (isPrestigeSkillTreeMaxed(user) && !have.has('prestige_skills_max')) {
candidates.push('prestige_skills_max');
}
return unlockMany(userId, candidates);
}
+42 -28
View File
@@ -4,48 +4,62 @@
* Prestige gain bonus multiplies credited amount (not the sell threshold check).
*/
import { applyGainMultiplier } from './prestige.js';
import { toCentsBigInt, centsJson } from './cents.js';
export function shouldAutoSell(user, valueCents) {
if (!user?.autoSellEnabled) return false;
const threshold = user.autoSellThresholdCents ?? 0;
return valueCents < threshold;
const threshold = toCentsBigInt(user.autoSellThresholdCents ?? 0);
return toCentsBigInt(valueCents) < threshold;
}
export async function applyAutoSell(tx, user, inventoryItems) {
/** Compute auto-sell payout without mutating inventory (for deferred claims). */
export function computeAutoSell(user, inventoryItems) {
if (!user?.autoSellEnabled) {
return { sold: [], balanceDelta: 0 };
return { sold: [], balanceDelta: 0n };
}
const threshold = user.autoSellThresholdCents ?? 0;
const threshold = toCentsBigInt(user.autoSellThresholdCents ?? 0);
const gainBonus = user.prestigeGainBonus ?? 0;
const sold = [];
let balanceDelta = 0;
let balanceDelta = 0n;
for (const inv of inventoryItems) {
if (!inv || inv.locked || inv.staked || inv.favorite || inv.valueCents >= threshold) continue;
const payout = applyGainMultiplier(inv.valueCents, gainBonus);
await tx.inventoryItem.delete({ where: { id: inv.id } });
await tx.transaction.create({
data: {
userId: user.id,
type: 'sell_item',
amount: payout,
meta: JSON.stringify({
inventoryItemId: inv.id,
itemId: inv.itemId,
autoSell: true,
valueCents: inv.valueCents,
prestigeGainBonus: gainBonus,
payout,
}),
},
});
if (!inv || inv.locked || inv.staked || inv.favorite) continue;
const value = toCentsBigInt(inv.valueCents);
if (value >= threshold) continue;
sold.push(inv);
balanceDelta += payout;
balanceDelta += applyGainMultiplier(value, gainBonus);
}
return { sold, balanceDelta };
}
export async function applyAutoSell(tx, user, inventoryItems) {
const { sold, balanceDelta } = computeAutoSell(user, inventoryItems);
const gainBonus = user.prestigeGainBonus ?? 0;
if (!sold.length) {
return { sold: [], balanceDelta: 0n };
}
const soldIds = sold.map((inv) => inv.id);
await tx.inventoryItem.deleteMany({
where: { id: { in: soldIds }, userId: user.id },
});
await tx.transaction.create({
data: {
userId: user.id,
type: 'sell_item',
amount: centsJson(balanceDelta),
meta: JSON.stringify({
count: sold.length,
inventoryItemIds: soldIds,
autoSell: true,
prestigeGainBonus: gainBonus,
payout: centsJson(balanceDelta),
}),
},
});
return { sold, balanceDelta };
}
+8 -1
View File
@@ -1,5 +1,6 @@
import { prisma } from './db.js';
import { applyCatalogLuck, normalizeRates } from './weighted.js';
import { toCentsBigInt } from './cents.js';
/**
* Count how many active cases the user has fully collected.
@@ -103,7 +104,13 @@ export async function getCatalogState(userId) {
orderBy: { dropRate: 'asc' },
},
},
orderBy: { price: 'asc' },
});
cases.sort((a, b) => {
const pa = toCentsBigInt(a.price);
const pb = toCentsBigInt(b.price);
if (pa < pb) return -1;
if (pa > pb) return 1;
return a.id - b.id;
});
const entries = await prisma.catalogEntry.findMany({
+227
View File
@@ -0,0 +1,227 @@
/**
* Safe integer-cents helpers. Money is stored as decimal strings in SQLite
* (Prisma BigInt is limited to signed 64-bit ~9.22e18).
*/
export function toCentsBigInt(value) {
if (typeof value === 'bigint') return value;
if (value == null || value === '') return 0n;
if (typeof value === 'number') {
if (!Number.isFinite(value)) return 0n;
return BigInt(Math.trunc(value));
}
const s = String(value).trim();
if (!s) return 0n;
// Accept plain ints or decimals (truncate toward zero)
const m = s.match(/^([+-]?\d+)/);
if (!m) return 0n;
try {
return BigInt(m[1]);
} catch {
return 0n;
}
}
/** JSON-safe cents (string) so clients keep full precision. */
export function centsJson(value) {
return toCentsBigInt(value).toString();
}
/**
* Parse a credits amount (may include decimals) into integer cents, BigInt-safe.
* "15.5" → 1550n, "15000000000000" → 1500000000000000n.
* Returns null if invalid / negative.
*/
export function creditsToCents(raw) {
if (raw == null || raw === '') return null;
if (typeof raw === 'bigint') {
return raw < 0n ? null : raw * 100n;
}
if (typeof raw === 'number') {
if (!Number.isFinite(raw) || raw < 0) return null;
// Prefer string path when outside safe integer range after *100
if (!Number.isSafeInteger(Math.round(raw * 100))) {
return creditsToCents(String(raw));
}
return BigInt(Math.round(raw * 100));
}
const s = String(raw).trim().replace(',', '.');
if (!s) return null;
const m = s.match(/^(\d+)(?:\.(\d{1,2})\d*)?$/);
if (!m) return null;
try {
const whole = BigInt(m[1]);
const frac = m[2] ? m[2].padEnd(2, '0') : '00';
return whole * 100n + BigInt(frac);
} catch {
return null;
}
}
/** Format cents as a decimal credits string without Number precision loss. */
export function centsToCreditsString(cents) {
const bi = toCentsBigInt(cents);
const neg = bi < 0n;
const abs = neg ? -bi : bi;
const whole = abs / 100n;
const frac = (abs % 100n).toString().padStart(2, '0');
const trimmed = frac === '00' ? whole.toString() : `${whole}.${frac}`.replace(/0+$/, '').replace(/\.$/, '');
return `${neg ? '-' : ''}${trimmed}`;
}
/**
* Multiply cents by (1 + pct/100), floored. Uses integer math only.
*/
export function applyPercentFloor(cents, pct) {
const base = toCentsBigInt(cents);
const p = Math.max(0, Math.floor(Number(pct) || 0));
if (base <= 0n) return 0n;
if (p <= 0) return base;
return (base * BigInt(100 + p)) / 100n;
}
/**
* Multiply cents by a float rate (e.g. 0.1), floored, via fixed-point (1e6).
*/
export function applyRateFloor(cents, rate) {
const base = toCentsBigInt(cents);
if (base <= 0n) return 0n;
const r = Number(rate);
if (!Number.isFinite(r) || r <= 0) return 0n;
const scale = 1_000_000;
const rateInt = Math.max(0, Math.floor(r * scale));
return (base * BigInt(rateInt)) / BigInt(scale);
}
export function minBigInt(a, b) {
return a <= b ? a : b;
}
export function maxBigInt(a, b) {
return a >= b ? a : b;
}
/** Add delta to a stored cents string/bigint; returns new value as string. */
export function addCents(current, delta) {
return (toCentsBigInt(current) + toCentsBigInt(delta)).toString();
}
/** SQLite INTEGER is signed 64-bit — beyond this, use CAS (string BigInt). */
const SQLITE_INT64_MAX = 9223372036854775807n;
function fitsSqliteInt64(value) {
const v = toCentsBigInt(value);
return v >= 0n && v <= SQLITE_INT64_MAX;
}
function insufficientBalanceError(message = 'Insufficient balance') {
const err = new Error(message);
err.status = 400;
err.code = 'INSUFFICIENT_BALANCE';
return err;
}
function balanceConflictError() {
const err = new Error('Balance update conflict, retry');
err.status = 409;
err.code = 'BALANCE_CONFLICT';
return err;
}
async function findUserOrThrow(tx, userId) {
const user = await tx.user.findUnique({ where: { id: userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
return user;
}
/**
* Atomically deduct cost: `balance = balance - cost` WHERE `balance >= cost`.
* SQL arithmetic when values fit SQLite INTEGER; otherwise optimistic CAS.
* @returns {Promise<user>}
*/
export async function spendUserBalance(tx, userId, cost, { message } = {}) {
const costBi = toCentsBigInt(cost);
if (costBi < 0n) throw new Error('cost must be non-negative');
if (costBi === 0n) return findUserOrThrow(tx, userId);
const costStr = costBi.toString();
for (let attempt = 0; attempt < 12; attempt += 1) {
const user = await findUserOrThrow(tx, userId);
const bal = toCentsBigInt(user.balance);
if (bal < costBi) throw insufficientBalanceError(message);
if (fitsSqliteInt64(bal) && fitsSqliteInt64(costBi)) {
const rows = await tx.$executeRaw`
UPDATE "User"
SET balance = CAST((CAST(balance AS INTEGER) - CAST(${costStr} AS INTEGER)) AS TEXT)
WHERE id = ${userId}
AND CAST(balance AS INTEGER) >= CAST(${costStr} AS INTEGER)
`;
if (rows > 0) return findUserOrThrow(tx, userId);
continue;
}
const next = (bal - costBi).toString();
const updated = await tx.user.updateMany({
where: { id: userId, balance: user.balance },
data: { balance: next },
});
if (updated.count === 1) return { ...user, balance: next };
}
throw balanceConflictError();
}
/**
* Atomically credit amount: `balance = balance + amount`.
* @returns {Promise<user>}
*/
export async function creditUserBalance(tx, userId, amount) {
const amt = toCentsBigInt(amount);
if (amt < 0n) throw new Error('amount must be non-negative');
if (amt === 0n) return findUserOrThrow(tx, userId);
const amtStr = amt.toString();
for (let attempt = 0; attempt < 12; attempt += 1) {
const user = await findUserOrThrow(tx, userId);
const bal = toCentsBigInt(user.balance);
const nextBi = bal + amt;
if (fitsSqliteInt64(bal) && fitsSqliteInt64(amt) && fitsSqliteInt64(nextBi)) {
const rows = await tx.$executeRaw`
UPDATE "User"
SET balance = CAST((CAST(balance AS INTEGER) + CAST(${amtStr} AS INTEGER)) AS TEXT)
WHERE id = ${userId}
`;
if (rows > 0) return findUserOrThrow(tx, userId);
continue;
}
const next = nextBi.toString();
const updated = await tx.user.updateMany({
where: { id: userId, balance: user.balance },
data: { balance: next },
});
if (updated.count === 1) return { ...user, balance: next };
}
throw balanceConflictError();
}
/**
* Apply signed delta (BigInt-safe). Negative → atomic spend; positive → credit.
* `currentBalance` is ignored (kept for call-site compatibility).
* @returns {Promise<user>}
*/
export async function adjustUserBalance(tx, userId, _currentBalance, delta) {
const d = toCentsBigInt(delta);
if (d < 0n) return spendUserBalance(tx, userId, -d);
if (d > 0n) return creditUserBalance(tx, userId, d);
return findUserOrThrow(tx, userId);
}
+10 -21
View File
@@ -1,23 +1,12 @@
import { PrismaClient } from '@prisma/client';
/** Prisma BigInt → Number so the rest of the app can keep using number arithmetic / JSON. */
function reviveMoney(value) {
if (typeof value === 'bigint') return Number(value);
if (value == null || typeof value !== 'object') return value;
if (value instanceof Date) return value;
if (Array.isArray(value)) return value.map(reviveMoney);
for (const key of Object.keys(value)) {
value[key] = reviveMoney(value[key]);
}
return value;
}
const base = new PrismaClient();
export const prisma = base.$extends({
query: {
$allOperations({ args, query }) {
return query(args).then(reviveMoney);
},
},
});
/**
* Plain Prisma client — no BigInt→Number revive.
*
* Credits / Pr / prices are stored as decimal **strings** and parsed with
* `toCentsBigInt` / `centsJson` so values can grow past Number.MAX_SAFE_INTEGER.
* Any remaining Prisma BigInt fields (e.g. playTimeSeconds) stay as bigint until
* an API boundary stringifies them (`res.json` replacer in index.js) or an
* explicit Number()/toString() conversion.
*/
export const prisma = new PrismaClient();
+11 -1
View File
@@ -13,9 +13,11 @@ function fingerprint(entry) {
return [
entry.username || '',
entry.item?.id || '',
String(entry.item?.valueCents ?? entry.item?.marketValue ?? ''),
entry.count || 1,
entry.openCount || 1,
ids.join(','),
entry.respin ? 'respin' : 'open',
ids.join(',') || `snap:${entry.item?.name || ''}`,
].join('|');
}
@@ -47,3 +49,11 @@ export function pushDrop(entry) {
const { _fp, ...publicEvent } = event;
return publicEvent;
}
export function clearDropFeed() {
feed.length = 0;
const io = getIO();
if (io) {
io.to('feed').emit('feed:history', []);
}
}
+61 -50
View File
@@ -13,7 +13,6 @@ 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';
@@ -23,12 +22,11 @@ import achievementsRoutes from './routes/achievements.js';
import vaultRoutes from './routes/vault.js';
import { setIO } from './realtime.js';
import { getDropFeed } from './feed.js';
import { ensureJackpotRound } from './services/jackpot.js';
import { recoverDuelSpins, getDuelRoom, listDuelRooms, startEmptyDuelRoomPurge } from './services/duel.js';
import { getJackpotState } from './services/jackpot.js';
import { ensureJackpotRound, listJackpotBattles, getJackpotRound } from './services/jackpot.js';
import { ensureAdminFromEnv } from './ensureAdmin.js';
import { ensureAchievements, checkAchievements } from './achievements.js';
import { ensureCategories } from './categories.js';
import { ensureShopOsuPacks } from './shopOsu.js';
import { trackConnect, trackDisconnect, settleOnlineVaultForConnected } from './presence.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -40,7 +38,7 @@ if (!fs.existsSync(uploadsDir)) {
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';
const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5888';
const TRUST_PROXY = process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1';
/** true | false | 'auto' (secure cookie only on HTTPS) */
@@ -96,6 +94,19 @@ const sessionMiddleware = session({
app.use(cors(corsOptions));
app.use(express.json());
app.use(sessionMiddleware);
// Serialize BigInt fields (balance / valueCents) as strings for JSON responses
app.use((_req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) =>
originalJson(
JSON.parse(
JSON.stringify(body, (_key, value) =>
typeof value === 'bigint' ? value.toString() : value
)
)
);
next();
});
app.use('/uploads', express.static(uploadsDir));
app.get('/api/health', (_req, res) => {
@@ -108,7 +119,6 @@ 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);
@@ -137,7 +147,7 @@ io.on('connection', async (socket) => {
if (userId) {
try {
const result = await trackConnect(userId, socket.id);
const result = await trackConnect(userId, socket.id, io);
if (!result.ok) {
socket.emit('session:limit', {
max: result.max,
@@ -147,6 +157,7 @@ io.on('connection', async (socket) => {
socket.disconnect(true);
return;
}
// Soft-replace of older sockets no longer emits session:limit (refresh-safe).
socket.join(`user:${userId}`);
socket.emit('session:ok', {
count: result.count,
@@ -158,52 +169,52 @@ io.on('connection', async (socket) => {
}
}
socket.on('jackpot:subscribe', async () => {
socket.on('jackpots:subscribe', async () => {
socket.join('jackpots');
try {
const battles = await listJackpotBattles();
socket.emit('jackpots:list', { battles });
} catch (err) {
console.error(err);
}
});
socket.on('jackpots:unsubscribe', () => {
socket.leave('jackpots');
});
socket.on('jackpot:subscribe', async (roundId) => {
const id = Number(roundId);
if (id) {
socket.join(`jackpot:${id}`);
try {
const state = await getJackpotRound(id);
if (state) socket.emit('jackpot:state', state);
} catch (err) {
console.error(err);
}
return;
}
// Legacy: join global room and send list head
socket.join('jackpot');
try {
const state = await getJackpotState();
socket.emit('jackpot:state', state);
const battles = await listJackpotBattles();
socket.emit('jackpots:list', { battles });
if (battles[0]) socket.emit('jackpot:state', battles[0]);
} catch (err) {
console.error(err);
}
});
socket.on('jackpot:unsubscribe', () => {
socket.on('jackpot:unsubscribe', (roundId) => {
const id = Number(roundId);
if (id) {
socket.leave(`jackpot:${id}`);
return;
}
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());
@@ -214,9 +225,10 @@ io.on('connection', async (socket) => {
});
socket.on('disconnect', () => {
if (userId) {
trackDisconnect(userId, socket.id)
.then(() => checkAchievements(userId))
const uid = socket.data.userId || userId;
if (uid) {
trackDisconnect(uid, socket.id)
.then(() => checkAchievements(uid))
.catch((err) => console.error(err));
}
});
@@ -244,6 +256,7 @@ async function bootstrap() {
await ensureAdminFromEnv();
await ensureAchievements();
await ensureCategories();
await ensureShopOsuPacks();
} catch (err) {
console.error(err.message || err);
process.exit(1);
@@ -253,14 +266,12 @@ async function bootstrap() {
console.log(`API listening on http://localhost:${PORT}`);
try {
await ensureJackpotRound();
await recoverDuelSpins();
startEmptyDuelRoomPurge();
// Online vault dividends: check every 60s for completed 10-min ticks
// Online vault dividends: check every 30s for completed ticks (5 min online)
setInterval(() => {
settleOnlineVaultForConnected().catch((err) =>
console.error('vault online interval', err)
);
}, 60_000);
}, 30_000);
} catch (err) {
console.error('Failed to bootstrap game services', err);
}
+7 -4
View File
@@ -19,21 +19,24 @@ export function publicUser(user) {
return {
id: user.id,
username: user.username,
balance: user.balance,
balance: user.balance == null ? '0' : user.balance.toString(),
osuBalance: user.osuBalance ?? 0,
prBalance: user.prBalance ?? 0,
prBalance: user.prBalance == null ? '0' : String(user.prBalance),
lastAdAt: user.lastAdAt || null,
role: user.role,
avatarUrl: user.avatarUrl || '',
bio: user.bio || '',
autoSellEnabled: Boolean(user.autoSellEnabled),
autoSellThresholdCents: user.autoSellThresholdCents ?? 1000,
autoSellThresholdCents:
user.autoSellThresholdCents == null
? '1000'
: user.autoSellThresholdCents.toString(),
prestigeCount: user.prestigeCount ?? 0,
prestigeChanceBonus: user.prestigeChanceBonus ?? 0,
prestigeGainBonus: user.prestigeGainBonus ?? 0,
prestigeWearBonus: user.prestigeWearBonus ?? 0,
prestigeOpenBonus: user.prestigeOpenBonus ?? 0,
prestigeCaseDiscount: user.prestigeCaseDiscount ?? 0,
prestigePrBonus: user.prestigePrBonus ?? 0,
prestigeQualityBonus: user.prestigeQualityBonus ?? 0,
prestigeAnimReduction: user.prestigeAnimReduction ?? 0,
prestigeRefundChance: user.prestigeRefundChance ?? 0,
+138
View File
@@ -0,0 +1,138 @@
import { prisma } from './db.js';
import { formatFiat, paymentsProvider } from './shopCatalog.js';
import { getPurchasableOsuPack } from './shopOsu.js';
/**
* Create a pending osu top-up order.
* Mock provider completes client-side via /confirm.
* Stripe provider (when configured) would return a clientSecret.
*/
export async function createOsuCheckout(userId, packId) {
const pack = await getPurchasableOsuPack(packId);
if (!pack) {
const err = new Error('Unknown or disabled osu pack');
err.status = 400;
throw err;
}
const provider = paymentsProvider();
const order = await prisma.paymentOrder.create({
data: {
userId: Number(userId),
packId: pack.id,
osuAmount: pack.osu,
priceCents: pack.priceCents,
currency: pack.currency,
status: 'pending',
provider,
meta: JSON.stringify({
packName: pack.name,
displayPrice: formatFiat(pack.priceCents, pack.currency),
}),
},
});
if (provider === 'stripe') {
// Placeholder for Stripe PaymentIntent wiring.
// Set PAYMENTS_PROVIDER=stripe + STRIPE_SECRET_KEY when ready.
const err = new Error(
'Stripe checkout is configured but not wired yet — set PAYMENTS_PROVIDER=mock for local buys'
);
err.status = 501;
throw err;
}
return {
order: serializeOrder(order),
pack: {
id: pack.id,
name: pack.name,
osu: pack.osu,
priceCents: pack.priceCents,
currency: pack.currency,
displayPrice: formatFiat(pack.priceCents, pack.currency),
},
mode: 'mock',
/** Client calls POST /api/shop/osu/confirm with this id in mock mode */
confirmable: true,
};
}
/**
* Complete a mock checkout and credit osu (idempotent).
*/
export async function confirmMockOsuPayment(userId, orderId) {
const id = String(orderId || '');
if (!id) {
const err = new Error('Missing order id');
err.status = 400;
throw err;
}
return prisma.$transaction(async (tx) => {
const order = await tx.paymentOrder.findUnique({ where: { id } });
if (!order || order.userId !== Number(userId)) {
const err = new Error('Order not found');
err.status = 404;
throw err;
}
if (order.status === 'paid') {
const user = await tx.user.findUnique({ where: { id: userId } });
return { order: serializeOrder(order), user, alreadyPaid: true };
}
if (order.status !== 'pending') {
const err = new Error(`Order is ${order.status}`);
err.status = 400;
throw err;
}
if (order.provider !== 'mock') {
const err = new Error('This order must be completed by the payment provider');
err.status = 400;
throw err;
}
const now = new Date();
const updatedOrder = await tx.paymentOrder.update({
where: { id: order.id },
data: { status: 'paid', paidAt: now },
});
const user = await tx.user.update({
where: { id: userId },
data: { osuBalance: { increment: order.osuAmount } },
});
await tx.transaction.create({
data: {
userId,
type: 'osu_purchase',
amount: String(order.osuAmount),
meta: JSON.stringify({
orderId: order.id,
packId: order.packId,
priceCents: order.priceCents,
currency: order.currency,
provider: order.provider,
}),
},
});
return { order: serializeOrder(updatedOrder), user, alreadyPaid: false };
});
}
export function serializeOrder(order) {
return {
id: order.id,
packId: order.packId,
osuAmount: order.osuAmount,
priceCents: order.priceCents,
currency: order.currency,
status: order.status,
provider: order.provider,
displayPrice: formatFiat(order.priceCents, order.currency),
createdAt: order.createdAt,
paidAt: order.paidAt || null,
};
}
+60 -14
View File
@@ -3,7 +3,7 @@ import { settleVaultYield } from './vault.js';
export const MAX_SOCKETS_PER_USER = Math.max(
1,
Number(process.env.MAX_SOCKETS_PER_USER || 10)
Number(process.env.MAX_SOCKETS_PER_USER || 2)
);
/** @type {Map<number, { sockets: Set<string>, sessionStartedAt: number, lastVaultCredit?: number }>} */
@@ -35,11 +35,13 @@ export function totalSocketCount() {
/**
* Track an authenticated socket. Updates lastConnection on first socket for this user.
* Rejects when the account already has MAX_SOCKETS_PER_USER live windows.
* Caps concurrent sockets at MAX_SOCKETS_PER_USER. When full, the oldest socket is
* replaced (covers refresh spam / ghost connections) instead of blocking the new tab.
* Slot claim is synchronous so concurrent connects cannot exceed the max.
* @returns {Promise<{ ok: true, count: number, max: number, vaultCredited?: number } | { ok: false, count: number, max: number }>}
* @param {import('socket.io').Server | null} [io]
* @returns {Promise<{ ok: true, count: number, max: number, vaultCredited?: string, replaced?: string[] } | { ok: false, count: number, max: number }>}
*/
export async function trackConnect(userId, socketId) {
export async function trackConnect(userId, socketId, io = null) {
const id = Number(userId);
const max = MAX_SOCKETS_PER_USER;
if (!id || !socketId) {
@@ -52,18 +54,42 @@ export async function trackConnect(userId, socketId) {
online.set(id, entry);
}
// Drop ids that are no longer connected (HMR / crashed tabs leave ghosts otherwise)
pruneDeadSockets(entry, io);
if (entry.sockets.size === 0) {
entry.sessionStartedAt = Date.now();
}
if (entry.sockets.has(socketId)) {
return { ok: true, count: entry.sockets.size, max };
}
if (entry.sockets.size >= max) {
return { ok: false, count: entry.sockets.size, max };
// At capacity: free the oldest slot(s) so a refresh never soft-locks the only open tab
const replaced = [];
while (entry.sockets.size >= max) {
const oldest = entry.sockets.values().next().value;
if (!oldest) break;
entry.sockets.delete(oldest);
replaced.push(oldest);
const oldSock = io?.sockets?.sockets?.get(oldest);
if (oldSock) {
try {
oldSock.emit('session:replaced', {
reason: 'slot_taken',
max,
});
} catch {
/* ignore */
}
oldSock.disconnect(true);
}
}
// Claim slot before any await (avoids race past the limit)
entry.sockets.add(socketId);
const isFirst = entry.sockets.size === 1;
let vaultCredited = 0;
const isFirst = entry.sockets.size === 1 && replaced.length === 0;
let vaultCredited = '0';
if (isFirst) {
try {
@@ -77,13 +103,23 @@ export async function trackConnect(userId, socketId) {
// Offline yield accrued while disconnected
try {
const settled = await settleVaultYield(id, 'offline');
vaultCredited = settled.credited;
vaultCredited = settled.credited || '0';
} catch (err) {
console.error('presence vault offline settle', err);
}
}
return { ok: true, count: entry.sockets.size, max, vaultCredited };
return { ok: true, count: entry.sockets.size, max, vaultCredited, replaced };
}
function pruneDeadSockets(entry, io) {
if (!entry?.sockets?.size) return;
if (!io?.sockets?.sockets) return;
for (const sid of [...entry.sockets]) {
if (!io.sockets.sockets.has(sid)) {
entry.sockets.delete(sid);
}
}
}
/**
@@ -123,7 +159,17 @@ export async function trackDisconnect(userId, socketId) {
/** Live playtime including the current open session (if any). */
export function livePlayTimeSeconds(user) {
const stored = Number(user?.playTimeSeconds ?? 0);
let stored = 0;
try {
// playTimeSeconds is Prisma BigInt — avoid Number() on the raw value path
// for consistency; seconds stay well within safe integer range for decades.
stored = Number(typeof user?.playTimeSeconds === 'bigint'
? user.playTimeSeconds
: BigInt(user?.playTimeSeconds ?? 0));
} catch {
stored = 0;
}
if (!Number.isFinite(stored) || stored < 0) stored = 0;
const entry = online.get(Number(user?.id));
if (!entry) return stored;
const live = Math.max(0, Math.floor((Date.now() - entry.sessionStartedAt) / 1000));
@@ -143,14 +189,14 @@ export function serializePresence(user) {
/** Settle online vault yield for all currently connected users. */
export async function settleOnlineVaultForConnected() {
const ids = onlineUserIds();
let total = 0;
let total = 0n;
for (const uid of ids) {
try {
const settled = await settleVaultYield(uid, 'online');
total += settled.credited;
total += BigInt(settled.credited || '0');
} catch (err) {
console.error('vault online tick', uid, err);
}
}
return total;
return total.toString();
}
+134 -57
View File
@@ -1,17 +1,19 @@
import { applyPercentFloor, toCentsBigInt } from './cents.js';
/** 1_000_000_000_000.00 cr in cents */
export const PRESTIGE_COST_CENTS = 100_000_000_000_000n;
/** Page unlock: 10_000_000_000.00 cr total wealth (balance + unlocked inventory). */
export const PRESTIGE_VISIBLE_AT_CENTS = 1_000_000_000_000;
export const PRESTIGE_VISIBLE_AT_CENTS = 1_000_000_000_000n;
/** Base Pr granted on every prestige. */
export const PR_BASE_REWARD = 1000;
/**
* For every 100_000_000_000.00 cr above the prestige cost, +1000 Pr on average.
* For every 250_000_000_000.00 cr above the prestige cost, +1000 Pr on average.
* (Stored in cents.)
*/
export const PR_EXCESS_CHUNK_CENTS = 10_000_000_000_000n;
export const PR_EXCESS_CHUNK_CENTS = 25_000_000_000_000n;
export const PR_PER_EXCESS_CHUNK = 1000;
const BASE_AD_COOLDOWN_MS = 60_000;
@@ -19,12 +21,14 @@ const BASE_AD_COOLDOWN_MS = 60_000;
/**
* Skill tree upgrades bought with Pr (stackable branches).
* level = how many times this branch has been purchased.
* maxLevel = hard cap on purchases.
*/
export const PRESTIGE_SKILLS = {
chance: {
id: 'chance',
field: 'prestigeChanceBonus',
delta: 1,
maxLevel: 100,
title: 'Fortune',
description: '+1% drop luck on case opens (permanent)',
unit: '%',
@@ -34,6 +38,7 @@ export const PRESTIGE_SKILLS = {
id: 'gain',
field: 'prestigeGainBonus',
delta: 2,
maxLevel: 20,
title: 'Yield',
description: '+2% credits on sell / auto-sell (permanent)',
unit: '%',
@@ -43,6 +48,7 @@ export const PRESTIGE_SKILLS = {
id: 'polish',
field: 'prestigeWearBonus',
delta: 1,
maxLevel: 100,
title: 'Polish',
description: 'Better float / wear rolls on case opens',
unit: '',
@@ -52,25 +58,27 @@ export const PRESTIGE_SKILLS = {
id: 'bulk',
field: 'prestigeOpenBonus',
delta: 1,
maxLevel: 20,
title: 'Bulk',
description: '+1 max simultaneous case opens (permanent)',
unit: '',
branch: 'drops',
},
discount: {
id: 'discount',
field: 'prestigeCaseDiscount',
prFarmer: {
id: 'prFarmer',
field: 'prestigePrBonus',
delta: 2,
title: 'Discount',
description: '2% case open price (permanent)',
maxLevel: 50,
title: 'PR Farmer',
description: '+2% Pr obtained when you prestige (permanent)',
unit: '%',
prefix: '',
branch: 'economy',
},
quality: {
id: 'quality',
field: 'prestigeQualityBonus',
delta: 1,
maxLevel: 100,
title: 'Quality',
description: 'Better float / wear rolls on case opens (items below Field-Tested get upgraded)',
unit: '',
@@ -80,6 +88,7 @@ export const PRESTIGE_SKILLS = {
id: 'noTime',
field: 'prestigeAnimReduction',
delta: 10,
maxLevel: 100,
title: 'noTime',
description: 'Reduced case opening animation time',
unit: '%',
@@ -90,6 +99,7 @@ export const PRESTIGE_SKILLS = {
id: 'refund',
field: 'prestigeRefundChance',
delta: 2,
maxLevel: 20,
title: 'Refund',
description: 'Chance to get a free case open refund',
unit: '%',
@@ -99,8 +109,9 @@ export const PRESTIGE_SKILLS = {
id: 'respin',
field: 'prestigeRespinChance',
delta: 1,
maxLevel: 20,
title: 'Respin',
description: 'Chance to get a free case respin (respins all cases if multi-opening)',
description: 'Chance to free-respin the open (shows first result, then reels restart)',
unit: '%',
branch: 'drops',
},
@@ -108,6 +119,7 @@ export const PRESTIGE_SKILLS = {
id: 'vaultCap',
field: 'prestigeVaultSlots',
delta: 1,
maxLevel: 10,
title: 'Vault Capacity',
description: '+1 max staking slot in the vault',
unit: '',
@@ -118,6 +130,7 @@ export const PRESTIGE_SKILLS = {
id: 'offlineYield',
field: 'prestigeOfflineYield',
delta: 10,
maxLevel: 100,
title: 'Offline Yield',
description: 'Increased dividends for staked items while offline',
unit: '%',
@@ -127,6 +140,7 @@ export const PRESTIGE_SKILLS = {
id: 'onlineYield',
field: 'prestigeOnlineYield',
delta: 10,
maxLevel: 100,
title: 'Online Yield',
description: 'Increased dividends for staked items while online',
unit: '%',
@@ -142,6 +156,37 @@ export function skillUpgradeCost(skillId, level = 0) {
return Math.round((1000 + lv * 500) * mult);
}
/** Total Pr cost to buy `count` levels starting at `level`. */
export function skillUpgradeCostBulk(skillId, level = 0, count = 1) {
const n = Math.max(1, Math.min(100, Math.floor(Number(count) || 1)));
const start = Math.max(0, Number(level) || 0);
let total = 0;
for (let i = 0; i < n; i += 1) {
total += skillUpgradeCost(skillId, start + i);
}
return total;
}
export function skillMaxLevel(skillId) {
const skill = PRESTIGE_SKILLS[skillId];
const max = Number(skill?.maxLevel);
return Number.isFinite(max) && max > 0 ? Math.floor(max) : Infinity;
}
/** How many levels can still be bought before the cap. */
export function skillLevelsRemaining(user, skillId) {
const max = skillMaxLevel(skillId);
if (!Number.isFinite(max)) return Infinity;
return Math.max(0, max - skillLevelFromUser(user, skillId));
}
/** True when every prestige skill is at its max level. */
export function isPrestigeSkillTreeMaxed(user) {
return Object.keys(PRESTIGE_SKILLS).every(
(id) => skillLevelFromUser(user, id) >= skillMaxLevel(id)
);
}
export function skillLevelFromUser(user, skillId) {
const skill = PRESTIGE_SKILLS[skillId];
if (!skill) return 0;
@@ -150,35 +195,64 @@ export function skillLevelFromUser(user, skillId) {
return Math.floor(value / delta);
}
/**
* Expected Pr (no RNG) for UI preview.
*/
export function estimatePrReward(balanceCents) {
const balance = BigInt(balanceCents);
const cost = PRESTIGE_COST_CENTS;
if (balance < cost) return 0;
const excess = balance - cost;
const chunk = PR_EXCESS_CHUNK_CENTS;
const exact = Number(excess) / Number(chunk);
return Math.round(PR_BASE_REWARD + exact * PR_PER_EXCESS_CHUNK);
/** Parse Pr amounts (stored as decimal strings; may exceed i64). */
export function toPrBigInt(value) {
return toCentsBigInt(value);
}
export function prJson(value) {
return toPrBigInt(value).toString();
}
/** Apply PR Farmer % to a base Pr reward (floor). Returns BigInt. */
export function applyPrBonus(basePr, prBonusPct = 0) {
const base = toPrBigInt(basePr);
const pct = Math.max(0, Math.floor(Number(prBonusPct) || 0));
if (base <= 0n) return 0n;
if (pct <= 0) return base;
return (base * BigInt(100 + pct)) / 100n;
}
/**
* Pr reward: 1000 base + 1000 per full 100B cr excess, plus a fractional
* roll so partial chunks average to the remaining Pr.
* Expected Pr (no RNG) for UI preview. Returns string.
*/
export function computePrReward(balanceCents) {
const balance = BigInt(balanceCents);
export function estimatePrReward(balanceCents, prBonusPct = 0) {
const balance = toCentsBigInt(balanceCents);
const cost = PRESTIGE_COST_CENTS;
if (balance < cost) return '0';
const excess = balance - cost;
const chunk = PR_EXCESS_CHUNK_CENTS;
const full = excess / chunk;
const rem = excess % chunk;
// Round partial chunk: rem/chunk >= 0.5 → +1 chunk of Pr
const half = chunk / 2n;
const roundedChunks = full + (rem >= half ? 1n : 0n);
const base = BigInt(PR_BASE_REWARD) + roundedChunks * BigInt(PR_PER_EXCESS_CHUNK);
return prJson(applyPrBonus(base, prBonusPct));
}
/**
* Pr reward: 1000 base + 1000 per full 250B cr excess, plus a fractional
* roll so partial chunks average to the remaining Pr.
* PR Farmer adds +N% to the final grant.
* Returns string (may exceed Number.MAX_SAFE_INTEGER).
*/
export function computePrReward(balanceCents, prBonusPct = 0) {
const balance = toCentsBigInt(balanceCents);
const cost = PRESTIGE_COST_CENTS;
const excess = balance > cost ? balance - cost : 0n;
const chunk = PR_EXCESS_CHUNK_CENTS;
const full = Number(excess / chunk);
const full = excess / chunk;
const rem = excess % chunk;
const frac = Number(rem) / Number(chunk);
const bonus =
full * PR_PER_EXCESS_CHUNK +
(Math.random() < frac ? PR_PER_EXCESS_CHUNK : 0);
return PR_BASE_REWARD + bonus;
// Math.random() < rem/chunk without Number overflow
let extra = 0n;
if (rem > 0n) {
const scale = 1_000_000_000n;
const roll = BigInt(Math.floor(Math.random() * 1e9));
if (roll * chunk < rem * scale) extra = BigInt(PR_PER_EXCESS_CHUNK);
}
const base = BigInt(PR_BASE_REWARD) + full * BigInt(PR_PER_EXCESS_CHUNK) + extra;
return prJson(applyPrBonus(base, prBonusPct));
}
export function adCooldownMs(_user) {
@@ -192,7 +266,7 @@ export function serializePrestige(user) {
gainBonus: user?.prestigeGainBonus ?? 0,
wearBonus: user?.prestigeWearBonus ?? 0,
openBonus: user?.prestigeOpenBonus ?? 0,
caseDiscount: user?.prestigeCaseDiscount ?? 0,
prBonus: user?.prestigePrBonus ?? 0,
qualityBonus: user?.prestigeQualityBonus ?? 0,
animReduction: user?.prestigeAnimReduction ?? 0,
refundChance: user?.prestigeRefundChance ?? 0,
@@ -200,7 +274,7 @@ export function serializePrestige(user) {
vaultSlots: user?.prestigeVaultSlots ?? 0,
offlineYield: user?.prestigeOfflineYield ?? 0,
onlineYield: user?.prestigeOnlineYield ?? 0,
prBalance: user?.prBalance ?? 0,
prBalance: prJson(user?.prBalance ?? 0),
costCents: Number(PRESTIGE_COST_CENTS),
visibleAtCents: PRESTIGE_VISIBLE_AT_CENTS,
prBaseReward: PR_BASE_REWARD,
@@ -210,10 +284,17 @@ export function serializePrestige(user) {
}
export function serializeSkillTree(user) {
const pr = toPrBigInt(user?.prBalance ?? 0);
return Object.values(PRESTIGE_SKILLS).map((skill) => {
const level = skillLevelFromUser(user, skill.id);
const maxLevel = skillMaxLevel(skill.id);
const room = Math.max(0, maxLevel - level);
const atMax = room <= 0;
const current = Number(user?.[skill.field] ?? 0);
const cost = skillUpgradeCost(skill.id, level);
const buyCount = atMax ? 0 : 1;
const buyCount10 = atMax ? 0 : Math.min(10, room);
const cost = buyCount ? skillUpgradeCost(skill.id, level) : 0;
const cost10 = buyCount10 ? skillUpgradeCostBulk(skill.id, level, buyCount10) : 0;
return {
id: skill.id,
title: skill.title,
@@ -223,28 +304,24 @@ export function serializeSkillTree(user) {
prefix: skill.prefix || (skill.unit === '%' || skill.unit === 'cr-cents' ? '+' : ''),
branch: skill.branch || 'misc',
level,
maxLevel: Number.isFinite(maxLevel) ? maxLevel : null,
atMax,
room,
buyCount10,
current,
nextValue: current + skill.delta,
nextValue: atMax ? current : current + skill.delta,
nextValue10: atMax ? current : current + skill.delta * buyCount10,
cost,
canAfford: (user?.prBalance ?? 0) >= cost,
cost10,
canAfford: !atMax && pr >= BigInt(cost),
canAfford10: buyCount10 >= 2 && pr >= BigInt(cost10),
};
});
}
/** Apply case price discount (%). Min 1 cent. */
export function applyCaseDiscount(priceCents, discountPct = 0) {
const base = Math.max(0, Math.floor(Number(priceCents) || 0));
const pct = Math.max(0, Number(discountPct) || 0);
if (pct <= 0) return Math.max(1, base);
return Math.max(1, Math.floor(base * (1 - pct / 100)));
}
/** Apply lifetime gain % to a credit amount (cents). */
/** Apply lifetime gain % to a credit amount (cents). Returns BigInt. */
export function applyGainMultiplier(cents, gainBonusPct = 0) {
const base = Number(cents) || 0;
const pct = Number(gainBonusPct) || 0;
if (pct <= 0) return Math.floor(base);
return Math.floor(base * (1 + pct / 100));
return applyPercentFloor(cents, gainBonusPct);
}
export function totalLuckPercent(catalogLuck = 0, prestigeChanceBonus = 0) {
@@ -255,17 +332,17 @@ export function isValidSkill(skillId) {
return Boolean(PRESTIGE_SKILLS[skillId]);
}
/** Visible if already prestiged, or total wealth reaches the unlock threshold. */
export function isPrestigePageVisible(user, totalCents) {
if ((user?.prestigeCount ?? 0) > 0) return true;
if ((user?.prBalance ?? 0) > 0) return true;
return (Number(totalCents) || 0) >= PRESTIGE_VISIBLE_AT_CENTS;
}
export async function getUnlockedInventoryValueCents(db, userId) {
const rows = await db.inventoryItem.findMany({
where: { userId, locked: false },
select: { valueCents: true },
});
return rows.reduce((sum, r) => sum + (Number(r.valueCents) || 0), 0);
return rows.reduce((sum, r) => sum + toCentsBigInt(r.valueCents), 0n);
}
/** Visible if already prestiged, or total wealth reaches the unlock threshold. */
export function isPrestigePageVisible(user, totalCents) {
if ((user?.prestigeCount ?? 0) > 0) return true;
if (toPrBigInt(user?.prBalance) > 0n) return true;
return toCentsBigInt(totalCents) >= PRESTIGE_VISIBLE_AT_CENTS;
}
+12 -8
View File
@@ -8,17 +8,21 @@ export function getIO() {
return io;
}
export function emitJackpot(event, payload) {
export function emitJackpot(roundId, event, payload) {
if (!io) return;
const id = Number(roundId);
if (id) {
io.to(`jackpot:${id}`).emit(event, payload);
}
// Legacy global room
io.to('jackpot').emit(event, payload);
// Hub list subscribers need spin/result for live cards
if (event === 'jackpot:spin' || event === 'jackpot:result') {
io.to('jackpots').emit(event, payload);
}
}
export function emitDuel(roomId, event, payload) {
export function emitJackpotsList(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);
io.to('jackpots').emit('jackpots:list', payload);
}
+174 -26
View File
@@ -3,7 +3,7 @@ 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';
import { createUploader, processUploadedImage } from '../upload.js';
import { onlineUserCount, totalSocketCount } from '../presence.js';
import { ACHIEVEMENT_DEFS } from '../achievements.js';
import {
@@ -12,10 +12,24 @@ import {
ensureCategory,
listCategoriesWithCounts,
} from '../categories.js';
import { toCentsBigInt, centsJson } from '../cents.js';
import { toPrBigInt, prJson } from '../prestige.js';
import {
listShopOsuPacksAdmin,
updateShopOsuPack,
setOsuTopupsSectionEnabled,
} from '../shopOsu.js';
const router = Router();
const upload = createUploader('asset');
/** Parse admin money input (cents) → non-negative decimal string for Prisma. */
function parseCentsField(raw) {
const bi = toCentsBigInt(raw);
if (bi < 0n) return null;
return centsJson(bi);
}
router.post('/login', async (req, res) => {
try {
const username = String(req.body.username || '').trim();
@@ -45,6 +59,39 @@ router.get('/rarities', requireAdmin, (_req, res) => {
res.json({ rarities: RARITIES });
});
router.get('/shop/osu-packs', requireAdmin, async (_req, res) => {
try {
const data = await listShopOsuPacksAdmin();
res.json(data);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load osu packs' });
}
});
router.put('/shop/osu-section', requireAdmin, async (req, res) => {
try {
const enabled = Boolean(req.body.enabled);
const sectionEnabled = await setOsuTopupsSectionEnabled(enabled);
const data = await listShopOsuPacksAdmin();
res.json({ ...data, sectionEnabled });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to update shop section' });
}
});
router.put('/shop/osu-packs/:id', requireAdmin, async (req, res) => {
try {
const pack = await updateShopOsuPack(req.params.id, req.body || {});
res.json({ pack });
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to update pack' });
}
});
router.get('/categories', requireAdmin, async (_req, res) => {
try {
const categories = await listCategoriesWithCounts();
@@ -218,16 +265,21 @@ router.get('/users', requireAdmin, async (_req, res) => {
res.json({
users: users.map((u) => {
const inventoryValue = u.inventory.reduce((s, inv) => s + inv.valueCents, 0);
const inventoryValue = u.inventory.reduce(
(s, inv) => s + toCentsBigInt(inv.valueCents),
0n
);
const balance = toCentsBigInt(u.balance);
return {
id: u.id,
username: u.username,
avatarUrl: u.avatarUrl || '',
balance: u.balance,
balance: centsJson(balance),
osuBalance: u.osuBalance ?? 0,
inventoryValue,
prBalance: prJson(u.prBalance ?? 0),
inventoryValue: centsJson(inventoryValue),
inventoryCount: u.inventory.length,
totalWealth: u.balance + inventoryValue,
totalWealth: centsJson(balance + inventoryValue),
casesOpened: opensByUser.get(u.id) || 0,
achievements: u._count.achievements,
prestigeCount: u.prestigeCount ?? 0,
@@ -253,22 +305,19 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
const data = {};
if (req.body.balance !== undefined) {
const balance = Number(req.body.balance);
if (!Number.isInteger(balance) || balance < 0) {
const balance = String(req.body.balance).trim();
if (!/^\d+$/.test(balance)) {
return res.status(400).json({ error: 'balance must be a non-negative integer (cents)' });
}
data.balance = balance;
}
if (req.body.balanceDelta !== undefined) {
const delta = Number(req.body.balanceDelta);
if (!Number.isInteger(delta)) {
const deltaRaw = String(req.body.balanceDelta).trim();
if (!/^-?\d+$/.test(deltaRaw)) {
return res.status(400).json({ error: 'balanceDelta must be an integer (cents)' });
}
const next = Number(user.balance) + delta;
if (next < 0) {
return res.status(400).json({ error: 'Resulting balance cannot be negative' });
}
data.balance = next;
const next = toCentsBigInt(user.balance) + BigInt(deltaRaw);
data.balance = (next < 0n ? 0n : next).toString();
}
if (req.body.osuBalance !== undefined) {
const osu = Number(req.body.osuBalance);
@@ -277,6 +326,21 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
}
data.osuBalance = osu;
}
if (req.body.osuBalanceDelta !== undefined) {
const delta = Number(req.body.osuBalanceDelta);
if (!Number.isInteger(delta) || delta === 0) {
return res.status(400).json({ error: 'osuBalanceDelta must be a non-zero integer' });
}
data.osuBalance = Math.max(0, (user.osuBalance ?? 0) + delta);
}
if (req.body.prBalanceDelta !== undefined) {
const deltaRaw = String(req.body.prBalanceDelta).trim();
if (!/^-?\d+$/.test(deltaRaw) || deltaRaw === '0' || deltaRaw === '-0') {
return res.status(400).json({ error: 'prBalanceDelta must be a non-zero integer' });
}
const next = toPrBigInt(user.prBalance) + BigInt(deltaRaw);
data.prBalance = prJson(next < 0n ? 0n : next);
}
if (!Object.keys(data).length) {
return res.status(400).json({ error: 'No changes provided' });
@@ -284,12 +348,15 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
const updated = await prisma.$transaction(async (tx) => {
const next = await tx.user.update({ where: { id }, data });
if (data.balance !== undefined && Number(data.balance) !== Number(user.balance)) {
if (
data.balance !== undefined &&
toCentsBigInt(data.balance) !== toCentsBigInt(user.balance)
) {
await tx.transaction.create({
data: {
userId: id,
type: 'admin_adjust',
amount: Number(data.balance) - Number(user.balance),
amount: centsJson(toCentsBigInt(data.balance) - toCentsBigInt(user.balance)),
meta: JSON.stringify({ reason: 'admin_panel', by: req.session.userId }),
},
});
@@ -305,14 +372,20 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
});
router.post('/upload', requireAdmin, (req, res) => {
upload.single('image')(req, res, (err) => {
upload.single('image')(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' });
}
res.status(201).json({ url: `/uploads/${req.file.filename}` });
try {
await processUploadedImage(req.file);
res.status(201).json({ url: `/uploads/${req.file.filename}` });
} catch (e) {
console.error(e);
res.status(400).json({ error: 'Image processing failed' });
}
});
});
@@ -356,11 +429,11 @@ router.post('/cases', requireAdmin, async (req, res) => {
try {
const name = String(req.body.name || '').trim();
const imageUrl = String(req.body.imageUrl || '');
const price = Number(req.body.price);
const price = parseCentsField(req.body.price);
const active = req.body.active !== false;
if (!name) return res.status(400).json({ error: 'Name is required' });
if (!Number.isInteger(price) || price < 0) {
if (price == null) {
return res.status(400).json({ error: 'Price must be a non-negative integer (cents)' });
}
@@ -381,8 +454,8 @@ router.put('/cases/:id', requireAdmin, async (req, res) => {
if (req.body.name !== undefined) data.name = String(req.body.name).trim();
if (req.body.imageUrl !== undefined) data.imageUrl = String(req.body.imageUrl);
if (req.body.price !== undefined) {
const price = Number(req.body.price);
if (!Number.isInteger(price) || price < 0) {
const price = parseCentsField(req.body.price);
if (price == null) {
return res.status(400).json({ error: 'Invalid price' });
}
data.price = price;
@@ -425,13 +498,13 @@ router.post('/items', requireAdmin, async (req, res) => {
const imageUrl = String(req.body.imageUrl || '');
const rarity = String(req.body.rarity || '');
const category = await ensureCategory(req.body.category);
const marketValue = Number(req.body.marketValue);
const marketValue = parseCentsField(req.body.marketValue);
if (!name) return res.status(400).json({ error: 'Name is required' });
if (!isValidRarity(rarity)) {
return res.status(400).json({ error: `Invalid rarity. Use: ${RARITIES.join(', ')}` });
}
if (!Number.isInteger(marketValue) || marketValue < 0) {
if (marketValue == null) {
return res.status(400).json({ error: 'marketValue must be a non-negative integer (cents)' });
}
@@ -461,8 +534,8 @@ router.put('/items/:id', requireAdmin, async (req, res) => {
data.category = await ensureCategory(req.body.category);
}
if (req.body.marketValue !== undefined) {
const marketValue = Number(req.body.marketValue);
if (!Number.isInteger(marketValue) || marketValue < 0) {
const marketValue = parseCentsField(req.body.marketValue);
if (marketValue == null) {
return res.status(400).json({ error: 'Invalid marketValue' });
}
data.marketValue = marketValue;
@@ -545,4 +618,79 @@ router.delete('/case-items/:id', requireAdmin, async (req, res) => {
}
});
/**
* Preview counts before a server prestige wipe.
*/
router.get('/prestige-server', requireAdmin, async (_req, res) => {
try {
const [players, inventory, prestigeLogs, opensKept] = await Promise.all([
prisma.user.count({ where: { role: { not: 'admin' } } }),
prisma.inventoryItem.count({
where: { user: { role: { not: 'admin' } } },
}),
prisma.prestigeLog.count({
where: { user: { role: { not: 'admin' } } },
}),
prisma.transaction.count({
where: {
user: { role: { not: 'admin' } },
type: { in: ['open_case', 'jackpot_win'] },
},
}),
]);
res.json({
preview: {
players,
inventoryItems: inventory,
prestigeLogs,
statsTransactionsKept: opensKept,
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load prestige-server preview' });
}
});
/**
* Full player wipe (server prestige). Requires admin password confirmation.
* Achievement fields must be configured before activation.
*/
router.post('/prestige-server', requireAdmin, async (req, res) => {
try {
const password = String(req.body.password || '');
if (!password) {
return res.status(400).json({ error: 'Admin password required' });
}
const admin = await prisma.user.findUnique({ where: { id: req.session.userId } });
if (!admin || admin.role !== 'admin') {
return res.status(401).json({ error: 'Unauthorized' });
}
const ok = await bcrypt.compare(password, admin.passwordHash);
if (!ok) {
return res.status(401).json({ error: 'Invalid admin password' });
}
const ach = req.body.achievement || {};
const { runServerPrestige } = await import('../serverPrestige.js');
const result = await runServerPrestige({
achievementKey: ach.key,
achievementName: ach.name,
achievementDescription: ach.description,
achievementIcon: ach.icon,
});
res.json({
ok: true,
...result,
});
} catch (err) {
console.error(err);
const status = err.status || 500;
res.status(status).json({ error: err.message || 'Server prestige failed' });
}
});
export default router;
+6 -6
View File
@@ -33,7 +33,7 @@ router.post('/register', async (req, res) => {
data: {
username,
passwordHash,
balance: STARTING_BALANCE,
balance: String(STARTING_BALANCE),
osuBalance: STARTING_OSU,
role: 'user',
},
@@ -43,7 +43,7 @@ router.post('/register', async (req, res) => {
data: {
userId: user.id,
type: 'seed_credit',
amount: STARTING_BALANCE,
amount: String(STARTING_BALANCE),
meta: JSON.stringify({ reason: 'starting_balance' }),
},
});
@@ -52,7 +52,7 @@ router.post('/register', async (req, res) => {
data: {
userId: user.id,
type: 'seed_osu',
amount: STARTING_OSU,
amount: String(STARTING_OSU),
meta: JSON.stringify({ reason: 'starting_osu' }),
},
});
@@ -97,20 +97,20 @@ router.post('/login', async (req, res) => {
});
// Offline vault yield if user is not already socket-connected
let vaultCredited = 0;
let vaultCredited = '0';
try {
const { isConnected } = await import('../presence.js');
const { settleVaultYield } = await import('../vault.js');
if (!isConnected(user.id)) {
const settled = await settleVaultYield(user.id, 'offline');
vaultCredited = settled.credited;
vaultCredited = settled.credited || '0';
}
} catch (err) {
console.error('login vault settle', err);
}
const refreshed =
vaultCredited > 0
vaultCredited !== '0' && vaultCredited !== 0
? await prisma.user.findUnique({ where: { id: user.id } })
: withConn;
+58 -46
View File
@@ -15,7 +15,8 @@ import {
fillCatalogSlots,
formatCaseItemsWithLuck,
} from '../catalog.js';
import { totalLuckPercent, applyCaseDiscount } from '../prestige.js';
import { totalLuckPercent } from '../prestige.js';
import { toCentsBigInt, adjustUserBalance, spendUserBalance, centsJson } from '../cents.js';
import { checkAchievements } from '../achievements.js';
const router = Router();
@@ -47,7 +48,6 @@ async function loadUserLuckAndKeys(userId) {
return {
luckPercent: 0,
openBonus: 0,
caseDiscount: 0,
user: null,
};
}
@@ -59,7 +59,6 @@ async function loadUserLuckAndKeys(userId) {
prestigeChanceBonus: true,
prestigeWearBonus: true,
prestigeOpenBonus: true,
prestigeCaseDiscount: true,
prestigeQualityBonus: true,
prestigeAnimReduction: true,
prestigeRefundChance: true,
@@ -70,7 +69,6 @@ async function loadUserLuckAndKeys(userId) {
return {
luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus),
openBonus: user?.prestigeOpenBonus ?? 0,
caseDiscount: user?.prestigeCaseDiscount ?? 0,
user,
};
}
@@ -102,17 +100,20 @@ router.get('/', async (req, res) => {
include: {
items: { include: { item: true } },
},
orderBy: { price: 'asc' },
});
cases.sort((a, b) => {
const pa = toCentsBigInt(a.price);
const pb = toCentsBigInt(b.price);
if (pa < pb) return -1;
if (pa > pb) return 1;
return a.id - b.id;
});
const progressMap = await loadKeyProgressMap(userId);
const { luckPercent, openBonus, caseDiscount } = await loadUserLuckAndKeys(userId);
const { luckPercent, openBonus } = await loadUserLuckAndKeys(userId);
res.json({
catalogLuckPercent: luckPercent,
cases: cases.map((c) => {
const unitPrice = userId
? applyCaseDiscount(c.price, caseDiscount)
: Number(c.price);
const formatted = formatCase(c, luckPercent, unitPrice);
const formatted = formatCase(c, luckPercent, userId ? c.price : Number(c.price));
if (!userId) return formatted;
const progress = progressMap.get(c.id);
return {
@@ -140,10 +141,8 @@ router.get('/:id', async (req, res) => {
if (!c || !c.active) {
return res.status(404).json({ error: 'Case not found' });
}
const { luckPercent, openBonus, caseDiscount } = await loadUserLuckAndKeys(userId);
const unitPrice = userId
? applyCaseDiscount(c.price, caseDiscount)
: Number(c.price);
const { luckPercent, openBonus } = await loadUserLuckAndKeys(userId);
const unitPrice = userId ? c.price : Number(c.price);
const formatted = formatCase(c, luckPercent, unitPrice);
if (userId) {
const progress = await prisma.caseKeyProgress.findUnique({
@@ -201,13 +200,10 @@ router.post('/:id/open', requireAuth, async (req, res) => {
throw err;
}
const unitPrice = applyCaseDiscount(c.price, user.prestigeCaseDiscount ?? 0);
const totalCost = unitPrice * count;
if (user.balance < totalCost) {
const err = new Error('Insufficient balance');
err.status = 400;
throw err;
}
const unitPrice = toCentsBigInt(c.price);
const totalCost = unitPrice * BigInt(count);
// Charge up front (atomic WHERE balance >= cost). Refunded opens credit back later.
await spendUserBalance(tx, userId, totalCost);
const catalogLuck = await countCompletedChapters(tx, userId);
const luckPercent = totalLuckPercent(catalogLuck, user.prestigeChanceBonus);
@@ -231,7 +227,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
valueCents: centsJson(rolled.valueCents),
},
});
@@ -241,7 +237,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
data: {
userId,
type: 'open_case',
amount: -unitPrice,
amount: centsJson(-unitPrice),
meta: JSON.stringify({
caseId: c.id,
caseName: c.name,
@@ -251,12 +247,12 @@ router.post('/:id/open', requireAuth, async (req, res) => {
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
valueCents: centsJson(rolled.valueCents),
willAutoSell,
openIndex: i,
openCount: batchCount,
unitPrice,
basePrice: Number(c.price),
unitPrice: centsJson(unitPrice),
basePrice: centsJson(c.price),
}),
},
});
@@ -268,11 +264,11 @@ router.post('/:id/open', requireAuth, async (req, res) => {
name: item.name,
imageUrl: item.imageUrl,
rarity: item.rarity,
marketValue: item.marketValue,
marketValue: toCentsBigInt(item.marketValue).toString(),
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
valueCents: toCentsBigInt(rolled.valueCents).toString(),
willAutoSell,
});
}
@@ -281,12 +277,32 @@ router.post('/:id/open', requireAuth, async (req, res) => {
let { drops, createdTxIds } = await createDrops(count);
// Respin: re-roll all drops for free (replace first batch)
// Respin: re-roll all drops for free (replace first batch).
// preRespinItems animate the discarded roll. Auto-sell-eligible items stay in
// inventory (with inventoryId) so the client can credit them when the first
// reels finish; the rest are discarded immediately.
let preRespinItems = null;
const respinChance = Math.max(0, Number(user.prestigeRespinChance) || 0);
const didRespin = respinChance > 0 && Math.random() * 100 < respinChance;
if (didRespin) {
const oldIds = drops.map((d) => d.inventoryId);
await tx.inventoryItem.deleteMany({ where: { id: { in: oldIds } } });
const discardIds = drops
.filter((d) => !d.willAutoSell)
.map((d) => d.inventoryId)
.filter(Boolean);
const pendingAutoSellIds = drops
.filter((d) => d.willAutoSell)
.map((d) => d.inventoryId)
.filter(Boolean);
preRespinItems = drops.map((d) => ({
...d,
inventoryId: d.willAutoSell ? d.inventoryId : null,
preRespin: true,
}));
if (discardIds.length) {
await tx.inventoryItem.deleteMany({ where: { id: { in: discardIds } } });
}
await tx.transaction.deleteMany({ where: { id: { in: createdTxIds } } });
const again = await createDrops(count);
drops = again.drops;
@@ -295,11 +311,12 @@ router.post('/:id/open', requireAuth, async (req, res) => {
data: {
userId,
type: 'case_respin',
amount: 0,
amount: '0',
meta: JSON.stringify({
caseId: c.id,
openCount: count,
inventoryItemIds: drops.map((d) => d.inventoryId),
preRespinPendingAutoSellIds: pendingAutoSellIds,
}),
},
});
@@ -326,41 +343,35 @@ router.post('/:id/open', requireAuth, async (req, res) => {
},
});
let balanceDelta = -totalCost;
let updatedUser = await tx.user.findUnique({ where: { id: userId } });
if (didRefund) {
balanceDelta += totalCost;
updatedUser = await adjustUserBalance(tx, userId, updatedUser.balance, totalCost);
await tx.transaction.create({
data: {
userId,
type: 'case_refund',
amount: totalCost,
amount: centsJson(totalCost),
meta: JSON.stringify({
caseId: c.id,
caseName: c.name,
openCount: count,
unitPrice,
totalCost,
unitPrice: centsJson(unitPrice),
totalCost: centsJson(totalCost),
}),
},
});
}
const updatedUser = await tx.user.update({
where: { id: userId },
data: {
balance: { increment: balanceDelta },
},
});
return {
user: publicUser(updatedUser),
caseName: c.name,
items: drops,
preRespinItems,
keyProgress: serializeKeyProgress(updatedProgress, 0, openBonus),
keysGained: applied.keysGained,
catalog,
unitPrice,
totalCost,
unitPrice: centsJson(unitPrice),
totalCost: centsJson(totalCost),
refunded: didRefund,
respin: didRespin,
animReduction: user.prestigeAnimReduction ?? 0,
@@ -374,6 +385,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
caseName: result.caseName,
count: result.items.length,
items: result.items,
preRespinItems: result.preRespinItems,
keyProgress: result.keyProgress,
keysGained: result.keysGained,
catalog: result.catalog,
-89
View File
@@ -1,89 +0,0 @@
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;
+83 -2
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth } from '../middleware.js';
import { getDropFeed, pushDrop } from '../feed.js';
import { toCentsBigInt } from '../cents.js';
const router = Router();
@@ -13,9 +14,89 @@ router.get('/', (_req, res) => {
* Announce case drop(s) to the live feed (after client spin finishes).
* For multi-opens: send all inventoryItemIds — only the best drop is shown,
* with count of how many times that item appeared (e.g. x3).
*
* Respin flow:
* - First spin (discarded): body.snapshotItems (no inventory left)
* - Final spin: body.inventoryItemIds + body.respin=true
*/
router.post('/announce', requireAuth, async (req, res) => {
try {
const caseName = String(req.body.caseName || '').slice(0, 80);
const respin = Boolean(req.body.respin);
const snapshotItems = Array.isArray(req.body.snapshotItems)
? req.body.snapshotItems
: null;
// Discarded first roll (respin preview) — announce from client snapshot.
// Prefer compact payload: snapshotBest + openCount (+ optional count).
if (snapshotItems?.length || req.body.snapshotBest) {
const user = await prisma.user.findUnique({
where: { id: req.session.userId },
select: { username: true, avatarUrl: true },
});
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const normalizeOne = (raw) => ({
id: Number(raw?.id) || 0,
name: String(raw?.name || '').slice(0, 120),
imageUrl: String(raw?.imageUrl || '').slice(0, 500),
rarity: String(raw?.rarity || '').slice(0, 40),
valueCents: String(raw?.valueCents ?? raw?.marketValue ?? '0'),
wear: raw?.wear ? String(raw.wear).slice(0, 40) : '',
floatValue: Number(raw?.floatValue) || 0,
paintSeed: Number(raw?.paintSeed) || 0,
});
let best;
let openCount;
let count;
if (req.body.snapshotBest) {
best = normalizeOne(req.body.snapshotBest);
if (!best.id || !best.name) {
return res.status(400).json({ error: 'Invalid snapshot best item' });
}
openCount = Math.max(1, Math.floor(Number(req.body.openCount) || 1));
count = Math.max(1, Math.min(openCount, Math.floor(Number(req.body.count) || 1)));
} else {
const normalized = snapshotItems.map(normalizeOne).filter((it) => it.id && it.name);
if (!normalized.length) {
return res.status(400).json({ error: 'Invalid snapshot items' });
}
best = normalized[0];
for (const it of normalized) {
if (toCentsBigInt(it.valueCents) > toCentsBigInt(best.valueCents)) best = it;
}
openCount = normalized.length;
count = normalized.filter((it) => it.id === best.id).length;
}
const drop = pushDrop({
source: 'case',
caseName,
respin: false,
count,
openCount,
inventoryIds: [],
username: user.username,
avatarUrl: user.avatarUrl || '',
item: {
inventoryId: null,
id: best.id,
name: best.name,
imageUrl: best.imageUrl,
rarity: best.rarity,
marketValue: best.valueCents,
valueCents: best.valueCents,
wear: best.wear,
floatValue: best.floatValue,
paintSeed: best.paintSeed,
},
});
return res.json({ drop });
}
let ids = [];
if (Array.isArray(req.body.inventoryItemIds)) {
ids = req.body.inventoryItemIds.map(Number).filter(Boolean);
@@ -51,15 +132,15 @@ router.post('/announce', requireAuth, async (req, res) => {
// Best drop = highest instance value (tie-break: first in batch)
let best = invItems[0];
for (const inv of invItems) {
if (inv.valueCents > best.valueCents) best = inv;
if (toCentsBigInt(inv.valueCents) > toCentsBigInt(best.valueCents)) best = inv;
}
const count = invItems.filter((inv) => inv.itemId === best.itemId).length;
const caseName = String(req.body.caseName || '').slice(0, 80);
const drop = pushDrop({
source: 'case',
caseName,
respin,
count,
openCount: invItems.length,
inventoryIds: invItems.map((i) => i.id),
+109 -92
View File
@@ -3,10 +3,9 @@ import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { applyAutoSell } from '../autoSell.js';
import { applyGainMultiplier } from '../prestige.js';
import { toCentsBigInt, adjustUserBalance, centsJson, creditsToCents } from '../cents.js';
import {
maxVaultSlots,
summarizeVaultRates,
estimatePendingYield,
summarizeVaultState,
} from '../vault.js';
import { isConnected } from '../presence.js';
@@ -54,16 +53,10 @@ router.get('/', requireAuth, async (req, res) => {
const staked = items.filter((i) => i.staked);
const mode = isConnected(userId) ? 'online' : 'offline';
const rates = summarizeVaultRates(staked, user);
res.json({
inventory: items.map(serializeInv),
vault: {
used: staked.length,
slots: maxVaultSlots(user),
...rates,
pendingYield: estimatePendingYield(staked, user, mode),
},
vault: summarizeVaultState(user, staked, mode),
});
} catch (err) {
console.error(err);
@@ -78,11 +71,11 @@ router.patch('/auto-sell', requireAuth, async (req, res) => {
data.autoSellEnabled = Boolean(req.body.enabled);
}
if (req.body.thresholdCredits != null) {
const credits = Number(req.body.thresholdCredits);
if (!Number.isFinite(credits) || credits < 0) {
const cents = creditsToCents(req.body.thresholdCredits);
if (cents == null) {
return res.status(400).json({ error: 'Invalid threshold' });
}
data.autoSellThresholdCents = Math.round(credits * 100);
data.autoSellThresholdCents = centsJson(cents);
}
if (!Object.keys(data).length) {
@@ -108,44 +101,56 @@ router.post('/auto-sell/apply', requireAuth, async (req, res) => {
return res.json({ user: publicUser(await prisma.user.findUnique({ where: { id: req.session.userId } })), soldIds: [], total: 0 });
}
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
const result = await prisma.$transaction(
async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
const invItems = await tx.inventoryItem.findMany({
where: {
id: { in: ids },
userId: req.session.userId,
locked: false,
staked: false,
},
});
const invItems = await tx.inventoryItem.findMany({
where: {
id: { in: ids },
userId: req.session.userId,
locked: false,
staked: false,
},
});
const { sold, balanceDelta } = await applyAutoSell(tx, user, invItems);
if (!balanceDelta) {
return { user, soldIds: [], total: 0 };
}
const { sold, balanceDelta } = await applyAutoSell(tx, user, invItems);
if (balanceDelta === 0n) {
return { user, soldIds: [], total: 0n, sold: [] };
}
const updatedUser = await tx.user.update({
where: { id: req.session.userId },
data: { balance: { increment: balanceDelta } },
});
const updatedUser = await adjustUserBalance(
tx,
req.session.userId,
user.balance,
balanceDelta
);
return {
user: updatedUser,
soldIds: sold.map((inv) => inv.id),
total: balanceDelta,
};
});
const gainBonus = user.prestigeGainBonus ?? 0;
return {
user: updatedUser,
soldIds: sold.map((inv) => inv.id),
total: balanceDelta,
sold: sold.map((inv) => ({
id: inv.id,
valueCents: centsJson(inv.valueCents),
payoutCents: centsJson(applyGainMultiplier(inv.valueCents, gainBonus)),
})),
};
},
{ timeout: 60_000, maxWait: 15_000 }
);
res.json({
user: publicUser(result.user),
soldIds: result.soldIds,
total: result.total,
total: centsJson(result.total),
sold: result.sold || [],
});
} catch (err) {
const status = err.status || 500;
@@ -161,74 +166,86 @@ router.post('/sell', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Select at least one item to sell' });
}
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
const result = await prisma.$transaction(
async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
const invItems = await tx.inventoryItem.findMany({
where: {
id: { in: ids },
userId: req.session.userId,
locked: false,
staked: false,
},
include: { item: true },
});
const invItems = await tx.inventoryItem.findMany({
where: {
id: { in: ids },
userId: req.session.userId,
locked: false,
staked: false,
},
select: {
id: true,
itemId: true,
valueCents: true,
favorite: true,
},
});
if (invItems.length !== ids.length) {
const err = new Error('Invalid, locked, or staked inventory items');
err.status = 400;
throw err;
}
if (invItems.length !== ids.length) {
const err = new Error('Invalid, locked, or staked inventory items');
err.status = 400;
throw err;
}
if (invItems.some((inv) => inv.favorite)) {
const err = new Error('Favorite items cannot be sold');
err.status = 400;
throw err;
}
if (invItems.some((inv) => inv.favorite)) {
const err = new Error('Favorite items cannot be sold');
err.status = 400;
throw err;
}
const gainBonus = user.prestigeGainBonus ?? 0;
let total = 0;
const gainBonus = user.prestigeGainBonus ?? 0;
let total = 0n;
for (const inv of invItems) {
total += applyGainMultiplier(inv.valueCents, gainBonus);
}
await tx.inventoryItem.deleteMany({
where: {
id: { in: ids },
userId: req.session.userId,
locked: false,
staked: false,
},
});
for (const inv of invItems) {
const payout = applyGainMultiplier(inv.valueCents, gainBonus);
total += payout;
await tx.inventoryItem.delete({ where: { id: inv.id } });
await tx.transaction.create({
data: {
userId: req.session.userId,
type: 'sell_item',
amount: payout,
amount: centsJson(total),
meta: JSON.stringify({
inventoryItemId: inv.id,
itemId: inv.itemId,
itemName: inv.item.name,
valueCents: inv.valueCents,
floatValue: inv.floatValue,
wear: inv.wear,
paintSeed: inv.paintSeed,
count: invItems.length,
inventoryItemIds: ids,
prestigeGainBonus: gainBonus,
payout,
payout: centsJson(total),
}),
},
});
}
const updatedUser = await tx.user.update({
where: { id: req.session.userId },
data: { balance: { increment: total } },
});
const updatedUser = await adjustUserBalance(
tx,
req.session.userId,
user.balance,
total
);
return { user: updatedUser, total, soldCount: invItems.length };
});
return { user: updatedUser, total, soldCount: invItems.length };
},
{ timeout: 60_000, maxWait: 15_000 }
);
res.json({
user: publicUser(result.user),
total: result.total,
total: centsJson(result.total),
soldCount: result.soldCount,
});
} catch (err) {
+41 -6
View File
@@ -1,7 +1,9 @@
import { Router } from 'express';
import { requireAuth } from '../middleware.js';
import {
getJackpotState,
listJackpotBattles,
getJackpotRound,
createJackpotBattle,
placeJackpotBet,
withdrawJackpotBet,
} from '../services/jackpot.js';
@@ -16,25 +18,58 @@ function handleError(res, err) {
router.get('/', async (_req, res) => {
try {
const state = await getJackpotState();
res.json({ round: state });
const battles = await listJackpotBattles();
res.json({ battles });
} catch (err) {
handleError(res, err);
}
});
router.get('/:id', async (req, res) => {
try {
const round = await getJackpotRound(req.params.id);
if (!round) {
return res.status(404).json({ error: 'Battle not found' });
}
res.json({ round });
} catch (err) {
handleError(res, err);
}
});
/** Create a battle by placing the first bet (no round id). */
router.post('/bet', requireAuth, async (req, res) => {
try {
const state = await placeJackpotBet(req.session.userId, req.body.inventoryItemIds);
const state = await createJackpotBattle(
req.session.userId,
req.body.inventoryItemIds
);
res.json({ round: state });
} catch (err) {
handleError(res, err);
}
});
router.post('/withdraw', requireAuth, async (req, res) => {
router.post('/:id/bet', requireAuth, async (req, res) => {
try {
const state = await withdrawJackpotBet(req.session.userId, req.body.inventoryItemIds);
const state = await placeJackpotBet(
req.params.id,
req.session.userId,
req.body.inventoryItemIds
);
res.json({ round: state });
} catch (err) {
handleError(res, err);
}
});
router.post('/:id/withdraw', requireAuth, async (req, res) => {
try {
const state = await withdrawJackpotBet(
req.params.id,
req.session.userId,
req.body.inventoryItemIds
);
res.json({ round: state });
} catch (err) {
handleError(res, err);
+14 -7
View File
@@ -1,5 +1,6 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { toCentsBigInt, centsJson } from '../cents.js';
const router = Router();
@@ -17,22 +18,28 @@ router.get('/', async (_req, res) => {
const ranked = users
.map((u) => {
const inventoryValue = u.inventory.reduce(
(sum, inv) => sum + inv.valueCents,
0
(sum, inv) => sum + toCentsBigInt(inv.valueCents),
0n
);
const balance = toCentsBigInt(u.balance);
const totalWealth = balance + inventoryValue;
return {
id: u.id,
username: u.username,
avatarUrl: u.avatarUrl || '',
balance: u.balance,
inventoryValue,
totalWealth: u.balance + inventoryValue,
balance: centsJson(balance),
inventoryValue: centsJson(inventoryValue),
totalWealth,
itemCount: u.inventory.length,
};
})
.sort((a, b) => b.totalWealth - a.totalWealth)
.sort((a, b) => (a.totalWealth < b.totalWealth ? 1 : a.totalWealth > b.totalWealth ? -1 : 0))
.slice(0, 50)
.map((entry, index) => ({ rank: index + 1, ...entry }));
.map((entry, index) => ({
rank: index + 1,
...entry,
totalWealth: centsJson(entry.totalWealth),
}));
res.json({ leaderboard: ranked });
} catch (err) {
+49 -25
View File
@@ -12,9 +12,13 @@ import {
serializePrestige,
serializeSkillTree,
skillLevelFromUser,
skillUpgradeCost,
skillLevelsRemaining,
skillUpgradeCostBulk,
toPrBigInt,
prJson,
} from '../prestige.js';
import { checkAchievements } from '../achievements.js';
import { centsJson, toCentsBigInt, addCents, spendUserBalance } from '../cents.js';
const router = Router();
const COST = PRESTIGE_COST_CENTS;
@@ -36,18 +40,20 @@ router.get('/', requireAuth, async (req, res) => {
}),
]);
const balance = BigInt(user.balance);
const totalCents = Number(user.balance) + inventoryValue;
const balance = toCentsBigInt(user.balance);
const totalCents = balance + inventoryValue;
const pageVisible = isPrestigePageVisible(user, totalCents);
const canAfford = balance >= COST;
const canPrestige = pageVisible && canAfford && lockedCount === 0;
const previewPr = canAfford ? estimatePrReward(user.balance) : 0;
const previewPr = canAfford
? estimatePrReward(user.balance, user.prestigePrBonus ?? 0)
: 0;
res.json({
prestige: serializePrestige(user),
skills: serializeSkillTree(user),
pageVisible,
totalCents,
totalCents: totalCents.toString(),
canAfford,
lockedItems: lockedCount,
canPrestige,
@@ -55,7 +61,7 @@ router.get('/', requireAuth, async (req, res) => {
history: logs.map((l) => ({
id: l.id,
choice: l.choice,
prGranted: l.prGranted ?? 0,
prGranted: prJson(l.prGranted ?? 0),
createdAt: l.createdAt,
})),
});
@@ -85,11 +91,6 @@ router.post('/', requireAuth, async (req, res) => {
err.status = 403;
throw err;
}
if (BigInt(user.balance) < COST) {
const err = new Error('Insufficient balance for prestige');
err.status = 400;
throw err;
}
const lockedCount = await tx.inventoryItem.count({
where: { userId: user.id, locked: true },
@@ -102,22 +103,28 @@ router.post('/', requireAuth, async (req, res) => {
throw err;
}
const prGranted = computePrReward(user.balance);
// Lock funds atomically (balance >= COST). Remainder wiped with the reset below.
await spendUserBalance(tx, user.id, COST, {
message: 'Insufficient balance for prestige',
});
const fresh = await tx.user.findUnique({ where: { id: user.id } });
const previousBalance = toCentsBigInt(user.balance);
const prGranted = computePrReward(previousBalance, user.prestigePrBonus ?? 0);
const prestigeCount = (user.prestigeCount ?? 0) + 1;
await tx.inventoryItem.deleteMany({ where: { userId: user.id } });
await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } });
await tx.catalogEntry.deleteMany({ where: { userId: user.id } });
await tx.transaction.create({
data: {
userId: user.id,
type: 'prestige',
amount: -Number(COST),
amount: centsJson(-COST),
meta: JSON.stringify({
prestigeCount,
prGranted,
previousBalance: Number(user.balance),
previousBalance: centsJson(previousBalance),
}),
},
});
@@ -126,20 +133,22 @@ router.post('/', requireAuth, async (req, res) => {
data: {
userId: user.id,
choice: 'reset',
prGranted,
prGranted: prJson(prGranted),
},
});
// Wipe leftover credits after the COST spend (prestige resets economy).
const updated = await tx.user.update({
where: { id: user.id },
data: {
prestigeCount,
balance: 0,
prBalance: { increment: prGranted },
balance: '0',
prBalance: addCents(fresh.prBalance, prGranted),
vaultYieldAt: null,
},
});
return { user: updated, prGranted };
return { user: updated, prGranted: prJson(prGranted) };
});
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
@@ -164,6 +173,10 @@ router.post('/upgrade', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Unknown skill' });
}
let count = Number(req.body?.count ?? 1);
if (!Number.isFinite(count)) count = 1;
count = Math.max(1, Math.min(10, Math.floor(count)));
const skill = PRESTIGE_SKILLS[skillId];
const result = await prisma.$transaction(async (tx) => {
@@ -180,8 +193,16 @@ router.post('/upgrade', requireAuth, async (req, res) => {
}
const level = skillLevelFromUser(user, skillId);
const cost = skillUpgradeCost(skillId, level);
if ((user.prBalance ?? 0) < cost) {
const room = skillLevelsRemaining(user, skillId);
if (room <= 0) {
const err = new Error('Skill is already at max level');
err.status = 400;
throw err;
}
count = Math.min(count, room);
const cost = skillUpgradeCostBulk(skillId, level, count);
if (toPrBigInt(user.prBalance) < BigInt(cost)) {
const err = new Error('Not enough Pr');
err.status = 400;
throw err;
@@ -190,8 +211,8 @@ router.post('/upgrade', requireAuth, async (req, res) => {
const updated = await tx.user.update({
where: { id: user.id },
data: {
prBalance: { decrement: cost },
[skill.field]: { increment: skill.delta },
prBalance: addCents(user.prBalance, -cost),
[skill.field]: { increment: skill.delta * count },
},
});
@@ -199,11 +220,12 @@ router.post('/upgrade', requireAuth, async (req, res) => {
data: {
userId: user.id,
type: 'prestige_upgrade',
amount: 0,
amount: '0',
meta: JSON.stringify({
skillId,
cost,
delta: skill.delta,
count,
delta: skill.delta * count,
field: skill.field,
}),
},
@@ -212,6 +234,8 @@ router.post('/upgrade', requireAuth, async (req, res) => {
return updated;
});
checkAchievements(result.id).catch((err) => console.error('achievements', err));
res.json({
user: publicUser(result),
prestige: serializePrestige(result),
+17 -41
View File
@@ -1,9 +1,5 @@
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';
import { getCompletedCatalogBadges } from '../catalog.js';
@@ -13,32 +9,15 @@ import {
checkAchievements,
getUnlockedAchievements,
} from '../achievements.js';
import { toCentsBigInt, centsJson } from '../cents.js';
import { createUploader, processUploadedImage, safeExt } from '../upload.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),
const upload = createUploader('avatar', {
maxBytes: 2 * 1024 * 1024,
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);
cb(null, `avatar-${req.session.userId}-${Date.now()}${safeExt(file.originalname, file.mimetype)}`);
},
});
@@ -70,6 +49,7 @@ function serializeInv(inv) {
paintSeed: inv.paintSeed,
valueCents: inv.valueCents,
favorite: Boolean(inv.favorite),
staked: Boolean(inv.staked),
item: {
id: inv.item.id,
name: inv.item.name,
@@ -103,18 +83,18 @@ router.get('/player/:username', async (req, res) => {
});
if (!user) return res.status(404).json({ error: 'Player not found' });
const inventoryValue = user.inventory.reduce((sum, inv) => sum + inv.valueCents, 0);
const inventoryValue = user.inventory.reduce(
(sum, inv) => sum + toCentsBigInt(inv.valueCents),
0n
);
await checkAchievements(user.id);
const [jackpotWins, duelWins, casesOpened, caseOpens, catalog, achievements] =
const [jackpotWins, casesOpened, caseOpens, catalog, achievements] =
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' },
}),
@@ -127,10 +107,9 @@ router.get('/player/:username', async (req, res) => {
user: publicUser(user),
profile: {
createdAt: user.createdAt,
inventoryValue,
inventoryValue: centsJson(inventoryValue),
inventoryCount: user.inventory.length,
jackpotWins,
duelWins,
casesOpened,
caseOpens,
catalogCompleted: catalog.completed,
@@ -158,20 +137,17 @@ router.get('/', requireAuth, async (req, res) => {
if (!user) return res.status(404).json({ error: 'User not found' });
const inventoryValue = user.inventory.reduce(
(sum, inv) => sum + (inv.locked ? 0 : inv.valueCents),
0
(sum, inv) => sum + (inv.locked ? 0n : toCentsBigInt(inv.valueCents)),
0n
);
await checkAchievements(user.id);
const [jackpotWins, duelWins, casesOpened, caseOpens, catalog, achievements] =
const [jackpotWins, casesOpened, caseOpens, catalog, achievements] =
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' },
}),
@@ -184,10 +160,9 @@ router.get('/', requireAuth, async (req, res) => {
user: publicUser(user),
profile: {
createdAt: user.createdAt,
inventoryValue,
inventoryValue: centsJson(inventoryValue),
inventoryCount: user.inventory.filter((i) => !i.locked).length,
jackpotWins,
duelWins,
casesOpened,
caseOpens,
catalogCompleted: catalog.completed,
@@ -256,6 +231,7 @@ router.post('/avatar', requireAuth, (req, res) => {
}
try {
await processUploadedImage(req.file);
const avatarUrl = `/uploads/${req.file.filename}`;
const user = await prisma.user.update({
where: { id: req.session.userId },
+79 -28
View File
@@ -3,17 +3,18 @@ import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { checkAchievements } from '../achievements.js';
import { adCooldownMs } from '../prestige.js';
import { centsJson, creditUserBalance } from '../cents.js';
import {
getCreditPack,
paymentsProvider,
scaleCreditPack,
scaleCreditPacks,
} from '../shopCatalog.js';
import { listEnabledShopOsuPacks, isOsuTopupsSectionEnabled } from '../shopOsu.js';
import { confirmMockOsuPayment, createOsuCheckout } from '../payments.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_REWARD_MIN = 10000; // 100.00 cr
const AD_REWARD_MAX = 20000; // 200.00 cr
@@ -38,13 +39,19 @@ router.get('/', requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
if (!user) return res.status(404).json({ error: 'User not found' });
const packages = SHOP_PACKAGES.map((pack) => ({
...pack,
baseOsuCost: pack.osuCost,
}));
const packages = scaleCreditPacks(user.balance);
const sectionEnabled = await isOsuTopupsSectionEnabled();
const osuTopups = sectionEnabled ? await listEnabledShopOsuPacks() : [];
res.json({
packages,
osuTopups,
osuTopupsEnabled: sectionEnabled && osuTopups.length > 0,
payments: {
provider: paymentsProvider(),
currency: 'eur',
mock: paymentsProvider() === 'mock',
},
user: publicUser(user),
ad: adPayload(user),
});
@@ -54,11 +61,12 @@ router.get('/', requireAuth, async (req, res) => {
}
});
/** Spend osu → credits (amount scaled to live balance at purchase time). */
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) {
const packDef = getCreditPack(packageId);
if (!packDef) {
return res.status(400).json({ error: 'Unknown package' });
}
@@ -69,6 +77,7 @@ router.post('/buy', requireAuth, async (req, res) => {
err.status = 404;
throw err;
}
const pack = scaleCreditPack(packDef, user.balance);
const osuCost = pack.osuCost;
if (user.osuBalance < osuCost) {
const err = new Error('Not enough osu');
@@ -76,35 +85,36 @@ router.post('/buy', requireAuth, async (req, res) => {
throw err;
}
const updated = await tx.user.update({
await tx.user.update({
where: { id: user.id },
data: {
osuBalance: { decrement: osuCost },
balance: { increment: pack.credits },
},
data: { osuBalance: { decrement: osuCost } },
});
const updated = await creditUserBalance(tx, user.id, pack.credits);
await tx.transaction.create({
data: {
userId: user.id,
type: 'shop_purchase',
amount: pack.credits,
amount: centsJson(pack.credits),
meta: JSON.stringify({
packageId: pack.id,
osuCost,
credits: pack.credits,
balancePct: pack.balancePct,
balanceBefore: String(user.balance),
scaled: pack.scaled,
}),
},
});
return { user: updated, osuCost };
return { user: updated, osuCost, pack };
});
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
res.json({
user: publicUser(result.user),
package: { ...pack, osuCost: result.osuCost },
package: result.pack,
});
} catch (err) {
const status = err.status || 500;
@@ -113,6 +123,49 @@ router.post('/buy', requireAuth, async (req, res) => {
}
});
/** Start real-money → osu checkout (mock or future Stripe). */
router.post('/osu/checkout', requireAuth, async (req, res) => {
try {
const packId = String(req.body.packId || '');
const checkout = await createOsuCheckout(req.session.userId, packId);
res.json(checkout);
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Checkout failed' });
}
});
/** Complete mock payment and credit osu. */
router.post('/osu/confirm', requireAuth, async (req, res) => {
try {
const orderId = String(req.body.orderId || '');
const result = await confirmMockOsuPayment(req.session.userId, orderId);
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
res.json({
user: publicUser(result.user),
order: result.order,
alreadyPaid: result.alreadyPaid,
osuGranted: result.order.osuAmount,
});
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Payment confirmation failed' });
}
});
/**
* Stripe webhook stub — verify signature & fulfill when PAYMENTS_PROVIDER=stripe.
* Keep raw body parsing in mind when wiring Stripe.
*/
router.post('/osu/webhook', async (req, res) => {
if (paymentsProvider() !== 'stripe') {
return res.status(404).json({ error: 'Stripe webhooks not enabled' });
}
res.status(501).json({ error: 'Stripe webhook not implemented yet' });
});
router.post('/ad', requireAuth, async (req, res) => {
try {
const result = await prisma.$transaction(async (tx) => {
@@ -138,19 +191,17 @@ router.post('/ad', requireAuth, async (req, res) => {
AD_REWARD_MIN + Math.floor(Math.random() * (AD_REWARD_MAX - AD_REWARD_MIN + 1));
const now = new Date();
const updated = await tx.user.update({
await tx.user.update({
where: { id: user.id },
data: {
balance: { increment: reward },
lastAdAt: now,
},
data: { lastAdAt: now },
});
const updated = await creditUserBalance(tx, user.id, reward);
await tx.transaction.create({
data: {
userId: user.id,
type: 'ad_reward',
amount: reward,
amount: centsJson(reward),
meta: JSON.stringify({ reward }),
},
});
+37 -46
View File
@@ -4,8 +4,7 @@ import { requireAuth, publicUser } from '../middleware.js';
import {
maxVaultSlots,
settleVaultYield,
summarizeVaultRates,
estimatePendingYield,
summarizeVaultState,
} from '../vault.js';
import { isConnected } from '../presence.js';
@@ -18,6 +17,8 @@ router.post('/stake', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Select at least one item to stake' });
}
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
@@ -61,16 +62,31 @@ router.post('/stake', requireAuth, async (req, res) => {
},
});
return { user, stakedIds: ids, maxSlots, used: used + ids.length };
// Shared timer: start only when vault was empty
let updatedUser = user;
if (used === 0 || !user.vaultYieldAt) {
updatedUser = await tx.user.update({
where: { id: user.id },
data: { vaultYieldAt: now },
});
}
const staked = await tx.inventoryItem.findMany({
where: { userId: user.id, staked: true },
include: { item: true },
});
return {
user: updatedUser,
stakedIds: ids,
vault: summarizeVaultState(updatedUser, staked, mode),
};
});
res.json({
user: publicUser(result.user),
stakedIds: result.stakedIds,
vault: {
used: result.used,
slots: result.maxSlots,
},
vault: result.vault,
});
} catch (err) {
const status = err.status || 500;
@@ -86,7 +102,6 @@ router.post('/unstake', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Select at least one item to unstake' });
}
// Settle yield before unstake (online if connected, else offline)
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
const settled = await settleVaultYield(req.session.userId, mode);
@@ -120,16 +135,25 @@ router.post('/unstake', requireAuth, async (req, res) => {
},
});
const used = await tx.inventoryItem.count({
const remaining = await tx.inventoryItem.findMany({
where: { userId: user.id, staked: true },
include: { item: true },
});
const refreshed = await tx.user.findUnique({ where: { id: user.id } });
let refreshed = user;
if (remaining.length === 0 && user.vaultYieldAt) {
refreshed = await tx.user.update({
where: { id: user.id },
data: { vaultYieldAt: null },
});
} else {
refreshed = await tx.user.findUnique({ where: { id: user.id } });
}
return {
user: refreshed,
unstakedIds: ids,
maxSlots: maxVaultSlots(refreshed),
used,
vault: summarizeVaultState(refreshed, remaining, mode),
};
});
@@ -137,10 +161,7 @@ router.post('/unstake', requireAuth, async (req, res) => {
user: publicUser(result.user),
unstakedIds: result.unstakedIds,
yieldCredited: settled.credited,
vault: {
used: result.used,
slots: result.maxSlots,
},
vault: result.vault,
});
} catch (err) {
const status = err.status || 500;
@@ -149,34 +170,4 @@ router.post('/unstake', requireAuth, async (req, res) => {
}
});
router.post('/claim', requireAuth, async (req, res) => {
try {
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
const settled = await settleVaultYield(req.session.userId, mode);
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
if (!user) return res.status(404).json({ error: 'User not found' });
const staked = await prisma.inventoryItem.findMany({
where: { userId: user.id, staked: true },
include: { item: true },
});
const rates = summarizeVaultRates(staked, user);
res.json({
user: publicUser(user),
credited: settled.credited,
ticks: settled.ticks,
vault: {
used: staked.length,
slots: maxVaultSlots(user),
...rates,
pendingYield: estimatePendingYield(staked, user, mode),
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message || 'Failed to claim vault yield' });
}
});
export default router;
+154
View File
@@ -0,0 +1,154 @@
import { prisma } from './db.js';
import { clearDropFeed } from './feed.js';
import { getIO } from './realtime.js';
/**
* Player progress wiped on server prestige.
* Kept: username, password, avatar, bio, role, createdAt (Joined),
* playTimeSeconds, lastConnection, achievements,
* and lifetime stats via transactions open_case / jackpot_win.
*/
export const PLAYER_PROGRESS_RESET = {
balance: '0',
osuBalance: 0,
prBalance: '0',
lastAdAt: null,
autoSellEnabled: false,
autoSellThresholdCents: '1000',
prestigeCount: 0,
prestigeChanceBonus: 0,
prestigeGainBonus: 0,
prestigeWearBonus: 0,
prestigePrBonus: 0,
prestigeOpenBonus: 0,
prestigeQualityBonus: 0,
prestigeAnimReduction: 0,
prestigeRefundChance: 0,
prestigeRespinChance: 0,
prestigeVaultSlots: 0,
prestigeOfflineYield: 0,
prestigeOnlineYield: 0,
vaultYieldAt: null,
};
/** Transaction types that back profile lifetime stats — never wiped. */
export const STATS_TRANSACTION_TYPES = ['open_case', 'jackpot_win'];
export function slugifyAchievementKey(raw) {
const s = String(raw || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_|_$/g, '')
.slice(0, 48);
return s || `server_prestige_${Date.now()}`;
}
/**
* Wipe non-admin economy / prestige progress.
* Preserves identity, presence stats, achievements, and lifetime win/open logs.
*/
export async function runServerPrestige({
achievementKey,
achievementName,
achievementDescription,
achievementIcon = '♛',
}) {
const key = slugifyAchievementKey(achievementKey);
const name = String(achievementName || '').trim().slice(0, 80);
const description = String(achievementDescription || '').trim().slice(0, 240);
const icon = String(achievementIcon || '♛').trim().slice(0, 8) || '♛';
if (!name) {
const err = new Error('Achievement name is required');
err.status = 400;
throw err;
}
if (!description) {
const err = new Error('Achievement description is required');
err.status = 400;
throw err;
}
const result = await prisma.$transaction(
async (tx) => {
const players = await tx.user.findMany({
where: { role: { not: 'admin' } },
select: { id: true },
});
const ids = players.map((p) => p.id);
// Active battle pots / locks (inventory FKs)
await tx.jackpotDeposit.deleteMany({});
await tx.jackpotRound.deleteMany({});
if (ids.length) {
await tx.prestigeLog.deleteMany({ where: { userId: { in: ids } } });
await tx.catalogEntry.deleteMany({ where: { userId: { in: ids } } });
await tx.caseKeyProgress.deleteMany({ where: { userId: { in: ids } } });
// Keep open_case / jackpot_win for profile stats
await tx.transaction.deleteMany({
where: {
userId: { in: ids },
type: { notIn: STATS_TRANSACTION_TYPES },
},
});
await tx.inventoryItem.deleteMany({ where: { userId: { in: ids } } });
await tx.user.updateMany({
where: { id: { in: ids } },
data: PLAYER_PROGRESS_RESET,
});
}
const maxOrder = await tx.achievement.aggregate({ _max: { sortOrder: true } });
const sortOrder = (maxOrder._max.sortOrder ?? 0) + 1;
const achievement = await tx.achievement.upsert({
where: { key },
create: { key, name, description, icon, sortOrder },
update: { name, description, icon },
});
if (ids.length) {
const already = await tx.userAchievement.findMany({
where: { userId: { in: ids }, achievementId: achievement.id },
select: { userId: true },
});
const have = new Set(already.map((r) => r.userId));
const missing = ids.filter((userId) => !have.has(userId));
if (missing.length) {
await tx.userAchievement.createMany({
data: missing.map((userId) => ({
userId,
achievementId: achievement.id,
})),
});
}
}
return {
playersReset: ids.length,
achievement: {
id: achievement.id,
key: achievement.key,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
},
};
},
{ timeout: 120_000, maxWait: 20_000 }
);
clearDropFeed();
const io = getIO();
if (io) {
io.emit('server:prestige-reset', {
at: new Date().toISOString(),
achievement: result.achievement,
playersReset: result.playersReset,
});
}
return result;
}
-544
View File
@@ -1,544 +0,0 @@
import { prisma } from '../db.js';
import { pickWeighted } from '../weighted.js';
import { applyAutoSell } from '../autoSell.js';
import { emitDuel, emitDuelsList } from '../realtime.js';
import { checkAchievements } from '../achievements.js';
const SPIN_MS = 5500;
/** Open rooms with zero deposits older than this are auto-cancelled. */
const EMPTY_ROOM_TTL_MS = 3 * 60 * 1000;
const pendingSpins = new Map();
let emptyRoomPurgeTimer = null;
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.valueCents,
valueCents: d.valueCents,
wear: d.inventoryItem.wear,
floatValue: d.inventoryItem.floatValue,
paintSeed: d.inventoryItem.paintSeed,
}
: 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() {
await purgeStaleEmptyDuelRooms();
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'),
};
}
/**
* Cancel open 1vX rooms that still have no bets (not even the creator)
* after EMPTY_ROOM_TTL_MS.
*/
export async function purgeStaleEmptyDuelRooms() {
const cutoff = new Date(Date.now() - EMPTY_ROOM_TTL_MS);
const stale = await prisma.duelRoom.findMany({
where: {
status: 'open',
createdAt: { lt: cutoff },
deposits: { none: {} },
},
select: { id: true },
});
if (!stale.length) return 0;
const ids = stale.map((r) => r.id);
await prisma.duelRoom.updateMany({
where: { id: { in: ids }, status: 'open' },
data: { status: 'cancelled', resolvedAt: new Date() },
});
for (const id of ids) {
emitDuel(id, 'duel:state', {
id,
status: 'cancelled',
playerCount: 0,
deposits: [],
players: [],
totalValue: 0,
});
}
await broadcastDuelsList();
return ids.length;
}
export function startEmptyDuelRoomPurge() {
if (emptyRoomPurgeTimer) return;
emptyRoomPurgeTimer = setInterval(() => {
purgeStaleEmptyDuelRooms().catch((err) =>
console.error('purge stale empty duel rooms', err)
);
}, 30_000);
// Run once shortly after boot
setTimeout(() => {
purgeStaleEmptyDuelRooms().catch((err) =>
console.error('purge stale empty duel rooms', err)
);
}, 5_000);
}
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, staked: false },
include: { item: true },
});
if (invItems.length !== ids.length) {
throw Object.assign(new Error('Invalid, locked, or staked inventory items'), { status: 400 });
}
for (const inv of invItems) {
if (inv.valueCents < 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.valueCents,
},
});
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.valueCents,
wear: inv.wear,
floatValue: inv.floatValue,
}),
},
});
}
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);
let winnerId = null;
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;
winnerId = current.winnerUserId;
for (const d of current.deposits) {
await tx.inventoryItem.update({
where: { id: d.inventoryItemId },
data: { userId: winnerId, locked: false },
});
}
const acquiredIds = current.deposits
.filter((d) => d.userId !== winnerId)
.map((d) => d.inventoryItemId);
let autoSellTotal = 0;
if (acquiredIds.length) {
const winner = await tx.user.findUnique({ where: { id: winnerId } });
const acquiredItems = await tx.inventoryItem.findMany({
where: { id: { in: acquiredIds } },
});
const { balanceDelta } = await applyAutoSell(tx, winner, acquiredItems);
autoSellTotal = balanceDelta;
}
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),
autoSellTotal,
}),
},
});
if (autoSellTotal > 0) {
await tx.user.update({
where: { id: winnerId },
data: { balance: { increment: autoSellTotal } },
});
}
return tx.duelRoom.update({
where: { id: current.id },
data: { status: 'resolved', resolvedAt: new Date() },
include: roomInclude(),
});
});
if (!room) return;
if (winnerId) {
checkAchievements(winnerId).catch((err) => console.error('achievements', err));
}
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);
}
}
+328 -252
View File
@@ -1,9 +1,10 @@
import { prisma } from '../db.js';
import { pickWeighted } from '../weighted.js';
import { applyAutoSell } from '../autoSell.js';
import { emitJackpot } from '../realtime.js';
import { emitJackpot, emitJackpotsList } from '../realtime.js';
import { pushDrop } from '../feed.js';
import { checkAchievements } from '../achievements.js';
import { toCentsBigInt, centsJson, creditUserBalance } from '../cents.js';
const ROUND_SECONDS = Number(process.env.JACKPOT_ROUND_SECONDS || 30);
const MAX_ITEMS = Number(process.env.JACKPOT_MAX_ITEMS || 3);
@@ -12,8 +13,8 @@ const SPIN_MS = 5500;
/** Sentinel endsAt while waiting for enough players (timer not armed). */
const WAITING_ENDS_AT = new Date('2099-01-01T00:00:00.000Z');
let resolveTimer = null;
let spinFollowUp = null;
const resolveTimers = new Map();
const spinFollowUps = new Map();
function isTimerWaiting(endsAt) {
return new Date(endsAt).getFullYear() >= 2099;
@@ -30,6 +31,14 @@ function depositInclude() {
};
}
function roundInclude() {
return {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true, avatarUrl: true } },
creator: { select: { id: true, username: true, avatarUrl: true } },
};
}
export function serializeJackpotRound(round) {
if (!round) return null;
@@ -56,24 +65,27 @@ export function serializeJackpotRound(round) {
}));
const byUser = new Map();
let totalValue = 0;
let totalValue = 0n;
for (const d of deposits) {
totalValue += d.valueCents;
const v = toCentsBigInt(d.valueCents);
totalValue += v;
const prev = byUser.get(d.userId) || {
userId: d.userId,
username: d.username,
avatarUrl: d.avatarUrl,
valueCents: 0,
valueCents: 0n,
itemCount: 0,
};
prev.valueCents += d.valueCents;
prev.valueCents += v;
prev.itemCount += 1;
byUser.set(d.userId, prev);
}
const players = [...byUser.values()].map((p) => ({
...p,
chance: totalValue > 0 ? (p.valueCents / totalValue) * 100 : 0,
valueCents: centsJson(p.valueCents),
chance:
totalValue > 0n ? Number((p.valueCents * 10000n) / totalValue) / 100 : 0,
}));
const waitingForPlayers =
@@ -82,6 +94,9 @@ export function serializeJackpotRound(round) {
return {
id: round.id,
status: round.status,
creatorId: round.creatorId || null,
creatorUsername: round.creator?.username || null,
name: `Battle ${round.id}`,
endsAt: waitingForPlayers ? null : round.endsAt,
waitingForPlayers,
roundSeconds: ROUND_SECONDS,
@@ -90,133 +105,198 @@ export function serializeJackpotRound(round) {
resolvedAt: round.resolvedAt || null,
winnerUserId: round.winnerUserId || null,
winnerUsername: round.winner?.username || null,
totalValue,
totalValue: centsJson(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 } },
async function loadRound(id) {
return prisma.jackpotRound.findUnique({
where: { id: Number(id) },
include: roundInclude(),
});
}
export async function getJackpotRound(id) {
return serializeJackpotRound(await loadRound(id));
}
export async function listJackpotBattles() {
const rounds = await prisma.jackpotRound.findMany({
where: {
OR: [
{ status: 'spinning' },
{ status: 'open', deposits: { some: {} } },
],
},
include: roundInclude(),
orderBy: { id: 'desc' },
});
return rounds.map(serializeJackpotRound);
}
export async function getJackpotState() {
const round = await loadOpenOrLatest();
return serializeJackpotRound(round);
export async function broadcastJackpotsList() {
const battles = await listJackpotBattles();
emitJackpotsList({ battles });
return battles;
}
export async function broadcastJackpot(extra = {}) {
const state = await getJackpotState();
emitJackpot('jackpot:state', { ...state, ...extra });
return state;
}
async function createOpenRound() {
return prisma.jackpotRound.create({
data: { status: 'open', endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
}
function clearResolveTimer() {
if (resolveTimer) {
clearTimeout(resolveTimer);
resolveTimer = null;
function clearResolveTimer(roundId) {
const t = resolveTimers.get(roundId);
if (t) {
clearTimeout(t);
resolveTimers.delete(roundId);
}
}
function clearTimers() {
clearResolveTimer();
if (spinFollowUp) {
clearTimeout(spinFollowUp);
spinFollowUp = null;
function clearSpinFollowUp(roundId) {
const t = spinFollowUps.get(roundId);
if (t) {
clearTimeout(t);
spinFollowUps.delete(roundId);
}
}
function scheduleResolve(endsAt) {
clearResolveTimer();
function clearTimers(roundId) {
clearResolveTimer(roundId);
clearSpinFollowUp(roundId);
}
function scheduleResolve(roundId, endsAt) {
clearResolveTimer(roundId);
if (isTimerWaiting(endsAt)) return;
const delay = Math.max(0, new Date(endsAt).getTime() - Date.now());
resolveTimer = setTimeout(() => {
resolveJackpotRound().catch((err) => console.error('jackpot resolve', err));
}, delay);
resolveTimers.set(
roundId,
setTimeout(() => {
resolveJackpotRound(roundId).catch((err) =>
console.error('jackpot resolve', err)
);
}, delay)
);
}
/**
* Recover timers for all active battles (boot). Does not create empty rounds.
* Cancels orphan open battles with zero deposits (legacy singleton leftovers).
*/
export async function ensureJackpotRound() {
let round = await prisma.jackpotRound.findFirst({
where: { status: 'open' },
include: { deposits: true },
orderBy: { id: 'desc' },
await prisma.jackpotRound.updateMany({
where: {
status: 'open',
deposits: { none: {} },
},
data: { status: 'cancelled', resolvedAt: new Date() },
});
if (!round) {
const spinning = await prisma.jackpotRound.findFirst({
where: { status: 'spinning' },
orderBy: { id: 'desc' },
});
if (spinning) {
spinFollowUp = setTimeout(() => {
finishJackpotSpin(spinning.id).catch((err) => console.error(err));
}, 1000);
return getJackpotState();
const rounds = await prisma.jackpotRound.findMany({
where: { status: { in: ['open', 'spinning'] } },
include: { deposits: true },
});
for (const round of rounds) {
if (round.status === 'spinning') {
clearSpinFollowUp(round.id);
spinFollowUps.set(
round.id,
setTimeout(() => {
finishJackpotSpin(round.id).catch((err) => console.error(err));
}, 1000)
);
continue;
}
round = await createOpenRound();
return getJackpotState();
}
const players = uniquePlayerCount(round.deposits);
const players = uniquePlayerCount(round.deposits);
if (players < MIN_PLAYERS) {
if (!isTimerWaiting(round.endsAt)) {
await prisma.jackpotRound.update({
where: { id: round.id },
data: { endsAt: WAITING_ENDS_AT },
});
}
clearResolveTimer(round.id);
continue;
}
if (players < MIN_PLAYERS) {
if (!isTimerWaiting(round.endsAt)) {
if (isTimerWaiting(round.endsAt)) {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
await prisma.jackpotRound.update({
where: { id: round.id },
data: { endsAt: WAITING_ENDS_AT },
data: { endsAt },
});
scheduleResolve(round.id, endsAt);
continue;
}
clearResolveTimer();
return getJackpotState();
if (new Date(round.endsAt).getTime() <= Date.now()) {
await resolveJackpotRound(round.id);
continue;
}
scheduleResolve(round.id, round.endsAt);
}
if (isTimerWaiting(round.endsAt)) {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
await prisma.jackpotRound.update({
where: { id: round.id },
data: { endsAt },
});
scheduleResolve(endsAt);
return getJackpotState();
}
if (new Date(round.endsAt).getTime() <= Date.now()) {
await resolveJackpotRound();
return getJackpotState();
}
scheduleResolve(round.endsAt);
return getJackpotState();
return listJackpotBattles();
}
export async function placeJackpotBet(userId, inventoryItemIds) {
async function applyDeposits(tx, roundId, userId, ids) {
const existingCount = await tx.jackpotDeposit.count({
where: { roundId, 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, staked: false },
include: { item: true },
});
if (invItems.length !== ids.length) {
throw Object.assign(new Error('Invalid, locked, or staked 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,
userId,
inventoryItemId: inv.id,
valueCents: inv.valueCents,
},
});
await tx.transaction.create({
data: {
userId,
type: 'jackpot_bet',
amount: '0',
meta: JSON.stringify({
roundId,
inventoryItemId: inv.id,
itemId: inv.itemId,
valueCents: centsJson(inv.valueCents),
wear: inv.wear,
floatValue: inv.floatValue,
}),
},
});
}
}
/**
* Create a new battle by placing the first bet.
*/
export async function createJackpotBattle(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 });
@@ -226,74 +306,58 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
}
const round = await prisma.$transaction(async (tx) => {
const open = await tx.jackpotRound.findFirst({
where: { status: 'open' },
orderBy: { id: 'desc' },
const created = await tx.jackpotRound.create({
data: {
status: 'open',
endsAt: WAITING_ENDS_AT,
creatorId: userId,
},
});
if (!open) {
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
await applyDeposits(tx, created.id, userId, ids);
return tx.jackpotRound.findUnique({
where: { id: created.id },
include: roundInclude(),
});
});
const state = serializeJackpotRound(round);
emitJackpot(round.id, 'jackpot:state', state);
await broadcastJackpotsList();
return state;
}
export async function placeJackpotBet(roundId, userId, inventoryItemIds) {
const id = Number(roundId);
if (!id) {
throw Object.assign(new Error('Invalid battle'), { status: 400 });
}
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.findUnique({ where: { id } });
if (!open || open.status !== 'open') {
throw Object.assign(new Error('Battle is not open'), { status: 409 });
}
if (!isTimerWaiting(open.endsAt) && 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, staked: false },
include: { item: true },
});
if (invItems.length !== ids.length) {
throw Object.assign(new Error('Invalid, locked, or staked 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.valueCents,
},
});
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.valueCents,
wear: inv.wear,
floatValue: inv.floatValue,
}),
},
});
}
await applyDeposits(tx, open.id, userId, ids);
let next = await tx.jackpotRound.findUnique({
where: { id: open.id },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
include: roundInclude(),
});
// Arm countdown once we have enough distinct players
if (
uniquePlayerCount(next.deposits) >= MIN_PLAYERS &&
isTimerWaiting(next.endsAt)
@@ -302,10 +366,7 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
next = await tx.jackpotRound.update({
where: { id: next.id },
data: { endsAt },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
include: roundInclude(),
});
}
@@ -316,29 +377,35 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
uniquePlayerCount(round.deposits) >= MIN_PLAYERS &&
!isTimerWaiting(round.endsAt)
) {
scheduleResolve(round.endsAt);
scheduleResolve(round.id, round.endsAt);
}
const state = serializeJackpotRound(round);
emitJackpot('jackpot:state', state);
emitJackpot(round.id, 'jackpot:state', state);
await broadcastJackpotsList();
return state;
}
export async function withdrawJackpotBet(userId, inventoryItemIds) {
export async function withdrawJackpotBet(roundId, userId, inventoryItemIds) {
const id = Number(roundId);
if (!id) {
throw Object.assign(new Error('Invalid battle'), { status: 400 });
}
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 });
const open = await tx.jackpotRound.findUnique({ where: { id } });
if (!open || open.status !== 'open') {
throw Object.assign(new Error('Battle is not open'), { status: 409 });
}
if (!isTimerWaiting(open.endsAt) && new Date(open.endsAt).getTime() - Date.now() < 3000) {
if (
!isTimerWaiting(open.endsAt) &&
new Date(open.endsAt).getTime() - Date.now() < 3000
) {
throw Object.assign(new Error('Too late to withdraw'), { status: 409 });
}
@@ -363,33 +430,44 @@ export async function withdrawJackpotBet(userId, inventoryItemIds) {
let next = await tx.jackpotRound.findUnique({
where: { id: open.id },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
include: roundInclude(),
});
// Disarm timer if we drop below min players
if (uniquePlayerCount(next.deposits) < MIN_PLAYERS && !isTimerWaiting(next.endsAt)) {
next = await tx.jackpotRound.update({
where: { id: next.id },
data: { endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
include: roundInclude(),
});
}
// Creator left alone with no deposits → cancel empty battle
if (!next.deposits.length) {
next = await tx.jackpotRound.update({
where: { id: next.id },
data: { status: 'cancelled', resolvedAt: new Date() },
include: roundInclude(),
});
}
return next;
});
if (round.status === 'cancelled') {
clearTimers(round.id);
const state = serializeJackpotRound(round);
emitJackpot(round.id, 'jackpot:state', state);
await broadcastJackpotsList();
return state;
}
if (isTimerWaiting(round.endsAt) || uniquePlayerCount(round.deposits) < MIN_PLAYERS) {
clearResolveTimer();
clearResolveTimer(round.id);
}
const state = serializeJackpotRound(round);
emitJackpot('jackpot:state', state);
emitJackpot(round.id, 'jackpot:state', state);
await broadcastJackpotsList();
return state;
}
@@ -400,10 +478,7 @@ async function finishJackpotSpin(roundId) {
const result = await prisma.$transaction(async (tx) => {
const round = await tx.jackpotRound.findUnique({
where: { id: roundId },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true, avatarUrl: true } },
},
include: roundInclude(),
});
if (!round || round.status !== 'spinning') return null;
@@ -418,7 +493,7 @@ async function finishJackpotSpin(roundId) {
const acquiredIds = round.deposits
.filter((d) => d.userId !== winnerId)
.map((d) => d.inventoryItemId);
let autoSellTotal = 0;
let autoSellTotal = 0n;
if (acquiredIds.length) {
const winner = await tx.user.findUnique({ where: { id: winnerId } });
const acquiredItems = await tx.inventoryItem.findMany({
@@ -428,39 +503,39 @@ async function finishJackpotSpin(roundId) {
autoSellTotal = balanceDelta;
}
const totalValue = round.deposits.reduce((s, d) => s + d.valueCents, 0);
const totalValue = round.deposits.reduce(
(s, d) => s + toCentsBigInt(d.valueCents),
0n
);
await tx.transaction.create({
data: {
userId: winnerId,
type: 'jackpot_win',
amount: totalValue,
amount: centsJson(totalValue),
meta: JSON.stringify({
roundId: round.id,
depositIds: round.deposits.map((d) => d.id),
autoSellTotal,
autoSellTotal: centsJson(autoSellTotal),
}),
},
});
if (autoSellTotal > 0) {
await tx.user.update({
where: { id: winnerId },
data: { balance: { increment: autoSellTotal } },
});
if (autoSellTotal > 0n) {
await creditUserBalance(tx, winnerId, autoSellTotal);
}
await tx.jackpotRound.update({
const resolved = await tx.jackpotRound.update({
where: { id: round.id },
data: { status: 'resolved', resolvedAt: new Date() },
include: roundInclude(),
});
// Best item in the pot (highest instance value) for Feed Drop, with xN count
let best = null;
for (const d of round.deposits) {
const inv = d.inventoryItem;
const item = inv?.item;
if (!item) continue;
if (!best || d.valueCents > best.deposit.valueCents) {
if (!best || toCentsBigInt(d.valueCents) > toCentsBigInt(best.deposit.valueCents)) {
best = { deposit: d, item, inv };
}
}
@@ -476,7 +551,7 @@ async function finishJackpotSpin(roundId) {
).length;
feedEntry = {
source: 'jackpot',
caseName: 'MultiPlayers Case',
caseName: `Battle ${round.id}`,
count,
openCount: round.deposits.length,
inventoryIds: round.deposits.map((d) => d.inventoryItemId).filter(Boolean),
@@ -497,9 +572,11 @@ async function finishJackpotSpin(roundId) {
};
}
return createOpenRoundInTx(tx);
return resolved;
});
clearTimers(roundId);
if (!result) return;
if (winnerId) {
@@ -515,72 +592,56 @@ async function finishJackpotSpin(roundId) {
}
const state = serializeJackpotRound(result);
// New round waits for players — do not arm timer yet
clearResolveTimer();
emitJackpot('jackpot:result', {
emitJackpot(roundId, 'jackpot:result', {
previousRoundId: roundId,
...state,
});
emitJackpot('jackpot:state', state);
await broadcastJackpotsList();
}
async function createOpenRoundInTx(tx) {
return tx.jackpotRound.create({
data: { status: 'open', endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
}
export async function resolveJackpotRound() {
clearTimers();
export async function resolveJackpotRound(roundId) {
const id = Number(roundId);
clearTimers(id);
const spun = await prisma.$transaction(async (tx) => {
const open = await tx.jackpotRound.findFirst({
where: { status: 'open' },
const open = await tx.jackpotRound.findUnique({
where: { id },
include: {
deposits: { include: depositInclude() },
},
orderBy: { id: 'desc' },
});
if (!open) return null;
if (!open || open.status !== 'open') return null;
const players = uniquePlayerCount(open.deposits);
if (players < MIN_PLAYERS) {
if (players < MIN_PLAYERS || !open.deposits.length) {
const waiting = await tx.jackpotRound.update({
where: { id: open.id },
data: { endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
return { kind: 'waiting', updated: waiting };
}
if (!open.deposits.length) {
const waiting = await tx.jackpotRound.update({
where: { id: open.id },
data: { endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
include: roundInclude(),
});
return { kind: 'waiting', updated: waiting };
}
const byUser = new Map();
for (const d of open.deposits) {
byUser.set(d.userId, (byUser.get(d.userId) || 0) + d.valueCents);
byUser.set(
d.userId,
(byUser.get(d.userId) || 0n) + toCentsBigInt(d.valueCents)
);
}
const entries = [...byUser.entries()].map(([userId, dropRate]) => ({
userId,
dropRate,
}));
const winner = pickWeighted(entries);
const maxV = [...byUser.values()].reduce((a, b) => (a > b ? a : b), 0n);
const weightEntries =
maxV > BigInt(Number.MAX_SAFE_INTEGER)
? [...byUser.entries()].map(([userId, v]) => ({
userId,
dropRate: Number((v * 1_000_000n) / maxV) || 1,
}))
: [...byUser.entries()].map(([userId, v]) => ({
userId,
dropRate: Number(v),
}));
const winner = pickWeighted(weightEntries);
const winnerDeposits = open.deposits.filter((d) => d.userId === winner.userId);
const reelDepositId = winnerDeposits[0]?.id || null;
@@ -588,10 +649,7 @@ export async function resolveJackpotRound() {
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 } },
},
include: roundInclude(),
});
return { kind: 'spin', updated, reelItemId: reelDepositId };
@@ -599,23 +657,41 @@ export async function resolveJackpotRound() {
if (!spun) return;
if (spun.kind === 'waiting' || spun.kind === 'extend') {
if (spun.kind === 'waiting') {
const state = serializeJackpotRound(spun.updated);
clearResolveTimer();
emitJackpot('jackpot:state', state);
clearResolveTimer(id);
emitJackpot(id, 'jackpot:state', state);
await broadcastJackpotsList();
return state;
}
const { updated, reelItemId } = spun;
const state = serializeJackpotRound(updated);
emitJackpot('jackpot:spin', {
emitJackpot(id, 'jackpot:spin', {
...state,
reelWinnerItemId: reelItemId,
});
await broadcastJackpotsList();
spinFollowUp = setTimeout(() => {
finishJackpotSpin(updated.id).catch((err) => console.error('jackpot finish', err));
}, SPIN_MS + 200);
spinFollowUps.set(
updated.id,
setTimeout(() => {
finishJackpotSpin(updated.id).catch((err) =>
console.error('jackpot finish', err)
);
}, SPIN_MS + 200)
);
return state;
}
/** @deprecated use listJackpotBattles / getJackpotRound */
export async function getJackpotState() {
const battles = await listJackpotBattles();
return battles[0] || null;
}
export async function broadcastJackpot(extra = {}) {
const battles = await broadcastJackpotsList();
return battles[0] ? { ...battles[0], ...extra } : null;
}
+175
View File
@@ -0,0 +1,175 @@
import { toCentsBigInt } from './cents.js';
/**
* Shop catalogs.
* - OSU_TOPUPS: real-money → osu (premium currency)
* - CREDIT_PACKS: osu → in-game credits (scaled to player balance)
*
* Fiat prices are in minor units (EUR cents). Swap provider later via PAYMENTS_PROVIDER.
*/
/** @typedef {{ id: string, name: string, osu: number, priceCents: number, currency: string, badge?: string|null, blurb: string, perOsuLabel: string }} OsuTopup */
/** @type {OsuTopup[]} */
export const OSU_TOPUPS = [
{
id: 'osu_50',
name: 'Spark',
osu: 50,
priceCents: 499,
currency: 'eur',
badge: null,
blurb: 'A light top-up to open a few cases',
perOsuLabel: '€0.10 / osu',
},
{
id: 'osu_150',
name: 'Pulse',
osu: 150,
priceCents: 1299,
currency: 'eur',
badge: 'Popular',
blurb: '150 osu · better rate than Spark',
perOsuLabel: '€0.087 / osu',
},
{
id: 'osu_400',
name: 'Orbit',
osu: 400,
priceCents: 2999,
currency: 'eur',
badge: null,
blurb: 'Solid stack for grinding cases & vault',
perOsuLabel: '€0.075 / osu',
},
{
id: 'osu_1000',
name: 'Nova',
osu: 1000,
priceCents: 6499,
currency: 'eur',
badge: 'Best value',
blurb: '1000 osu · lowest price per osu',
perOsuLabel: '€0.065 / osu',
},
{
id: 'osu_2500',
name: 'Eclipse',
osu: 2500,
priceCents: 14999,
currency: 'eur',
badge: null,
blurb: 'Whale pack for long prestige runs',
perOsuLabel: '€0.060 / osu',
},
];
/**
* Credit packs priced in osu. Rewards scale with the buyer's credit balance:
* credits = max(minCredits, balance * balancePct / 100).
* Larger packs grant a higher % of balance per osu.
*/
export const CREDIT_PACKS = [
{
id: 'cr_spark',
name: 'Credit Spark',
osuCost: 10,
balancePct: 20,
minCredits: 5000,
tag: null,
},
{
id: 'cr_boost',
name: 'Credit Boost',
osuCost: 25,
balancePct: 55,
minCredits: 15000,
tag: null,
},
{
id: 'cr_surge',
name: 'Credit Surge',
osuCost: 60,
balancePct: 140,
minCredits: 40000,
tag: 'Fair',
},
{
id: 'cr_nova',
name: 'Credit Nova',
osuCost: 150,
balancePct: 400,
minCredits: 110000,
tag: 'Popular',
},
{
id: 'cr_orbit',
name: 'Credit Orbit',
osuCost: 400,
balancePct: 1200,
minCredits: 320000,
tag: null,
},
{
id: 'cr_eclipse',
name: 'Credit Eclipse',
osuCost: 1000,
balancePct: 3500,
minCredits: 900000,
tag: 'Best rate',
},
];
/** @deprecated use CREDIT_PACKS */
export const SHOP_PACKAGES = CREDIT_PACKS;
export function getOsuTopup(id) {
return OSU_TOPUPS.find((p) => p.id === String(id || ''));
}
export function getCreditPack(id) {
return CREDIT_PACKS.find((p) => p.id === String(id || ''));
}
/** Resolve pack payout for a player's current balance (cents → string cents). */
export function scaleCreditPack(pack, balanceCents) {
const bal = toCentsBigInt(balanceCents);
const pct = Math.max(0, Math.floor(Number(pack.balancePct) || 0));
const fromPct = pct > 0 ? (bal * BigInt(pct)) / 100n : 0n;
const min = BigInt(Math.max(0, Math.floor(Number(pack.minCredits) || 0)));
const credits = fromPct > min ? fromPct : min;
return {
id: pack.id,
name: pack.name,
osuCost: pack.osuCost,
baseOsuCost: pack.osuCost,
balancePct: pct,
minCredits: min.toString(),
credits: credits.toString(),
tag: pack.tag ?? null,
scaled: fromPct > min,
};
}
export function scaleCreditPacks(balanceCents) {
return CREDIT_PACKS.map((pack) => scaleCreditPack(pack, balanceCents));
}
export function formatFiat(priceCents, currency = 'eur') {
const n = Number(priceCents) || 0;
const major = n / 100;
try {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: String(currency || 'eur').toUpperCase(),
}).format(major);
} catch {
return `${major.toFixed(2)}`;
}
}
export function paymentsProvider() {
const raw = String(process.env.PAYMENTS_PROVIDER || 'mock').toLowerCase();
if (raw === 'stripe' && process.env.STRIPE_SECRET_KEY) return 'stripe';
return 'mock';
}
+160
View File
@@ -0,0 +1,160 @@
import { prisma } from './db.js';
import { OSU_TOPUPS, formatFiat } from './shopCatalog.js';
export const OSU_SECTION_KEY = 'shop.osuTopupsEnabled';
function serializePack(row) {
if (!row) return null;
return {
id: row.id,
name: row.name,
osu: row.osu,
priceCents: row.priceCents,
currency: row.currency || 'eur',
badge: row.badge || null,
blurb: row.blurb || '',
perOsuLabel: row.perOsuLabel || '',
enabled: Boolean(row.enabled),
sortOrder: row.sortOrder ?? 0,
displayPrice: formatFiat(row.priceCents, row.currency),
updatedAt: row.updatedAt || null,
};
}
/** Seed missing packs from code defaults (does not overwrite existing rows). */
export async function ensureShopOsuPacks() {
const existing = await prisma.shopOsuPack.findMany({ select: { id: true } });
const have = new Set(existing.map((r) => r.id));
let order = 0;
for (const pack of OSU_TOPUPS) {
if (have.has(pack.id)) continue;
await prisma.shopOsuPack.create({
data: {
id: pack.id,
name: pack.name,
osu: pack.osu,
priceCents: pack.priceCents,
currency: pack.currency || 'eur',
badge: pack.badge || '',
blurb: pack.blurb || '',
perOsuLabel: pack.perOsuLabel || '',
enabled: true,
sortOrder: order,
},
});
order += 1;
}
const setting = await prisma.appSetting.findUnique({ where: { key: OSU_SECTION_KEY } });
if (!setting) {
await prisma.appSetting.create({
data: { key: OSU_SECTION_KEY, value: '1' },
});
}
}
export async function isOsuTopupsSectionEnabled() {
const setting = await prisma.appSetting.findUnique({ where: { key: OSU_SECTION_KEY } });
if (!setting) return true;
return setting.value !== '0' && setting.value !== 'false';
}
export async function setOsuTopupsSectionEnabled(enabled) {
await prisma.appSetting.upsert({
where: { key: OSU_SECTION_KEY },
create: { key: OSU_SECTION_KEY, value: enabled ? '1' : '0' },
update: { value: enabled ? '1' : '0' },
});
return isOsuTopupsSectionEnabled();
}
export async function listShopOsuPacksAdmin() {
await ensureShopOsuPacks();
const rows = await prisma.shopOsuPack.findMany({ orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] });
const sectionEnabled = await isOsuTopupsSectionEnabled();
return {
sectionEnabled,
packs: rows.map(serializePack),
};
}
/** Public catalog: only when section on + pack enabled. */
export async function listEnabledShopOsuPacks() {
await ensureShopOsuPacks();
if (!(await isOsuTopupsSectionEnabled())) return [];
const rows = await prisma.shopOsuPack.findMany({
where: { enabled: true },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return rows.map(serializePack);
}
/** For checkout — must be enabled and section on. */
export async function getPurchasableOsuPack(packId) {
await ensureShopOsuPacks();
if (!(await isOsuTopupsSectionEnabled())) return null;
const row = await prisma.shopOsuPack.findUnique({ where: { id: String(packId || '') } });
if (!row || !row.enabled) return null;
return serializePack(row);
}
export async function updateShopOsuPack(id, body) {
const existing = await prisma.shopOsuPack.findUnique({ where: { id: String(id) } });
if (!existing) {
throw Object.assign(new Error('Pack not found'), { status: 404 });
}
const data = {};
if (body.name !== undefined) {
const name = String(body.name || '').trim();
if (!name) throw Object.assign(new Error('Name is required'), { status: 400 });
data.name = name;
}
if (body.osu !== undefined) {
const osu = Number(body.osu);
if (!Number.isInteger(osu) || osu < 1) {
throw Object.assign(new Error('osu must be a positive integer'), { status: 400 });
}
data.osu = osu;
}
if (body.priceCents !== undefined) {
const priceCents = Number(body.priceCents);
if (!Number.isInteger(priceCents) || priceCents < 0) {
throw Object.assign(new Error('priceCents must be a non-negative integer'), { status: 400 });
}
data.priceCents = priceCents;
}
if (body.currency !== undefined) {
data.currency = String(body.currency || 'eur').toLowerCase().slice(0, 8) || 'eur';
}
if (body.badge !== undefined) {
data.badge = body.badge == null ? '' : String(body.badge).trim();
}
if (body.blurb !== undefined) {
data.blurb = String(body.blurb || '').trim();
}
if (body.perOsuLabel !== undefined) {
data.perOsuLabel = String(body.perOsuLabel || '').trim();
}
if (body.enabled !== undefined) {
data.enabled = Boolean(body.enabled);
}
if (body.sortOrder !== undefined) {
const sortOrder = Number(body.sortOrder);
if (!Number.isInteger(sortOrder)) {
throw Object.assign(new Error('sortOrder must be an integer'), { status: 400 });
}
data.sortOrder = sortOrder;
}
if (!Object.keys(data).length) {
throw Object.assign(new Error('No changes provided'), { status: 400 });
}
const updated = await prisma.shopOsuPack.update({
where: { id: existing.id },
data,
});
return serializePack(updated);
}
+104 -9
View File
@@ -1,31 +1,126 @@
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import sharp from 'sharp';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const uploadsDir = path.join(__dirname, '../uploads');
/** Max stored dimensions (aspect ratio preserved, never upscaled). */
export const MAX_IMAGE_WIDTH = 1920;
export const MAX_IMAGE_HEIGHT = 1080;
/** Raster only — no SVG (stored XSS if served as image/*). */
export const ALLOWED_IMAGE_MIMES = new Set([
'image/png',
'image/jpeg',
'image/jpg',
'image/webp',
'image/gif',
]);
export const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
/** sharp `metadata().format` values we accept */
const ALLOWED_SHARP_FORMATS = new Set(['png', 'jpeg', 'jpg', 'webp', 'gif']);
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
export function createUploader(prefix) {
export function safeExt(originalname, mimetype = '') {
const fromName = path.extname(originalname || '').toLowerCase();
if (ALLOWED_IMAGE_EXTS.has(fromName)) return fromName === '.jpeg' ? '.jpg' : fromName;
if (mimetype === 'image/png') return '.png';
if (mimetype === 'image/webp') return '.webp';
if (mimetype === 'image/gif') return '.gif';
if (mimetype === 'image/jpeg' || mimetype === 'image/jpg') return '.jpg';
return '.png';
}
function isAllowedUpload(file) {
const mime = String(file?.mimetype || '').toLowerCase();
const ext = path.extname(file?.originalname || '').toLowerCase();
if (!ALLOWED_IMAGE_MIMES.has(mime)) return false;
// If the client sent an extension, it must match the allowlist (blocks .svg renamed tricks loosely)
if (ext && !ALLOWED_IMAGE_EXTS.has(ext)) return false;
return true;
}
/**
* Downscale an uploaded image if larger than 1920×1080, keeping aspect ratio.
* Also rejects non-raster / spoofed files (e.g. SVG with a fake image/* type).
* Overwrites the file in place. No-ops when already within bounds.
*/
export async function processUploadedImage(file) {
if (!file?.path) return;
const filePath = file.path;
let meta;
try {
const animated = file.mimetype === 'image/gif';
meta = await sharp(filePath, { animated, failOn: 'none' }).metadata();
} catch {
await fs.promises.unlink(filePath).catch(() => {});
throw new Error('Invalid or unsupported image file');
}
const format = String(meta.format || '').toLowerCase();
if (!ALLOWED_SHARP_FORMATS.has(format)) {
await fs.promises.unlink(filePath).catch(() => {});
throw new Error('Only PNG, JPEG, WebP, and GIF images are allowed');
}
let width = meta.width || 0;
let height = meta.height || 0;
if (!width || !height) return;
// EXIF orientations 58 swap display width/height
const orientation = meta.orientation || 1;
if (orientation >= 5 && orientation <= 8) {
[width, height] = [height, width];
}
if (width <= MAX_IMAGE_WIDTH && height <= MAX_IMAGE_HEIGHT) return;
const tmpPath = `${filePath}.resized`;
try {
const animated = format === 'gif';
await sharp(filePath, { animated, failOn: 'none' })
.rotate() // bake EXIF orientation before resize
.resize({
width: MAX_IMAGE_WIDTH,
height: MAX_IMAGE_HEIGHT,
fit: 'inside',
withoutEnlargement: true,
})
.toFile(tmpPath);
await fs.promises.rename(tmpPath, filePath);
} catch (err) {
await fs.promises.unlink(tmpPath).catch(() => {});
throw err;
}
}
export function createUploader(prefix, { maxBytes = 3 * 1024 * 1024, filename } = {}) {
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}`);
},
filename:
filename ||
((_req, file, cb) => {
cb(
null,
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${safeExt(file.originalname, file.mimetype)}`
);
}),
});
return multer({
storage,
limits: { fileSize: 3 * 1024 * 1024 },
limits: { fileSize: maxBytes },
fileFilter: (_req, file, cb) => {
if (!file.mimetype.startsWith('image/')) {
return cb(new Error('Only images are allowed'));
if (!isAllowedUpload(file)) {
return cb(new Error('Only PNG, JPEG, WebP, and GIF images are allowed'));
}
cb(null, true);
},
+171 -59
View File
@@ -1,10 +1,18 @@
import { prisma } from './db.js';
import { applyRateFloor, toCentsBigInt, minBigInt, centsJson, creditUserBalance } from './cents.js';
import { getIO } from './realtime.js';
import { publicUser } from './middleware.js';
export const BASE_VAULT_SLOTS = 3;
export const VAULT_TICK_MS = 600_000; // 10 minutes
export const ONLINE_TICK_MS = 300_000; // 5 minutes
export const OFFLINE_TICK_MS = 600_000; // 10 minutes
/** @deprecated use ONLINE_TICK_MS / OFFLINE_TICK_MS */
export const VAULT_TICK_MS = OFFLINE_TICK_MS;
export const ONLINE_BASE_RATE = 0.1; // 10% of value per tick
export const OFFLINE_BASE_RATE = 0.01; // 1% of value per tick
export const WEAR_FLOAT_K = 3;
/** Soft cap: at most 500% of item value per tick */
export const MAX_TICK_VALUE_MULT = 5n;
/** Rarity → yield multiplier (seed rarities + fallbacks). */
export const RARITY_YIELD_MULT = {
@@ -17,6 +25,10 @@ export const RARITY_YIELD_MULT = {
Extraordinary: 1.8,
};
export function vaultTickMs(mode) {
return mode === 'online' ? ONLINE_TICK_MS : OFFLINE_TICK_MS;
}
export function maxVaultSlots(user) {
return BASE_VAULT_SLOTS + Math.max(0, Math.floor(Number(user?.prestigeVaultSlots) || 0));
}
@@ -41,117 +53,217 @@ export function skillYieldMult(user, mode) {
}
export function tickPayoutCents(inv, user, mode) {
const value = Math.max(0, Math.floor(Number(inv.valueCents) || 0));
if (value <= 0) return 0;
const value = toCentsBigInt(inv.valueCents);
if (value <= 0n) return 0n;
const baseRate = mode === 'online' ? ONLINE_BASE_RATE : OFFLINE_BASE_RATE;
const wear = wearYieldMult(inv.floatValue);
const rarity = rarityYieldMult(inv.item?.rarity ?? inv.rarity);
const skill = skillYieldMult(user, mode);
const payout = Math.floor(value * baseRate * wear * rarity * skill);
// Soft cap: at most 100% of item value per tick
return Math.min(value, Math.max(0, payout));
const combined = baseRate * wear * rarity * skill;
const payout = applyRateFloor(value, combined);
return minBigInt(value * MAX_TICK_VALUE_MULT, payout);
}
export function estimateTickYield(inv, user, mode) {
return tickPayoutCents(inv, user, mode);
}
/** Shared vault timer anchor (user-level). Falls back to oldest item stake/yield. */
export function resolveVaultAnchor(user, items, now = Date.now()) {
if (user?.vaultYieldAt) {
return new Date(user.vaultYieldAt).getTime();
}
let earliest = null;
for (const inv of items || []) {
const raw = inv.lastYieldAt || inv.stakedAt;
if (!raw) continue;
const ms = new Date(raw).getTime();
if (!Number.isFinite(ms)) continue;
if (earliest == null || ms < earliest) earliest = ms;
}
return earliest ?? now;
}
export function nextYieldAtMs(user, items, mode, now = Date.now()) {
if (!items?.length) return null;
const tickMs = vaultTickMs(mode);
const anchor = resolveVaultAnchor(user, items, now);
const ticksDone = Math.floor(Math.max(0, now - anchor) / tickMs);
return anchor + (ticksDone + 1) * tickMs;
}
/**
* Settle complete vault ticks for a user.
* Settle complete vault ticks for a user (shared timer across all staked items).
* @param {'online'|'offline'} mode
* @param {Date|number} [until] settle up to this time (default now)
* @returns {{ credited: number, ticks: number, itemIds: number[] }}
* @returns {{ credited: string, ticks: number, itemIds: number[], balance?: string, vault?: object }}
*/
export async function settleVaultYield(userId, mode, until = Date.now()) {
const id = Number(userId);
if (!id) return { credited: 0, ticks: 0, itemIds: [] };
if (!id) return { credited: '0', ticks: 0, itemIds: [] };
const untilMs = until instanceof Date ? until.getTime() : Number(until) || Date.now();
const tickMs = vaultTickMs(mode);
return prisma.$transaction(async (tx) => {
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id } });
if (!user) return { credited: 0, ticks: 0, itemIds: [] };
if (!user) return { credited: '0', ticks: 0, itemIds: [], user: null, items: [] };
const items = await tx.inventoryItem.findMany({
where: { userId: id, staked: true },
include: { item: true },
});
if (!items.length) return { credited: 0, ticks: 0, itemIds: [] };
if (!items.length) {
if (user.vaultYieldAt) {
await tx.user.update({ where: { id }, data: { vaultYieldAt: null } });
}
return { credited: '0', ticks: 0, itemIds: [], user, items: [] };
}
let totalCredit = 0;
let maxTicks = 0;
const anchorMs = resolveVaultAnchor(user, items, untilMs);
const elapsed = Math.max(0, untilMs - anchorMs);
const ticks = Math.floor(elapsed / tickMs);
if (ticks <= 0) {
// Ensure shared timer is persisted (migration from per-item anchors)
if (!user.vaultYieldAt) {
const updated = await tx.user.update({
where: { id },
data: { vaultYieldAt: new Date(anchorMs) },
});
return { credited: '0', ticks: 0, itemIds: [], user: updated, items };
}
return { credited: '0', ticks: 0, itemIds: [], user, items };
}
let totalCredit = 0n;
const itemIds = [];
const now = new Date(untilMs);
for (const inv of items) {
const anchor = inv.lastYieldAt || inv.stakedAt || now;
const anchorMs = new Date(anchor).getTime();
const elapsed = Math.max(0, untilMs - anchorMs);
const ticks = Math.floor(elapsed / VAULT_TICK_MS);
if (ticks <= 0) continue;
const perTick = tickPayoutCents(inv, user, mode);
const credit = perTick * ticks;
if (credit > 0) {
const credit = perTick * BigInt(ticks);
if (credit > 0n) {
totalCredit += credit;
itemIds.push(inv.id);
}
maxTicks = Math.max(maxTicks, ticks);
const advanced = new Date(anchorMs + ticks * VAULT_TICK_MS);
await tx.inventoryItem.update({
where: { id: inv.id },
data: { lastYieldAt: advanced },
data: { lastYieldAt: new Date(anchorMs + ticks * tickMs) },
});
}
if (totalCredit <= 0) {
return { credited: 0, ticks: maxTicks, itemIds: [] };
const advanced = new Date(anchorMs + ticks * tickMs);
let updatedUser;
if (totalCredit > 0n) {
await creditUserBalance(tx, id, totalCredit);
updatedUser = await tx.user.update({
where: { id },
data: { vaultYieldAt: advanced },
});
} else if (!user.vaultYieldAt || user.vaultYieldAt.getTime() !== advanced.getTime()) {
updatedUser = await tx.user.update({
where: { id },
data: { vaultYieldAt: advanced },
});
} else {
updatedUser = user;
}
await tx.user.update({
where: { id },
data: { balance: { increment: totalCredit } },
});
if (totalCredit > 0n) {
await tx.transaction.create({
data: {
userId: id,
type: 'vault_yield',
amount: centsJson(totalCredit),
meta: JSON.stringify({
mode,
ticks,
inventoryItemIds: itemIds,
tickMs,
}),
},
});
}
await tx.transaction.create({
data: {
userId: id,
type: 'vault_yield',
amount: totalCredit,
meta: JSON.stringify({
mode,
ticks: maxTicks,
inventoryItemIds: itemIds,
}),
},
});
return { credited: totalCredit, ticks: maxTicks, itemIds };
return {
credited: totalCredit.toString(),
ticks,
itemIds,
user: updatedUser,
items,
};
});
const vault = result.user
? summarizeVaultState(result.user, result.items || [], mode, untilMs)
: null;
if (result.credited !== '0' && result.user) {
emitVaultYield(id, {
credited: result.credited,
ticks: result.ticks,
balance: result.user.balance?.toString?.() ?? String(result.user.balance),
user: publicUser(result.user),
vault,
});
}
return {
credited: result.credited,
ticks: result.ticks,
itemIds: result.itemIds,
balance: result.user?.balance?.toString?.() ?? undefined,
vault,
};
}
function emitVaultYield(userId, payload) {
const io = getIO();
if (!io) return;
io.to(`user:${userId}`).emit('vault:yield', payload);
}
/** Estimate pending yield for UI (incomplete ticks not included). */
export function estimatePendingYield(items, user, mode, now = Date.now()) {
let total = 0;
if (!items?.length) return '0';
const tickMs = vaultTickMs(mode);
const anchor = resolveVaultAnchor(user, items, now);
const ticks = Math.floor(Math.max(0, now - anchor) / tickMs);
if (ticks <= 0) return '0';
let total = 0n;
for (const inv of items) {
const anchor = inv.lastYieldAt || inv.stakedAt;
if (!anchor) continue;
const elapsed = Math.max(0, now - new Date(anchor).getTime());
const ticks = Math.floor(elapsed / VAULT_TICK_MS);
if (ticks <= 0) continue;
total += tickPayoutCents(inv, user, mode) * ticks;
total += tickPayoutCents(inv, user, mode) * BigInt(ticks);
}
return total;
return total.toString();
}
/** Per-tick rate summary for UI (one tick online / offline). */
export function summarizeVaultRates(items, user) {
let onlinePerTick = 0;
let offlinePerTick = 0;
let onlinePerTick = 0n;
let offlinePerTick = 0n;
for (const inv of items) {
onlinePerTick += tickPayoutCents(inv, user, 'online');
offlinePerTick += tickPayoutCents(inv, user, 'offline');
}
return { onlinePerTick, offlinePerTick, tickMs: VAULT_TICK_MS };
return {
onlinePerTick: onlinePerTick.toString(),
offlinePerTick: offlinePerTick.toString(),
tickMsOnline: ONLINE_TICK_MS,
tickMsOffline: OFFLINE_TICK_MS,
tickMs: ONLINE_TICK_MS,
};
}
export function summarizeVaultState(user, items, mode, now = Date.now()) {
const rates = summarizeVaultRates(items, user);
const nextMs = nextYieldAtMs(user, items, mode, now);
return {
used: items.length,
slots: maxVaultSlots(user),
...rates,
mode,
tickMs: vaultTickMs(mode),
vaultYieldAt: user.vaultYieldAt ? new Date(user.vaultYieldAt).toISOString() : null,
nextYieldAt: nextMs != null ? new Date(nextMs).toISOString() : null,
pendingYield: estimatePendingYield(items, user, mode, now),
};
}
+10 -4
View File
@@ -5,6 +5,8 @@
* Per-drop float f ∈ [0, 1) drives wear grade and price multiplier.
*/
import { toCentsBigInt } from './cents.js';
export const WEAR_BANDS = [
{
wear: 'Factory New',
@@ -96,10 +98,14 @@ export function priceMultiplier(floatValue) {
}
export function computeValueCents(baseMarketValue, floatValue) {
const base = Math.max(0, Math.floor(Number(baseMarketValue) || 0));
if (base <= 0) return 0;
const cents = Math.round(base * priceMultiplier(floatValue));
return Math.max(1, cents);
// Always BigInt-parse — Number() silently corrupts values above ~9e15
let base = toCentsBigInt(baseMarketValue);
if (base < 0n) base = 0n;
if (base <= 0n) return 0n;
// Fixed-point multiplier (×10000) to avoid Number precision loss on huge bases
const multBp = Math.round(priceMultiplier(floatValue) * 10000);
const cents = (base * BigInt(Math.max(0, multBp)) + 5000n) / 10000n;
return cents < 1n ? 1n : cents;
}
export function rollFloat() {