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 ADMIN_PASSWORD=change-me-strong-password
# Starting balances for new accounts (credits in cents, osu as units) # 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_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
JACKPOT_ROUND_SECONDS=30 JACKPOT_ROUND_SECONDS=30
+11 -12
View File
@@ -16,16 +16,16 @@ npm run db:seed
npm run dev npm run dev
``` ```
- App: http://localhost:5173 - App: http://localhost:5888
- API: http://localhost:3001 - 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: Create `server/.env` if missing:
``` ```
DATABASE_URL="file:./dev.db" DATABASE_URL="file:./dev.db"
PORT=3001 PORT=3001
CLIENT_ORIGIN=http://localhost:5173 CLIENT_ORIGIN=http://localhost:5888
SESSION_SECRET=dev-secret SESSION_SECRET=dev-secret
STARTING_BALANCE=10000 STARTING_BALANCE=10000
STARTING_OSU=100 STARTING_OSU=100
@@ -47,21 +47,20 @@ New registrations receive **100.00** starting credits and **100 osu** (`STARTING
## Features ## Features
- Login / register (username + password) - 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 - **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) - **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 - **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) - 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 - **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`): - **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 - Create / join MultiPlayers pots — live shared pot, up to 3 items, win chance by value, Socket.IO sync
- **1vX Case** — custom rooms (player/item limits, min value), spectate in-progress - Spectate in-progress battles from the hub
- **Coinflip** — stub (coming soon) - **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
- **Shop** (nav balance pill → `/shop`): spend osu on credit packs; watch an ad every 1 min for 100.00200.00 cr
- Profile (username in nav): avatar, bio, password, stats, logout - Profile (username in nav): avatar, bio, password, stats, logout
- **Presence**: online status (Socket.IO), total play time, last connection — shown on own and public profiles - **Presence**: online status (Socket.IO), total play time, last connection — shown on own and public profiles
- **Achievements** (`/achievements`, also from profile): 10 starter badges unlocked by play (cases, battles, catalog, prestige, shop, playtime); earned badges appear on the profile - **Achievements** (`/achievements`, also from profile): 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) - **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 - **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) - 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 ## 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` - 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) - 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` - 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 - 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) - Avatars / uploads under `server/uploads/` (volume in Docker)
- Real-time via Socket.IO; Docker Hub image name is lowercase: `foufure/casegambling` - 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 - 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 Layout from './components/Layout';
import { useAuth } from './AuthContext'; import { useAuth } from './AuthContext';
import Admin from './pages/Admin'; import Admin from './pages/Admin';
@@ -9,10 +9,7 @@ import Leaderboard from './pages/Leaderboard';
import Login from './pages/Login'; import Login from './pages/Login';
import Register from './pages/Register'; import Register from './pages/Register';
import BattleHub from './pages/BattleHub'; import BattleHub from './pages/BattleHub';
import MultiPlayersPage from './pages/MultiPlayersPage'; import BattleRoomPage from './pages/BattleRoomPage';
import DuelListPage from './pages/DuelListPage';
import DuelRoomPage from './pages/DuelRoomPage';
import CoinflipStub from './pages/CoinflipStub';
import ProfilePage from './pages/ProfilePage'; import ProfilePage from './pages/ProfilePage';
import PlayerProfilePage from './pages/PlayerProfilePage'; import PlayerProfilePage from './pages/PlayerProfilePage';
import CatalogPage from './pages/CatalogPage'; import CatalogPage from './pages/CatalogPage';
@@ -28,11 +25,6 @@ function HomeRedirect() {
return <Navigate to="/dashboard" replace />; return <Navigate to="/dashboard" replace />;
} }
function BetDuelRedirect() {
const { id } = useParams();
return <Navigate to={`/battle/1vx/${id}`} replace />;
}
export default function App() { export default function App() {
return ( return (
<Routes> <Routes>
@@ -48,15 +40,17 @@ export default function App() {
<Route path="/prestige" element={<PrestigePage />} /> <Route path="/prestige" element={<PrestigePage />} />
<Route path="/achievements" element={<AchievementsPage />} /> <Route path="/achievements" element={<AchievementsPage />} />
<Route path="/battle" element={<BattleHub />} /> <Route path="/battle" element={<BattleHub />} />
<Route path="/battle/multi" element={<MultiPlayersPage />} /> <Route path="/battle/create" element={<BattleRoomPage create />} />
<Route path="/battle/1vx" element={<DuelListPage />} /> <Route path="/battle/multi" element={<Navigate to="/battle/create" replace />} />
<Route path="/battle/1vx/:id" element={<DuelRoomPage />} /> <Route path="/battle/1vx" element={<Navigate to="/battle" replace />} />
<Route path="/battle/coinflip" element={<CoinflipStub />} /> <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" element={<Navigate to="/battle" replace />} />
<Route path="/bet/multi" element={<Navigate to="/battle/multi" replace />} /> <Route path="/bet/multi" element={<Navigate to="/battle/create" replace />} />
<Route path="/bet/1vx" element={<Navigate to="/battle/1vx" replace />} /> <Route path="/bet/1vx" element={<Navigate to="/battle" replace />} />
<Route path="/bet/1vx/:id" element={<BetDuelRedirect />} /> <Route path="/bet/1vx/:id" element={<Navigate to="/battle" replace />} />
<Route path="/bet/coinflip" element={<Navigate to="/battle/coinflip" replace />} /> <Route path="/bet/coinflip" element={<Navigate to="/battle" replace />} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/player/:username" element={<PlayerProfilePage />} /> <Route path="/player/:username" element={<PlayerProfilePage />} />
<Route path="/shop" element={<ShopPage />} /> <Route path="/shop" element={<ShopPage />} />
+18
View File
@@ -1,5 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { api } from './api'; import { api } from './api';
import { getSocket, refreshSocketSession } from './socket';
const AuthContext = createContext(null); const AuthContext = createContext(null);
@@ -22,6 +23,23 @@ export function AuthProvider({ children }) {
refresh(); refresh();
}, [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 login = async (username, password) => {
const data = await api.login(username, password); const data = await api.login(username, password);
setUser(data.user); setUser(data.user);
+3 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, formatCredits } from '../api'; import { api, formatCredits, centsJson, toCentsBigInt } from '../api';
import ImageField from './ImageField'; import ImageField from './ImageField';
const emptyCase = { name: '', price: 100, imageUrl: '', active: true }; const emptyCase = { name: '', price: 100, imageUrl: '', active: true };
@@ -62,7 +62,7 @@ export default function AdminCases() {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
try { try {
await api.createCase({ ...caseForm, price: Number(caseForm.price) }); await api.createCase({ ...caseForm, price: centsJson(toCentsBigInt(caseForm.price)) });
setCaseForm(emptyCase); setCaseForm(emptyCase);
await load(); await load();
} catch (err) { } catch (err) {
@@ -112,7 +112,7 @@ export default function AdminCases() {
try { try {
await api.updateCase(editingId, { await api.updateCase(editingId, {
name: edit.name, name: edit.name,
price: Number(edit.price), price: centsJson(toCentsBigInt(edit.price)),
imageUrl: edit.imageUrl, imageUrl: edit.imageUrl,
active: edit.active, active: edit.active,
}); });
+6
View File
@@ -78,6 +78,12 @@ export default function AdminDashboard() {
<Link to="/admin/drops" className="btn secondary"> <Link to="/admin/drops" className="btn secondary">
Drop rates Drop rates
</Link> </Link>
<Link to="/admin/shop" className="btn secondary">
Shop osu packs
</Link>
<Link to="/admin/prestige-server" className="btn secondary">
Prestige Server
</Link>
</div> </div>
<section className="admin-block" style={{ marginTop: '1.25rem' }}> <section className="admin-block" style={{ marginTop: '1.25rem' }}>
+139 -15
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, formatCredits } from '../api'; import { api, formatCredits, compareCents } from '../api';
function dropPercents(items) { function dropPercents(items) {
const total = items.reduce((s, ci) => s + Number(ci.dropRate || 0), 0); 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); 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() { export default function AdminDrops() {
const [cases, setCases] = useState([]); const [cases, setCases] = useState([]);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [selectedCaseId, setSelectedCaseId] = useState(''); const [selectedCaseId, setSelectedCaseId] = useState('');
const [dropForm, setDropForm] = useState({ itemId: '', dropRate: 1 }); 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 load = useCallback(async () => {
const [c, i] = await Promise.all([api.adminCases(), api.adminItems()]); 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; if (prev && c.cases.some((x) => String(x.id) === String(prev))) return prev;
return c.cases[0] ? String(c.cases[0].id) : ''; return c.cases[0] ? String(c.cases[0].id) : '';
}); });
setDropForm((f) => ({
...f,
itemId: f.itemId || (i.items[0] ? String(i.items[0].id) : ''),
}));
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -37,10 +75,43 @@ export default function AdminDrops() {
[cases, selectedCaseId] [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(() => { const poolItems = useMemo(() => {
if (!selected) return []; if (!selected) return [];
return [...selected.items].sort((a, b) => Number(a.dropRate) - Number(b.dropRate)); return sortPool(selected.items, poolSort);
}, [selected]); }, [selected, poolSort]);
const percents = useMemo(() => dropPercents(poolItems), [poolItems]); const percents = useMemo(() => dropPercents(poolItems), [poolItems]);
@@ -76,6 +147,37 @@ export default function AdminDrops() {
Add drop · {selected.name}{' '} Add drop · {selected.name}{' '}
<span className="muted">({formatCredits(selected.price)} cr)</span> <span className="muted">({formatCredits(selected.price)} cr)</span>
</h3> </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 <form
className="admin-row" className="admin-row"
onSubmit={async (e) => { onSubmit={async (e) => {
@@ -95,12 +197,18 @@ export default function AdminDrops() {
<select <select
value={dropForm.itemId} value={dropForm.itemId}
onChange={(e) => setDropForm({ ...dropForm, itemId: e.target.value })} onChange={(e) => setDropForm({ ...dropForm, itemId: e.target.value })}
disabled={!catalogItems.length}
aria-label="Select item to add"
> >
{items.map((item) => ( {catalogItems.length === 0 ? (
<option key={item.id} value={item.id}> <option value="">No matching items</option>
{item.name} ({item.rarity}) ) : (
</option> catalogItems.map((item) => (
))} <option key={item.id} value={item.id}>
{item.name} ({item.rarity}) · {formatCredits(item.marketValue)} cr
</option>
))
)}
</select> </select>
<input <input
type="number" type="number"
@@ -111,14 +219,28 @@ export default function AdminDrops() {
placeholder="Weight" placeholder="Weight"
required required
/> />
<button className="btn" type="submit"> <button className="btn" type="submit" disabled={!dropForm.itemId}>
Add drop Add drop
</button> </button>
</form> </form>
</div> </div>
<div className="admin-block"> <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 ? ( {poolItems.length === 0 ? (
<div className="muted">No drops yet.</div> <div className="muted">No drops yet.</div>
) : ( ) : (
@@ -130,7 +252,9 @@ export default function AdminDrops() {
<img src={ci.item.imageUrl} alt="" className="drop-thumb" /> <img src={ci.item.imageUrl} alt="" className="drop-thumb" />
) : null} ) : null}
{ci.item.name}{' '} {ci.item.name}{' '}
<span className="muted">({ci.item.rarity})</span> <span className="muted">
({ci.item.rarity}) · {formatCredits(ci.item.marketValue)} cr
</span>
</span> </span>
<input <input
type="number" type="number"
+37 -32
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, formatCredits } from '../api'; import { api, formatCredits, compareCents, centsJson, toCentsBigInt } from '../api';
import ImageField from './ImageField'; import ImageField from './ImageField';
const emptyItem = { const emptyItem = {
@@ -10,23 +10,21 @@ const emptyItem = {
imageUrl: '', imageUrl: '',
}; };
function CategoryField({ value, onChange, categories, id }) { function CategoryField({ value, onChange, categories }) {
const listId = id || 'item-categories'; const options =
value && !categories.includes(value) ? [...categories, value] : categories;
return ( return (
<> <select value={value} onChange={(e) => onChange(e.target.value)} required>
<input {options.length === 0 ? (
list={listId} <option value="">No categories create one first</option>
value={value} ) : (
onChange={(e) => onChange(e.target.value)} options.map((c) => (
placeholder="e.g. Rifle, Knife…" <option key={c} value={c}>
required {c}
/> </option>
<datalist id={listId}> ))
{categories.map((c) => ( )}
<option key={c} value={c} /> </select>
))}
</datalist>
</>
); );
} }
@@ -51,16 +49,23 @@ export default function AdminItems() {
]); ]);
setItems(i.items); setItems(i.items);
setRarities(r.rarities); setRarities(r.rarities);
setCategories( const names = (c.names || c.categories || []).map((entry) =>
(c.names || c.categories || []).map((entry) => typeof entry === 'string' ? entry : entry.name
typeof entry === 'string' ? entry : entry.name
)
); );
setItemForm((f) => ({ setCategories(names);
...f, setItemForm((f) => {
rarity: f.rarity || r.rarities[0] || 'MilSpec', const nextCategory =
category: f.category || 'Misc', 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(() => { useEffect(() => {
@@ -89,7 +94,7 @@ export default function AdminItems() {
list = [...list]; list = [...list];
list.sort((a, b) => { list.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name); 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); if (sort === 'rarity') return a.rarity.localeCompare(b.rarity);
// category (default): group by category, then name // category (default): group by category, then name
const ca = (a.category || 'Misc').localeCompare(b.category || 'Misc'); const ca = (a.category || 'Misc').localeCompare(b.category || 'Misc');
@@ -164,12 +169,14 @@ export default function AdminItems() {
try { try {
await api.createItem({ await api.createItem({
...itemForm, ...itemForm,
marketValue: Number(itemForm.marketValue), marketValue: centsJson(toCentsBigInt(itemForm.marketValue)),
}); });
setItemForm({ setItemForm({
...emptyItem, ...emptyItem,
rarity: rarities[0] || 'MilSpec', rarity: rarities[0] || 'MilSpec',
category: 'Misc', category: categories.includes('Misc')
? 'Misc'
: categories[0] || '',
}); });
await load(); await load();
} catch (err) { } catch (err) {
@@ -188,7 +195,6 @@ export default function AdminItems() {
<label> <label>
Category Category
<CategoryField <CategoryField
id="create-item-categories"
value={itemForm.category} value={itemForm.category}
categories={categories} categories={categories}
onChange={(category) => setItemForm({ ...itemForm, category })} onChange={(category) => setItemForm({ ...itemForm, category })}
@@ -247,7 +253,7 @@ export default function AdminItems() {
name: edit.name, name: edit.name,
rarity: edit.rarity, rarity: edit.rarity,
category: edit.category, category: edit.category,
marketValue: Number(edit.marketValue), marketValue: centsJson(toCentsBigInt(edit.marketValue)),
imageUrl: edit.imageUrl, imageUrl: edit.imageUrl,
}); });
setEditingId(null); setEditingId(null);
@@ -270,7 +276,6 @@ export default function AdminItems() {
<label> <label>
Category Category
<CategoryField <CategoryField
id={`edit-item-categories-${item.id}`}
value={edit.category} value={edit.category}
categories={categories} categories={categories}
onChange={(category) => setEdit({ ...edit, category })} onChange={(category) => setEdit({ ...edit, category })}
+2
View File
@@ -8,6 +8,8 @@ const NAV = [
{ to: '/admin/items', label: 'Items' }, { to: '/admin/items', label: 'Items' },
{ to: '/admin/categories', label: 'Categories' }, { to: '/admin/categories', label: 'Categories' },
{ to: '/admin/drops', label: 'Drops' }, { to: '/admin/drops', label: 'Drops' },
{ to: '/admin/shop', label: 'Shop' },
{ to: '/admin/prestige-server', label: 'Prestige Server' },
]; ];
export default function AdminLayout() { export default function AdminLayout() {
+200 -51
View File
@@ -4,8 +4,105 @@ import {
formatCredits, formatCredits,
formatLastConnection, formatLastConnection,
formatPlayTime, formatPlayTime,
compareCents,
creditsToCentsString,
toCentsBigInt,
} from '../api'; } 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() { export default function AdminPlayers() {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -13,8 +110,14 @@ export default function AdminPlayers() {
const [sort, setSort] = useState('wealth'); const [sort, setSort] = useState('wealth');
const [adjustId, setAdjustId] = useState(null); const [adjustId, setAdjustId] = useState(null);
const [deltaCredits, setDeltaCredits] = useState('100'); const [deltaCredits, setDeltaCredits] = useState('100');
const [deltaPr, setDeltaPr] = useState('1000');
const [deltaOsu, setDeltaOsu] = useState('100');
const [busy, setBusy] = useState(false); 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 load = useCallback(async () => {
const data = await api.adminUsers(); const data = await api.adminUsers();
setUsers(data.users); setUsers(data.users);
@@ -39,23 +142,24 @@ export default function AdminPlayers() {
new Date(a.lastConnection || 0).getTime() new Date(a.lastConnection || 0).getTime()
); );
} }
return Number(b.totalWealth) - Number(a.totalWealth); return compareCents(b.totalWealth, a.totalWealth);
}); });
return list; return list;
}, [users, query, sort]); }, [users, query, sort]);
const applyDelta = async (userId, credits) => { const resetDeltas = () => {
const cents = Math.round(Number(credits) * 100); setDeltaCredits('100');
if (!Number.isInteger(cents) || cents === 0) { setDeltaPr('1000');
setError('Enter a non-zero credit amount (e.g. 100 or -50)'); setDeltaOsu('100');
return; };
}
const applyPatch = async (userId, body) => {
setBusy(true); setBusy(true);
setError(''); setError('');
try { try {
await api.adminUpdateUser(userId, { balanceDelta: cents }); await api.adminUpdateUser(userId, body);
setAdjustId(null); setAdjustId(null);
setDeltaCredits('100'); resetDeltas();
await load(); await load();
} catch (err) { } catch (err) {
setError(err.message); 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 ( return (
<div> <div>
<div className="section-head"> <div className="section-head">
<div> <div>
<h1>Players</h1> <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>
<div className="admin-toolbar"> <div className="admin-toolbar">
<select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort"> <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">
@@ -108,8 +244,9 @@ export default function AdminPlayers() {
{u.inventoryCount}) {u.inventoryCount})
</div> </div>
<div className="muted"> <div className="muted">
Opens {u.casesOpened} · prestige {u.prestigeCount} · achievements{' '} {formatUnits(u.prBalance ?? 0)} Pr · {formatUnits(u.osuBalance ?? 0)} osu · opens{' '}
{u.achievements} · play {formatPlayTime(u.playTimeSeconds)} · last{' '} {u.casesOpened} · prestige {u.prestigeCount} · achievements {u.achievements} · play{' '}
{formatPlayTime(u.playTimeSeconds)} · last{' '}
{formatLastConnection(u.lastConnection)} {formatLastConnection(u.lastConnection)}
</div> </div>
</div> </div>
@@ -118,50 +255,62 @@ export default function AdminPlayers() {
className="btn secondary" className="btn secondary"
onClick={() => { onClick={() => {
setAdjustId(adjustId === u.id ? null : u.id); setAdjustId(adjustId === u.id ? null : u.id);
setDeltaCredits('100'); resetDeltas();
}} }}
> >
Adjust credits Adjust
</button> </button>
</div> </div>
{adjustId === u.id && ( {adjustId === u.id && (
<form <div className="admin-adjust-panel">
className="admin-adjust-row" <AdjustRow
onSubmit={(e) => { label="Δ credits (e.g. 100 or -50)"
e.preventDefault(); value={deltaCredits}
applyDelta(u.id, deltaCredits); onChange={setDeltaCredits}
}} step="0.01"
> delta={deltaCents == null ? null : BigInt(deltaCents)}
<label> preview={formatSignedPreview(
Δ credits (e.g. 100 or -50) deltaCents == null ? null : BigInt(deltaCents),
<input 'cr',
type="number" true
step="0.01" )}
value={deltaCredits} result={
onChange={(e) => setDeltaCredits(e.target.value)} deltaCents == null
required ? null
/> : `→ bal ${formatCredits(balanceAfterDelta(u.balance, deltaCents))} cr`
</label> }
<button className="btn" type="submit" disabled={busy}> onSubmit={() => applyCredits(u.id)}
Apply busy={busy}
</button> />
<button <AdjustRow
type="button" label="Δ Pr (e.g. 1000 or -100)"
className="btn ghost" value={deltaPr}
onClick={() => applyDelta(u.id, '1000')} onChange={setDeltaPr}
disabled={busy} delta={prDelta}
> preview={formatSignedPreview(prDelta, 'Pr')}
+1000 result={
</button> prDelta == null
<button ? null
type="button" : `${formatUnits(balanceAfterDelta(u.prBalance ?? 0, prDelta))} Pr`
className="btn ghost" }
onClick={() => applyDelta(u.id, '-100')} onSubmit={() => applyPr(u.id)}
disabled={busy} busy={busy}
> />
100 <AdjustRow
</button> label="Δ osu (e.g. 100 or -50)"
</form> 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> </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'} {uploading ? '…' : 'Upload'}
<input <input
type="file" type="file"
accept="image/*" accept="image/png,image/jpeg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif"
hidden hidden
onChange={onFile} onChange={onFile}
disabled={disabled || uploading} disabled={disabled || uploading}
+145 -39
View File
@@ -35,10 +35,10 @@ export const api = {
catalog: () => request('/api/catalog'), catalog: () => request('/api/catalog'),
prestige: () => request('/api/prestige'), prestige: () => request('/api/prestige'),
doPrestige: () => request('/api/prestige', { method: 'POST', body: JSON.stringify({}) }), doPrestige: () => request('/api/prestige', { method: 'POST', body: JSON.stringify({}) }),
prestigeUpgrade: (skillId) => prestigeUpgrade: (skillId, count = 1) =>
request('/api/prestige/upgrade', { request('/api/prestige/upgrade', {
method: 'POST', method: 'POST',
body: JSON.stringify({ skillId }), body: JSON.stringify({ skillId, count }),
}), }),
openCase: (id, count = 1) => openCase: (id, count = 1) =>
request(`/api/cases/${id}/open`, { request(`/api/cases/${id}/open`, {
@@ -56,7 +56,6 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ inventoryItemIds }), body: JSON.stringify({ inventoryItemIds }),
}), }),
vaultClaim: () => request('/api/vault/claim', { method: 'POST' }),
sellInventory: (inventoryItemIds) => sellInventory: (inventoryItemIds) =>
request('/api/inventory/sell', { request('/api/inventory/sell', {
method: 'POST', method: 'POST',
@@ -83,41 +82,65 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ packageId }), 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' }), shopAd: () => request('/api/shop/ad', { method: 'POST' }),
feed: () => request('/api/feed'), feed: () => request('/api/feed'),
feedAnnounce: (inventoryItemIds, caseName) => { feedAnnounce: (inventoryItemIds, caseName, opts = {}) => {
const ids = Array.isArray(inventoryItemIds) const ids = Array.isArray(inventoryItemIds)
? 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', { return request('/api/feed/announce', {
method: 'POST', method: 'POST',
body: JSON.stringify({ inventoryItemIds: ids, caseName }), body: JSON.stringify(body),
}); });
}, },
leaderboard: () => request('/api/leaderboard'), leaderboard: () => request('/api/leaderboard'),
jackpot: () => request('/api/jackpot'), jackpot: () => request('/api/jackpot'),
jackpotBet: (inventoryItemIds) => jackpotRound: (id) => request(`/api/jackpot/${id}`),
jackpotCreateBet: (inventoryItemIds) =>
request('/api/jackpot/bet', { request('/api/jackpot/bet', {
method: 'POST', method: 'POST',
body: JSON.stringify({ inventoryItemIds }), body: JSON.stringify({ inventoryItemIds }),
}), }),
jackpotWithdraw: (inventoryItemIds) => jackpotBet: (id, inventoryItemIds) =>
request('/api/jackpot/withdraw', { request(`/api/jackpot/${id}/bet`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ inventoryItemIds }), body: JSON.stringify({ inventoryItemIds }),
}), }),
duels: () => request('/api/duels'), jackpotWithdraw: (id, inventoryItemIds) =>
duel: (id) => request(`/api/duels/${id}`), request(`/api/jackpot/${id}/withdraw`, {
createDuel: (body) => method: 'POST',
request('/api/duels', { method: 'POST', body: JSON.stringify(body) }), body: JSON.stringify({ inventoryItemIds }),
duelBet: (id, inventoryItemIds) => }),
request(`/api/duels/${id}/bet`, { // legacy alias
jackpotCreate: (inventoryItemIds) =>
request('/api/jackpot/bet', {
method: 'POST', method: 'POST',
body: JSON.stringify({ inventoryItemIds }), 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'), profile: () => request('/api/profile'),
playerProfile: (username) => playerProfile: (username) =>
request(`/api/profile/player/${encodeURIComponent(username)}`), request(`/api/profile/player/${encodeURIComponent(username)}`),
@@ -140,6 +163,12 @@ export const api = {
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}), }),
adminStats: () => request('/api/admin/stats'), 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'), adminUsers: () => request('/api/admin/users'),
adminUpdateUser: (id, body) => adminUpdateUser: (id, body) =>
request(`/api/admin/users/${id}`, { request(`/api/admin/users/${id}`, {
@@ -150,6 +179,17 @@ export const api = {
adminItems: () => request('/api/admin/items'), adminItems: () => request('/api/admin/items'),
adminRarities: () => request('/api/admin/rarities'), adminRarities: () => request('/api/admin/rarities'),
adminCategories: () => request('/api/admin/categories'), 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) => createCategory: (body) =>
request('/api/admin/categories', { method: 'POST', body: JSON.stringify(body) }), request('/api/admin/categories', { method: 'POST', body: JSON.stringify(body) }),
updateCategory: (id, 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) { export function formatCreditsExact(cents) {
const n = Number(cents); let bi = toCentsBigInt(cents);
if (!Number.isFinite(n)) return '0.00'; const neg = bi < 0n;
const fixed = (n / 100).toFixed(2); if (neg) bi = -bi;
const neg = fixed.startsWith('-'); const whole = bi / 100n;
const body = neg ? fixed.slice(1) : fixed; const frac = (bi % 100n).toString().padStart(2, '0');
const [intPart, dec] = body.split('.'); const grouped = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f');
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f'); return `${neg ? '-' : ''}${grouped}.${frac}`;
return `${neg ? '-' : ''}${grouped}.${dec}`; }
/** 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). */ /** 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. * K / M / B / T, then aa, ab, … (×1000 each). Input is cents.
*/ */
export function formatCredits(cents) { export function formatCredits(cents) {
const n = Number(cents); let bi = toCentsBigInt(cents);
if (!Number.isFinite(n)) return '0.00'; const neg = bi < 0n;
const neg = n < 0; if (neg) bi = -bi;
const credits = Math.abs(n) / 100;
if (credits < 1000) { const creditsWhole = bi / 100n;
return `${neg ? '-' : ''}${credits.toFixed(2)}`; const creditsFrac = bi % 100n;
if (creditsWhole < 1000n) {
return `${neg ? '-' : ''}${creditsWhole}.${creditsFrac.toString().padStart(2, '0')}`;
} }
let tier = 0; let tier = 0;
let scaled = credits; let scaled = creditsWhole;
while (scaled >= 1000 && tier < 1000) { let rem = 0n;
scaled /= 1000; while (scaled >= 1000n) {
rem = scaled % 1000n;
scaled /= 1000n;
tier += 1; tier += 1;
} }
const suffix = tierSuffix(tier); // scaled < 1000 → safe as Number; rem gives ~3 decimal digits of precision
const shown = String(Number(scaled.toFixed(2))); const approx = Number(scaled) + Number(rem) / 1000;
return `${neg ? '-' : ''}${shown}${suffix}`; 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) { 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; if (!entry?.id) return;
setDrops((prev) => { setDrops((prev) => {
if (prev.some((d) => d.id === entry.id)) return 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( const nearDup = prev.find(
(d) => (d) =>
d.username === entry.username && d.username === entry.username &&
d.item?.id === entry.item?.id && d.item?.id === entry.item?.id &&
(d.count || 1) === (entry.count || 1) && (d.count || 1) === (entry.count || 1) &&
(d.openCount || 1) === (entry.openCount || 1) && (d.openCount || 1) === (entry.openCount || 1) &&
Boolean(d.respin) === Boolean(entry.respin) &&
Math.abs(new Date(d.at) - new Date(entry.at)) < 15000 Math.abs(new Date(d.at) - new Date(entry.at)) < 15000
); );
if (nearDup) return prev; 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.emit('feed:subscribe');
socket.on('feed:history', onHistory); socket.on('feed:history', onHistory);
socket.on('feed:drop', onDrop); socket.on('feed:drop', onDrop);
@@ -117,7 +116,11 @@ export default function DropFeed() {
visible.map((d) => { visible.map((d) => {
const color = rarityColor(d.item?.rarity); const color = rarityColor(d.item?.rarity);
return ( 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 }}> <div className="drop-feed-item-visual" style={{ color }}>
{d.item?.imageUrl ? ( {d.item?.imageUrl ? (
<img src={d.item.imageUrl} alt="" /> <img src={d.item.imageUrl} alt="" />
@@ -148,16 +151,18 @@ export default function DropFeed() {
{d.username} {d.username}
</> </>
) : ( ) : (
d.username <>
{d.respin ? (
<span className="drop-feed-respin-label">Respin</span>
) : null}
{d.respin ? ' ' : null}
{d.username}
</>
)} )}
</span> </span>
</div> </div>
<div className="muted drop-feed-sub"> <div className="muted drop-feed-sub">
{d.source === 'jackpot' {d.caseName ? `${d.caseName} · ` : ''}
? 'MultiPlayers Case · '
: d.caseName
? `${d.caseName} · `
: ''}
{formatCredits(d.item?.marketValue || 0)} cr {formatCredits(d.item?.marketValue || 0)} cr
{d.openCount > 1 {d.openCount > 1
? d.source === 'jackpot' ? 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'; 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) { export function itemValueCents(item) {
if (!item) return 0; if (!item) return 0n;
if (item.valueCents != null && Number.isFinite(Number(item.valueCents))) { if (item.valueCents != null && item.valueCents !== '') {
return Number(item.valueCents); return toCentsBigInt(item.valueCents);
} }
return Number(item.marketValue) || 0; return toCentsBigInt(item.marketValue);
} }
export function formatFloat(f) { export function formatFloat(f) {
@@ -41,6 +41,7 @@ export default function ItemTile({
const titleParts = []; const titleParts = [];
if (staked) titleParts.push('Vault'); if (staked) titleParts.push('Vault');
if (favorite) titleParts.push('Favorite'); if (favorite) titleParts.push('Favorite');
if (item.rarity) titleParts.push(item.rarity);
if (floatTitle) titleParts.push(floatTitle); if (floatTitle) titleParts.push(floatTitle);
else titleParts.push(item.name); else titleParts.push(item.name);
if (showValue) titleParts.push(`${formatCreditsExact(value)} cr`); if (showValue) titleParts.push(`${formatCreditsExact(value)} cr`);
@@ -76,16 +77,13 @@ export default function ItemTile({
)} )}
</div> </div>
<div className="item-name">{item.name}</div> <div className="item-name">{item.name}</div>
<div className="item-meta" style={{ color }}>
{item.rarity}
</div>
{showWear && wear ? ( {showWear && wear ? (
<div className="item-wear" title={floatTitle}> <div className="item-wear" title={floatTitle}>
{wear} {wear}
</div> </div>
) : null} ) : null}
{showValue && ( {showValue && (
<div className="item-meta" title={`${formatCreditsExact(value)} cr`}> <div className="item-meta item-price" title={`${formatCreditsExact(value)} cr`}>
{formatCredits(value)} cr {formatCredits(value)} cr
</div> </div>
)} )}
+74 -49
View File
@@ -5,7 +5,7 @@ import { api, formatCredits, formatCreditsExact } from '../api';
import DropFeed from './DropFeed'; import DropFeed from './DropFeed';
import SessionLimitOverlay from './SessionLimitOverlay'; import SessionLimitOverlay from './SessionLimitOverlay';
import { onInventoryChanged } from '../inventoryEvents'; import { onInventoryChanged } from '../inventoryEvents';
import { getSocket, onSessionLimitChange } from '../socket'; import { getSocket, onSessionLimitChange, getSessionLimitPayload } from '../socket';
const FEED_KEY = 'caseorion-feed-drop'; const FEED_KEY = 'caseorion-feed-drop';
@@ -17,27 +17,20 @@ function ScrollToTop() {
return null; return null;
} }
function countWaitingBattles(jackpotRound, duelOpen) { function countWaitingBattles(jackpotBattles) {
let n = 0; let n = 0;
// MultiPlayers: only if someone has already bet and round still waits for more players for (const battle of jackpotBattles || []) {
const jpPlayers = jackpotRound?.players?.length ?? 0; if (battle.status === 'spinning') continue;
const jpDeposits = jackpotRound?.deposits?.length ?? 0; const jpPlayers = battle?.players?.length ?? 0;
if ( const jpDeposits = battle?.deposits?.length ?? 0;
jackpotRound?.waitingForPlayers && if (
jackpotRound.status !== 'spinning' && battle?.waitingForPlayers &&
(jpPlayers > 0 || jpDeposits > 0) battle.status === 'open' &&
) { (jpPlayers > 0 || jpDeposits > 0)
n += 1; ) {
} 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;
} }
return n; return n;
@@ -45,6 +38,7 @@ function countWaitingBattles(jackpotRound, duelOpen) {
export default function Layout() { export default function Layout() {
const { user } = useAuth(); const { user } = useAuth();
const { pathname } = useLocation();
const [feedOpen, setFeedOpen] = useState(() => { const [feedOpen, setFeedOpen] = useState(() => {
try { try {
return localStorage.getItem(FEED_KEY) === '1'; return localStorage.getItem(FEED_KEY) === '1';
@@ -54,7 +48,12 @@ export default function Layout() {
}); });
const [waitingBattles, setWaitingBattles] = useState(0); const [waitingBattles, setWaitingBattles] = useState(0);
const [prestigeVisible, setPrestigeVisible] = useState(false); 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(() => { useEffect(() => {
try { try {
@@ -64,6 +63,47 @@ export default function Layout() {
} }
}, [feedOpen]); }, [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(() => { useEffect(() => {
if (!user || user.role === 'admin') { if (!user || user.role === 'admin') {
setPrestigeVisible(false); setPrestigeVisible(false);
@@ -97,24 +137,22 @@ export default function Layout() {
return undefined; return undefined;
} }
let jackpotRound = null; let jackpotBattles = [];
let duelOpen = [];
let cancelled = false; let cancelled = false;
const sync = () => { const sync = () => {
if (cancelled) return; if (cancelled) return;
setWaitingBattles((prev) => { setWaitingBattles((prev) => {
const next = countWaitingBattles(jackpotRound, duelOpen); const next = countWaitingBattles(jackpotBattles);
return prev === next ? prev : next; return prev === next ? prev : next;
}); });
}; };
(async () => { (async () => {
try { try {
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]); const jp = await api.jackpot();
if (cancelled) return; if (cancelled) return;
jackpotRound = jp.round; jackpotBattles = jp.battles || [];
duelOpen = duels.open || [];
sync(); sync();
} catch { } catch {
/* nav badge is optional */ /* nav badge is optional */
@@ -125,35 +163,22 @@ export default function Layout() {
const offLimitChange = onSessionLimitChange((payload) => { const offLimitChange = onSessionLimitChange((payload) => {
setSessionLimit(payload); setSessionLimit(payload);
}); });
const onJackpot = (state) => { const onJackpots = (payload) => {
jackpotRound = state; jackpotBattles = payload.battles || [];
sync();
};
const onDuels = (list) => {
duelOpen = list.open || [];
sync(); sync();
}; };
socket.emit('jackpot:subscribe'); socket.emit('jackpots:subscribe');
socket.emit('duels:subscribe'); socket.on('jackpots:list', onJackpots);
socket.on('jackpot:state', onJackpot);
socket.on('jackpot:spin', onJackpot);
socket.on('jackpot:result', onJackpot);
socket.on('duels:list', onDuels);
socket.on('connect', () => { socket.on('connect', () => {
socket.emit('jackpot:subscribe'); socket.emit('jackpots:subscribe');
socket.emit('duels:subscribe');
}); });
return () => { return () => {
cancelled = true; cancelled = true;
offLimitChange(); offLimitChange();
socket.emit('jackpot:unsubscribe'); socket.emit('jackpots:unsubscribe');
socket.emit('duels:unsubscribe'); socket.off('jackpots:list', onJackpots);
socket.off('jackpot:state', onJackpot);
socket.off('jackpot:spin', onJackpot);
socket.off('jackpot:result', onJackpot);
socket.off('duels:list', onDuels);
}; };
}, [user?.id, user?.role]); }, [user?.id, user?.role]);
@@ -164,8 +189,8 @@ export default function Layout() {
className={`app-shell${feedOpen ? ' feed-open' : ''}${sessionLimit ? ' session-locked' : ''}`} className={`app-shell${feedOpen ? ' feed-open' : ''}${sessionLimit ? ' session-locked' : ''}`}
> >
<ScrollToTop /> <ScrollToTop />
{sessionLimit && <SessionLimitOverlay max={sessionLimit.max || 10} />} {sessionLimit && <SessionLimitOverlay max={sessionLimit.max || 2} />}
<header className="topnav"> <header className={`topnav${navHidden ? ' topnav--hidden' : ''}`}>
<div className="topnav-left"> <div className="topnav-left">
<NavLink to="/dashboard" className="brand"> <NavLink to="/dashboard" className="brand">
CaseOrion CaseOrion
+11 -5
View File
@@ -2,8 +2,14 @@ export default function ProfilePrestigeStats({ prestige }) {
if (!prestige || !prestige.count) return null; if (!prestige || !prestige.count) return null;
const rows = [ 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, : null,
{ label: 'Fortune', value: <strong>+{prestige.chanceBonus}%</strong> }, { label: 'Fortune', value: <strong>+{prestige.chanceBonus}%</strong> },
{ label: 'Yield', value: <strong>+{prestige.gainBonus}%</strong> }, { label: 'Yield', value: <strong>+{prestige.gainBonus}%</strong> },
@@ -15,9 +21,9 @@ export default function ProfilePrestigeStats({ prestige }) {
label: 'Bulk', label: 'Bulk',
value: <strong>+{prestige.openBonus} opens</strong>, value: <strong>+{prestige.openBonus} opens</strong>,
}, },
(prestige.caseDiscount || 0) > 0 && { (prestige.prBonus || 0) > 0 && {
label: 'Discount', label: 'PR Farmer',
value: <strong>{prestige.caseDiscount}%</strong>, value: <strong>+{prestige.prBonus}%</strong>,
}, },
(prestige.qualityBonus || 0) > 0 && { (prestige.qualityBonus || 0) > 0 && {
label: 'Quality', label: 'Quality',
@@ -18,7 +18,7 @@ const FAKE_ADS = [
}, },
]; ];
export default function SessionLimitOverlay({ max = 10 }) { export default function SessionLimitOverlay({ max = 2 }) {
const [ad] = useState( const [ad] = useState(
() => FAKE_ADS[Math.floor(Math.random() * FAKE_ADS.length)] () => FAKE_ADS[Math.floor(Math.random() * FAKE_ADS.length)]
); );
+493 -16
View File
@@ -89,6 +89,20 @@ h3 {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 20; 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 { .brand {
@@ -321,11 +335,23 @@ h3 {
.drop-feed { .drop-feed {
width: 100%; width: 100%;
max-height: 220px; max-height: none;
position: static; position: static;
border-right: none; border-right: none;
border-bottom: 1px solid var(--stroke); 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 { .balance-pill {
@@ -384,9 +410,13 @@ a.balance-pill-link.active {
} }
.btn.danger { .btn.danger {
background: transparent; background: var(--danger);
border-color: rgba(235, 75, 75, 0.5); border-color: transparent;
color: var(--danger); color: #1a0606;
}
.btn.danger:hover:not(:disabled) {
background: #f06565;
} }
.btn.ghost { .btn.ghost {
@@ -668,7 +698,7 @@ a.balance-pill-link.active {
} }
.item-wear { .item-wear {
color: var(--gold); color: var(--accent);
font-size: 0.72rem; font-size: 0.72rem;
font-family: var(--font-display); font-family: var(--font-display);
letter-spacing: 0.02em; letter-spacing: 0.02em;
@@ -693,6 +723,39 @@ a.balance-pill-link.active {
line-height: 1.2; 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 { .case-hero {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -734,7 +797,8 @@ a.balance-pill-link.active {
transition: width 300ms ease; transition: width 300ms ease;
} }
.case-key-badge { .case-price,
.item-price {
color: var(--gold); color: var(--gold);
font-family: var(--font-display); font-family: var(--font-display);
} }
@@ -768,6 +832,11 @@ a.balance-pill-link.active {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.multi-reel-hint {
margin: 0;
font-size: 0.85rem;
}
.multi-reels.many { .multi-reels.many {
gap: 1.25rem; gap: 1.25rem;
} }
@@ -1703,6 +1772,15 @@ a.balance-pill-link.active {
border-top: 1px solid var(--stroke); 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 { .admin-adjust-row label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1719,6 +1797,35 @@ a.balance-pill-link.active {
min-width: 8rem; 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 { .admin-card-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1843,18 +1950,24 @@ a.balance-pill-link.active {
.item-vault-badge { .item-vault-badge {
position: absolute; position: absolute;
top: 0.35rem; top: 0.3rem;
left: 0.35rem; left: 0.3rem;
z-index: 1; z-index: 2;
width: 1.15rem; min-width: 1.35rem;
height: 1.15rem; height: 1.35rem;
padding: 0 0.2rem;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 0.2rem; border-radius: 0.25rem;
background: rgba(80, 160, 220, 0.35); background: #0a121c;
color: #9fd4ff; color: #e8f6ff;
font-size: 0.7rem; border: 1.5px solid #7ec8ff;
font-weight: 700; 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; line-height: 1;
pointer-events: none; pointer-events: none;
} }
@@ -1869,6 +1982,24 @@ a.balance-pill-link.active {
.vault-panel .section-head { .vault-panel .section-head {
margin-bottom: 0.75rem; 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 { .vault-stats {
@@ -1881,8 +2012,60 @@ a.balance-pill-link.active {
.vault-grid { .vault-grid {
margin-bottom: 0.5rem; 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 { .item-tile.disabled {
opacity: 0.4; opacity: 0.4;
pointer-events: none; pointer-events: none;
@@ -1909,6 +2092,10 @@ a.balance-pill-link.active {
min-height: 220px; min-height: 220px;
} }
.mode-card-wide {
grid-column: 1 / -1;
}
.mode-card-top { .mode-card-top {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -2148,6 +2335,32 @@ a.mode-card:hover {
align-items: center; 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 { .room-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2658,6 +2871,238 @@ a.achievement-badge:hover .achievement-badge-name {
padding: 0.35rem 0.7rem; 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 { .inv-toolbar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -2667,6 +3112,17 @@ a.achievement-badge:hover .achievement-badge-name {
margin-bottom: 1rem; 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 { .inv-toolbar-left {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -2786,6 +3242,13 @@ a.achievement-badge:hover .achievement-badge-name {
margin: 0.35rem 0; margin: 0.35rem 0;
} }
.pack-credits {
font-family: var(--font-display);
font-size: 1.4rem;
color: var(--gold);
margin: 0.35rem 0;
}
.ad-panel { .ad-panel {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@@ -3289,6 +3752,20 @@ a.achievement-badge:hover .achievement-badge-name {
font-family: var(--font-display); 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 { .prestige-history {
margin-top: 1.5rem; margin-top: 1.5rem;
padding-top: 1rem; padding-top: 1rem;
+4
View File
@@ -8,6 +8,8 @@ import AdminItems from '../admin/AdminItems';
import AdminCategories from '../admin/AdminCategories'; import AdminCategories from '../admin/AdminCategories';
import AdminDrops from '../admin/AdminDrops'; import AdminDrops from '../admin/AdminDrops';
import AdminPlayers from '../admin/AdminPlayers'; import AdminPlayers from '../admin/AdminPlayers';
import AdminPrestigeServer from '../admin/AdminPrestigeServer';
import AdminShop from '../admin/AdminShop';
function AdminLogin() { function AdminLogin() {
const { adminLogin } = useAuth(); const { adminLogin } = useAuth();
@@ -74,6 +76,8 @@ export default function Admin() {
<Route path="items" element={<AdminItems />} /> <Route path="items" element={<AdminItems />} />
<Route path="categories" element={<AdminCategories />} /> <Route path="categories" element={<AdminCategories />} />
<Route path="drops" element={<AdminDrops />} /> <Route path="drops" element={<AdminDrops />} />
<Route path="prestige-server" element={<AdminPrestigeServer />} />
<Route path="shop" element={<AdminShop />} />
<Route path="*" element={<Navigate to="/admin" replace />} /> <Route path="*" element={<Navigate to="/admin" replace />} />
</Route> </Route>
</Routes> </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 { Link, Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api'; import { api } from '../api';
import { useAuth } from '../AuthContext'; import { useAuth } from '../AuthContext';
import CaseReel from '../components/CaseReel'; import BattleLiveCard from '../components/BattleLiveCard';
import { useCountdown } from '../hooks/useCountdown';
import { rarityColor } from '../rarities';
import { getSocket } from '../socket'; 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() { export default function BattleHub() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
const [round, setRound] = useState(null); const [battles, setBattles] = useState([]);
const [duelOpen, setDuelOpen] = useState([]); const [spinById, setSpinById] = 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);
useEffect(() => { useEffect(() => {
if (!user || user.role === 'admin') return undefined; if (!user || user.role === 'admin') return undefined;
@@ -45,103 +16,53 @@ export default function BattleHub() {
(async () => { (async () => {
try { try {
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]); const data = await api.jackpot();
if (cancelled) return; if (!cancelled) setBattles(data.battles || []);
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);
}
} catch { } catch {
/* hub still usable without live preview */ /* hub still usable */
} }
})(); })();
const socket = getSocket(); const socket = getSocket();
const onState = (state) => setRound(state); const onList = (payload) => {
const onSpin = (state) => { setBattles(payload.battles || []);
setRound(state); };
setSpinDeposits(state.deposits || []); const onSpin = (state) => {
setSpinActive(true); if (!state?.id) return;
setSpinning(true); setSpinById((prev) => ({ ...prev, [state.id]: state }));
setRevealWinner(false); setBattles((prev) => {
setReelWinnerItemId(state.reelWinnerItemId); const idx = prev.findIndex((b) => b.id === state.id);
setLastWinner(state.winnerUsername); 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('jackpots:subscribe');
socket.emit('duels:subscribe'); socket.on('jackpots:list', onList);
socket.on('jackpot:state', onState);
socket.on('jackpot:spin', onSpin); socket.on('jackpot:spin', onSpin);
socket.on('jackpot:result', onResult); socket.on('jackpot:result', onResult);
socket.on('duels:list', onDuels); socket.on('connect', () => socket.emit('jackpots:subscribe'));
socket.on('connect', () => {
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
});
return () => { return () => {
cancelled = true; cancelled = true;
socket.emit('jackpot:unsubscribe'); socket.emit('jackpots:unsubscribe');
socket.emit('duels:unsubscribe'); socket.off('jackpots:list', onList);
socket.off('jackpot:state', onState);
socket.off('jackpot:spin', onSpin); socket.off('jackpot:spin', onSpin);
socket.off('jackpot:result', onResult); socket.off('jackpot:result', onResult);
socket.off('duels:list', onDuels);
}; };
}, [user]); }, [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 (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />; if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />; if (user.role === 'admin') return <Navigate to="/admin" replace />;
@@ -151,106 +72,28 @@ export default function BattleHub() {
<div className="section-head"> <div className="section-head">
<div> <div>
<h1>Battle</h1> <h1>Battle</h1>
<p className="muted">Multiplayer item gambling modes.</p>
</div> </div>
</div> </div>
<div className="mode-grid"> <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"> <div className="mode-card-top">
<h2>MultiPlayers Case</h2> <h2>Create a Battle</h2>
<span className={`mode-status${round?.status === 'spinning' || spinning ? ' spinning' : ''}`}>
{statusLabel}
</span>
</div> </div>
<p className="muted mode-blurb"> <p className="muted mode-blurb">
Join the live pot. Bet up to {round?.maxItemsPerPlayer || 3} items chance scales with Bet items to open a new pot. Others can join chance scales with value. Timer starts at
value. 2 players.
</p> </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>
<Link to="/battle/1vx" className="mode-card"> {battles.map((b) => (
<div className="mode-card-top"> <BattleLiveCard key={b.id} battle={b} spinEvent={spinById[b.id]} />
<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>
<div className="mode-card stub"> {battles.length === 0 && (
<h2>Coinflip</h2> <p className="muted mode-pot-empty" style={{ gridColumn: '1 / -1' }}>
<p className="muted">Classic 1v1 item flip. Coming soon.</p> No open battles create one to get started.
<span className="badge">Soon</span> </p>
</div> )}
</div> </div>
</section> </section>
); );
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, Navigate } from 'react-router-dom'; import { Link, Navigate, useNavigate, useParams } from 'react-router-dom';
import { api, formatCredits, formatChance } from '../api'; import { api, formatCredits, formatChance, compareCents } from '../api';
import { useAuth } from '../AuthContext'; import { useAuth } from '../AuthContext';
import ItemTile, { itemValueCents } from '../components/ItemTile'; import ItemTile, { itemValueCents } from '../components/ItemTile';
import CaseReel from '../components/CaseReel'; import CaseReel from '../components/CaseReel';
@@ -8,7 +8,13 @@ import { useCountdown } from '../hooks/useCountdown';
import { onInventoryChanged } from '../inventoryEvents'; import { onInventoryChanged } from '../inventoryEvents';
import { getSocket } from '../socket'; 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 { user, loading, refresh } = useAuth();
const [round, setRound] = useState(null); const [round, setRound] = useState(null);
const [inventory, setInventory] = useState([]); const [inventory, setInventory] = useState([]);
@@ -37,8 +43,8 @@ export default function MultiPlayersPage() {
() => () =>
[...inventory].sort( [...inventory].sort(
(a, b) => (a, b) =>
itemValueCents(b.item) - itemValueCents(a.item) || compareCents(itemValueCents(b.item), itemValueCents(a.item)) ||
(b.valueCents ?? 0) - (a.valueCents ?? 0) compareCents(b.valueCents, a.valueCents)
), ),
[inventory] [inventory]
); );
@@ -46,23 +52,56 @@ export default function MultiPlayersPage() {
useEffect(() => { useEffect(() => {
if (!user || user.role === 'admin') return undefined; if (!user || user.role === 'admin') return undefined;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
const [jp] = await Promise.all([api.jackpot(), loadInventory()]); await loadInventory();
if (!cancelled) setRound(jp.round); 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) { } catch (err) {
if (!cancelled) setError(err.message); if (!cancelled) setError(err.message);
} }
})(); })();
if (create || !battleId) {
return () => {
cancelled = true;
};
}
const socket = getSocket(); const socket = getSocket();
const onState = (state) => { const onState = (state) => {
if (state?.id && state.id !== battleId) return;
setRound(state); setRound(state);
if (state?.status === 'cancelled') {
navigate('/battle', { replace: true });
return;
}
if (state?.status === 'open') { if (state?.status === 'open') {
loadInventory().catch(() => {}); loadInventory().catch(() => {});
} }
}; };
const onSpin = (state) => { const onSpin = (state) => {
if (state?.id && state.id !== battleId) return;
setRound(state); setRound(state);
setSpinDeposits(state.deposits || []); setSpinDeposits(state.deposits || []);
setSpinActive(true); setSpinActive(true);
@@ -72,25 +111,30 @@ export default function MultiPlayersPage() {
setLastWinner(state.winnerUsername); setLastWinner(state.winnerUsername);
}; };
const onResult = (state) => { const onResult = (state) => {
if (state?.previousRoundId && state.previousRoundId !== battleId) return;
if (state?.id && state.id !== battleId && !state.previousRoundId) return;
setRound(state); setRound(state);
if (state.winnerUsername) setLastWinner(state.winnerUsername);
setRevealWinner(true);
setSpinning(false);
loadInventory().catch(() => {}); loadInventory().catch(() => {});
refresh().catch(() => {}); refresh().catch(() => {});
}; };
socket.emit('jackpot:subscribe'); socket.emit('jackpot:subscribe', battleId);
socket.on('jackpot:state', onState); socket.on('jackpot:state', onState);
socket.on('jackpot:spin', onSpin); socket.on('jackpot:spin', onSpin);
socket.on('jackpot:result', onResult); socket.on('jackpot:result', onResult);
socket.on('connect', () => socket.emit('jackpot:subscribe')); socket.on('connect', () => socket.emit('jackpot:subscribe', battleId));
return () => { return () => {
cancelled = true; cancelled = true;
socket.emit('jackpot:unsubscribe'); socket.emit('jackpot:unsubscribe', battleId);
socket.off('jackpot:state', onState); socket.off('jackpot:state', onState);
socket.off('jackpot:spin', onSpin); socket.off('jackpot:spin', onSpin);
socket.off('jackpot:result', onResult); socket.off('jackpot:result', onResult);
}; };
}, [user, loadInventory, refresh]); }, [user, create, battleId, loadInventory, refresh, navigate]);
useEffect(() => { useEffect(() => {
if (!user || user.role === 'admin') return undefined; if (!user || user.role === 'admin') return undefined;
@@ -106,6 +150,8 @@ export default function MultiPlayersPage() {
[round, user] [round, user]
); );
const mySlotsLeft = Math.max(0, maxItems - myDeposits.length); const mySlotsLeft = Math.max(0, maxItems - myDeposits.length);
const canBet =
create || round?.status === 'open' || (!round && create);
const potItems = useMemo(() => { const potItems = useMemo(() => {
const source = spinActive && spinDeposits.length ? spinDeposits : round?.deposits || []; const source = spinActive && spinDeposits.length ? spinDeposits : round?.deposits || [];
@@ -140,7 +186,14 @@ export default function MultiPlayersPage() {
setBusy(true); setBusy(true);
setError(''); setError('');
try { 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); setRound(data.round);
setSelected([]); setSelected([]);
await loadInventory(); await loadInventory();
@@ -152,10 +205,15 @@ export default function MultiPlayersPage() {
}; };
const withdraw = async (inventoryItemIds) => { const withdraw = async (inventoryItemIds) => {
if (!round?.id) return;
setBusy(true); setBusy(true);
setError(''); setError('');
try { 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); setRound(data.round);
await loadInventory(); await loadInventory();
} catch (err) { } catch (err) {
@@ -169,15 +227,22 @@ export default function MultiPlayersPage() {
if (!user) return <Navigate to="/login" replace />; if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" 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 ( return (
<div className="bet-page"> <div className="bet-page">
<div className="section-head"> <div className="section-head">
<div> <div>
<h1>MultiPlayers Case</h1> <h1>{title}</h1>
<p className="muted">Live shared pot win chance = your value / total pot.</p> <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> </div>
<Link to="/battle" className="btn secondary"> <Link to="/battle" className="btn secondary">
Modes Back
</Link> </Link>
</div> </div>
@@ -185,25 +250,27 @@ export default function MultiPlayersPage() {
<div className="jackpot-status"> <div className="jackpot-status">
<div className="timer-block"> <div className="timer-block">
<span className="muted">Round</span> <span className="muted">Battle</span>
<strong>#{round?.id || '—'}</strong> <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>
<div className="timer-block"> <div className="timer-block">
<span className="muted">Timer</span> <span className="muted">Timer</span>
<strong className="timer-digits"> <strong className="timer-digits">
{round?.status === 'spinning' || spinning {round?.status === 'spinning' || spinning
? 'SPIN' ? 'SPIN'
: round?.waitingForPlayers : round?.waitingForPlayers || (create && !round)
? 'WAIT' ? 'WAIT'
: round?.status === 'open' && round?.endsAt : round?.status === 'open' && round?.endsAt
? timerLabel ? timerLabel
: '—'} : '—'}
</strong> </strong>
{round?.waitingForPlayers && round?.status === 'open' && ( {((round?.waitingForPlayers && round?.status === 'open') || (create && !round)) && (
<span className="muted"> <span className="muted">
Need {round.minPlayers || 2} players ({round.players?.length || 0}/ Need {round?.minPlayers || 2} players ({round?.players?.length || 0}/
{round.minPlayers || 2}) {round?.minPlayers || 2})
</span> </span>
)} )}
{round?.status === 'open' && {round?.status === 'open' &&
@@ -289,7 +356,9 @@ export default function MultiPlayersPage() {
{myDeposits.length > 0 && round?.status === 'open' && ( {myDeposits.length > 0 && round?.status === 'open' && (
<section className="section"> <section className="section">
<div className="section-head"> <div className="section-head">
<h2>Your bets ({myDeposits.length}/{maxItems})</h2> <h2>
Your bets ({myDeposits.length}/{maxItems})
</h2>
<button <button
type="button" type="button"
className="btn danger" className="btn danger"
@@ -302,11 +371,11 @@ export default function MultiPlayersPage() {
</section> </section>
)} )}
{round?.status === 'open' && ( {isOpen && canBet && (
<section className="section"> <section className="section">
<div className="section-head"> <div className="section-head">
<div> <div>
<h2>Bet from inventory</h2> <h2>{create && !round ? 'Bet to create' : 'Bet from inventory'}</h2>
<p className="muted"> <p className="muted">
Select up to {mySlotsLeft} more item{mySlotsLeft === 1 ? '' : 's'}. Select up to {mySlotsLeft} more item{mySlotsLeft === 1 ? '' : 's'}.
</p> </p>
@@ -317,7 +386,7 @@ export default function MultiPlayersPage() {
disabled={busy || !selected.length || mySlotsLeft === 0} disabled={busy || !selected.length || mySlotsLeft === 0}
onClick={placeBet} onClick={placeBet}
> >
Place bet {create && !round ? 'Create battle' : 'Place bet'}
</button> </button>
</div> </div>
{inventoryByValue.length === 0 ? ( {inventoryByValue.length === 0 ? (
@@ -331,8 +400,12 @@ export default function MultiPlayersPage() {
key={inv.id} key={inv.id}
item={inv.item} item={inv.item}
favorite={inv.favorite} favorite={inv.favorite}
staked={inv.staked}
selected={selected.includes(inv.id)} 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)} onClick={() => toggleSelect(inv.id)}
/> />
))} ))}
+415 -167
View File
@@ -1,14 +1,16 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom'; import { Navigate, useParams } from 'react-router-dom';
import { api, formatCredits } from '../api'; import { api, formatCredits, compareCents, toCentsBigInt } from '../api';
import { useAuth } from '../AuthContext'; import { useAuth } from '../AuthContext';
import CaseReel from '../components/CaseReel'; import CaseReel from '../components/CaseReel';
import ItemTile from '../components/ItemTile'; import ItemTile, { itemValueCents } from '../components/ItemTile';
import { notifyInventoryChanged } from '../inventoryEvents'; import { notifyInventoryChanged } from '../inventoryEvents';
import { rarityColor } from '../rarities'; import { rarityColor } from '../rarities';
import { loadCaseOpenQty, resolveOpenQty, saveCaseOpenQty } from '../userPrefs'; import { loadCaseOpenQty, resolveOpenQty, saveCaseOpenQty } from '../userPrefs';
const DEFAULT_MAX_OPENS = 10; 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. */ /** 15, then steps of 5 (10, 15…), plus max if not already listed. */
function buildOpenQuantityOptions(maxOpens) { function buildOpenQuantityOptions(maxOpens) {
@@ -20,26 +22,69 @@ function buildOpenQuantityOptions(maxOpens) {
return options; 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() { export default function CasePage() {
const { id } = useParams(); const { id } = useParams();
const { user, loading, updateBalance } = useAuth(); const { user, loading, updateBalance } = useAuth();
const [crate, setCrate] = useState(null); const [crate, setCrate] = useState(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [keyMessage, setKeyMessage] = useState(''); const [keyMessage, setKeyMessage] = useState('');
const [catalogMessage, setCatalogMessage] = useState('');
const [openBonusMessage, setOpenBonusMessage] = useState(''); const [openBonusMessage, setOpenBonusMessage] = useState('');
const [animReduction, setAnimReduction] = useState(0); const [animReduction, setAnimReduction] = useState(0);
const [quantity, setQuantity] = useState(1); const [quantity, setQuantity] = useState(1);
const [opening, setOpening] = useState(false); const [opening, setOpening] = useState(false);
const [spins, setSpins] = useState([]); const [spins, setSpins] = useState([]);
const [allDrops, setAllDrops] = useState([]);
const [caseName, setCaseName] = 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 announcedRef = useRef(false);
const preAnnouncedRef = useRef(false);
const finishingRef = useRef(false); const finishingRef = useRef(false);
const pendingAnnounceRef = useRef({ ids: [], caseName: '' }); const pendingAnnounceRef = useRef({ ids: [], caseName: '', respin: false });
const pendingAutoSellRef = useRef([]); 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 quantityRef = useRef(1);
const caseIdRef = useRef(null); const caseIdRef = useRef(null);
const prefReadyRef = useRef(false); 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 keyProgress = crate?.keyProgress ?? null;
const maxOpens = keyProgress?.maxOpens ?? DEFAULT_MAX_OPENS; const maxOpens = keyProgress?.maxOpens ?? DEFAULT_MAX_OPENS;
@@ -61,10 +106,15 @@ export default function CasePage() {
const announcePendingDrop = async (useKeepalive = false) => { const announcePendingDrop = async (useKeepalive = false) => {
const ids = pendingAnnounceRef.current.ids.filter(Boolean); const ids = pendingAnnounceRef.current.ids.filter(Boolean);
const announceCaseName = pendingAnnounceRef.current.caseName || caseName || crate?.name || ''; const announceCaseName = pendingAnnounceRef.current.caseName || caseName || crate?.name || '';
const respin = Boolean(pendingAnnounceRef.current.respin);
if (!ids.length || announcedRef.current) return; if (!ids.length || announcedRef.current) return;
announcedRef.current = true; announcedRef.current = true;
const body = JSON.stringify({ inventoryItemIds: ids, caseName: announceCaseName }); const body = JSON.stringify({
inventoryItemIds: ids,
caseName: announceCaseName,
respin,
});
if (useKeepalive) { if (useKeepalive) {
fetch('/api/feed/announce', { fetch('/api/feed/announce', {
@@ -80,15 +130,51 @@ export default function CasePage() {
} }
try { try {
await api.feedAnnounce(ids, announceCaseName); await api.feedAnnounce(ids, announceCaseName, { respin });
} catch { } catch {
announcedRef.current = false; 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 applyPendingAutoSellKeepalive = () => {
const ids = pendingAutoSellRef.current.filter(Boolean); const ids = [
...pendingPreRespinAutoSellRef.current,
...pendingAutoSellRef.current,
].filter(Boolean);
if (!ids.length) return; if (!ids.length) return;
pendingPreRespinAutoSellRef.current = [];
pendingAutoSellRef.current = []; pendingAutoSellRef.current = [];
fetch('/api/inventory/auto-sell/apply', { fetch('/api/inventory/auto-sell/apply', {
method: 'POST', method: 'POST',
@@ -99,42 +185,79 @@ export default function CasePage() {
}).catch(() => {}); }).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; if (finishingRef.current) return;
finishingRef.current = true; finishingRef.current = true;
const ids = completedSpins.map((s) => s.item?.inventoryId).filter(Boolean); // Keep full-batch ids already stored in pending refs (not only showcase reels)
pendingAnnounceRef.current = { ids, caseName: announceCaseName }; if (announceCaseName) {
pendingAnnounceRef.current = {
...pendingAnnounceRef.current,
caseName: announceCaseName,
};
}
try { try {
await announcePendingDrop(false); await announcePendingDrop(false);
const toSell = completedSpins const toSell = [...pendingAutoSellRef.current];
.filter((s) => s.item?.willAutoSell)
.map((s) => s.item.inventoryId)
.filter(Boolean);
pendingAutoSellRef.current = []; pendingAutoSellRef.current = [];
if (toSell.length) { if (toSell.length) {
const data = await api.applyPendingAutoSell(toSell); const data = await api.applyPendingAutoSell(toSell);
updateBalance(data.user.balance); updateBalance(data.user.balance);
notifyInventoryChanged(); notifyInventoryChanged();
const soldSet = new Set(data.soldIds); if (data.soldIds?.length) markSpinsAutoSold(data.soldIds, data.sold);
setSpins((curr) =>
curr.map((s) =>
soldSet.has(s.item.inventoryId)
? {
...s,
item: {
...s.item,
autoSold: true,
soldForCents: s.item.valueCents,
},
}
: s
)
);
} }
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -147,6 +270,7 @@ export default function CasePage() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
prefReadyRef.current = false; prefReadyRef.current = false;
prevMaxOpensRef.current = null;
caseIdRef.current = null; caseIdRef.current = null;
setCrate(null); setCrate(null);
(async () => { (async () => {
@@ -167,9 +291,16 @@ export default function CasePage() {
const maxAffordable = useMemo(() => { const maxAffordable = useMemo(() => {
if (!crate || !user) return 1; if (!crate || !user) return 1;
if (crate.price <= 0) return maxOpens; try {
const affordable = Math.floor(user.balance / crate.price); const price = BigInt(String(crate.price ?? 0).split('.')[0] || '0');
return Math.min(maxOpens, Math.max(affordable, 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]); }, [crate, user, maxOpens]);
const openOptions = useMemo(() => buildOpenQuantityOptions(maxOpens), [maxOpens]); const openOptions = useMemo(() => buildOpenQuantityOptions(maxOpens), [maxOpens]);
@@ -182,18 +313,28 @@ export default function CasePage() {
if (!prefReadyRef.current) { if (!prefReadyRef.current) {
prefReadyRef.current = true; prefReadyRef.current = true;
prevMaxOpensRef.current = maxOpens;
setQuantity(resolved); setQuantity(resolved);
return; 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) => { setQuantity((q) => {
const allowed = openOptions.filter((n) => n <= Math.max(1, maxAffordable)); const allowed = openOptions.filter((n) => n <= Math.max(1, maxAffordable));
if (!allowed.length) return 1; 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; if (allowed.includes(q)) return q;
const below = allowed.filter((n) => n <= q); const below = allowed.filter((n) => n <= q);
return below.length ? below[below.length - 1] : allowed[0]; return below.length ? below[below.length - 1] : allowed[0];
}); });
}, [crate, maxAffordable, openOptions, user?.id]); }, [crate, maxAffordable, openOptions, user?.id, maxOpens]);
useEffect(() => { useEffect(() => {
if (!prefReadyRef.current || !user?.id || crate?.id == null) return; if (!prefReadyRef.current || !user?.id || crate?.id == null) return;
@@ -202,6 +343,37 @@ export default function CasePage() {
useEffect(() => { useEffect(() => {
return () => { 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); announcePendingDrop(true);
applyPendingAutoSellKeepalive(); applyPendingAutoSellKeepalive();
}; };
@@ -213,7 +385,7 @@ export default function CasePage() {
const ca = Number(a.chance ?? a.dropRate ?? 0); const ca = Number(a.chance ?? a.dropRate ?? 0);
const cb = Number(b.chance ?? b.dropRate ?? 0); const cb = Number(b.chance ?? b.dropRate ?? 0);
if (ca !== cb) return ca - cb; if (ca !== cb) return ca - cb;
return (b.marketValue || 0) - (a.marketValue || 0); return compareCents(b.marketValue, a.marketValue);
}); });
}, [crate]); }, [crate]);
@@ -225,31 +397,57 @@ export default function CasePage() {
); );
}, [keyProgress]); }, [keyProgress]);
if (loading) return <p className="muted">Loading</p>; const totalCost = useMemo(() => {
if (!user) return <Navigate to="/login" replace />; if (!crate) return '0';
if (user.role === 'admin') return <Navigate to="/admin" replace />; return (toCentsBigInt(crate.price) * BigInt(quantity)).toString();
}, [crate, quantity]);
const totalCost = crate ? crate.price * quantity : 0; const canOpen = useMemo(() => {
const canOpen = if (!user || !crate || opening || !crate.items?.length) return false;
crate && if (quantity < 1 || quantity > maxOpens) return false;
!opening && return toCentsBigInt(user.balance) >= toCentsBigInt(totalCost);
crate.items.length > 0 && }, [user, crate, opening, quantity, maxOpens, totalCost]);
quantity >= 1 &&
quantity <= maxOpens &&
user.balance >= 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 () => { const openCase = async () => {
if (!canOpen) return; if (!canOpen || !crate) return;
setError(''); setError('');
setKeyMessage(''); setKeyMessage('');
setCatalogMessage('');
setOpenBonusMessage(''); setOpenBonusMessage('');
setSpins([]); setSpins([]);
setAllDrops([]);
setOpening(true); setOpening(true);
setRespinRestarting(false);
announcedRef.current = false; announcedRef.current = false;
preAnnouncedRef.current = false;
finishingRef.current = false; finishingRef.current = false;
pendingRespinFinalRef.current = null;
pendingPreRespinAnnounceRef.current = null;
pendingPreRespinAutoSellRef.current = [];
respinPhaseRef.current = null;
try { try {
const result = await api.openCase(crate.id, quantity); const result = await api.openCase(crate.id, quantity);
@@ -259,10 +457,9 @@ export default function CasePage() {
if (result.animReduction != null) { if (result.animReduction != null) {
setAnimReduction(result.animReduction); setAnimReduction(result.animReduction);
} }
const bonusParts = []; if (result.refunded) {
if (result.refunded) bonusParts.push('Refund! Case cost returned'); setOpenBonusMessage('Refund! Case cost returned');
if (result.respin) bonusParts.push('Respin! All drops re-rolled'); }
if (bonusParts.length) setOpenBonusMessage(bonusParts.join(' · '));
if (result.keyProgress) { if (result.keyProgress) {
setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev)); setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
} }
@@ -272,19 +469,6 @@ export default function CasePage() {
); );
} }
if (result.catalog) { 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) { if (result.catalog.luckPercent != null) {
setCrate((prev) => setCrate((prev) =>
prev ? { ...prev, catalogLuckPercent: result.catalog.luckPercent } : prev prev ? { ...prev, catalogLuckPercent: result.catalog.luckPercent } : prev
@@ -308,24 +492,34 @@ export default function CasePage() {
setCaseName(resolvedCaseName); setCaseName(resolvedCaseName);
const items = result.items?.length ? result.items : result.item ? [result.item] : []; const items = result.items?.length ? result.items : result.item ? [result.item] : [];
const inventoryIds = items.map((item) => item?.inventoryId).filter(Boolean); const inventoryIds = items.map((item) => item?.inventoryId).filter(Boolean);
const pre = result.respin && result.preRespinItems?.length ? result.preRespinItems : null;
pendingAnnounceRef.current = { pendingAnnounceRef.current = {
ids: inventoryIds, ids: inventoryIds,
caseName: resolvedCaseName || '', caseName: resolvedCaseName || '',
respin: Boolean(pre),
}; };
pendingAutoSellRef.current = items pendingAutoSellRef.current = items
.filter((item) => item?.willAutoSell) .filter((item) => item?.willAutoSell)
.map((item) => item.inventoryId) .map((item) => item.inventoryId)
.filter(Boolean); .filter(Boolean);
const next = items.map((item, idx) => ({
key: `${item.inventoryId || item.id}-${idx}-${Date.now()}`, if (pre) {
item, pendingPreRespinAutoSellRef.current = pre
spinning: false, .filter((item) => item?.willAutoSell && item?.inventoryId)
done: false, .map((item) => item.inventoryId);
})); pendingPreRespinAnnounceRef.current = {
setSpins(next); items: pre,
requestAnimationFrame(() => { caseName: resolvedCaseName || '',
setSpins((prev) => prev.map((s) => ({ ...s, spinning: true }))); };
}); pendingRespinFinalRef.current = items;
respinPhaseRef.current = 'pre';
beginReelSpins(pre);
} else {
pendingPreRespinAutoSellRef.current = [];
respinPhaseRef.current = null;
beginReelSpins(items);
}
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
setOpening(false); setOpening(false);
@@ -336,109 +530,149 @@ export default function CasePage() {
setSpins((prev) => { setSpins((prev) => {
const alreadyDone = prev.find((s) => s.key === key)?.done; const alreadyDone = prev.find((s) => s.key === key)?.done;
if (alreadyDone) return prev; if (alreadyDone) return prev;
return prev.map((s) =>
const next = prev.map((s) =>
s.key === key ? { ...s, spinning: false, done: true } : 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 ( return (
<div className="case-page"> <div className="case-page">
<Link to="/cases" className="muted">
Back to cases
</Link>
{!crate && !error && <p className="muted">Loading case</p>} {!crate && !error && <p className="muted">Loading case</p>}
{error && <div className="error">{error}</div>} {error && <div className="error">{error}</div>}
{keyMessage && <div className="ok-msg">{keyMessage}</div>} {keyMessage && <div className="ok-msg">{keyMessage}</div>}
{catalogMessage && <div className="ok-msg">{catalogMessage}</div>}
{openBonusMessage && <div className="ok-msg">{openBonusMessage}</div>} {openBonusMessage && <div className="ok-msg">{openBonusMessage}</div>}
{crate && ( {crate && (
<> <>
<div className="case-hero"> <div
<h1>{crate.name}</h1> className={`case-hero-wrap${heroHidden ? ' case-hero-wrap--hidden' : ''}`}
<p className="muted"> aria-hidden={heroHidden}
Cost {formatCredits(crate.price)} cr each >
{crate.basePrice != null && Number(crate.basePrice) !== Number(crate.price) <div className="case-hero">
? ` (was ${formatCredits(crate.basePrice)})` <h1>{crate.name}</h1>
: ''}{' '}
· Balance {formatCredits(user.balance)} cr
{crate.catalogLuckPercent > 0
? ` · Catalog luck +${crate.catalogLuckPercent}%`
: ''}
</p>
{keyProgress && ( {keyProgress && (
<div className="case-key-progress"> <div className="case-key-progress">
<div className="case-key-head"> <div className="case-key-head">
<span> <span>
Keys <strong>{keyProgress.keys}</strong> · Max{' '} Keys <strong>{keyProgress.keys}</strong> · Max{' '}
<strong>x{keyProgress.maxOpens}</strong> <strong>x{keyProgress.maxOpens}</strong>
</span> </span>
<span className="muted"> <span className="muted">
Next key: {keyProgress.opensSinceLastKey} / {keyProgress.opensRequired} opens Next key: {keyProgress.opensSinceLastKey} / {keyProgress.opensRequired} opens
</span> </span>
</div> </div>
<div className="case-key-bar" role="progressbar" aria-valuenow={progressPct}> <div className="case-key-bar" role="progressbar" aria-valuenow={progressPct}>
<div className="case-key-bar-fill" style={{ width: `${progressPct}%` }} /> <div className="case-key-bar-fill" style={{ width: `${progressPct}%` }} />
</div>
</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>
)}
<div className="open-qty"> <button
<label className="muted"> type="button"
Open className={`case-icon ${canOpen ? '' : 'disabled'}${crate.imageUrl ? ' has-img' : ''}`}
<select onClick={openCase}
value={quantity} disabled={!canOpen}
disabled={opening} title="Open case"
onChange={(e) => { >
const next = Number(e.target.value); {crate.imageUrl ? <img src={crate.imageUrl} alt="" /> : '▣'}
setQuantity(next); </button>
persistQty(crate.id, next); <button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
}} {opening
> ? `Opening ${quantity}×…`
{openOptions.map((n) => ( : `Open ${quantity}× for ${formatCredits(totalCost)} cr`}
<option key={n} value={n} disabled={n > maxAffordable && maxAffordable > 0}> </button>
{n}×
</option>
))}
</select>
</label>
<span className="muted">
Total {formatCredits(totalCost)} cr
{maxAffordable < quantity ? ' · insufficient balance' : ''}
</span>
</div> </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> </div>
{spins.length > 0 && ( {spins.length > 0 && (
<div className={`multi-reels${spins.length > 1 ? ' many' : ''}`}> <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) => ( {spins.map((spin, idx) => (
<div key={spin.key} className="multi-reel-slot"> <div key={spin.key} className="multi-reel-slot">
{spins.length > 1 && ( {spins.length > 1 && (
@@ -464,14 +698,18 @@ export default function CasePage() {
{spin.item.rarity} {spin.item.rarity}
</div> </div>
<div className="muted"> <div className="muted">
{[
spin.item.wear || null,
`${formatCredits(spin.item.valueCents ?? spin.item.marketValue)} cr`,
]
.filter(Boolean)
.join(' · ')}
{spin.item.autoSold ? ( {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>
</div> </div>
)} )}
@@ -480,13 +718,23 @@ export default function CasePage() {
</div> </div>
)} )}
{allDone && spins.length > 1 && ( {allDone && allDrops.length > 1 && (
<div className="won-banner section"> <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' }}> <div className="grid" style={{ marginTop: '0.75rem' }}>
{spins.map((s) => ( {[...allDrops]
<ItemTile key={s.key} item={s.item} /> .sort((a, b) => compareCents(dropValue(b), dropValue(a)))
))} .map((item) => (
<ItemTile
key={item.inventoryId || `${item.id}-${item.floatValue}`}
item={item}
/>
))}
</div> </div>
</div> </div>
)} )}
+1 -7
View File
@@ -30,12 +30,6 @@ export default function CasesPage() {
return ( return (
<section className="section"> <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>} {error && <div className="error">{error}</div>}
<div className="grid case-grid"> <div className="grid case-grid">
{cases.map((c) => ( {cases.map((c) => (
@@ -44,7 +38,7 @@ export default function CasesPage() {
{c.imageUrl ? <img src={c.imageUrl} alt="" loading="lazy" /> : '▣'} {c.imageUrl ? <img src={c.imageUrl} alt="" loading="lazy" /> : '▣'}
</div> </div>
<div className="case-name">{c.name}</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> <div className="case-meta">{c.items.length} possible drops</div>
{c.keyProgress && ( {c.keyProgress && (
<div className="case-meta case-key-badge"> <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 { useEffect, useMemo, useState } from 'react';
import { Link, Navigate } from 'react-router-dom'; 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 { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile'; import ItemTile from '../components/ItemTile';
import { onInventoryChanged } from '../inventoryEvents'; import { onInventoryChanged } from '../inventoryEvents';
import { getSocket, onSocketSessionOk } from '../socket';
import { loadInventorySort, saveInventorySort } from '../userPrefs'; import { loadInventorySort, saveInventorySort } from '../userPrefs';
const PAGE_SIZE = 50; 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() { export default function Dashboard() {
const { user, loading, patchUser } = useAuth(); const { user, loading, patchUser } = useAuth();
const [inventory, setInventory] = useState([]); const [inventory, setInventory] = useState([]);
@@ -21,6 +30,7 @@ export default function Dashboard() {
const [maxValue, setMaxValue] = useState('10'); const [maxValue, setMaxValue] = useState('10');
const [autoSellEnabled, setAutoSellEnabled] = useState(false); const [autoSellEnabled, setAutoSellEnabled] = useState(false);
const [settingsBusy, setSettingsBusy] = useState(false); const [settingsBusy, setSettingsBusy] = useState(false);
const [now, setNow] = useState(() => Date.now());
useEffect(() => { useEffect(() => {
if (!user?.id) return; if (!user?.id) return;
@@ -32,7 +42,7 @@ export default function Dashboard() {
if (!user) return; if (!user) return;
setAutoSellEnabled(Boolean(user.autoSellEnabled)); setAutoSellEnabled(Boolean(user.autoSellEnabled));
if (user.autoSellThresholdCents != null) { if (user.autoSellThresholdCents != null) {
setMaxValue((user.autoSellThresholdCents / 100).toFixed(2).replace(/\.?0+$/, '') || '0'); setMaxValue(centsToCreditsString(user.autoSellThresholdCents));
} }
}, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]); }, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]);
@@ -64,13 +74,64 @@ export default function Dashboard() {
}); });
}, [user]); }, [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 sorted = useMemo(() => {
const list = [...inventory]; const list = inventory.filter((inv) => !inv.staked);
if (sort === 'value') { if (sort === 'value') {
list.sort( list.sort((a, b) =>
(a, b) => compareCents(
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) - b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue,
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue) a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue
)
); );
} else { } else {
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt)); 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); if (page > totalPages) setPage(totalPages);
}, [page, totalPages]); }, [page, totalPages]);
const selectedValue = useMemo( const selectedValue = useMemo(() => {
() => let sum = 0n;
inventory for (const inv of inventory) {
.filter((inv) => selected.includes(inv.id)) if (!selected.includes(inv.id)) continue;
.reduce((sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0), 0), sum += toCentsBigInt(inv.valueCents ?? inv.item?.valueCents);
[inventory, selected] }
); return sum.toString();
}, [inventory, selected]);
const thresholdCents = useMemo(() => { const thresholdCents = useMemo(() => creditsToCentsString(maxValue), [maxValue]);
const credits = Number(maxValue);
if (!Number.isFinite(credits) || credits < 0) return null;
return Math.round(credits * 100);
}, [maxValue]);
const belowThreshold = useMemo(() => { const belowThreshold = useMemo(() => {
if (thresholdCents == null) return []; if (thresholdCents == null) return [];
return inventory.filter( return inventory.filter((inv) => {
(inv) => if (inv.favorite || inv.staked) return false;
!inv.favorite && return compareCents(inv.valueCents ?? inv.item?.valueCents, thresholdCents) < 0;
!inv.staked && });
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
);
}, [inventory, thresholdCents]); }, [inventory, thresholdCents]);
const selectedItems = useMemo( const selectedItems = useMemo(
@@ -134,14 +190,13 @@ export default function Dashboard() {
const canStake = selectedUnstaked.length > 0 && selectedUnstaked.length <= vaultFree; const canStake = selectedUnstaked.length > 0 && selectedUnstaked.length <= vaultFree;
const canUnstake = selectedStaked.length > 0; const canUnstake = selectedStaked.length > 0;
const belowValue = useMemo( const belowValue = useMemo(() => {
() => let sum = 0n;
belowThreshold.reduce( for (const inv of belowThreshold) {
(sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0), sum += toCentsBigInt(inv.valueCents ?? inv.item?.valueCents);
0 }
), return sum.toString();
[belowThreshold] }, [belowThreshold]);
);
const toggle = (id) => { const toggle = (id) => {
setSelected((prev) => 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) => { const setFavorite = async (favorite) => {
if (!selected.length) return; if (!selected.length) return;
setBusy(true); setBusy(true);
@@ -257,7 +291,8 @@ export default function Dashboard() {
if (!ids.length || thresholdCents == null) return; if (!ids.length || thresholdCents == null) return;
const favSkipped = inventory.filter( const favSkipped = inventory.filter(
(inv) => (inv) =>
inv.favorite && (inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents inv.favorite &&
compareCents(inv.valueCents ?? inv.item?.valueCents, thresholdCents) < 0
).length; ).length;
const ok = window.confirm( const ok = window.confirm(
`Sell ${ids.length} item${ids.length === 1 ? '' : 's'} below ${formatCredits(thresholdCents)} cr for ${formatCredits(belowValue)} cr?` + `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) => { const onAutoSellToggle = async (enabled) => {
setAutoSellEnabled(enabled); setAutoSellEnabled(enabled);
const credits = Number(maxValue); if (creditsToCentsString(maxValue) == null) return;
if (!Number.isFinite(credits) || credits < 0) return; await saveAutoSell(enabled, maxValue);
await saveAutoSell(enabled, credits);
}; };
const onThresholdBlur = async () => { const onThresholdBlur = async () => {
const credits = Number(maxValue); const cents = creditsToCentsString(maxValue);
if (!Number.isFinite(credits) || credits < 0) return; if (cents == null) return;
const savedCents = user?.autoSellThresholdCents ?? 1000; const savedCents = String(user?.autoSellThresholdCents ?? 1000);
if (Math.round(credits * 100) === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) { if (cents === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
return; return;
} }
await saveAutoSell(autoSellEnabled, credits); await saveAutoSell(autoSellEnabled, maxValue);
}; };
if (loading) return <p className="muted">Loading</p>; if (loading) return <p className="muted">Loading</p>;
@@ -303,15 +337,56 @@ export default function Dashboard() {
return ( return (
<div> <div>
<section className="section"> {vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
<div className="section-head">
<div>
<h1>Inventory</h1>
<p className="muted">Select items to sell at market value.</p>
</div>
</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="inv-toolbar-left">
<div className="sort-toggle" role="group" aria-label="Sort inventory"> <div className="sort-toggle" role="group" aria-label="Sort inventory">
<button <button
@@ -335,87 +410,103 @@ export default function Dashboard() {
Most recent Most recent
</button> </button>
</div> </div>
{inventory.length > 0 && ( {sorted.length > 0 && (
<span className="muted inv-count"> <span className="muted inv-count">
{inventory.length} item{inventory.length === 1 ? '' : 's'} {sorted.length} item{sorted.length === 1 ? '' : 's'}
</span> </span>
)} )}
</div> </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> </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"> <div className="inv-bulk-sell">
{inventory.length > 0 && ( {sorted.length > 0 && (
<> <>
<button <button
type="button" type="button"
@@ -440,11 +531,13 @@ export default function Dashboard() {
</label> </label>
<span className="muted inv-bulk-preview"> <span className="muted inv-bulk-preview">
{belowThreshold.length} item{belowThreshold.length === 1 ? '' : 's'} ·{' '} {belowThreshold.length} item{belowThreshold.length === 1 ? '' : 's'} ·{' '}
{formatCredits(belowValue)} cr <span className="item-price">
{formatCredits(belowValue)} cr
</span>
</span> </span>
</> </>
)} )}
{inventory.length === 0 && ( {sorted.length === 0 && (
<label className="inv-bulk-label"> <label className="inv-bulk-label">
Threshold Threshold
<input <input
@@ -471,76 +564,26 @@ export default function Dashboard() {
<span>Auto-sell</span> <span>Auto-sell</span>
</label> </label>
<span className="inv-auto-sell-at muted"> <span className="inv-auto-sell-at muted">
at{' '} sell below {formatCredits(creditsToCentsString(maxValue) ?? 0)} cr
{formatCredits(
Number.isFinite(Number(maxValue)) && Number(maxValue) >= 0
? Math.round(Number(maxValue) * 100)
: 0
)}{' '}
cr
</span> </span>
</div> </div>
</div> </div>
{error && <div className="error">{error}</div>} {error && <div className="error">{error}</div>}
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
<div className="vault-panel admin-block"> {sorted.length === 0 ? (
<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 ? (
<div className="empty"> <div className="empty">
Empty for now {' '} {stakedItems.length > 0 ? (
<Link to="/cases" style={{ color: 'var(--accent)' }}> 'All items are in the vault.'
open a case ) : (
</Link> <>
. Empty for now {' '}
<Link to="/cases" style={{ color: 'var(--accent)' }}>
open a case
</Link>
.
</>
)}
</div> </div>
) : ( ) : (
<> <>
@@ -550,7 +593,6 @@ export default function Dashboard() {
key={inv.id} key={inv.id}
item={inv.item} item={inv.item}
favorite={inv.favorite} favorite={inv.favorite}
staked={inv.staked}
selected={selected.includes(inv.id)} selected={selected.includes(inv.id)}
onClick={() => toggle(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 { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile'; import ItemTile from '../components/ItemTile';
import ProfileCaseStats from '../components/ProfileCaseStats'; import ProfileCaseStats from '../components/ProfileCaseStats';
@@ -38,10 +38,11 @@ export default function PlayerProfilePage() {
const sorted = useMemo(() => { const sorted = useMemo(() => {
const list = [...inventory]; const list = [...inventory];
if (sort === 'value') { if (sort === 'value') {
list.sort( list.sort((a, b) =>
(a, b) => compareCents(
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) - b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue,
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue) a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue
)
); );
} else { } else {
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt)); 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> Cases opened <strong>{profile.casesOpened}</strong>
</li> </li>
<li> <li>
Jackpot wins <strong>{profile.jackpotWins}</strong> Battle wins <strong>{profile.jackpotWins}</strong>
</li>
<li>
Duel wins <strong>{profile.duelWins}</strong>
</li> </li>
</ul> </ul>
<ProfileAchievementBadges achievements={profile.achievements} /> <ProfileAchievementBadges achievements={profile.achievements} />
@@ -223,7 +221,12 @@ export default function PlayerProfilePage() {
<> <>
<div className="grid"> <div className="grid">
{pageItems.map((inv) => ( {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> </div>
{totalPages > 1 && ( {totalPages > 1 && (
+47 -21
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api'; import { api, formatCredits, toCentsBigInt } from '../api';
import { useAuth } from '../AuthContext'; import { useAuth } from '../AuthContext';
function formatSkillValue(skill, value) { function formatSkillValue(skill, value) {
@@ -67,7 +67,7 @@ export default function PrestigePage() {
patchUser(result.user); patchUser(result.user);
await refresh(); await refresh();
setMessage( 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(); await load();
} catch (err) { } catch (err) {
@@ -77,14 +77,14 @@ export default function PrestigePage() {
} }
}; };
const onUpgrade = async (skillId) => { const onUpgrade = async (skillId, count = 1) => {
setBusy(true); setBusy(true);
setError(''); setError('');
setMessage(''); setMessage('');
try { try {
const result = await api.prestigeUpgrade(skillId); const result = await api.prestigeUpgrade(skillId, count);
patchUser(result.user); patchUser(result.user);
setMessage(`Upgraded ${skillId}.`); setMessage(count > 1 ? `Upgraded ${skillId} ×${count}.` : `Upgraded ${skillId}.`);
setData((prev) => setData((prev) =>
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 const statusLabel = !data?.canAfford
? `Need ${formatCredits(shortfall)} cr more` ? `Need ${formatCredits(shortfall)} cr more`
: data?.lockedItems > 0 : data?.lockedItems > 0
@@ -178,8 +181,8 @@ export default function PrestigePage() {
<strong>+{p.openBonus || 0}</strong> <strong>+{p.openBonus || 0}</strong>
</div> </div>
<div> <div>
<span className="muted">Discount</span> <span className="muted">PR Farmer</span>
<strong>{p.caseDiscount || 0}%</strong> <strong>+{p.prBonus || 0}%</strong>
</div> </div>
<div> <div>
<span className="muted">Quality</span> <span className="muted">Quality</span>
@@ -251,6 +254,7 @@ export default function PrestigePage() {
<li>All Pr already earned</li> <li>All Pr already earned</li>
<li>Skill-tree bonuses</li> <li>Skill-tree bonuses</li>
<li>Account &amp; prestige count</li> <li>Account &amp; prestige count</li>
<li>Catalog progress</li>
</ul> </ul>
</div> </div>
<div> <div>
@@ -258,7 +262,7 @@ export default function PrestigePage() {
<ul> <ul>
<li>Balance</li> <li>Balance</li>
<li>Inventory &amp; vault stakes</li> <li>Inventory &amp; vault stakes</li>
<li>Case keys &amp; catalog progress</li> <li>Case keys</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -308,21 +312,43 @@ export default function PrestigePage() {
<p className="muted">{skill.description}</p> <p className="muted">{skill.description}</p>
<div className="prestige-skill-meta"> <div className="prestige-skill-meta">
<span> <span>
Lv {skill.level} · now{' '} Lv {skill.level}
{skill.maxLevel != null ? `/${skill.maxLevel}` : ''}
{' · now '}
<strong>{formatSkillValue(skill, skill.current)}</strong> <strong>{formatSkillValue(skill, skill.current)}</strong>
</span> </span>
<span className="prestige-delta"> {!skill.atMax && (
{formatSkillValue(skill, skill.nextValue)} <span className="prestige-delta">
</span> {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> </div>
<button
type="button"
className="btn secondary"
disabled={busy || !skill.canAfford}
onClick={() => onUpgrade(skill.id)}
>
Buy · {skill.cost} Pr
</button>
</article> </article>
</div> </div>
))} ))}
+16 -7
View File
@@ -6,6 +6,7 @@ import ProfileAchievementBadges from '../components/ProfileAchievementBadges';
import ProfilePrestigeStats from '../components/ProfilePrestigeStats'; import ProfilePrestigeStats from '../components/ProfilePrestigeStats';
import { Link, Navigate, useNavigate } from 'react-router-dom'; import { Link, Navigate, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { onSocketSessionOk } from '../socket';
export default function ProfilePage() { export default function ProfilePage() {
const { user, loading, refresh, logout } = useAuth(); const { user, loading, refresh, logout } = useAuth();
@@ -21,7 +22,7 @@ export default function ProfilePage() {
useEffect(() => { useEffect(() => {
if (!user || user.role === 'admin') return; if (!user || user.role === 'admin') return;
let cancelled = false; let cancelled = false;
(async () => { const loadProfile = async () => {
try { try {
const data = await api.profile(); const data = await api.profile();
if (!cancelled) { if (!cancelled) {
@@ -31,9 +32,14 @@ export default function ProfilePage() {
} catch (err) { } catch (err) {
if (!cancelled) setError(err.message); if (!cancelled) setError(err.message);
} }
})(); };
loadProfile();
const offSession = onSocketSessionOk(() => {
loadProfile().catch(() => {});
});
return () => { return () => {
cancelled = true; cancelled = true;
offSession();
}; };
}, [user]); }, [user]);
@@ -130,7 +136,13 @@ export default function ProfilePage() {
</div> </div>
<label className="btn secondary file-btn"> <label className="btn secondary file-btn">
Change photo 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> </label>
<div className="muted" style={{ marginTop: '0.75rem' }}> <div className="muted" style={{ marginTop: '0.75rem' }}>
Balance {formatCredits(user.balance)} cr Balance {formatCredits(user.balance)} cr
@@ -178,10 +190,7 @@ export default function ProfilePage() {
Cases opened <strong>{profile.casesOpened}</strong> Cases opened <strong>{profile.casesOpened}</strong>
</li> </li>
<li> <li>
Jackpot wins <strong>{profile.jackpotWins}</strong> Battle wins <strong>{profile.jackpotWins}</strong>
</li>
<li>
Duel wins <strong>{profile.duelWins}</strong>
</li> </li>
</ul> </ul>
<ProfileAchievementBadges <ProfileAchievementBadges
+160 -54
View File
@@ -23,20 +23,33 @@ function useAdCountdown(nextAdAt) {
return remaining; 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() { export default function ShopPage() {
const { user, loading, patchUser } = useAuth(); const { user, loading, patchUser } = useAuth();
const [packages, setPackages] = useState([]); const [packages, setPackages] = useState([]);
const [osuTopups, setOsuTopups] = useState([]);
const [osuTopupsEnabled, setOsuTopupsEnabled] = useState(true);
const [payments, setPayments] = useState(null);
const [ad, setAd] = useState(null); const [ad, setAd] = useState(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [watching, setWatching] = useState(false); const [watching, setWatching] = useState(false);
const [confirmingId, setConfirmingId] = useState(null);
const remaining = useAdCountdown(ad?.ready ? null : ad?.nextAdAt); const remaining = useAdCountdown(ad?.ready ? null : ad?.nextAdAt);
const load = useCallback(async () => { const load = useCallback(async () => {
const data = await api.shop(); const data = await api.shop();
setPackages(data.packages || []); setPackages(data.packages || []);
setOsuTopups(data.osuTopups || []);
setOsuTopupsEnabled(data.osuTopupsEnabled !== false && (data.osuTopups || []).length > 0);
setPayments(data.payments || null);
setAd(data.ad); setAd(data.ad);
patchUser({ patchUser({
balance: data.user.balance, balance: data.user.balance,
@@ -60,7 +73,7 @@ export default function ShopPage() {
}; };
}, [user?.id, user?.role, load]); }, [user?.id, user?.role, load]);
const buy = async (packageId) => { const buyCredits = async (packageId) => {
setBusy(true); setBusy(true);
setError(''); setError('');
setMessage(''); setMessage('');
@@ -70,7 +83,7 @@ export default function ShopPage() {
balance: data.user.balance, balance: data.user.balance,
osuBalance: data.user.osuBalance, osuBalance: data.user.osuBalance,
}); });
setMessage(`Bought ${data.package.name} — +${formatCredits(data.package.credits)} cr`); setMessage(`Exchanged — +${formatCredits(data.package.credits)} cr`);
await load(); await load();
} catch (err) { } catch (err) {
setError(err.message); 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 () => { const watchAd = async () => {
setBusy(true); setBusy(true);
setError(''); setError('');
setMessage(''); setMessage('');
setWatching(true); setWatching(true);
// Simulated short ad
await new Promise((r) => setTimeout(r, 1800)); await new Promise((r) => setTimeout(r, 1800));
try { try {
const data = await api.shopAd(); const data = await api.shopAd();
@@ -115,30 +157,122 @@ export default function ShopPage() {
if (user.role === 'admin') return <Navigate to="/admin" replace />; if (user.role === 'admin') return <Navigate to="/admin" replace />;
const adReady = ad?.ready || (ad?.nextAdAt && remaining === 0); const adReady = ad?.ready || (ad?.nextAdAt && remaining === 0);
const osuBal = user.osuBalance ?? 0;
return ( return (
<div> <div className="shop-page">
<section className="section"> <header className="shop-hero">
<div className="section-head"> <div className="shop-hero-copy">
<div> <p className="shop-kicker">CaseOrion Shop</p>
<h1>Shop</h1> <h1>Fuel your runs</h1>
<p className="muted">Spend osu for credit packs, or watch an ad every minute.</p> <p className="shop-lede">
</div> <strong>osu</strong> is the premium wallet bought with real money, spent on credit
<div className="row-actions"> packs. Credits are for opening cases, vaulting, and battling.
<span className="osu-pill">{user.osuBalance ?? 0} osu</span>
<span className="balance-pill">{formatCredits(user.balance)} cr</span>
</div>
</div>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
<div className="admin-block ad-panel">
<h2>Watch an ad</h2>
<p className="muted">
Earn between {formatCredits(ad?.rewardMin || 10000)} and{' '}
{formatCredits(ad?.rewardMax || 20000)} cr. Cooldown: 1 minute.
</p> </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 ? ( {watching ? (
<div className="ad-sim"> <div className="ad-sim">
<div className="ad-bar" /> <div className="ad-bar" />
@@ -147,43 +281,15 @@ export default function ShopPage() {
) : ( ) : (
<button <button
type="button" type="button"
className="btn" className="btn secondary"
disabled={busy || !adReady} disabled={busy || !adReady}
onClick={watchAd} onClick={watchAd}
> >
{adReady {adReady ? 'Watch ad' : `Ready in ${formatCountdown(remaining)}`}
? 'Watch ad'
: `Available in ${String(Math.floor(remaining / 60)).padStart(2, '0')}:${String(
remaining % 60
).padStart(2, '0')}`}
</button> </button>
)} )}
</div> </div>
</section> </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> </div>
); );
} }
+107 -3
View File
@@ -4,10 +4,14 @@ let socket = null;
let limited = false; let limited = false;
let limitPayload = null; let limitPayload = null;
const limitListeners = new Set(); const limitListeners = new Set();
const sessionOkListeners = new Set();
let unloadBound = false;
let connectTimer = null;
let reconnectSeq = 0;
function notifyLimit(payload) { function notifyLimit(payload) {
limited = true; limited = true;
limitPayload = payload || { max: 10 }; limitPayload = payload || { max: 2 };
for (const fn of limitListeners) { for (const fn of limitListeners) {
try { try {
fn(limitPayload); 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() { export function getSocket() {
if (!socket) { if (!socket) {
// Connect only after auth is known (see refreshSocketSession) to avoid
// anonymous+auth double handshakes that stack slots on rapid refresh.
socket = io({ socket = io({
path: '/socket.io', path: '/socket.io',
withCredentials: true, withCredentials: true,
autoConnect: true, autoConnect: false,
reconnection: true, reconnection: true,
reconnectionDelay: 500, reconnectionDelay: 500,
reconnectionAttempts: Infinity, reconnectionAttempts: Infinity,
}); });
bindUnloadOnce();
socket.on('session:limit', (payload) => { socket.on('session:limit', (payload) => {
socket.io.reconnection(false); socket.io.reconnection(false);
notifyLimit(payload); 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()) { if (!socket.io.reconnection()) {
socket.io.reconnection(true); socket.io.reconnection(true);
} }
notifyOk(); notifyOk();
notifySessionOk(payload);
}); });
} }
return socket; 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() { export function isSessionLimited() {
return limited; return limited;
} }
@@ -70,10 +167,17 @@ export function onSessionLimitChange(fn) {
return () => limitListeners.delete(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. */ /** Allow reconnect after the user closes other tabs and reloads. */
export function clearSessionLimit() { export function clearSessionLimit() {
notifyOk(); notifyOk();
if (socket) { if (socket) {
socket.io.reconnection(true); socket.io.reconnection(true);
if (!socket.connected) socket.connect();
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
host: true, host: true,
port: 5173, port: 5888,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3001', 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: # Usage:
# ./scripts/local-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS # ./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/casegambling-YYYYMMDD-HHMMSS.tar.gz
# ./scripts/local-db-import.sh ./backups/….tar.gz <username> # player-only merge
# #
# Typical flow (prod → laptop): # Typical flow (prod → laptop):
# # on prod: # # on prod:
# ./scripts/docker-db-export.sh # ./scripts/docker-db-export.sh
# # copy the .tar.gz here, then: # # copy the .tar.gz here, then:
# ./scripts/local-db-import.sh ./backups/casegambling-….tar.gz # ./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. # Stop `npm run dev` before importing so SQLite is not locked.
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MERGE_PLAYER_SCRIPT="$ROOT/scripts/merge-player-db.sh"
if [[ -z "${1:-}" ]]; then if [[ -z "${1:-}" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
echo "Usage: $0 <backup-dir-or-tar.gz>" >&2 echo "Usage: $0 <backup-dir-or-tar.gz> [username]" >&2
echo "Example: $0 ./backups/casegambling-20260116-203000.tar.gz" >&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 echo "Create a backup from Docker: ./scripts/docker-db-export.sh" >&2
exit 1 exit 1
fi fi
BACKUP_ARG="$1"
USERNAME="${2:-}"
# Resolve backup path from the caller's cwd # Resolve backup path from the caller's cwd
if [[ "$1" = /* ]]; then if [[ "$BACKUP_ARG" = /* ]]; then
SRC="$1" SRC="$BACKUP_ARG"
else else
SRC="$(pwd)/$1" SRC="$(pwd)/$BACKUP_ARG"
fi fi
LOCAL_DB="${LOCAL_DB:-$ROOT/server/prisma/dev.db}" LOCAL_DB="${LOCAL_DB:-$ROOT/server/prisma/dev.db}"
@@ -49,6 +57,25 @@ is_archive() {
esac 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 if [[ -f "$SRC" ]] && is_archive "$SRC"; then
echo "==> Extracting $(basename "$SRC")" echo "==> Extracting $(basename "$SRC")"
TMP="$(mktemp -d)" TMP="$(mktemp -d)"
@@ -84,6 +111,35 @@ if [[ -z "$DB" ]]; then
exit 1 exit 1
fi 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")" mkdir -p "$(dirname "$LOCAL_DB")"
echo "==> Importing database → $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. # Run this on the machine that runs docker compose.
# #
# Usage: # Usage:
# ./scripts/docker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS # ./scripts/xdocker-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.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 # Expects a backup from docker-db-export.sh or local-db-export.sh
# (prod.db or dev.db + uploads/). # (prod.db or dev.db + uploads/).
@@ -24,18 +25,24 @@ else
exit 1 exit 1
fi fi
if [[ -z "${1:-}" ]]; then MERGE_PLAYER_SCRIPT="$ROOT/scripts/merge-player-db.sh"
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 "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 echo "Create a backup first: ./scripts/docker-db-export.sh (or local-db-export.sh)" >&2
exit 1 exit 1
fi fi
BACKUP_ARG="$1"
USERNAME="${2:-}"
# Resolve backup path from the caller's cwd (before any cd) # Resolve backup path from the caller's cwd (before any cd)
if [[ "$1" = /* ]]; then if [[ "$BACKUP_ARG" = /* ]]; then
SRC="$1" SRC="$BACKUP_ARG"
else else
SRC="$(pwd)/$1" SRC="$(pwd)/$BACKUP_ARG"
fi fi
cd "$ROOT" cd "$ROOT"
@@ -52,10 +59,14 @@ fi
SERVICE="${SERVICE:-casegambling}" SERVICE="${SERVICE:-casegambling}"
TMP="" TMP=""
WORK=""
cleanup() { cleanup() {
if [[ -n "$TMP" && -d "$TMP" ]]; then if [[ -n "$TMP" && -d "$TMP" ]]; then
rm -rf "$TMP" rm -rf "$TMP"
fi fi
if [[ -n "$WORK" && -d "$WORK" ]]; then
rm -rf "$WORK"
fi
} }
trap cleanup EXIT trap cleanup EXIT
@@ -66,6 +77,25 @@ is_archive() {
esac 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 if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker not found" >&2 echo "Error: docker not found" >&2
exit 1 exit 1
@@ -109,6 +139,58 @@ fi
echo "==> Ensuring compose service exists" echo "==> Ensuring compose service exists"
"${COMPOSE[@]}" up -d --no-start "$SERVICE" >/dev/null "${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)" echo "==> Stopping $SERVICE (safe SQLite replace)"
"${COMPOSE[@]}" stop "$SERVICE" >/dev/null 2>&1 || true "${COMPOSE[@]}" stop "$SERVICE" >/dev/null 2>&1 || true
+508
View File
@@ -15,10 +15,448 @@
"express-session": "^1.18.1", "express-session": "^1.18.1",
"multer": "^2.2.0", "multer": "^2.2.0",
"prisma": "^6.11.1", "prisma": "^6.11.1",
"sharp": "^0.34.4",
"socket.io": "^4.8.3" "socket.io": "^4.8.3"
}, },
"devDependencies": {} "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": { "node_modules/@prisma/client": {
"version": "6.19.3", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
@@ -457,6 +895,15 @@
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"license": "MIT" "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": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -1420,6 +1867,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "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": { "node_modules/send": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@@ -1471,6 +1930,48 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "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": { "node_modules/side-channel": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@@ -1671,6 +2172,13 @@
"node": ">=0.6" "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": { "node_modules/type-is": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "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", "express-session": "^1.18.1",
"multer": "^2.2.0", "multer": "^2.2.0",
"prisma": "^6.11.1", "prisma": "^6.11.1",
"sharp": "^0.34.4",
"socket.io": "^4.8.3" "socket.io": "^4.8.3"
}, }
"devDependencies": {}
} }
+58 -52
View File
@@ -11,29 +11,21 @@ model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @unique username String @unique
passwordHash String passwordHash String
balance BigInt @default(0) balance String @default("0")
osuBalance Int @default(0) osuBalance Int @default(0)
prBalance Int @default(0) prBalance String @default("0")
lastAdAt DateTime? lastAdAt DateTime?
role String @default("user") role String @default("user")
avatarUrl String @default("") avatarUrl String @default("")
bio String @default("") bio String @default("")
autoSellEnabled Boolean @default(false) autoSellEnabled Boolean @default(false)
autoSellThresholdCents BigInt @default(1000) autoSellThresholdCents String @default("1000")
prestigeCount Int @default(0) prestigeCount Int @default(0)
prestigeChanceBonus Int @default(0) prestigeChanceBonus Int @default(0)
prestigeGainBonus 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) prestigeWearBonus Int @default(0)
prestigePrBonus Int @default(0) prestigePrBonus Int @default(0)
prestigeShopDiscount Int @default(0)
prestigeStartOsu Int @default(0)
prestigeDropValueBonus Int @default(0)
prestigeOpenBonus Int @default(0) prestigeOpenBonus Int @default(0)
prestigeCaseDiscount Int @default(0)
prestigeQualityBonus Int @default(0) prestigeQualityBonus Int @default(0)
prestigeAnimReduction Int @default(0) prestigeAnimReduction Int @default(0)
prestigeRefundChance Int @default(0) prestigeRefundChance Int @default(0)
@@ -41,27 +33,46 @@ model User {
prestigeVaultSlots Int @default(0) prestigeVaultSlots Int @default(0)
prestigeOfflineYield Int @default(0) prestigeOfflineYield Int @default(0)
prestigeOnlineYield Int @default(0) prestigeOnlineYield Int @default(0)
vaultYieldAt DateTime?
lastConnection DateTime? lastConnection DateTime?
playTimeSeconds BigInt @default(0) playTimeSeconds BigInt @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
inventory InventoryItem[] inventory InventoryItem[]
transactions Transaction[] transactions Transaction[]
jackpotWins JackpotRound[] @relation("JackpotWinner") jackpotWins JackpotRound[] @relation("JackpotWinner")
jackpotCreated JackpotRound[] @relation("JackpotCreator")
jackpotDeposits JackpotDeposit[] jackpotDeposits JackpotDeposit[]
duelRoomsCreated DuelRoom[] @relation("DuelCreator")
duelWins DuelRoom[] @relation("DuelWinner")
duelDeposits DuelDeposit[]
caseKeyProgress CaseKeyProgress[] caseKeyProgress CaseKeyProgress[]
catalogEntries CatalogEntry[] catalogEntries CatalogEntry[]
prestigeLogs PrestigeLog[] prestigeLogs PrestigeLog[]
achievements UserAchievement[] 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 { model Case {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
imageUrl String @default("") imageUrl String @default("")
price BigInt price String @default("0")
active Boolean @default(true) active Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
items CaseItem[] items CaseItem[]
@@ -81,7 +92,7 @@ model Item {
imageUrl String @default("") imageUrl String @default("")
rarity String rarity String
category String @default("Misc") category String @default("Misc")
marketValue BigInt marketValue String @default("0")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
cases CaseItem[] cases CaseItem[]
inventory InventoryItem[] inventory InventoryItem[]
@@ -110,20 +121,19 @@ model InventoryItem {
floatValue Float @default(0.265) floatValue Float @default(0.265)
wear String @default("Field-Tested") wear String @default("Field-Tested")
paintSeed Int @default(0) paintSeed Int @default(0)
valueCents BigInt @default(0) valueCents String @default("0")
favorite Boolean @default(false) favorite Boolean @default(false)
obtainedAt DateTime @default(now()) obtainedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
jackpotDeposits JackpotDeposit[] jackpotDeposits JackpotDeposit[]
duelDeposits DuelDeposit[]
} }
model Transaction { model Transaction {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
type String type String
amount BigInt amount String
meta String @default("{}") meta String @default("{}")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -133,11 +143,15 @@ model JackpotRound {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
status String @default("open") status String @default("open")
endsAt DateTime endsAt DateTime
creatorId Int?
winnerUserId Int? winnerUserId Int?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
resolvedAt DateTime? resolvedAt DateTime?
creator User? @relation("JackpotCreator", fields: [creatorId], references: [id])
winner User? @relation("JackpotWinner", fields: [winnerUserId], references: [id]) winner User? @relation("JackpotWinner", fields: [winnerUserId], references: [id])
deposits JackpotDeposit[] deposits JackpotDeposit[]
@@index([status])
} }
model JackpotDeposit { model JackpotDeposit {
@@ -145,7 +159,7 @@ model JackpotDeposit {
roundId Int roundId Int
userId Int userId Int
inventoryItemId Int inventoryItemId Int
valueCents BigInt valueCents String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
round JackpotRound @relation(fields: [roundId], references: [id], onDelete: Cascade) round JackpotRound @relation(fields: [roundId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -154,37 +168,6 @@ model JackpotDeposit {
@@unique([roundId, inventoryItemId]) @@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 { model CaseKeyProgress {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
@@ -215,7 +198,7 @@ model PrestigeLog {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
choice String choice String
prGranted Int @default(0) prGranted String @default("0")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
@@ -241,3 +224,26 @@ model UserAchievement {
@@unique([userId, achievementId]) @@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 { prisma } from './db.js';
import { countCompletedChapters } from './catalog.js'; import { countCompletedChapters } from './catalog.js';
import { livePlayTimeSeconds } from './presence.js'; import { livePlayTimeSeconds } from './presence.js';
import { isPrestigeSkillTreeMaxed } from './prestige.js';
/** First batch of achievements — upserted on seed / boot. */ /** First batch of achievements — upserted on seed / boot. */
export const ACHIEVEMENT_DEFS = [ export const ACHIEVEMENT_DEFS = [
@@ -34,44 +35,44 @@ export const ACHIEVEMENT_DEFS = [
}, },
{ {
key: 'jackpot_win', key: 'jackpot_win',
name: 'Pot Lucky', name: 'Battle Winner',
description: 'Win a MultiPlayers Case round.', description: 'Win a Battle pot.',
icon: '♠', icon: '♠',
sortOrder: 5, sortOrder: 5,
}, },
{
key: 'duel_win',
name: 'Duel Victor',
description: 'Win a 1vX Case battle.',
icon: '⚔',
sortOrder: 6,
},
{ {
key: 'catalog_chapter', key: 'catalog_chapter',
name: 'Chapter Complete', name: 'Chapter Complete',
description: 'Finish a full catalog chapter.', description: 'Finish a full catalog chapter.',
icon: '◆', icon: '◆',
sortOrder: 7, sortOrder: 6,
}, },
{ {
key: 'first_prestige', key: 'first_prestige',
name: 'Prestige Bound', name: 'Prestige Bound',
description: 'Prestige for the first time.', description: 'Prestige for the first time.',
icon: '✦', icon: '✦',
sortOrder: 8, sortOrder: 7,
}, },
{ {
key: 'shop_customer', key: 'shop_customer',
name: 'Shop Regular', name: 'Shop Regular',
description: 'Buy a shop pack or claim an ad reward.', description: 'Buy a shop pack or claim an ad reward.',
icon: '◈', icon: '◈',
sortOrder: 9, sortOrder: 8,
}, },
{ {
key: 'playtime_hour', key: 'playtime_hour',
name: 'Hourglass', name: 'Hourglass',
description: 'Spend 1 hour playing on CaseOrion.', description: 'Spend 1 hour playing on CaseOrion.',
icon: '⌛', icon: '⌛',
sortOrder: 9,
},
{
key: 'prestige_skills_max',
name: 'Skill Absolute',
description: 'Reach max level on every Prestige skill tree branch.',
icon: '♛',
sortOrder: 10, 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) { function serializeAchievement(row, unlockedAt = null) {
@@ -160,16 +166,15 @@ export async function checkAchievements(userId) {
const user = await prisma.user.findUnique({ where: { id: userId } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user || user.role === 'admin') return []; if (!user || user.role === 'admin') return [];
const [casesOpened, jackpotWins, duelWins, chapters, shopOrAd, owned] = const [casesOpened, jackpotWins, chapters, shopOrAd, owned] =
await Promise.all([ await Promise.all([
prisma.transaction.count({ where: { userId, type: 'open_case' } }), prisma.transaction.count({ where: { userId, type: 'open_case' } }),
prisma.transaction.count({ where: { userId, type: 'jackpot_win' } }), prisma.transaction.count({ where: { userId, type: 'jackpot_win' } }),
prisma.transaction.count({ where: { userId, type: 'duel_win' } }),
countCompletedChapters(prisma, userId), countCompletedChapters(prisma, userId),
prisma.transaction.count({ prisma.transaction.count({
where: { where: {
userId, userId,
type: { in: ['shop_purchase', 'ad_reward'] }, type: { in: ['shop_purchase', 'ad_reward', 'osu_purchase'] },
}, },
}), }),
prisma.userAchievement.findMany({ 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 >= 25 && !have.has('case_25')) candidates.push('case_25');
if (casesOpened >= 100 && !have.has('case_100')) candidates.push('case_100'); if (casesOpened >= 100 && !have.has('case_100')) candidates.push('case_100');
if (jackpotWins >= 1 && !have.has('jackpot_win')) candidates.push('jackpot_win'); 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 (chapters >= 1 && !have.has('catalog_chapter')) candidates.push('catalog_chapter');
if ((user.prestigeCount ?? 0) >= 1 && !have.has('first_prestige')) { if ((user.prestigeCount ?? 0) >= 1 && !have.has('first_prestige')) {
candidates.push('first_prestige'); candidates.push('first_prestige');
@@ -195,6 +199,9 @@ export async function checkAchievements(userId) {
if (livePlayTimeSeconds(user) >= 3600 && !have.has('playtime_hour')) { if (livePlayTimeSeconds(user) >= 3600 && !have.has('playtime_hour')) {
candidates.push('playtime_hour'); candidates.push('playtime_hour');
} }
if (isPrestigeSkillTreeMaxed(user) && !have.has('prestige_skills_max')) {
candidates.push('prestige_skills_max');
}
return unlockMany(userId, candidates); return unlockMany(userId, candidates);
} }
+42 -28
View File
@@ -4,48 +4,62 @@
* Prestige gain bonus multiplies credited amount (not the sell threshold check). * Prestige gain bonus multiplies credited amount (not the sell threshold check).
*/ */
import { applyGainMultiplier } from './prestige.js'; import { applyGainMultiplier } from './prestige.js';
import { toCentsBigInt, centsJson } from './cents.js';
export function shouldAutoSell(user, valueCents) { export function shouldAutoSell(user, valueCents) {
if (!user?.autoSellEnabled) return false; if (!user?.autoSellEnabled) return false;
const threshold = user.autoSellThresholdCents ?? 0; const threshold = toCentsBigInt(user.autoSellThresholdCents ?? 0);
return valueCents < threshold; 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) { 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 gainBonus = user.prestigeGainBonus ?? 0;
const sold = []; const sold = [];
let balanceDelta = 0; let balanceDelta = 0n;
for (const inv of inventoryItems) { for (const inv of inventoryItems) {
if (!inv || inv.locked || inv.staked || inv.favorite || inv.valueCents >= threshold) continue; if (!inv || inv.locked || inv.staked || inv.favorite) continue;
const value = toCentsBigInt(inv.valueCents);
const payout = applyGainMultiplier(inv.valueCents, gainBonus); if (value >= threshold) continue;
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,
}),
},
});
sold.push(inv); sold.push(inv);
balanceDelta += payout; balanceDelta += applyGainMultiplier(value, gainBonus);
} }
return { sold, balanceDelta }; 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 { prisma } from './db.js';
import { applyCatalogLuck, normalizeRates } from './weighted.js'; import { applyCatalogLuck, normalizeRates } from './weighted.js';
import { toCentsBigInt } from './cents.js';
/** /**
* Count how many active cases the user has fully collected. * Count how many active cases the user has fully collected.
@@ -103,7 +104,13 @@ export async function getCatalogState(userId) {
orderBy: { dropRate: 'asc' }, 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({ 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'; import { PrismaClient } from '@prisma/client';
/** Prisma BigInt → Number so the rest of the app can keep using number arithmetic / JSON. */ /**
function reviveMoney(value) { * Plain Prisma client — no BigInt→Number revive.
if (typeof value === 'bigint') return Number(value); *
if (value == null || typeof value !== 'object') return value; * Credits / Pr / prices are stored as decimal **strings** and parsed with
if (value instanceof Date) return value; * `toCentsBigInt` / `centsJson` so values can grow past Number.MAX_SAFE_INTEGER.
if (Array.isArray(value)) return value.map(reviveMoney); * Any remaining Prisma BigInt fields (e.g. playTimeSeconds) stay as bigint until
for (const key of Object.keys(value)) { * an API boundary stringifies them (`res.json` replacer in index.js) or an
value[key] = reviveMoney(value[key]); * explicit Number()/toString() conversion.
} */
return value; export const prisma = new PrismaClient();
}
const base = new PrismaClient();
export const prisma = base.$extends({
query: {
$allOperations({ args, query }) {
return query(args).then(reviveMoney);
},
},
});
+11 -1
View File
@@ -13,9 +13,11 @@ function fingerprint(entry) {
return [ return [
entry.username || '', entry.username || '',
entry.item?.id || '', entry.item?.id || '',
String(entry.item?.valueCents ?? entry.item?.marketValue ?? ''),
entry.count || 1, entry.count || 1,
entry.openCount || 1, entry.openCount || 1,
ids.join(','), entry.respin ? 'respin' : 'open',
ids.join(',') || `snap:${entry.item?.name || ''}`,
].join('|'); ].join('|');
} }
@@ -47,3 +49,11 @@ export function pushDrop(entry) {
const { _fp, ...publicEvent } = event; const { _fp, ...publicEvent } = event;
return publicEvent; 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 leaderboardRoutes from './routes/leaderboard.js';
import adminRoutes from './routes/admin.js'; import adminRoutes from './routes/admin.js';
import jackpotRoutes from './routes/jackpot.js'; import jackpotRoutes from './routes/jackpot.js';
import duelsRoutes from './routes/duels.js';
import profileRoutes from './routes/profile.js'; import profileRoutes from './routes/profile.js';
import shopRoutes from './routes/shop.js'; import shopRoutes from './routes/shop.js';
import feedRoutes from './routes/feed.js'; import feedRoutes from './routes/feed.js';
@@ -23,12 +22,11 @@ import achievementsRoutes from './routes/achievements.js';
import vaultRoutes from './routes/vault.js'; import vaultRoutes from './routes/vault.js';
import { setIO } from './realtime.js'; import { setIO } from './realtime.js';
import { getDropFeed } from './feed.js'; import { getDropFeed } from './feed.js';
import { ensureJackpotRound } from './services/jackpot.js'; import { ensureJackpotRound, listJackpotBattles, getJackpotRound } from './services/jackpot.js';
import { recoverDuelSpins, getDuelRoom, listDuelRooms, startEmptyDuelRoomPurge } from './services/duel.js';
import { getJackpotState } from './services/jackpot.js';
import { ensureAdminFromEnv } from './ensureAdmin.js'; import { ensureAdminFromEnv } from './ensureAdmin.js';
import { ensureAchievements, checkAchievements } from './achievements.js'; import { ensureAchievements, checkAchievements } from './achievements.js';
import { ensureCategories } from './categories.js'; import { ensureCategories } from './categories.js';
import { ensureShopOsuPacks } from './shopOsu.js';
import { trackConnect, trackDisconnect, settleOnlineVaultForConnected } from './presence.js'; import { trackConnect, trackDisconnect, settleOnlineVaultForConnected } from './presence.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -40,7 +38,7 @@ if (!fs.existsSync(uploadsDir)) {
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const PORT = Number(process.env.PORT || 3001); 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'; const TRUST_PROXY = process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1';
/** true | false | 'auto' (secure cookie only on HTTPS) */ /** true | false | 'auto' (secure cookie only on HTTPS) */
@@ -96,6 +94,19 @@ const sessionMiddleware = session({
app.use(cors(corsOptions)); app.use(cors(corsOptions));
app.use(express.json()); app.use(express.json());
app.use(sessionMiddleware); 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.use('/uploads', express.static(uploadsDir));
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
@@ -108,7 +119,6 @@ app.use('/api/inventory', inventoryRoutes);
app.use('/api/leaderboard', leaderboardRoutes); app.use('/api/leaderboard', leaderboardRoutes);
app.use('/api/admin', adminRoutes); app.use('/api/admin', adminRoutes);
app.use('/api/jackpot', jackpotRoutes); app.use('/api/jackpot', jackpotRoutes);
app.use('/api/duels', duelsRoutes);
app.use('/api/profile', profileRoutes); app.use('/api/profile', profileRoutes);
app.use('/api/shop', shopRoutes); app.use('/api/shop', shopRoutes);
app.use('/api/feed', feedRoutes); app.use('/api/feed', feedRoutes);
@@ -137,7 +147,7 @@ io.on('connection', async (socket) => {
if (userId) { if (userId) {
try { try {
const result = await trackConnect(userId, socket.id); const result = await trackConnect(userId, socket.id, io);
if (!result.ok) { if (!result.ok) {
socket.emit('session:limit', { socket.emit('session:limit', {
max: result.max, max: result.max,
@@ -147,6 +157,7 @@ io.on('connection', async (socket) => {
socket.disconnect(true); socket.disconnect(true);
return; return;
} }
// Soft-replace of older sockets no longer emits session:limit (refresh-safe).
socket.join(`user:${userId}`); socket.join(`user:${userId}`);
socket.emit('session:ok', { socket.emit('session:ok', {
count: result.count, 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'); socket.join('jackpot');
try { try {
const state = await getJackpotState(); const battles = await listJackpotBattles();
socket.emit('jackpot:state', state); socket.emit('jackpots:list', { battles });
if (battles[0]) socket.emit('jackpot:state', battles[0]);
} catch (err) { } catch (err) {
console.error(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.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.on('feed:subscribe', () => {
socket.join('feed'); socket.join('feed');
socket.emit('feed:history', getDropFeed()); socket.emit('feed:history', getDropFeed());
@@ -214,9 +225,10 @@ io.on('connection', async (socket) => {
}); });
socket.on('disconnect', () => { socket.on('disconnect', () => {
if (userId) { const uid = socket.data.userId || userId;
trackDisconnect(userId, socket.id) if (uid) {
.then(() => checkAchievements(userId)) trackDisconnect(uid, socket.id)
.then(() => checkAchievements(uid))
.catch((err) => console.error(err)); .catch((err) => console.error(err));
} }
}); });
@@ -244,6 +256,7 @@ async function bootstrap() {
await ensureAdminFromEnv(); await ensureAdminFromEnv();
await ensureAchievements(); await ensureAchievements();
await ensureCategories(); await ensureCategories();
await ensureShopOsuPacks();
} catch (err) { } catch (err) {
console.error(err.message || err); console.error(err.message || err);
process.exit(1); process.exit(1);
@@ -253,14 +266,12 @@ async function bootstrap() {
console.log(`API listening on http://localhost:${PORT}`); console.log(`API listening on http://localhost:${PORT}`);
try { try {
await ensureJackpotRound(); await ensureJackpotRound();
await recoverDuelSpins(); // Online vault dividends: check every 30s for completed ticks (5 min online)
startEmptyDuelRoomPurge();
// Online vault dividends: check every 60s for completed 10-min ticks
setInterval(() => { setInterval(() => {
settleOnlineVaultForConnected().catch((err) => settleOnlineVaultForConnected().catch((err) =>
console.error('vault online interval', err) console.error('vault online interval', err)
); );
}, 60_000); }, 30_000);
} catch (err) { } catch (err) {
console.error('Failed to bootstrap game services', err); console.error('Failed to bootstrap game services', err);
} }
+7 -4
View File
@@ -19,21 +19,24 @@ export function publicUser(user) {
return { return {
id: user.id, id: user.id,
username: user.username, username: user.username,
balance: user.balance, balance: user.balance == null ? '0' : user.balance.toString(),
osuBalance: user.osuBalance ?? 0, osuBalance: user.osuBalance ?? 0,
prBalance: user.prBalance ?? 0, prBalance: user.prBalance == null ? '0' : String(user.prBalance),
lastAdAt: user.lastAdAt || null, lastAdAt: user.lastAdAt || null,
role: user.role, role: user.role,
avatarUrl: user.avatarUrl || '', avatarUrl: user.avatarUrl || '',
bio: user.bio || '', bio: user.bio || '',
autoSellEnabled: Boolean(user.autoSellEnabled), autoSellEnabled: Boolean(user.autoSellEnabled),
autoSellThresholdCents: user.autoSellThresholdCents ?? 1000, autoSellThresholdCents:
user.autoSellThresholdCents == null
? '1000'
: user.autoSellThresholdCents.toString(),
prestigeCount: user.prestigeCount ?? 0, prestigeCount: user.prestigeCount ?? 0,
prestigeChanceBonus: user.prestigeChanceBonus ?? 0, prestigeChanceBonus: user.prestigeChanceBonus ?? 0,
prestigeGainBonus: user.prestigeGainBonus ?? 0, prestigeGainBonus: user.prestigeGainBonus ?? 0,
prestigeWearBonus: user.prestigeWearBonus ?? 0, prestigeWearBonus: user.prestigeWearBonus ?? 0,
prestigeOpenBonus: user.prestigeOpenBonus ?? 0, prestigeOpenBonus: user.prestigeOpenBonus ?? 0,
prestigeCaseDiscount: user.prestigeCaseDiscount ?? 0, prestigePrBonus: user.prestigePrBonus ?? 0,
prestigeQualityBonus: user.prestigeQualityBonus ?? 0, prestigeQualityBonus: user.prestigeQualityBonus ?? 0,
prestigeAnimReduction: user.prestigeAnimReduction ?? 0, prestigeAnimReduction: user.prestigeAnimReduction ?? 0,
prestigeRefundChance: user.prestigeRefundChance ?? 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( export const MAX_SOCKETS_PER_USER = Math.max(
1, 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 }>} */ /** @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. * 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. * 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 id = Number(userId);
const max = MAX_SOCKETS_PER_USER; const max = MAX_SOCKETS_PER_USER;
if (!id || !socketId) { if (!id || !socketId) {
@@ -52,18 +54,42 @@ export async function trackConnect(userId, socketId) {
online.set(id, entry); 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)) { if (entry.sockets.has(socketId)) {
return { ok: true, count: entry.sockets.size, max }; return { ok: true, count: entry.sockets.size, max };
} }
if (entry.sockets.size >= max) { // At capacity: free the oldest slot(s) so a refresh never soft-locks the only open tab
return { ok: false, count: entry.sockets.size, max }; 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) // Claim slot before any await (avoids race past the limit)
entry.sockets.add(socketId); entry.sockets.add(socketId);
const isFirst = entry.sockets.size === 1; const isFirst = entry.sockets.size === 1 && replaced.length === 0;
let vaultCredited = 0; let vaultCredited = '0';
if (isFirst) { if (isFirst) {
try { try {
@@ -77,13 +103,23 @@ export async function trackConnect(userId, socketId) {
// Offline yield accrued while disconnected // Offline yield accrued while disconnected
try { try {
const settled = await settleVaultYield(id, 'offline'); const settled = await settleVaultYield(id, 'offline');
vaultCredited = settled.credited; vaultCredited = settled.credited || '0';
} catch (err) { } catch (err) {
console.error('presence vault offline settle', 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). */ /** Live playtime including the current open session (if any). */
export function livePlayTimeSeconds(user) { 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)); const entry = online.get(Number(user?.id));
if (!entry) return stored; if (!entry) return stored;
const live = Math.max(0, Math.floor((Date.now() - entry.sessionStartedAt) / 1000)); 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. */ /** Settle online vault yield for all currently connected users. */
export async function settleOnlineVaultForConnected() { export async function settleOnlineVaultForConnected() {
const ids = onlineUserIds(); const ids = onlineUserIds();
let total = 0; let total = 0n;
for (const uid of ids) { for (const uid of ids) {
try { try {
const settled = await settleVaultYield(uid, 'online'); const settled = await settleVaultYield(uid, 'online');
total += settled.credited; total += BigInt(settled.credited || '0');
} catch (err) { } catch (err) {
console.error('vault online tick', uid, 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 */ /** 1_000_000_000_000.00 cr in cents */
export const PRESTIGE_COST_CENTS = 100_000_000_000_000n; export const PRESTIGE_COST_CENTS = 100_000_000_000_000n;
/** Page unlock: 10_000_000_000.00 cr total wealth (balance + unlocked inventory). */ /** Page unlock: 10_000_000_000.00 cr total wealth (balance + unlocked inventory). */
export const PRESTIGE_VISIBLE_AT_CENTS = 1_000_000_000_000; export const PRESTIGE_VISIBLE_AT_CENTS = 1_000_000_000_000n;
/** Base Pr granted on every prestige. */ /** Base Pr granted on every prestige. */
export const PR_BASE_REWARD = 1000; 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.) * (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; export const PR_PER_EXCESS_CHUNK = 1000;
const BASE_AD_COOLDOWN_MS = 60_000; 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). * Skill tree upgrades bought with Pr (stackable branches).
* level = how many times this branch has been purchased. * level = how many times this branch has been purchased.
* maxLevel = hard cap on purchases.
*/ */
export const PRESTIGE_SKILLS = { export const PRESTIGE_SKILLS = {
chance: { chance: {
id: 'chance', id: 'chance',
field: 'prestigeChanceBonus', field: 'prestigeChanceBonus',
delta: 1, delta: 1,
maxLevel: 100,
title: 'Fortune', title: 'Fortune',
description: '+1% drop luck on case opens (permanent)', description: '+1% drop luck on case opens (permanent)',
unit: '%', unit: '%',
@@ -34,6 +38,7 @@ export const PRESTIGE_SKILLS = {
id: 'gain', id: 'gain',
field: 'prestigeGainBonus', field: 'prestigeGainBonus',
delta: 2, delta: 2,
maxLevel: 20,
title: 'Yield', title: 'Yield',
description: '+2% credits on sell / auto-sell (permanent)', description: '+2% credits on sell / auto-sell (permanent)',
unit: '%', unit: '%',
@@ -43,6 +48,7 @@ export const PRESTIGE_SKILLS = {
id: 'polish', id: 'polish',
field: 'prestigeWearBonus', field: 'prestigeWearBonus',
delta: 1, delta: 1,
maxLevel: 100,
title: 'Polish', title: 'Polish',
description: 'Better float / wear rolls on case opens', description: 'Better float / wear rolls on case opens',
unit: '', unit: '',
@@ -52,25 +58,27 @@ export const PRESTIGE_SKILLS = {
id: 'bulk', id: 'bulk',
field: 'prestigeOpenBonus', field: 'prestigeOpenBonus',
delta: 1, delta: 1,
maxLevel: 20,
title: 'Bulk', title: 'Bulk',
description: '+1 max simultaneous case opens (permanent)', description: '+1 max simultaneous case opens (permanent)',
unit: '', unit: '',
branch: 'drops', branch: 'drops',
}, },
discount: { prFarmer: {
id: 'discount', id: 'prFarmer',
field: 'prestigeCaseDiscount', field: 'prestigePrBonus',
delta: 2, delta: 2,
title: 'Discount', maxLevel: 50,
description: '2% case open price (permanent)', title: 'PR Farmer',
description: '+2% Pr obtained when you prestige (permanent)',
unit: '%', unit: '%',
prefix: '',
branch: 'economy', branch: 'economy',
}, },
quality: { quality: {
id: 'quality', id: 'quality',
field: 'prestigeQualityBonus', field: 'prestigeQualityBonus',
delta: 1, delta: 1,
maxLevel: 100,
title: 'Quality', title: 'Quality',
description: 'Better float / wear rolls on case opens (items below Field-Tested get upgraded)', description: 'Better float / wear rolls on case opens (items below Field-Tested get upgraded)',
unit: '', unit: '',
@@ -80,6 +88,7 @@ export const PRESTIGE_SKILLS = {
id: 'noTime', id: 'noTime',
field: 'prestigeAnimReduction', field: 'prestigeAnimReduction',
delta: 10, delta: 10,
maxLevel: 100,
title: 'noTime', title: 'noTime',
description: 'Reduced case opening animation time', description: 'Reduced case opening animation time',
unit: '%', unit: '%',
@@ -90,6 +99,7 @@ export const PRESTIGE_SKILLS = {
id: 'refund', id: 'refund',
field: 'prestigeRefundChance', field: 'prestigeRefundChance',
delta: 2, delta: 2,
maxLevel: 20,
title: 'Refund', title: 'Refund',
description: 'Chance to get a free case open refund', description: 'Chance to get a free case open refund',
unit: '%', unit: '%',
@@ -99,8 +109,9 @@ export const PRESTIGE_SKILLS = {
id: 'respin', id: 'respin',
field: 'prestigeRespinChance', field: 'prestigeRespinChance',
delta: 1, delta: 1,
maxLevel: 20,
title: 'Respin', 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: '%', unit: '%',
branch: 'drops', branch: 'drops',
}, },
@@ -108,6 +119,7 @@ export const PRESTIGE_SKILLS = {
id: 'vaultCap', id: 'vaultCap',
field: 'prestigeVaultSlots', field: 'prestigeVaultSlots',
delta: 1, delta: 1,
maxLevel: 10,
title: 'Vault Capacity', title: 'Vault Capacity',
description: '+1 max staking slot in the vault', description: '+1 max staking slot in the vault',
unit: '', unit: '',
@@ -118,6 +130,7 @@ export const PRESTIGE_SKILLS = {
id: 'offlineYield', id: 'offlineYield',
field: 'prestigeOfflineYield', field: 'prestigeOfflineYield',
delta: 10, delta: 10,
maxLevel: 100,
title: 'Offline Yield', title: 'Offline Yield',
description: 'Increased dividends for staked items while offline', description: 'Increased dividends for staked items while offline',
unit: '%', unit: '%',
@@ -127,6 +140,7 @@ export const PRESTIGE_SKILLS = {
id: 'onlineYield', id: 'onlineYield',
field: 'prestigeOnlineYield', field: 'prestigeOnlineYield',
delta: 10, delta: 10,
maxLevel: 100,
title: 'Online Yield', title: 'Online Yield',
description: 'Increased dividends for staked items while online', description: 'Increased dividends for staked items while online',
unit: '%', unit: '%',
@@ -142,6 +156,37 @@ export function skillUpgradeCost(skillId, level = 0) {
return Math.round((1000 + lv * 500) * mult); 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) { export function skillLevelFromUser(user, skillId) {
const skill = PRESTIGE_SKILLS[skillId]; const skill = PRESTIGE_SKILLS[skillId];
if (!skill) return 0; if (!skill) return 0;
@@ -150,35 +195,64 @@ export function skillLevelFromUser(user, skillId) {
return Math.floor(value / delta); return Math.floor(value / delta);
} }
/** /** Parse Pr amounts (stored as decimal strings; may exceed i64). */
* Expected Pr (no RNG) for UI preview. export function toPrBigInt(value) {
*/ return toCentsBigInt(value);
export function estimatePrReward(balanceCents) { }
const balance = BigInt(balanceCents);
const cost = PRESTIGE_COST_CENTS; export function prJson(value) {
if (balance < cost) return 0; return toPrBigInt(value).toString();
const excess = balance - cost; }
const chunk = PR_EXCESS_CHUNK_CENTS;
const exact = Number(excess) / Number(chunk); /** Apply PR Farmer % to a base Pr reward (floor). Returns BigInt. */
return Math.round(PR_BASE_REWARD + exact * PR_PER_EXCESS_CHUNK); 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 * Expected Pr (no RNG) for UI preview. Returns string.
* roll so partial chunks average to the remaining Pr.
*/ */
export function computePrReward(balanceCents) { export function estimatePrReward(balanceCents, prBonusPct = 0) {
const balance = BigInt(balanceCents); 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 cost = PRESTIGE_COST_CENTS;
const excess = balance > cost ? balance - cost : 0n; const excess = balance > cost ? balance - cost : 0n;
const chunk = PR_EXCESS_CHUNK_CENTS; const chunk = PR_EXCESS_CHUNK_CENTS;
const full = Number(excess / chunk); const full = excess / chunk;
const rem = excess % chunk; const rem = excess % chunk;
const frac = Number(rem) / Number(chunk); // Math.random() < rem/chunk without Number overflow
const bonus = let extra = 0n;
full * PR_PER_EXCESS_CHUNK + if (rem > 0n) {
(Math.random() < frac ? PR_PER_EXCESS_CHUNK : 0); const scale = 1_000_000_000n;
return PR_BASE_REWARD + bonus; 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) { export function adCooldownMs(_user) {
@@ -192,7 +266,7 @@ export function serializePrestige(user) {
gainBonus: user?.prestigeGainBonus ?? 0, gainBonus: user?.prestigeGainBonus ?? 0,
wearBonus: user?.prestigeWearBonus ?? 0, wearBonus: user?.prestigeWearBonus ?? 0,
openBonus: user?.prestigeOpenBonus ?? 0, openBonus: user?.prestigeOpenBonus ?? 0,
caseDiscount: user?.prestigeCaseDiscount ?? 0, prBonus: user?.prestigePrBonus ?? 0,
qualityBonus: user?.prestigeQualityBonus ?? 0, qualityBonus: user?.prestigeQualityBonus ?? 0,
animReduction: user?.prestigeAnimReduction ?? 0, animReduction: user?.prestigeAnimReduction ?? 0,
refundChance: user?.prestigeRefundChance ?? 0, refundChance: user?.prestigeRefundChance ?? 0,
@@ -200,7 +274,7 @@ export function serializePrestige(user) {
vaultSlots: user?.prestigeVaultSlots ?? 0, vaultSlots: user?.prestigeVaultSlots ?? 0,
offlineYield: user?.prestigeOfflineYield ?? 0, offlineYield: user?.prestigeOfflineYield ?? 0,
onlineYield: user?.prestigeOnlineYield ?? 0, onlineYield: user?.prestigeOnlineYield ?? 0,
prBalance: user?.prBalance ?? 0, prBalance: prJson(user?.prBalance ?? 0),
costCents: Number(PRESTIGE_COST_CENTS), costCents: Number(PRESTIGE_COST_CENTS),
visibleAtCents: PRESTIGE_VISIBLE_AT_CENTS, visibleAtCents: PRESTIGE_VISIBLE_AT_CENTS,
prBaseReward: PR_BASE_REWARD, prBaseReward: PR_BASE_REWARD,
@@ -210,10 +284,17 @@ export function serializePrestige(user) {
} }
export function serializeSkillTree(user) { export function serializeSkillTree(user) {
const pr = toPrBigInt(user?.prBalance ?? 0);
return Object.values(PRESTIGE_SKILLS).map((skill) => { return Object.values(PRESTIGE_SKILLS).map((skill) => {
const level = skillLevelFromUser(user, skill.id); 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 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 { return {
id: skill.id, id: skill.id,
title: skill.title, title: skill.title,
@@ -223,28 +304,24 @@ export function serializeSkillTree(user) {
prefix: skill.prefix || (skill.unit === '%' || skill.unit === 'cr-cents' ? '+' : ''), prefix: skill.prefix || (skill.unit === '%' || skill.unit === 'cr-cents' ? '+' : ''),
branch: skill.branch || 'misc', branch: skill.branch || 'misc',
level, level,
maxLevel: Number.isFinite(maxLevel) ? maxLevel : null,
atMax,
room,
buyCount10,
current, current,
nextValue: current + skill.delta, nextValue: atMax ? current : current + skill.delta,
nextValue10: atMax ? current : current + skill.delta * buyCount10,
cost, 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. */ /** Apply lifetime gain % to a credit amount (cents). Returns BigInt. */
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). */
export function applyGainMultiplier(cents, gainBonusPct = 0) { export function applyGainMultiplier(cents, gainBonusPct = 0) {
const base = Number(cents) || 0; return applyPercentFloor(cents, gainBonusPct);
const pct = Number(gainBonusPct) || 0;
if (pct <= 0) return Math.floor(base);
return Math.floor(base * (1 + pct / 100));
} }
export function totalLuckPercent(catalogLuck = 0, prestigeChanceBonus = 0) { export function totalLuckPercent(catalogLuck = 0, prestigeChanceBonus = 0) {
@@ -255,17 +332,17 @@ export function isValidSkill(skillId) {
return Boolean(PRESTIGE_SKILLS[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) { export async function getUnlockedInventoryValueCents(db, userId) {
const rows = await db.inventoryItem.findMany({ const rows = await db.inventoryItem.findMany({
where: { userId, locked: false }, where: { userId, locked: false },
select: { valueCents: true }, 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; return io;
} }
export function emitJackpot(event, payload) { export function emitJackpot(roundId, event, payload) {
if (!io) return; 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); 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; if (!io) return;
io.to(`duel:${roomId}`).emit(event, payload); io.to('jackpots').emit('jackpots:list', payload);
}
export function emitDuelsList(payload) {
if (!io) return;
io.to('duels').emit('duels:list', payload);
} }
+174 -26
View File
@@ -3,7 +3,7 @@ import bcrypt from 'bcrypt';
import { prisma } from '../db.js'; import { prisma } from '../db.js';
import { requireAdmin, publicUser } from '../middleware.js'; import { requireAdmin, publicUser } from '../middleware.js';
import { isValidRarity, RARITIES } from '../rarities.js'; import { isValidRarity, RARITIES } from '../rarities.js';
import { createUploader } from '../upload.js'; import { createUploader, processUploadedImage } from '../upload.js';
import { onlineUserCount, totalSocketCount } from '../presence.js'; import { onlineUserCount, totalSocketCount } from '../presence.js';
import { ACHIEVEMENT_DEFS } from '../achievements.js'; import { ACHIEVEMENT_DEFS } from '../achievements.js';
import { import {
@@ -12,10 +12,24 @@ import {
ensureCategory, ensureCategory,
listCategoriesWithCounts, listCategoriesWithCounts,
} from '../categories.js'; } 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 router = Router();
const upload = createUploader('asset'); 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) => { router.post('/login', async (req, res) => {
try { try {
const username = String(req.body.username || '').trim(); const username = String(req.body.username || '').trim();
@@ -45,6 +59,39 @@ router.get('/rarities', requireAdmin, (_req, res) => {
res.json({ rarities: RARITIES }); 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) => { router.get('/categories', requireAdmin, async (_req, res) => {
try { try {
const categories = await listCategoriesWithCounts(); const categories = await listCategoriesWithCounts();
@@ -218,16 +265,21 @@ router.get('/users', requireAdmin, async (_req, res) => {
res.json({ res.json({
users: users.map((u) => { 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 { return {
id: u.id, id: u.id,
username: u.username, username: u.username,
avatarUrl: u.avatarUrl || '', avatarUrl: u.avatarUrl || '',
balance: u.balance, balance: centsJson(balance),
osuBalance: u.osuBalance ?? 0, osuBalance: u.osuBalance ?? 0,
inventoryValue, prBalance: prJson(u.prBalance ?? 0),
inventoryValue: centsJson(inventoryValue),
inventoryCount: u.inventory.length, inventoryCount: u.inventory.length,
totalWealth: u.balance + inventoryValue, totalWealth: centsJson(balance + inventoryValue),
casesOpened: opensByUser.get(u.id) || 0, casesOpened: opensByUser.get(u.id) || 0,
achievements: u._count.achievements, achievements: u._count.achievements,
prestigeCount: u.prestigeCount ?? 0, prestigeCount: u.prestigeCount ?? 0,
@@ -253,22 +305,19 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
const data = {}; const data = {};
if (req.body.balance !== undefined) { if (req.body.balance !== undefined) {
const balance = Number(req.body.balance); const balance = String(req.body.balance).trim();
if (!Number.isInteger(balance) || balance < 0) { if (!/^\d+$/.test(balance)) {
return res.status(400).json({ error: 'balance must be a non-negative integer (cents)' }); return res.status(400).json({ error: 'balance must be a non-negative integer (cents)' });
} }
data.balance = balance; data.balance = balance;
} }
if (req.body.balanceDelta !== undefined) { if (req.body.balanceDelta !== undefined) {
const delta = Number(req.body.balanceDelta); const deltaRaw = String(req.body.balanceDelta).trim();
if (!Number.isInteger(delta)) { if (!/^-?\d+$/.test(deltaRaw)) {
return res.status(400).json({ error: 'balanceDelta must be an integer (cents)' }); return res.status(400).json({ error: 'balanceDelta must be an integer (cents)' });
} }
const next = Number(user.balance) + delta; const next = toCentsBigInt(user.balance) + BigInt(deltaRaw);
if (next < 0) { data.balance = (next < 0n ? 0n : next).toString();
return res.status(400).json({ error: 'Resulting balance cannot be negative' });
}
data.balance = next;
} }
if (req.body.osuBalance !== undefined) { if (req.body.osuBalance !== undefined) {
const osu = Number(req.body.osuBalance); const osu = Number(req.body.osuBalance);
@@ -277,6 +326,21 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
} }
data.osuBalance = osu; 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) { if (!Object.keys(data).length) {
return res.status(400).json({ error: 'No changes provided' }); 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 updated = await prisma.$transaction(async (tx) => {
const next = await tx.user.update({ where: { id }, data }); 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({ await tx.transaction.create({
data: { data: {
userId: id, userId: id,
type: 'admin_adjust', 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 }), 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) => { router.post('/upload', requireAdmin, (req, res) => {
upload.single('image')(req, res, (err) => { upload.single('image')(req, res, async (err) => {
if (err) { if (err) {
return res.status(400).json({ error: err.message || 'Upload failed' }); return res.status(400).json({ error: err.message || 'Upload failed' });
} }
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' }); 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 { try {
const name = String(req.body.name || '').trim(); const name = String(req.body.name || '').trim();
const imageUrl = String(req.body.imageUrl || ''); const imageUrl = String(req.body.imageUrl || '');
const price = Number(req.body.price); const price = parseCentsField(req.body.price);
const active = req.body.active !== false; const active = req.body.active !== false;
if (!name) return res.status(400).json({ error: 'Name is required' }); 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)' }); 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.name !== undefined) data.name = String(req.body.name).trim();
if (req.body.imageUrl !== undefined) data.imageUrl = String(req.body.imageUrl); if (req.body.imageUrl !== undefined) data.imageUrl = String(req.body.imageUrl);
if (req.body.price !== undefined) { if (req.body.price !== undefined) {
const price = Number(req.body.price); const price = parseCentsField(req.body.price);
if (!Number.isInteger(price) || price < 0) { if (price == null) {
return res.status(400).json({ error: 'Invalid price' }); return res.status(400).json({ error: 'Invalid price' });
} }
data.price = price; data.price = price;
@@ -425,13 +498,13 @@ router.post('/items', requireAdmin, async (req, res) => {
const imageUrl = String(req.body.imageUrl || ''); const imageUrl = String(req.body.imageUrl || '');
const rarity = String(req.body.rarity || ''); const rarity = String(req.body.rarity || '');
const category = await ensureCategory(req.body.category); 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 (!name) return res.status(400).json({ error: 'Name is required' });
if (!isValidRarity(rarity)) { if (!isValidRarity(rarity)) {
return res.status(400).json({ error: `Invalid rarity. Use: ${RARITIES.join(', ')}` }); 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)' }); 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); data.category = await ensureCategory(req.body.category);
} }
if (req.body.marketValue !== undefined) { if (req.body.marketValue !== undefined) {
const marketValue = Number(req.body.marketValue); const marketValue = parseCentsField(req.body.marketValue);
if (!Number.isInteger(marketValue) || marketValue < 0) { if (marketValue == null) {
return res.status(400).json({ error: 'Invalid marketValue' }); return res.status(400).json({ error: 'Invalid marketValue' });
} }
data.marketValue = 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; export default router;
+6 -6
View File
@@ -33,7 +33,7 @@ router.post('/register', async (req, res) => {
data: { data: {
username, username,
passwordHash, passwordHash,
balance: STARTING_BALANCE, balance: String(STARTING_BALANCE),
osuBalance: STARTING_OSU, osuBalance: STARTING_OSU,
role: 'user', role: 'user',
}, },
@@ -43,7 +43,7 @@ router.post('/register', async (req, res) => {
data: { data: {
userId: user.id, userId: user.id,
type: 'seed_credit', type: 'seed_credit',
amount: STARTING_BALANCE, amount: String(STARTING_BALANCE),
meta: JSON.stringify({ reason: 'starting_balance' }), meta: JSON.stringify({ reason: 'starting_balance' }),
}, },
}); });
@@ -52,7 +52,7 @@ router.post('/register', async (req, res) => {
data: { data: {
userId: user.id, userId: user.id,
type: 'seed_osu', type: 'seed_osu',
amount: STARTING_OSU, amount: String(STARTING_OSU),
meta: JSON.stringify({ reason: '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 // Offline vault yield if user is not already socket-connected
let vaultCredited = 0; let vaultCredited = '0';
try { try {
const { isConnected } = await import('../presence.js'); const { isConnected } = await import('../presence.js');
const { settleVaultYield } = await import('../vault.js'); const { settleVaultYield } = await import('../vault.js');
if (!isConnected(user.id)) { if (!isConnected(user.id)) {
const settled = await settleVaultYield(user.id, 'offline'); const settled = await settleVaultYield(user.id, 'offline');
vaultCredited = settled.credited; vaultCredited = settled.credited || '0';
} }
} catch (err) { } catch (err) {
console.error('login vault settle', err); console.error('login vault settle', err);
} }
const refreshed = const refreshed =
vaultCredited > 0 vaultCredited !== '0' && vaultCredited !== 0
? await prisma.user.findUnique({ where: { id: user.id } }) ? await prisma.user.findUnique({ where: { id: user.id } })
: withConn; : withConn;
+58 -46
View File
@@ -15,7 +15,8 @@ import {
fillCatalogSlots, fillCatalogSlots,
formatCaseItemsWithLuck, formatCaseItemsWithLuck,
} from '../catalog.js'; } 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'; import { checkAchievements } from '../achievements.js';
const router = Router(); const router = Router();
@@ -47,7 +48,6 @@ async function loadUserLuckAndKeys(userId) {
return { return {
luckPercent: 0, luckPercent: 0,
openBonus: 0, openBonus: 0,
caseDiscount: 0,
user: null, user: null,
}; };
} }
@@ -59,7 +59,6 @@ async function loadUserLuckAndKeys(userId) {
prestigeChanceBonus: true, prestigeChanceBonus: true,
prestigeWearBonus: true, prestigeWearBonus: true,
prestigeOpenBonus: true, prestigeOpenBonus: true,
prestigeCaseDiscount: true,
prestigeQualityBonus: true, prestigeQualityBonus: true,
prestigeAnimReduction: true, prestigeAnimReduction: true,
prestigeRefundChance: true, prestigeRefundChance: true,
@@ -70,7 +69,6 @@ async function loadUserLuckAndKeys(userId) {
return { return {
luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus), luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus),
openBonus: user?.prestigeOpenBonus ?? 0, openBonus: user?.prestigeOpenBonus ?? 0,
caseDiscount: user?.prestigeCaseDiscount ?? 0,
user, user,
}; };
} }
@@ -102,17 +100,20 @@ router.get('/', async (req, res) => {
include: { include: {
items: { include: { item: true } }, 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 progressMap = await loadKeyProgressMap(userId);
const { luckPercent, openBonus, caseDiscount } = await loadUserLuckAndKeys(userId); const { luckPercent, openBonus } = await loadUserLuckAndKeys(userId);
res.json({ res.json({
catalogLuckPercent: luckPercent, catalogLuckPercent: luckPercent,
cases: cases.map((c) => { cases: cases.map((c) => {
const unitPrice = userId const formatted = formatCase(c, luckPercent, userId ? c.price : Number(c.price));
? applyCaseDiscount(c.price, caseDiscount)
: Number(c.price);
const formatted = formatCase(c, luckPercent, unitPrice);
if (!userId) return formatted; if (!userId) return formatted;
const progress = progressMap.get(c.id); const progress = progressMap.get(c.id);
return { return {
@@ -140,10 +141,8 @@ router.get('/:id', async (req, res) => {
if (!c || !c.active) { if (!c || !c.active) {
return res.status(404).json({ error: 'Case not found' }); return res.status(404).json({ error: 'Case not found' });
} }
const { luckPercent, openBonus, caseDiscount } = await loadUserLuckAndKeys(userId); const { luckPercent, openBonus } = await loadUserLuckAndKeys(userId);
const unitPrice = userId const unitPrice = userId ? c.price : Number(c.price);
? applyCaseDiscount(c.price, caseDiscount)
: Number(c.price);
const formatted = formatCase(c, luckPercent, unitPrice); const formatted = formatCase(c, luckPercent, unitPrice);
if (userId) { if (userId) {
const progress = await prisma.caseKeyProgress.findUnique({ const progress = await prisma.caseKeyProgress.findUnique({
@@ -201,13 +200,10 @@ router.post('/:id/open', requireAuth, async (req, res) => {
throw err; throw err;
} }
const unitPrice = applyCaseDiscount(c.price, user.prestigeCaseDiscount ?? 0); const unitPrice = toCentsBigInt(c.price);
const totalCost = unitPrice * count; const totalCost = unitPrice * BigInt(count);
if (user.balance < totalCost) { // Charge up front (atomic WHERE balance >= cost). Refunded opens credit back later.
const err = new Error('Insufficient balance'); await spendUserBalance(tx, userId, totalCost);
err.status = 400;
throw err;
}
const catalogLuck = await countCompletedChapters(tx, userId); const catalogLuck = await countCompletedChapters(tx, userId);
const luckPercent = totalLuckPercent(catalogLuck, user.prestigeChanceBonus); const luckPercent = totalLuckPercent(catalogLuck, user.prestigeChanceBonus);
@@ -231,7 +227,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
floatValue: rolled.floatValue, floatValue: rolled.floatValue,
wear: rolled.wear, wear: rolled.wear,
paintSeed: rolled.paintSeed, paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents, valueCents: centsJson(rolled.valueCents),
}, },
}); });
@@ -241,7 +237,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
data: { data: {
userId, userId,
type: 'open_case', type: 'open_case',
amount: -unitPrice, amount: centsJson(-unitPrice),
meta: JSON.stringify({ meta: JSON.stringify({
caseId: c.id, caseId: c.id,
caseName: c.name, caseName: c.name,
@@ -251,12 +247,12 @@ router.post('/:id/open', requireAuth, async (req, res) => {
floatValue: rolled.floatValue, floatValue: rolled.floatValue,
wear: rolled.wear, wear: rolled.wear,
paintSeed: rolled.paintSeed, paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents, valueCents: centsJson(rolled.valueCents),
willAutoSell, willAutoSell,
openIndex: i, openIndex: i,
openCount: batchCount, openCount: batchCount,
unitPrice, unitPrice: centsJson(unitPrice),
basePrice: Number(c.price), basePrice: centsJson(c.price),
}), }),
}, },
}); });
@@ -268,11 +264,11 @@ router.post('/:id/open', requireAuth, async (req, res) => {
name: item.name, name: item.name,
imageUrl: item.imageUrl, imageUrl: item.imageUrl,
rarity: item.rarity, rarity: item.rarity,
marketValue: item.marketValue, marketValue: toCentsBigInt(item.marketValue).toString(),
floatValue: rolled.floatValue, floatValue: rolled.floatValue,
wear: rolled.wear, wear: rolled.wear,
paintSeed: rolled.paintSeed, paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents, valueCents: toCentsBigInt(rolled.valueCents).toString(),
willAutoSell, willAutoSell,
}); });
} }
@@ -281,12 +277,32 @@ router.post('/:id/open', requireAuth, async (req, res) => {
let { drops, createdTxIds } = await createDrops(count); 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 respinChance = Math.max(0, Number(user.prestigeRespinChance) || 0);
const didRespin = respinChance > 0 && Math.random() * 100 < respinChance; const didRespin = respinChance > 0 && Math.random() * 100 < respinChance;
if (didRespin) { if (didRespin) {
const oldIds = drops.map((d) => d.inventoryId); const discardIds = drops
await tx.inventoryItem.deleteMany({ where: { id: { in: oldIds } } }); .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 } } }); await tx.transaction.deleteMany({ where: { id: { in: createdTxIds } } });
const again = await createDrops(count); const again = await createDrops(count);
drops = again.drops; drops = again.drops;
@@ -295,11 +311,12 @@ router.post('/:id/open', requireAuth, async (req, res) => {
data: { data: {
userId, userId,
type: 'case_respin', type: 'case_respin',
amount: 0, amount: '0',
meta: JSON.stringify({ meta: JSON.stringify({
caseId: c.id, caseId: c.id,
openCount: count, openCount: count,
inventoryItemIds: drops.map((d) => d.inventoryId), 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) { if (didRefund) {
balanceDelta += totalCost; updatedUser = await adjustUserBalance(tx, userId, updatedUser.balance, totalCost);
await tx.transaction.create({ await tx.transaction.create({
data: { data: {
userId, userId,
type: 'case_refund', type: 'case_refund',
amount: totalCost, amount: centsJson(totalCost),
meta: JSON.stringify({ meta: JSON.stringify({
caseId: c.id, caseId: c.id,
caseName: c.name, caseName: c.name,
openCount: count, openCount: count,
unitPrice, unitPrice: centsJson(unitPrice),
totalCost, totalCost: centsJson(totalCost),
}), }),
}, },
}); });
} }
const updatedUser = await tx.user.update({
where: { id: userId },
data: {
balance: { increment: balanceDelta },
},
});
return { return {
user: publicUser(updatedUser), user: publicUser(updatedUser),
caseName: c.name, caseName: c.name,
items: drops, items: drops,
preRespinItems,
keyProgress: serializeKeyProgress(updatedProgress, 0, openBonus), keyProgress: serializeKeyProgress(updatedProgress, 0, openBonus),
keysGained: applied.keysGained, keysGained: applied.keysGained,
catalog, catalog,
unitPrice, unitPrice: centsJson(unitPrice),
totalCost, totalCost: centsJson(totalCost),
refunded: didRefund, refunded: didRefund,
respin: didRespin, respin: didRespin,
animReduction: user.prestigeAnimReduction ?? 0, animReduction: user.prestigeAnimReduction ?? 0,
@@ -374,6 +385,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
caseName: result.caseName, caseName: result.caseName,
count: result.items.length, count: result.items.length,
items: result.items, items: result.items,
preRespinItems: result.preRespinItems,
keyProgress: result.keyProgress, keyProgress: result.keyProgress,
keysGained: result.keysGained, keysGained: result.keysGained,
catalog: result.catalog, 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 { prisma } from '../db.js';
import { requireAuth } from '../middleware.js'; import { requireAuth } from '../middleware.js';
import { getDropFeed, pushDrop } from '../feed.js'; import { getDropFeed, pushDrop } from '../feed.js';
import { toCentsBigInt } from '../cents.js';
const router = Router(); const router = Router();
@@ -13,9 +14,89 @@ router.get('/', (_req, res) => {
* Announce case drop(s) to the live feed (after client spin finishes). * Announce case drop(s) to the live feed (after client spin finishes).
* For multi-opens: send all inventoryItemIds — only the best drop is shown, * For multi-opens: send all inventoryItemIds — only the best drop is shown,
* with count of how many times that item appeared (e.g. x3). * 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) => { router.post('/announce', requireAuth, async (req, res) => {
try { 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 = []; let ids = [];
if (Array.isArray(req.body.inventoryItemIds)) { if (Array.isArray(req.body.inventoryItemIds)) {
ids = req.body.inventoryItemIds.map(Number).filter(Boolean); 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) // Best drop = highest instance value (tie-break: first in batch)
let best = invItems[0]; let best = invItems[0];
for (const inv of invItems) { 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 count = invItems.filter((inv) => inv.itemId === best.itemId).length;
const caseName = String(req.body.caseName || '').slice(0, 80);
const drop = pushDrop({ const drop = pushDrop({
source: 'case', source: 'case',
caseName, caseName,
respin,
count, count,
openCount: invItems.length, openCount: invItems.length,
inventoryIds: invItems.map((i) => i.id), 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 { requireAuth, publicUser } from '../middleware.js';
import { applyAutoSell } from '../autoSell.js'; import { applyAutoSell } from '../autoSell.js';
import { applyGainMultiplier } from '../prestige.js'; import { applyGainMultiplier } from '../prestige.js';
import { toCentsBigInt, adjustUserBalance, centsJson, creditsToCents } from '../cents.js';
import { import {
maxVaultSlots, summarizeVaultState,
summarizeVaultRates,
estimatePendingYield,
} from '../vault.js'; } from '../vault.js';
import { isConnected } from '../presence.js'; import { isConnected } from '../presence.js';
@@ -54,16 +53,10 @@ router.get('/', requireAuth, async (req, res) => {
const staked = items.filter((i) => i.staked); const staked = items.filter((i) => i.staked);
const mode = isConnected(userId) ? 'online' : 'offline'; const mode = isConnected(userId) ? 'online' : 'offline';
const rates = summarizeVaultRates(staked, user);
res.json({ res.json({
inventory: items.map(serializeInv), inventory: items.map(serializeInv),
vault: { vault: summarizeVaultState(user, staked, mode),
used: staked.length,
slots: maxVaultSlots(user),
...rates,
pendingYield: estimatePendingYield(staked, user, mode),
},
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -78,11 +71,11 @@ router.patch('/auto-sell', requireAuth, async (req, res) => {
data.autoSellEnabled = Boolean(req.body.enabled); data.autoSellEnabled = Boolean(req.body.enabled);
} }
if (req.body.thresholdCredits != null) { if (req.body.thresholdCredits != null) {
const credits = Number(req.body.thresholdCredits); const cents = creditsToCents(req.body.thresholdCredits);
if (!Number.isFinite(credits) || credits < 0) { if (cents == null) {
return res.status(400).json({ error: 'Invalid threshold' }); return res.status(400).json({ error: 'Invalid threshold' });
} }
data.autoSellThresholdCents = Math.round(credits * 100); data.autoSellThresholdCents = centsJson(cents);
} }
if (!Object.keys(data).length) { 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 }); 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 result = await prisma.$transaction(
const user = await tx.user.findUnique({ where: { id: req.session.userId } }); async (tx) => {
if (!user) { const user = await tx.user.findUnique({ where: { id: req.session.userId } });
const err = new Error('User not found'); if (!user) {
err.status = 401; const err = new Error('User not found');
throw err; err.status = 401;
} throw err;
}
const invItems = await tx.inventoryItem.findMany({ const invItems = await tx.inventoryItem.findMany({
where: { where: {
id: { in: ids }, id: { in: ids },
userId: req.session.userId, userId: req.session.userId,
locked: false, locked: false,
staked: false, staked: false,
}, },
}); });
const { sold, balanceDelta } = await applyAutoSell(tx, user, invItems); const { sold, balanceDelta } = await applyAutoSell(tx, user, invItems);
if (!balanceDelta) { if (balanceDelta === 0n) {
return { user, soldIds: [], total: 0 }; return { user, soldIds: [], total: 0n, sold: [] };
} }
const updatedUser = await tx.user.update({ const updatedUser = await adjustUserBalance(
where: { id: req.session.userId }, tx,
data: { balance: { increment: balanceDelta } }, req.session.userId,
}); user.balance,
balanceDelta
);
return { const gainBonus = user.prestigeGainBonus ?? 0;
user: updatedUser, return {
soldIds: sold.map((inv) => inv.id), user: updatedUser,
total: balanceDelta, 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({ res.json({
user: publicUser(result.user), user: publicUser(result.user),
soldIds: result.soldIds, soldIds: result.soldIds,
total: result.total, total: centsJson(result.total),
sold: result.sold || [],
}); });
} catch (err) { } catch (err) {
const status = err.status || 500; 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' }); return res.status(400).json({ error: 'Select at least one item to sell' });
} }
const result = await prisma.$transaction(async (tx) => { const result = await prisma.$transaction(
const user = await tx.user.findUnique({ where: { id: req.session.userId } }); async (tx) => {
if (!user) { const user = await tx.user.findUnique({ where: { id: req.session.userId } });
const err = new Error('User not found'); if (!user) {
err.status = 401; const err = new Error('User not found');
throw err; err.status = 401;
} throw err;
}
const invItems = await tx.inventoryItem.findMany({ const invItems = await tx.inventoryItem.findMany({
where: { where: {
id: { in: ids }, id: { in: ids },
userId: req.session.userId, userId: req.session.userId,
locked: false, locked: false,
staked: false, staked: false,
}, },
include: { item: true }, select: {
}); id: true,
itemId: true,
valueCents: true,
favorite: true,
},
});
if (invItems.length !== ids.length) { if (invItems.length !== ids.length) {
const err = new Error('Invalid, locked, or staked inventory items'); const err = new Error('Invalid, locked, or staked inventory items');
err.status = 400; err.status = 400;
throw err; throw err;
} }
if (invItems.some((inv) => inv.favorite)) { if (invItems.some((inv) => inv.favorite)) {
const err = new Error('Favorite items cannot be sold'); const err = new Error('Favorite items cannot be sold');
err.status = 400; err.status = 400;
throw err; throw err;
} }
const gainBonus = user.prestigeGainBonus ?? 0; const gainBonus = user.prestigeGainBonus ?? 0;
let total = 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({ await tx.transaction.create({
data: { data: {
userId: req.session.userId, userId: req.session.userId,
type: 'sell_item', type: 'sell_item',
amount: payout, amount: centsJson(total),
meta: JSON.stringify({ meta: JSON.stringify({
inventoryItemId: inv.id, count: invItems.length,
itemId: inv.itemId, inventoryItemIds: ids,
itemName: inv.item.name,
valueCents: inv.valueCents,
floatValue: inv.floatValue,
wear: inv.wear,
paintSeed: inv.paintSeed,
prestigeGainBonus: gainBonus, prestigeGainBonus: gainBonus,
payout, payout: centsJson(total),
}), }),
}, },
}); });
}
const updatedUser = await tx.user.update({ const updatedUser = await adjustUserBalance(
where: { id: req.session.userId }, tx,
data: { balance: { increment: total } }, 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({ res.json({
user: publicUser(result.user), user: publicUser(result.user),
total: result.total, total: centsJson(result.total),
soldCount: result.soldCount, soldCount: result.soldCount,
}); });
} catch (err) { } catch (err) {
+41 -6
View File
@@ -1,7 +1,9 @@
import { Router } from 'express'; import { Router } from 'express';
import { requireAuth } from '../middleware.js'; import { requireAuth } from '../middleware.js';
import { import {
getJackpotState, listJackpotBattles,
getJackpotRound,
createJackpotBattle,
placeJackpotBet, placeJackpotBet,
withdrawJackpotBet, withdrawJackpotBet,
} from '../services/jackpot.js'; } from '../services/jackpot.js';
@@ -16,25 +18,58 @@ function handleError(res, err) {
router.get('/', async (_req, res) => { router.get('/', async (_req, res) => {
try { try {
const state = await getJackpotState(); const battles = await listJackpotBattles();
res.json({ round: state }); res.json({ battles });
} catch (err) { } catch (err) {
handleError(res, 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) => { router.post('/bet', requireAuth, async (req, res) => {
try { 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 }); res.json({ round: state });
} catch (err) { } catch (err) {
handleError(res, err); handleError(res, err);
} }
}); });
router.post('/withdraw', requireAuth, async (req, res) => { router.post('/:id/bet', requireAuth, async (req, res) => {
try { 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 }); res.json({ round: state });
} catch (err) { } catch (err) {
handleError(res, err); handleError(res, err);
+14 -7
View File
@@ -1,5 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import { prisma } from '../db.js'; import { prisma } from '../db.js';
import { toCentsBigInt, centsJson } from '../cents.js';
const router = Router(); const router = Router();
@@ -17,22 +18,28 @@ router.get('/', async (_req, res) => {
const ranked = users const ranked = users
.map((u) => { .map((u) => {
const inventoryValue = u.inventory.reduce( const inventoryValue = u.inventory.reduce(
(sum, inv) => sum + inv.valueCents, (sum, inv) => sum + toCentsBigInt(inv.valueCents),
0 0n
); );
const balance = toCentsBigInt(u.balance);
const totalWealth = balance + inventoryValue;
return { return {
id: u.id, id: u.id,
username: u.username, username: u.username,
avatarUrl: u.avatarUrl || '', avatarUrl: u.avatarUrl || '',
balance: u.balance, balance: centsJson(balance),
inventoryValue, inventoryValue: centsJson(inventoryValue),
totalWealth: u.balance + inventoryValue, totalWealth,
itemCount: u.inventory.length, 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) .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 }); res.json({ leaderboard: ranked });
} catch (err) { } catch (err) {
+49 -25
View File
@@ -12,9 +12,13 @@ import {
serializePrestige, serializePrestige,
serializeSkillTree, serializeSkillTree,
skillLevelFromUser, skillLevelFromUser,
skillUpgradeCost, skillLevelsRemaining,
skillUpgradeCostBulk,
toPrBigInt,
prJson,
} from '../prestige.js'; } from '../prestige.js';
import { checkAchievements } from '../achievements.js'; import { checkAchievements } from '../achievements.js';
import { centsJson, toCentsBigInt, addCents, spendUserBalance } from '../cents.js';
const router = Router(); const router = Router();
const COST = PRESTIGE_COST_CENTS; const COST = PRESTIGE_COST_CENTS;
@@ -36,18 +40,20 @@ router.get('/', requireAuth, async (req, res) => {
}), }),
]); ]);
const balance = BigInt(user.balance); const balance = toCentsBigInt(user.balance);
const totalCents = Number(user.balance) + inventoryValue; const totalCents = balance + inventoryValue;
const pageVisible = isPrestigePageVisible(user, totalCents); const pageVisible = isPrestigePageVisible(user, totalCents);
const canAfford = balance >= COST; const canAfford = balance >= COST;
const canPrestige = pageVisible && canAfford && lockedCount === 0; 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({ res.json({
prestige: serializePrestige(user), prestige: serializePrestige(user),
skills: serializeSkillTree(user), skills: serializeSkillTree(user),
pageVisible, pageVisible,
totalCents, totalCents: totalCents.toString(),
canAfford, canAfford,
lockedItems: lockedCount, lockedItems: lockedCount,
canPrestige, canPrestige,
@@ -55,7 +61,7 @@ router.get('/', requireAuth, async (req, res) => {
history: logs.map((l) => ({ history: logs.map((l) => ({
id: l.id, id: l.id,
choice: l.choice, choice: l.choice,
prGranted: l.prGranted ?? 0, prGranted: prJson(l.prGranted ?? 0),
createdAt: l.createdAt, createdAt: l.createdAt,
})), })),
}); });
@@ -85,11 +91,6 @@ router.post('/', requireAuth, async (req, res) => {
err.status = 403; err.status = 403;
throw err; 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({ const lockedCount = await tx.inventoryItem.count({
where: { userId: user.id, locked: true }, where: { userId: user.id, locked: true },
@@ -102,22 +103,28 @@ router.post('/', requireAuth, async (req, res) => {
throw err; 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; const prestigeCount = (user.prestigeCount ?? 0) + 1;
await tx.inventoryItem.deleteMany({ where: { userId: user.id } }); await tx.inventoryItem.deleteMany({ where: { userId: user.id } });
await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } }); await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } });
await tx.catalogEntry.deleteMany({ where: { userId: user.id } });
await tx.transaction.create({ await tx.transaction.create({
data: { data: {
userId: user.id, userId: user.id,
type: 'prestige', type: 'prestige',
amount: -Number(COST), amount: centsJson(-COST),
meta: JSON.stringify({ meta: JSON.stringify({
prestigeCount, prestigeCount,
prGranted, prGranted,
previousBalance: Number(user.balance), previousBalance: centsJson(previousBalance),
}), }),
}, },
}); });
@@ -126,20 +133,22 @@ router.post('/', requireAuth, async (req, res) => {
data: { data: {
userId: user.id, userId: user.id,
choice: 'reset', choice: 'reset',
prGranted, prGranted: prJson(prGranted),
}, },
}); });
// Wipe leftover credits after the COST spend (prestige resets economy).
const updated = await tx.user.update({ const updated = await tx.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
prestigeCount, prestigeCount,
balance: 0, balance: '0',
prBalance: { increment: prGranted }, 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)); 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' }); 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 skill = PRESTIGE_SKILLS[skillId];
const result = await prisma.$transaction(async (tx) => { const result = await prisma.$transaction(async (tx) => {
@@ -180,8 +193,16 @@ router.post('/upgrade', requireAuth, async (req, res) => {
} }
const level = skillLevelFromUser(user, skillId); const level = skillLevelFromUser(user, skillId);
const cost = skillUpgradeCost(skillId, level); const room = skillLevelsRemaining(user, skillId);
if ((user.prBalance ?? 0) < cost) { 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'); const err = new Error('Not enough Pr');
err.status = 400; err.status = 400;
throw err; throw err;
@@ -190,8 +211,8 @@ router.post('/upgrade', requireAuth, async (req, res) => {
const updated = await tx.user.update({ const updated = await tx.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
prBalance: { decrement: cost }, prBalance: addCents(user.prBalance, -cost),
[skill.field]: { increment: skill.delta }, [skill.field]: { increment: skill.delta * count },
}, },
}); });
@@ -199,11 +220,12 @@ router.post('/upgrade', requireAuth, async (req, res) => {
data: { data: {
userId: user.id, userId: user.id,
type: 'prestige_upgrade', type: 'prestige_upgrade',
amount: 0, amount: '0',
meta: JSON.stringify({ meta: JSON.stringify({
skillId, skillId,
cost, cost,
delta: skill.delta, count,
delta: skill.delta * count,
field: skill.field, field: skill.field,
}), }),
}, },
@@ -212,6 +234,8 @@ router.post('/upgrade', requireAuth, async (req, res) => {
return updated; return updated;
}); });
checkAchievements(result.id).catch((err) => console.error('achievements', err));
res.json({ res.json({
user: publicUser(result), user: publicUser(result),
prestige: serializePrestige(result), prestige: serializePrestige(result),
+17 -41
View File
@@ -1,9 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import bcrypt from 'bcrypt'; 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 { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js'; import { requireAuth, publicUser } from '../middleware.js';
import { getCompletedCatalogBadges } from '../catalog.js'; import { getCompletedCatalogBadges } from '../catalog.js';
@@ -13,32 +9,15 @@ import {
checkAchievements, checkAchievements,
getUnlockedAchievements, getUnlockedAchievements,
} from '../achievements.js'; } from '../achievements.js';
import { toCentsBigInt, centsJson } from '../cents.js';
import { createUploader, processUploadedImage, safeExt } from '../upload.js';
const router = Router(); const router = Router();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const uploadsDir = path.join(__dirname, '../../uploads');
if (!fs.existsSync(uploadsDir)) { const upload = createUploader('avatar', {
fs.mkdirSync(uploadsDir, { recursive: true }); maxBytes: 2 * 1024 * 1024,
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (req, file, cb) => { filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.png'; cb(null, `avatar-${req.session.userId}-${Date.now()}${safeExt(file.originalname, file.mimetype)}`);
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);
}, },
}); });
@@ -70,6 +49,7 @@ function serializeInv(inv) {
paintSeed: inv.paintSeed, paintSeed: inv.paintSeed,
valueCents: inv.valueCents, valueCents: inv.valueCents,
favorite: Boolean(inv.favorite), favorite: Boolean(inv.favorite),
staked: Boolean(inv.staked),
item: { item: {
id: inv.item.id, id: inv.item.id,
name: inv.item.name, 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' }); 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); await checkAchievements(user.id);
const [jackpotWins, duelWins, casesOpened, caseOpens, catalog, achievements] = const [jackpotWins, casesOpened, caseOpens, catalog, achievements] =
await Promise.all([ await Promise.all([
prisma.transaction.count({ prisma.transaction.count({
where: { userId: user.id, type: 'jackpot_win' }, where: { userId: user.id, type: 'jackpot_win' },
}), }),
prisma.transaction.count({
where: { userId: user.id, type: 'duel_win' },
}),
prisma.transaction.count({ prisma.transaction.count({
where: { userId: user.id, type: 'open_case' }, where: { userId: user.id, type: 'open_case' },
}), }),
@@ -127,10 +107,9 @@ router.get('/player/:username', async (req, res) => {
user: publicUser(user), user: publicUser(user),
profile: { profile: {
createdAt: user.createdAt, createdAt: user.createdAt,
inventoryValue, inventoryValue: centsJson(inventoryValue),
inventoryCount: user.inventory.length, inventoryCount: user.inventory.length,
jackpotWins, jackpotWins,
duelWins,
casesOpened, casesOpened,
caseOpens, caseOpens,
catalogCompleted: catalog.completed, catalogCompleted: catalog.completed,
@@ -158,20 +137,17 @@ router.get('/', requireAuth, async (req, res) => {
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) return res.status(404).json({ error: 'User not found' });
const inventoryValue = user.inventory.reduce( const inventoryValue = user.inventory.reduce(
(sum, inv) => sum + (inv.locked ? 0 : inv.valueCents), (sum, inv) => sum + (inv.locked ? 0n : toCentsBigInt(inv.valueCents)),
0 0n
); );
await checkAchievements(user.id); await checkAchievements(user.id);
const [jackpotWins, duelWins, casesOpened, caseOpens, catalog, achievements] = const [jackpotWins, casesOpened, caseOpens, catalog, achievements] =
await Promise.all([ await Promise.all([
prisma.transaction.count({ prisma.transaction.count({
where: { userId: user.id, type: 'jackpot_win' }, where: { userId: user.id, type: 'jackpot_win' },
}), }),
prisma.transaction.count({
where: { userId: user.id, type: 'duel_win' },
}),
prisma.transaction.count({ prisma.transaction.count({
where: { userId: user.id, type: 'open_case' }, where: { userId: user.id, type: 'open_case' },
}), }),
@@ -184,10 +160,9 @@ router.get('/', requireAuth, async (req, res) => {
user: publicUser(user), user: publicUser(user),
profile: { profile: {
createdAt: user.createdAt, createdAt: user.createdAt,
inventoryValue, inventoryValue: centsJson(inventoryValue),
inventoryCount: user.inventory.filter((i) => !i.locked).length, inventoryCount: user.inventory.filter((i) => !i.locked).length,
jackpotWins, jackpotWins,
duelWins,
casesOpened, casesOpened,
caseOpens, caseOpens,
catalogCompleted: catalog.completed, catalogCompleted: catalog.completed,
@@ -256,6 +231,7 @@ router.post('/avatar', requireAuth, (req, res) => {
} }
try { try {
await processUploadedImage(req.file);
const avatarUrl = `/uploads/${req.file.filename}`; const avatarUrl = `/uploads/${req.file.filename}`;
const user = await prisma.user.update({ const user = await prisma.user.update({
where: { id: req.session.userId }, 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 { requireAuth, publicUser } from '../middleware.js';
import { checkAchievements } from '../achievements.js'; import { checkAchievements } from '../achievements.js';
import { adCooldownMs } from '../prestige.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(); 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_MIN = 10000; // 100.00 cr
const AD_REWARD_MAX = 20000; // 200.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 } }); const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
if (!user) return res.status(404).json({ error: 'User not found' }); if (!user) return res.status(404).json({ error: 'User not found' });
const packages = SHOP_PACKAGES.map((pack) => ({ const packages = scaleCreditPacks(user.balance);
...pack, const sectionEnabled = await isOsuTopupsSectionEnabled();
baseOsuCost: pack.osuCost, const osuTopups = sectionEnabled ? await listEnabledShopOsuPacks() : [];
}));
res.json({ res.json({
packages, packages,
osuTopups,
osuTopupsEnabled: sectionEnabled && osuTopups.length > 0,
payments: {
provider: paymentsProvider(),
currency: 'eur',
mock: paymentsProvider() === 'mock',
},
user: publicUser(user), user: publicUser(user),
ad: adPayload(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) => { router.post('/buy', requireAuth, async (req, res) => {
try { try {
const packageId = String(req.body.packageId || ''); const packageId = String(req.body.packageId || '');
const pack = SHOP_PACKAGES.find((p) => p.id === packageId); const packDef = getCreditPack(packageId);
if (!pack) { if (!packDef) {
return res.status(400).json({ error: 'Unknown package' }); return res.status(400).json({ error: 'Unknown package' });
} }
@@ -69,6 +77,7 @@ router.post('/buy', requireAuth, async (req, res) => {
err.status = 404; err.status = 404;
throw err; throw err;
} }
const pack = scaleCreditPack(packDef, user.balance);
const osuCost = pack.osuCost; const osuCost = pack.osuCost;
if (user.osuBalance < osuCost) { if (user.osuBalance < osuCost) {
const err = new Error('Not enough osu'); const err = new Error('Not enough osu');
@@ -76,35 +85,36 @@ router.post('/buy', requireAuth, async (req, res) => {
throw err; throw err;
} }
const updated = await tx.user.update({ await tx.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: { osuBalance: { decrement: osuCost } },
osuBalance: { decrement: osuCost },
balance: { increment: pack.credits },
},
}); });
const updated = await creditUserBalance(tx, user.id, pack.credits);
await tx.transaction.create({ await tx.transaction.create({
data: { data: {
userId: user.id, userId: user.id,
type: 'shop_purchase', type: 'shop_purchase',
amount: pack.credits, amount: centsJson(pack.credits),
meta: JSON.stringify({ meta: JSON.stringify({
packageId: pack.id, packageId: pack.id,
osuCost, osuCost,
credits: pack.credits, 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)); checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
res.json({ res.json({
user: publicUser(result.user), user: publicUser(result.user),
package: { ...pack, osuCost: result.osuCost }, package: result.pack,
}); });
} catch (err) { } catch (err) {
const status = err.status || 500; 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) => { router.post('/ad', requireAuth, async (req, res) => {
try { try {
const result = await prisma.$transaction(async (tx) => { 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)); AD_REWARD_MIN + Math.floor(Math.random() * (AD_REWARD_MAX - AD_REWARD_MIN + 1));
const now = new Date(); const now = new Date();
const updated = await tx.user.update({ await tx.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: { lastAdAt: now },
balance: { increment: reward },
lastAdAt: now,
},
}); });
const updated = await creditUserBalance(tx, user.id, reward);
await tx.transaction.create({ await tx.transaction.create({
data: { data: {
userId: user.id, userId: user.id,
type: 'ad_reward', type: 'ad_reward',
amount: reward, amount: centsJson(reward),
meta: JSON.stringify({ reward }), meta: JSON.stringify({ reward }),
}, },
}); });
+37 -46
View File
@@ -4,8 +4,7 @@ import { requireAuth, publicUser } from '../middleware.js';
import { import {
maxVaultSlots, maxVaultSlots,
settleVaultYield, settleVaultYield,
summarizeVaultRates, summarizeVaultState,
estimatePendingYield,
} from '../vault.js'; } from '../vault.js';
import { isConnected } from '../presence.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' }); 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 result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } }); const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) { if (!user) {
@@ -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({ res.json({
user: publicUser(result.user), user: publicUser(result.user),
stakedIds: result.stakedIds, stakedIds: result.stakedIds,
vault: { vault: result.vault,
used: result.used,
slots: result.maxSlots,
},
}); });
} catch (err) { } catch (err) {
const status = err.status || 500; 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' }); 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 mode = isConnected(req.session.userId) ? 'online' : 'offline';
const settled = await settleVaultYield(req.session.userId, mode); 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 }, 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 { return {
user: refreshed, user: refreshed,
unstakedIds: ids, unstakedIds: ids,
maxSlots: maxVaultSlots(refreshed), vault: summarizeVaultState(refreshed, remaining, mode),
used,
}; };
}); });
@@ -137,10 +161,7 @@ router.post('/unstake', requireAuth, async (req, res) => {
user: publicUser(result.user), user: publicUser(result.user),
unstakedIds: result.unstakedIds, unstakedIds: result.unstakedIds,
yieldCredited: settled.credited, yieldCredited: settled.credited,
vault: { vault: result.vault,
used: result.used,
slots: result.maxSlots,
},
}); });
} catch (err) { } catch (err) {
const status = err.status || 500; 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; 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 { prisma } from '../db.js';
import { pickWeighted } from '../weighted.js'; import { pickWeighted } from '../weighted.js';
import { applyAutoSell } from '../autoSell.js'; import { applyAutoSell } from '../autoSell.js';
import { emitJackpot } from '../realtime.js'; import { emitJackpot, emitJackpotsList } from '../realtime.js';
import { pushDrop } from '../feed.js'; import { pushDrop } from '../feed.js';
import { checkAchievements } from '../achievements.js'; import { checkAchievements } from '../achievements.js';
import { toCentsBigInt, centsJson, creditUserBalance } from '../cents.js';
const ROUND_SECONDS = Number(process.env.JACKPOT_ROUND_SECONDS || 30); const ROUND_SECONDS = Number(process.env.JACKPOT_ROUND_SECONDS || 30);
const MAX_ITEMS = Number(process.env.JACKPOT_MAX_ITEMS || 3); 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). */ /** Sentinel endsAt while waiting for enough players (timer not armed). */
const WAITING_ENDS_AT = new Date('2099-01-01T00:00:00.000Z'); const WAITING_ENDS_AT = new Date('2099-01-01T00:00:00.000Z');
let resolveTimer = null; const resolveTimers = new Map();
let spinFollowUp = null; const spinFollowUps = new Map();
function isTimerWaiting(endsAt) { function isTimerWaiting(endsAt) {
return new Date(endsAt).getFullYear() >= 2099; 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) { export function serializeJackpotRound(round) {
if (!round) return null; if (!round) return null;
@@ -56,24 +65,27 @@ export function serializeJackpotRound(round) {
})); }));
const byUser = new Map(); const byUser = new Map();
let totalValue = 0; let totalValue = 0n;
for (const d of deposits) { for (const d of deposits) {
totalValue += d.valueCents; const v = toCentsBigInt(d.valueCents);
totalValue += v;
const prev = byUser.get(d.userId) || { const prev = byUser.get(d.userId) || {
userId: d.userId, userId: d.userId,
username: d.username, username: d.username,
avatarUrl: d.avatarUrl, avatarUrl: d.avatarUrl,
valueCents: 0, valueCents: 0n,
itemCount: 0, itemCount: 0,
}; };
prev.valueCents += d.valueCents; prev.valueCents += v;
prev.itemCount += 1; prev.itemCount += 1;
byUser.set(d.userId, prev); byUser.set(d.userId, prev);
} }
const players = [...byUser.values()].map((p) => ({ const players = [...byUser.values()].map((p) => ({
...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 = const waitingForPlayers =
@@ -82,6 +94,9 @@ export function serializeJackpotRound(round) {
return { return {
id: round.id, id: round.id,
status: round.status, status: round.status,
creatorId: round.creatorId || null,
creatorUsername: round.creator?.username || null,
name: `Battle ${round.id}`,
endsAt: waitingForPlayers ? null : round.endsAt, endsAt: waitingForPlayers ? null : round.endsAt,
waitingForPlayers, waitingForPlayers,
roundSeconds: ROUND_SECONDS, roundSeconds: ROUND_SECONDS,
@@ -90,133 +105,198 @@ export function serializeJackpotRound(round) {
resolvedAt: round.resolvedAt || null, resolvedAt: round.resolvedAt || null,
winnerUserId: round.winnerUserId || null, winnerUserId: round.winnerUserId || null,
winnerUsername: round.winner?.username || null, winnerUsername: round.winner?.username || null,
totalValue, totalValue: centsJson(totalValue),
maxItemsPerPlayer: MAX_ITEMS, maxItemsPerPlayer: MAX_ITEMS,
players, players,
deposits, deposits,
}; };
} }
async function loadOpenOrLatest() { async function loadRound(id) {
const open = await prisma.jackpotRound.findFirst({ return prisma.jackpotRound.findUnique({
where: { status: { in: ['open', 'spinning'] } }, where: { id: Number(id) },
include: { include: roundInclude(),
deposits: { include: depositInclude() }, });
winner: { select: { id: true, username: true } }, }
},
orderBy: { id: 'desc' }, export async function getJackpotRound(id) {
}); return serializeJackpotRound(await loadRound(id));
if (open) return open; }
return prisma.jackpotRound.findFirst({ export async function listJackpotBattles() {
include: { const rounds = await prisma.jackpotRound.findMany({
deposits: { include: depositInclude() }, where: {
winner: { select: { id: true, username: true } }, OR: [
{ status: 'spinning' },
{ status: 'open', deposits: { some: {} } },
],
}, },
include: roundInclude(),
orderBy: { id: 'desc' }, orderBy: { id: 'desc' },
}); });
return rounds.map(serializeJackpotRound);
} }
export async function getJackpotState() { export async function broadcastJackpotsList() {
const round = await loadOpenOrLatest(); const battles = await listJackpotBattles();
return serializeJackpotRound(round); emitJackpotsList({ battles });
return battles;
} }
export async function broadcastJackpot(extra = {}) { function clearResolveTimer(roundId) {
const state = await getJackpotState(); const t = resolveTimers.get(roundId);
emitJackpot('jackpot:state', { ...state, ...extra }); if (t) {
return state; clearTimeout(t);
} resolveTimers.delete(roundId);
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 clearTimers() { function clearSpinFollowUp(roundId) {
clearResolveTimer(); const t = spinFollowUps.get(roundId);
if (spinFollowUp) { if (t) {
clearTimeout(spinFollowUp); clearTimeout(t);
spinFollowUp = null; spinFollowUps.delete(roundId);
} }
} }
function scheduleResolve(endsAt) { function clearTimers(roundId) {
clearResolveTimer(); clearResolveTimer(roundId);
clearSpinFollowUp(roundId);
}
function scheduleResolve(roundId, endsAt) {
clearResolveTimer(roundId);
if (isTimerWaiting(endsAt)) return; if (isTimerWaiting(endsAt)) return;
const delay = Math.max(0, new Date(endsAt).getTime() - Date.now()); const delay = Math.max(0, new Date(endsAt).getTime() - Date.now());
resolveTimer = setTimeout(() => { resolveTimers.set(
resolveJackpotRound().catch((err) => console.error('jackpot resolve', err)); roundId,
}, delay); 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() { export async function ensureJackpotRound() {
let round = await prisma.jackpotRound.findFirst({ await prisma.jackpotRound.updateMany({
where: { status: 'open' }, where: {
include: { deposits: true }, status: 'open',
orderBy: { id: 'desc' }, deposits: { none: {} },
},
data: { status: 'cancelled', resolvedAt: new Date() },
}); });
if (!round) { const rounds = await prisma.jackpotRound.findMany({
const spinning = await prisma.jackpotRound.findFirst({ where: { status: { in: ['open', 'spinning'] } },
where: { status: 'spinning' }, include: { deposits: true },
orderBy: { id: 'desc' }, });
});
if (spinning) { for (const round of rounds) {
spinFollowUp = setTimeout(() => { if (round.status === 'spinning') {
finishJackpotSpin(spinning.id).catch((err) => console.error(err)); clearSpinFollowUp(round.id);
}, 1000); spinFollowUps.set(
return getJackpotState(); 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({ await prisma.jackpotRound.update({
where: { id: round.id }, 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)) { return listJackpotBattles();
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();
} }
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))]; const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) { if (!ids.length) {
throw Object.assign(new Error('Select at least one item'), { status: 400 }); 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 round = await prisma.$transaction(async (tx) => {
const open = await tx.jackpotRound.findFirst({ const created = await tx.jackpotRound.create({
where: { status: 'open' }, data: {
orderBy: { id: 'desc' }, 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()) { if (!isTimerWaiting(open.endsAt) && new Date(open.endsAt).getTime() <= Date.now()) {
throw Object.assign(new Error('Round is closing'), { status: 409 }); throw Object.assign(new Error('Round is closing'), { status: 409 });
} }
const existingCount = await tx.jackpotDeposit.count({ await applyDeposits(tx, open.id, userId, ids);
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,
}),
},
});
}
let next = await tx.jackpotRound.findUnique({ let next = await tx.jackpotRound.findUnique({
where: { id: open.id }, where: { id: open.id },
include: { include: roundInclude(),
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
}); });
// Arm countdown once we have enough distinct players
if ( if (
uniquePlayerCount(next.deposits) >= MIN_PLAYERS && uniquePlayerCount(next.deposits) >= MIN_PLAYERS &&
isTimerWaiting(next.endsAt) isTimerWaiting(next.endsAt)
@@ -302,10 +366,7 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
next = await tx.jackpotRound.update({ next = await tx.jackpotRound.update({
where: { id: next.id }, where: { id: next.id },
data: { endsAt }, data: { endsAt },
include: { include: roundInclude(),
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
}); });
} }
@@ -316,29 +377,35 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
uniquePlayerCount(round.deposits) >= MIN_PLAYERS && uniquePlayerCount(round.deposits) >= MIN_PLAYERS &&
!isTimerWaiting(round.endsAt) !isTimerWaiting(round.endsAt)
) { ) {
scheduleResolve(round.endsAt); scheduleResolve(round.id, round.endsAt);
} }
const state = serializeJackpotRound(round); const state = serializeJackpotRound(round);
emitJackpot('jackpot:state', state); emitJackpot(round.id, 'jackpot:state', state);
await broadcastJackpotsList();
return state; 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))]; const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) { if (!ids.length) {
throw Object.assign(new Error('Select items to withdraw'), { status: 400 }); throw Object.assign(new Error('Select items to withdraw'), { status: 400 });
} }
const round = await prisma.$transaction(async (tx) => { const round = await prisma.$transaction(async (tx) => {
const open = await tx.jackpotRound.findFirst({ const open = await tx.jackpotRound.findUnique({ where: { id } });
where: { status: 'open' }, if (!open || open.status !== 'open') {
orderBy: { id: 'desc' }, throw Object.assign(new Error('Battle is not open'), { status: 409 });
});
if (!open) {
throw Object.assign(new Error('No open jackpot round'), { 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 }); 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({ let next = await tx.jackpotRound.findUnique({
where: { id: open.id }, where: { id: open.id },
include: { include: roundInclude(),
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
}); });
// Disarm timer if we drop below min players
if (uniquePlayerCount(next.deposits) < MIN_PLAYERS && !isTimerWaiting(next.endsAt)) { if (uniquePlayerCount(next.deposits) < MIN_PLAYERS && !isTimerWaiting(next.endsAt)) {
next = await tx.jackpotRound.update({ next = await tx.jackpotRound.update({
where: { id: next.id }, where: { id: next.id },
data: { endsAt: WAITING_ENDS_AT }, data: { endsAt: WAITING_ENDS_AT },
include: { include: roundInclude(),
deposits: { include: depositInclude() }, });
winner: { select: { id: true, username: true } }, }
},
// 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; 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) { if (isTimerWaiting(round.endsAt) || uniquePlayerCount(round.deposits) < MIN_PLAYERS) {
clearResolveTimer(); clearResolveTimer(round.id);
} }
const state = serializeJackpotRound(round); const state = serializeJackpotRound(round);
emitJackpot('jackpot:state', state); emitJackpot(round.id, 'jackpot:state', state);
await broadcastJackpotsList();
return state; return state;
} }
@@ -400,10 +478,7 @@ async function finishJackpotSpin(roundId) {
const result = await prisma.$transaction(async (tx) => { const result = await prisma.$transaction(async (tx) => {
const round = await tx.jackpotRound.findUnique({ const round = await tx.jackpotRound.findUnique({
where: { id: roundId }, where: { id: roundId },
include: { include: roundInclude(),
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true, avatarUrl: true } },
},
}); });
if (!round || round.status !== 'spinning') return null; if (!round || round.status !== 'spinning') return null;
@@ -418,7 +493,7 @@ async function finishJackpotSpin(roundId) {
const acquiredIds = round.deposits const acquiredIds = round.deposits
.filter((d) => d.userId !== winnerId) .filter((d) => d.userId !== winnerId)
.map((d) => d.inventoryItemId); .map((d) => d.inventoryItemId);
let autoSellTotal = 0; let autoSellTotal = 0n;
if (acquiredIds.length) { if (acquiredIds.length) {
const winner = await tx.user.findUnique({ where: { id: winnerId } }); const winner = await tx.user.findUnique({ where: { id: winnerId } });
const acquiredItems = await tx.inventoryItem.findMany({ const acquiredItems = await tx.inventoryItem.findMany({
@@ -428,39 +503,39 @@ async function finishJackpotSpin(roundId) {
autoSellTotal = balanceDelta; 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({ await tx.transaction.create({
data: { data: {
userId: winnerId, userId: winnerId,
type: 'jackpot_win', type: 'jackpot_win',
amount: totalValue, amount: centsJson(totalValue),
meta: JSON.stringify({ meta: JSON.stringify({
roundId: round.id, roundId: round.id,
depositIds: round.deposits.map((d) => d.id), depositIds: round.deposits.map((d) => d.id),
autoSellTotal, autoSellTotal: centsJson(autoSellTotal),
}), }),
}, },
}); });
if (autoSellTotal > 0) { if (autoSellTotal > 0n) {
await tx.user.update({ await creditUserBalance(tx, winnerId, autoSellTotal);
where: { id: winnerId },
data: { balance: { increment: autoSellTotal } },
});
} }
await tx.jackpotRound.update({ const resolved = await tx.jackpotRound.update({
where: { id: round.id }, where: { id: round.id },
data: { status: 'resolved', resolvedAt: new Date() }, 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; let best = null;
for (const d of round.deposits) { for (const d of round.deposits) {
const inv = d.inventoryItem; const inv = d.inventoryItem;
const item = inv?.item; const item = inv?.item;
if (!item) continue; if (!item) continue;
if (!best || d.valueCents > best.deposit.valueCents) { if (!best || toCentsBigInt(d.valueCents) > toCentsBigInt(best.deposit.valueCents)) {
best = { deposit: d, item, inv }; best = { deposit: d, item, inv };
} }
} }
@@ -476,7 +551,7 @@ async function finishJackpotSpin(roundId) {
).length; ).length;
feedEntry = { feedEntry = {
source: 'jackpot', source: 'jackpot',
caseName: 'MultiPlayers Case', caseName: `Battle ${round.id}`,
count, count,
openCount: round.deposits.length, openCount: round.deposits.length,
inventoryIds: round.deposits.map((d) => d.inventoryItemId).filter(Boolean), 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 (!result) return;
if (winnerId) { if (winnerId) {
@@ -515,72 +592,56 @@ async function finishJackpotSpin(roundId) {
} }
const state = serializeJackpotRound(result); const state = serializeJackpotRound(result);
// New round waits for players — do not arm timer yet emitJackpot(roundId, 'jackpot:result', {
clearResolveTimer();
emitJackpot('jackpot:result', {
previousRoundId: roundId, previousRoundId: roundId,
...state, ...state,
}); });
emitJackpot('jackpot:state', state); await broadcastJackpotsList();
} }
async function createOpenRoundInTx(tx) { export async function resolveJackpotRound(roundId) {
return tx.jackpotRound.create({ const id = Number(roundId);
data: { status: 'open', endsAt: WAITING_ENDS_AT }, clearTimers(id);
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
}
export async function resolveJackpotRound() {
clearTimers();
const spun = await prisma.$transaction(async (tx) => { const spun = await prisma.$transaction(async (tx) => {
const open = await tx.jackpotRound.findFirst({ const open = await tx.jackpotRound.findUnique({
where: { status: 'open' }, where: { id },
include: { include: {
deposits: { include: depositInclude() }, deposits: { include: depositInclude() },
}, },
orderBy: { id: 'desc' },
}); });
if (!open) return null; if (!open || open.status !== 'open') return null;
const players = uniquePlayerCount(open.deposits); const players = uniquePlayerCount(open.deposits);
if (players < MIN_PLAYERS) { if (players < MIN_PLAYERS || !open.deposits.length) {
const waiting = await tx.jackpotRound.update({ const waiting = await tx.jackpotRound.update({
where: { id: open.id }, where: { id: open.id },
data: { endsAt: WAITING_ENDS_AT }, data: { endsAt: WAITING_ENDS_AT },
include: { include: roundInclude(),
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 } },
},
}); });
return { kind: 'waiting', updated: waiting }; return { kind: 'waiting', updated: waiting };
} }
const byUser = new Map(); const byUser = new Map();
for (const d of open.deposits) { 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]) => ({ const maxV = [...byUser.values()].reduce((a, b) => (a > b ? a : b), 0n);
userId, const weightEntries =
dropRate, maxV > BigInt(Number.MAX_SAFE_INTEGER)
})); ? [...byUser.entries()].map(([userId, v]) => ({
const winner = pickWeighted(entries); 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 winnerDeposits = open.deposits.filter((d) => d.userId === winner.userId);
const reelDepositId = winnerDeposits[0]?.id || null; const reelDepositId = winnerDeposits[0]?.id || null;
@@ -588,10 +649,7 @@ export async function resolveJackpotRound() {
const updated = await tx.jackpotRound.update({ const updated = await tx.jackpotRound.update({
where: { id: open.id }, where: { id: open.id },
data: { status: 'spinning', winnerUserId: winner.userId }, data: { status: 'spinning', winnerUserId: winner.userId },
include: { include: roundInclude(),
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
}); });
return { kind: 'spin', updated, reelItemId: reelDepositId }; return { kind: 'spin', updated, reelItemId: reelDepositId };
@@ -599,23 +657,41 @@ export async function resolveJackpotRound() {
if (!spun) return; if (!spun) return;
if (spun.kind === 'waiting' || spun.kind === 'extend') { if (spun.kind === 'waiting') {
const state = serializeJackpotRound(spun.updated); const state = serializeJackpotRound(spun.updated);
clearResolveTimer(); clearResolveTimer(id);
emitJackpot('jackpot:state', state); emitJackpot(id, 'jackpot:state', state);
await broadcastJackpotsList();
return state; return state;
} }
const { updated, reelItemId } = spun; const { updated, reelItemId } = spun;
const state = serializeJackpotRound(updated); const state = serializeJackpotRound(updated);
emitJackpot('jackpot:spin', { emitJackpot(id, 'jackpot:spin', {
...state, ...state,
reelWinnerItemId: reelItemId, reelWinnerItemId: reelItemId,
}); });
await broadcastJackpotsList();
spinFollowUp = setTimeout(() => { spinFollowUps.set(
finishJackpotSpin(updated.id).catch((err) => console.error('jackpot finish', err)); updated.id,
}, SPIN_MS + 200); setTimeout(() => {
finishJackpotSpin(updated.id).catch((err) =>
console.error('jackpot finish', err)
);
}, SPIN_MS + 200)
);
return state; 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 multer from 'multer';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import sharp from 'sharp';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const uploadsDir = path.join(__dirname, '../uploads'); 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)) { if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true }); 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({ const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir), destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, file, cb) => { filename:
const ext = path.extname(file.originalname).toLowerCase() || '.png'; filename ||
const safe = ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext) ? ext : '.png'; ((_req, file, cb) => {
cb(null, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${safe}`); cb(
}, null,
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${safeExt(file.originalname, file.mimetype)}`
);
}),
}); });
return multer({ return multer({
storage, storage,
limits: { fileSize: 3 * 1024 * 1024 }, limits: { fileSize: maxBytes },
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
if (!file.mimetype.startsWith('image/')) { if (!isAllowedUpload(file)) {
return cb(new Error('Only images are allowed')); return cb(new Error('Only PNG, JPEG, WebP, and GIF images are allowed'));
} }
cb(null, true); cb(null, true);
}, },
+171 -59
View File
@@ -1,10 +1,18 @@
import { prisma } from './db.js'; 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 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 ONLINE_BASE_RATE = 0.1; // 10% of value per tick
export const OFFLINE_BASE_RATE = 0.01; // 1% of value per tick export const OFFLINE_BASE_RATE = 0.01; // 1% of value per tick
export const WEAR_FLOAT_K = 3; 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). */ /** Rarity → yield multiplier (seed rarities + fallbacks). */
export const RARITY_YIELD_MULT = { export const RARITY_YIELD_MULT = {
@@ -17,6 +25,10 @@ export const RARITY_YIELD_MULT = {
Extraordinary: 1.8, Extraordinary: 1.8,
}; };
export function vaultTickMs(mode) {
return mode === 'online' ? ONLINE_TICK_MS : OFFLINE_TICK_MS;
}
export function maxVaultSlots(user) { export function maxVaultSlots(user) {
return BASE_VAULT_SLOTS + Math.max(0, Math.floor(Number(user?.prestigeVaultSlots) || 0)); 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) { export function tickPayoutCents(inv, user, mode) {
const value = Math.max(0, Math.floor(Number(inv.valueCents) || 0)); const value = toCentsBigInt(inv.valueCents);
if (value <= 0) return 0; if (value <= 0n) return 0n;
const baseRate = mode === 'online' ? ONLINE_BASE_RATE : OFFLINE_BASE_RATE; const baseRate = mode === 'online' ? ONLINE_BASE_RATE : OFFLINE_BASE_RATE;
const wear = wearYieldMult(inv.floatValue); const wear = wearYieldMult(inv.floatValue);
const rarity = rarityYieldMult(inv.item?.rarity ?? inv.rarity); const rarity = rarityYieldMult(inv.item?.rarity ?? inv.rarity);
const skill = skillYieldMult(user, mode); const skill = skillYieldMult(user, mode);
const payout = Math.floor(value * baseRate * wear * rarity * skill); const combined = baseRate * wear * rarity * skill;
// Soft cap: at most 100% of item value per tick const payout = applyRateFloor(value, combined);
return Math.min(value, Math.max(0, payout)); return minBigInt(value * MAX_TICK_VALUE_MULT, payout);
} }
export function estimateTickYield(inv, user, mode) { export function estimateTickYield(inv, user, mode) {
return tickPayoutCents(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 {'online'|'offline'} mode
* @param {Date|number} [until] settle up to this time (default now) * @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()) { export async function settleVaultYield(userId, mode, until = Date.now()) {
const id = Number(userId); 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 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 } }); 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({ const items = await tx.inventoryItem.findMany({
where: { userId: id, staked: true }, where: { userId: id, staked: true },
include: { item: 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; const anchorMs = resolveVaultAnchor(user, items, untilMs);
let maxTicks = 0; 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 itemIds = [];
const now = new Date(untilMs);
for (const inv of items) { 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 perTick = tickPayoutCents(inv, user, mode);
const credit = perTick * ticks; const credit = perTick * BigInt(ticks);
if (credit > 0) { if (credit > 0n) {
totalCredit += credit; totalCredit += credit;
itemIds.push(inv.id); itemIds.push(inv.id);
} }
maxTicks = Math.max(maxTicks, ticks);
const advanced = new Date(anchorMs + ticks * VAULT_TICK_MS);
await tx.inventoryItem.update({ await tx.inventoryItem.update({
where: { id: inv.id }, where: { id: inv.id },
data: { lastYieldAt: advanced }, data: { lastYieldAt: new Date(anchorMs + ticks * tickMs) },
}); });
} }
if (totalCredit <= 0) { const advanced = new Date(anchorMs + ticks * tickMs);
return { credited: 0, ticks: maxTicks, itemIds: [] }; 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({ if (totalCredit > 0n) {
where: { id }, await tx.transaction.create({
data: { balance: { increment: totalCredit } }, data: {
}); userId: id,
type: 'vault_yield',
amount: centsJson(totalCredit),
meta: JSON.stringify({
mode,
ticks,
inventoryItemIds: itemIds,
tickMs,
}),
},
});
}
await tx.transaction.create({ return {
data: { credited: totalCredit.toString(),
userId: id, ticks,
type: 'vault_yield', itemIds,
amount: totalCredit, user: updatedUser,
meta: JSON.stringify({ items,
mode, };
ticks: maxTicks,
inventoryItemIds: itemIds,
}),
},
});
return { credited: totalCredit, ticks: maxTicks, itemIds };
}); });
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). */ /** Estimate pending yield for UI (incomplete ticks not included). */
export function estimatePendingYield(items, user, mode, now = Date.now()) { 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) { for (const inv of items) {
const anchor = inv.lastYieldAt || inv.stakedAt; total += tickPayoutCents(inv, user, mode) * BigInt(ticks);
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;
} }
return total; return total.toString();
} }
/** Per-tick rate summary for UI (one tick online / offline). */ /** Per-tick rate summary for UI (one tick online / offline). */
export function summarizeVaultRates(items, user) { export function summarizeVaultRates(items, user) {
let onlinePerTick = 0; let onlinePerTick = 0n;
let offlinePerTick = 0; let offlinePerTick = 0n;
for (const inv of items) { for (const inv of items) {
onlinePerTick += tickPayoutCents(inv, user, 'online'); onlinePerTick += tickPayoutCents(inv, user, 'online');
offlinePerTick += tickPayoutCents(inv, user, 'offline'); 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. * Per-drop float f ∈ [0, 1) drives wear grade and price multiplier.
*/ */
import { toCentsBigInt } from './cents.js';
export const WEAR_BANDS = [ export const WEAR_BANDS = [
{ {
wear: 'Factory New', wear: 'Factory New',
@@ -96,10 +98,14 @@ export function priceMultiplier(floatValue) {
} }
export function computeValueCents(baseMarketValue, floatValue) { export function computeValueCents(baseMarketValue, floatValue) {
const base = Math.max(0, Math.floor(Number(baseMarketValue) || 0)); // Always BigInt-parse — Number() silently corrupts values above ~9e15
if (base <= 0) return 0; let base = toCentsBigInt(baseMarketValue);
const cents = Math.round(base * priceMultiplier(floatValue)); if (base < 0n) base = 0n;
return Math.max(1, cents); 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() { export function rollFloat() {