update nice visuel
This commit is contained in:
+9
-1
@@ -18,8 +18,16 @@ ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-strong-password
|
||||
|
||||
# Starting balances for new accounts (credits in cents, osu as units)
|
||||
# In production, keep STARTING_OSU low/0 — osu is the real-money currency.
|
||||
STARTING_BALANCE=10000
|
||||
STARTING_OSU=100
|
||||
STARTING_OSU=25
|
||||
|
||||
# Payments (osu top-ups)
|
||||
# mock = instant confirm for local/dev (default)
|
||||
# stripe = requires STRIPE_SECRET_KEY (+ webhook wiring)
|
||||
PAYMENTS_PROVIDER=mock
|
||||
# STRIPE_SECRET_KEY=sk_live_...
|
||||
# STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# Jackpot
|
||||
JACKPOT_ROUND_SECONDS=30
|
||||
|
||||
@@ -16,16 +16,16 @@ npm run db:seed
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- App: http://localhost:5173
|
||||
- App: http://localhost:5888
|
||||
- API: http://localhost:3001
|
||||
- LAN: http://\<your-lan-ip\>:5173 (Vite binds to `0.0.0.0`)
|
||||
- LAN: http://\<your-lan-ip\>:5888 (Vite binds to `0.0.0.0`)
|
||||
|
||||
Create `server/.env` if missing:
|
||||
|
||||
```
|
||||
DATABASE_URL="file:./dev.db"
|
||||
PORT=3001
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
CLIENT_ORIGIN=http://localhost:5888
|
||||
SESSION_SECRET=dev-secret
|
||||
STARTING_BALANCE=10000
|
||||
STARTING_OSU=100
|
||||
@@ -47,21 +47,20 @@ New registrations receive **100.00** starting credits and **100 osu** (`STARTING
|
||||
## Features
|
||||
|
||||
- Login / register (username + password)
|
||||
- **Inventory**: sort by recent or value, multi-select sell at **instance** value (float/wear), bulk **Sell below** threshold, **favorites** (★ — cannot be sold or auto-sold; can still be bet in battles), **Vault** staking (up to **3** slots by default; +1 per Vault Capacity skill) — staked items earn dividends every **10 min** (10% value online / 1% offline, scaled by float² wear and rarity)
|
||||
- **Inventory**: sort by recent or value, multi-select sell at **instance** value (float/wear), bulk **Sell below** threshold, **favorites** (★ — cannot be sold or auto-sold; can still be bet in battles), **Vault** staking (up to **3** slots by default; +1 per Vault Capacity skill) — shared timer; dividends auto-pay **every 5 min online** (10% value) / **10 min offline** (1%), scaled by float² wear and rarity (cap **500%** value / tick)
|
||||
- **Auto-sell**: optional threshold (shared with bulk sell); on **case opens**, credits are applied after the reel finishes; on **battle wins**, items taken from other players below the threshold are sold immediately
|
||||
- **Wear (float)**: each case drop gets a random float `0–1` and paint seed; grades FN / MW / FT / WW / BS (CS ranges). Catalog `marketValue` is the **Field-Tested** reference; instance `valueCents` scales by float within the wear band (FN up to +50%, MW +15–30%, FT mid ≈ catalog, WW down to −40%, BS lower)
|
||||
- **Feed Drop**: toggle in nav — live left column of latest case unboxes + MultiPlayers Battle winners
|
||||
- Cases page: browse and open crates (CS-style reel, result rolled server-side)
|
||||
- **Case keys** (per crate): **1 key** → **x10**; each **100 opens** of that case earns +1 key up to **x20** (keys 1→11, +1 open per key: x11…x20); from **x20** onward each next key costs **200, 300, 400…** opens (100 × tier) and adds +1 max open (x21, x22…) — no cap
|
||||
- **Battle** hub (`/battle`):
|
||||
- **MultiPlayers Case** — live shared pot preview on the hub (top items, pot total, players), up to 3 items, win chance by value, Socket.IO sync
|
||||
- **1vX Case** — custom rooms (player/item limits, min value), spectate in-progress
|
||||
- **Coinflip** — stub (coming soon)
|
||||
- **Shop** (nav balance pill → `/shop`): spend osu on credit packs; watch an ad every 1 min for 100.00–200.00 cr
|
||||
- Create / join MultiPlayers pots — live shared pot, up to 3 items, win chance by value, Socket.IO sync
|
||||
- Spectate in-progress battles from the hub
|
||||
- **Shop** (`/shop`): buy **osu** with real money (mock checkout in dev, Stripe-ready), exchange osu for credit packs, or watch an ad every 1 min for 100.00–200.00 cr
|
||||
- Profile (username in nav): avatar, bio, password, stats, logout
|
||||
- **Presence**: online status (Socket.IO), total play time, last connection — shown on own and public profiles
|
||||
- **Achievements** (`/achievements`, also from profile): 10 starter badges unlocked by play (cases, battles, catalog, prestige, shop, playtime); earned badges appear on the profile
|
||||
- **Prestige**: pay **1 000 000 000 000.00** cr to reset; earn **Pr** (1000 base + ~1000 per **100 000 000 000.00** cr excess); spend Pr on a skill tree (Fortune, Yield, Polish, Bulk, Discount, Quality, noTime, Refund, Respin, Vault Capacity, Offline/Online Yield)
|
||||
- **Achievements** (`/achievements`, also from profile): starter badges unlocked by play (cases, battles, catalog, prestige, shop, playtime); earned badges appear on the profile
|
||||
- **Prestige**: pay **1 000 000 000 000.00** cr to reset; earn **Pr** (1000 base + ~1000 per **250 000 000 000.00** cr excess); spend Pr on a skill tree (Fortune, Yield, Polish, Bulk, PR Farmer, Quality, noTime, Refund, Respin, Vault Capacity, Offline/Online Yield)
|
||||
- **Leaderboard**: richest players (balance + inventory value); click a row to open a public **player profile** (`/player/:username`) with stats and inventory
|
||||
- Admin: overview dashboard, players (credit adjust), cases / items / drops (split pages, search filters)
|
||||
|
||||
@@ -114,12 +113,12 @@ Typical **prod → local** flow: `docker-db-export.sh` on the server → `scp` t
|
||||
|
||||
## Notes
|
||||
|
||||
- Balances and prices are integers in **cents**; **osu** is a separate demo currency for the shop
|
||||
- Balances and prices are integers in **cents**; **osu** is the premium (real-money) currency — top-ups create `PaymentOrder` rows (`PAYMENTS_PROVIDER=mock|stripe`)
|
||||
- Catalog item price = Field-Tested reference; each inventory row stores `floatValue`, `wear`, `paintSeed`, `valueCents`, `favorite`, optional vault `staked` / `stakedAt` / `lastYieldAt`
|
||||
- Per-user auto-sell settings: `autoSellEnabled`, `autoSellThresholdCents` (default threshold **10.00** cr)
|
||||
- Case key progress stored in `CaseKeyProgress` (per user + case); backfill: `npm run db:backfill-case-keys --prefix server`
|
||||
- Inventory items locked while deposited in a bet; unlocked or transferred on resolve; staked vault items cannot be sold, auto-sold, or bet
|
||||
- Avatars / uploads under `server/uploads/` (volume in Docker)
|
||||
- Real-time via Socket.IO; Docker Hub image name is lowercase: `foufure/casegambling`
|
||||
- Max **10** simultaneous browser windows/tabs per account (Socket.IO); override with `MAX_SOCKETS_PER_USER`
|
||||
- Max **2** simultaneous browser windows/tabs per account (Socket.IO); override with `MAX_SOCKETS_PER_USER`
|
||||
- Swap `DATABASE_URL` to PostgreSQL later without changing app logic
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
# CaseOrion — Analyse & améliorations
|
||||
|
||||
**Date :** 2026-07-25
|
||||
**Périmètre :** analyse complète (server + client + Docker), **sans modification de code**.
|
||||
**Stack :** Express / Prisma / SQLite / Socket.IO · React (Vite) · ~16k LOC client+server.
|
||||
|
||||
---
|
||||
|
||||
## Synthèse
|
||||
|
||||
Le projet est une API jeu cohérente en **mono-process**, avec un effort sérieux sur l’économie haute (crédits en **strings + BigInt** via `cents.js`).
|
||||
Les risques les plus graves sont : **odds duel cassées**, **paiements mock en prod**, **`db push --accept-data-loss` au boot**, **comparaisons argent via `Number()`**, et **état in-memory** (sessions, timers battles, présence).
|
||||
|
||||
| Zone | État |
|
||||
|------|------|
|
||||
| Cases / wear / keys | Solide (roll serveur, BigInt value) |
|
||||
| Auto-sell | Logique OK (`value < seuil`) ; UX/confusion catalogue vs instance |
|
||||
| Jackpot / Battle | Agrégation BigInt correcte |
|
||||
| Duels 1vX | **Supprimé** (2026-07-25) |
|
||||
| Vault / prestige | Fonctionnel ; settle concurrent risqué |
|
||||
| Shop | Mock OK en dev ; dangereux si prod |
|
||||
| Docker | Marche en single-node ; pas scale-ready |
|
||||
|
||||
---
|
||||
|
||||
## P0 — Critique (corriger en premier)
|
||||
|
||||
### ~~1. Odds des duels faussées (multi-items)~~ — **résolu par suppression**
|
||||
Le mode **1vX Case** (rooms custom / `duel.js`) a été **retiré** (2026-07-25). Il ne reste que le Battle MultiPlayers (`jackpot.js`). Les anciennes routes `/battle/1vx` redirigent vers `/battle`.
|
||||
|
||||
### 1. Paiements mock utilisables en production
|
||||
**Fichiers :** `server/src/payments.js`, `routes/shop.js`, `.env.prod.example`
|
||||
|
||||
`PAYMENTS_PROVIDER=mock` + `POST /api/shop/osu/confirm` crédite l’osu **sans paiement**.
|
||||
|
||||
| Proposition | Détail |
|
||||
|-------------|--------|
|
||||
| **A** | Refuser mock si `NODE_ENV=production` sauf `ALLOW_MOCK_PAYMENTS=1` |
|
||||
| B | Brancher Stripe (webhook raw body + signature) |
|
||||
| C | Désactiver toute la section osu en prod tant que non prêt |
|
||||
|
||||
---
|
||||
|
||||
### 3. `prisma db push --accept-data-loss` à chaque démarrage Docker
|
||||
**Fichiers :** `Dockerfile`, `docker-compose.yml`
|
||||
|
||||
Peut **détruire / tronquer** des colonnes au restart. Inadapté à une vraie prod.
|
||||
|
||||
| Proposition | Détail |
|
||||
|-------------|--------|
|
||||
| **A (recommandé)** | Prisma Migrate (`migrate deploy`) ; push manuel hors boot |
|
||||
| B | Garder push **sans** `--accept-data-loss` + fail loud |
|
||||
| C | Job one-shot de migration séparé du `CMD` app |
|
||||
|
||||
---
|
||||
|
||||
### ~~4. `db.js` coerce tous les BigInt → `Number`~~ — **corrigé**
|
||||
`reviveMoney` **supprimé**. Choix retenu : argent en **strings** + `toCentsBigInt` (pas de plafond `Number`), BigInt Prisma éventuels laissés intacts jusqu’au JSON (`toString` dans `index.js`). Pas de conversion globale silencieuse.
|
||||
|
||||
---
|
||||
|
||||
### ~~5. Uploads SVG → XSS stocké~~ — **corrigé**
|
||||
Allowlist **PNG / JPEG / WebP / GIF** (MIME + extension + vérif `sharp` du format réel). SVG et autres `image/*` refusés (admin + avatar).
|
||||
|
||||
---
|
||||
|
||||
## P1 — Économie & précision (haute priorité)
|
||||
|
||||
### ~~6. Finir la migration argent BigInt-safe (bout en bout)~~ — **corrigé (A)**
|
||||
Helpers client dans `api.js` : `toCentsBigInt`, `compareCents`, `centsJson` (miroir serveur). Branchés sur ItemTile, CasePage, Dashboard, Battle, Prestige shortfall, Admin sorts/inputs. Affichage via les mêmes helpers.
|
||||
|
||||
---
|
||||
|
||||
### ~~7. Courses sur le solde (TOCTOU)~~ — **corrigé (A)**
|
||||
`spendUserBalance` / `creditUserBalance` / `adjustUserBalance` dans `cents.js` : update SQL atomique `balance = balance ± x WHERE balance >= cost` (INTEGER SQLite) ou CAS string pour montants > Int64. Branché open case, prestige, sell/auto-sell, vault, shop, jackpot.
|
||||
|
||||
Même famille restante : double settle vault (login + socket + intervalle) — le crédit est atomique, mais le calcul de ticks peut encore se chevaucher.
|
||||
|
||||
---
|
||||
|
||||
### ~~8. Auto-sell — clarté & robustesse~~ — **partiel (A)**
|
||||
Won banner case : **toujours** wear + valeur d’instance ; si auto-sold, ajoute aussi le payout.
|
||||
|
||||
Reste ouvert : B immédiat serveur · C TTL claim · D confirm prestige.
|
||||
|
||||
---
|
||||
|
||||
### ~~9. Colonnes prestige mortes / non branchées~~ — **corrigé (B)**
|
||||
Supprimées du schema + reset server-prestige : `prestigeKeyOpenReduction`, `prestigeStartBonus`, `prestigeAdBonus`, `prestigeAdCooldownReduction`, `prestigeShopDiscount`, `prestigeStartOsu`, `prestigeDropValueBonus`, `prestigeCaseDiscount`.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Sécurité & prod
|
||||
|
||||
### 10. Sessions MemoryStore
|
||||
Perdues au restart ; incompatible multi-replicas.
|
||||
|
||||
| Proposition | A : SQLite/Redis store · B : sticky single node documenté · C : JWT (changement plus large) |
|
||||
|
||||
### 11. CORS trop permissif
|
||||
`index.js` autorise toujours localhost + LAN HTTP, même “prod”.
|
||||
|
||||
| Proposition | A : en prod, **uniquement** `CLIENT_ORIGIN` + `CORS_ORIGINS` |
|
||||
|
||||
### 12. Rate limiting absent
|
||||
Login / register / open / ad / shop confirm non limités.
|
||||
|
||||
| Proposition | A : `express-rate-limit` par IP+user · B : captcha register · C : cooldowns serveur déjà partiels (ad) à généraliser |
|
||||
|
||||
### 13. Admin via `session.role` sans re-check DB
|
||||
Élévation stale possible.
|
||||
|
||||
| Proposition | A : `requireAdmin` recharge le user · B : claim `roleVersion` en session |
|
||||
|
||||
### 14. Secrets & config
|
||||
`SESSION_SECRET=dev-secret` par défaut ; pas de `server/.env.example` local (seulement README + `.env.prod.example`).
|
||||
|
||||
| Proposition | A : fail boot si secret faible en prod · B : ajouter `.env.example` |
|
||||
|
||||
### 15. Mots de passe faibles
|
||||
Min 6 caractères seulement.
|
||||
|
||||
| Proposition | A : min 8 + zxcvbn soft · B : garder simple (jeu privé) mais documenter |
|
||||
|
||||
---
|
||||
|
||||
## P3 — Qualité code / refactor
|
||||
|
||||
### 16. Duplication jackpot ↔ duel
|
||||
Serialize, finish spin, auto-sell winner : quasi copy-paste.
|
||||
|
||||
| Proposition | A : module `services/battleCommon.js` · B : unifier modèle “room” (plus gros refactor) |
|
||||
|
||||
### 17. `serializeInv` dupliqué
|
||||
`inventory.js` / `profile.js` (et variantes).
|
||||
|
||||
| Proposition | A : `serializeInventoryItem` partagé |
|
||||
|
||||
### 18. Client — listeners `socket.on('connect')` jamais retirés
|
||||
Layout, BattleHub, BattleRoom, Duel*, DropFeed → fuite de handlers au remount.
|
||||
|
||||
| Proposition | A : cleanup `socket.off` dans chaque `useEffect` |
|
||||
|
||||
### 19. Battle hub incomplet
|
||||
Routes 1vX existent ; hub n’expose que MultiPlayers + Coinflip stub. Copy “MultiPlayers” / redirects legacy.
|
||||
|
||||
| Proposition | A : cartes hub 1vX + Coinflip disabled clair · B : retirer Coinflip jusqu’à implémentation |
|
||||
|
||||
### 20. Guards auth copiés partout
|
||||
Chaque page : loading / !user / admin redirect.
|
||||
|
||||
| Proposition | A : `<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 d’intégration |
|
||||
|
||||
### 22. Admin UX
|
||||
- Inputs prix en cents bruts + `Number`
|
||||
- Pas d’édition packs crédits / ads (seulement osu packs)
|
||||
- Adjust player sans confirm
|
||||
- Pas de lien “voir le site joueur”
|
||||
|
||||
| Proposition | A : champs crédits + confirm adjust · B : admin shop packs crédits |
|
||||
|
||||
### 23. Tests absents
|
||||
Aucun test automatisé visible sur la logique critique (odds, cents, auto-sell, vault).
|
||||
|
||||
| Proposition | A : Vitest unitaire `cents` / `wear` / `autoSell` / duel weights · B : smoke API Playwright |
|
||||
|
||||
### 24. Observabilité
|
||||
`console.error` local ; pas de métriques / tracing.
|
||||
|
||||
| Proposition | A : structured logs (pino) + request id · B : health DB ready |
|
||||
|
||||
---
|
||||
|
||||
## P4 — Produit / design (nice to have)
|
||||
|
||||
| # | Sujet | Proposition |
|
||||
|---|--------|-------------|
|
||||
| 25 | Prestige sans confirm | Dialog “tu perds inventaire + keys” |
|
||||
| 26 | Catalog conservé au prestige | Documenter (déjà le cas) ou option reset |
|
||||
| 27 | Favoris bettables | Clarifier UI ou bloquer en battle |
|
||||
| 28 | Feed montre parfois FT | Afficher valeur d’instance |
|
||||
| 29 | Lazy routes / code-split | `React.lazy` admin vs player |
|
||||
| 30 | SQLite scale | Postgres si charge / multi-instance |
|
||||
| 31 | Achievements / shop ads hardcodés | Config admin |
|
||||
| 32 | Animation CaseReel perf | Lazy images ; réduire slots si `animReduction` |
|
||||
|
||||
---
|
||||
|
||||
## Vérification logique (résumé métier)
|
||||
|
||||
| Système | Verdict |
|
||||
|---------|---------|
|
||||
| **Drop case** | Serveur fait foi ; luck = catalog chapters + Fortune |
|
||||
| **Wear → prix** | Correct (FT = ref) ; Polish/Quality biaisent vers le haut |
|
||||
| **Auto-sell** | `instance < seuil` ; Yield après coup ; pas de bug “35T vendu sous 15T” si prestige max + vrai 35T instance |
|
||||
| **Keys** | Progression documentée ; `keyOpenReduction` non branché |
|
||||
| **Vault** | Timer partagé ; risque double settle |
|
||||
| **Jackpot** | Poids valeur OK |
|
||||
| **Duel** | **Poids multi-items KO** |
|
||||
| **Prestige joueur** | Wipe bal/inv/keys ; keep skills/Pr/**catalog** |
|
||||
| **Server prestige** | Wipe économie globale ; keep account/achievements/stats |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap proposée (ordre d’exécution)
|
||||
|
||||
```
|
||||
Semaine 1 (P0)
|
||||
├─ Fix duel toCentsBigInt + test
|
||||
├─ Gate mock payments en prod
|
||||
├─ Retirer reviveMoney Number(bigint)
|
||||
├─ Allowlist uploads (no SVG)
|
||||
└─ Plan migration Docker (stop accept-data-loss au boot)
|
||||
|
||||
Semaine 2 (P1)
|
||||
├─ BigInt-safe client (ItemTile, sorts, prestige shortfall, admin inputs)
|
||||
├─ Balance updates atomiques + vault settle idempotent
|
||||
└─ Brancher ou supprimer prestige fields morts
|
||||
|
||||
Semaine 3 (P2–P3)
|
||||
├─ Rate limit + CORS prod + session store
|
||||
├─ Refactor battle common + socket cleanup
|
||||
├─ Hub 1vX + tests unitaires cents/odds
|
||||
└─ Confirms UX (prestige, admin adjust)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alternatives d’architecture (si le projet grossit)
|
||||
|
||||
| Option | Quand | Coût |
|
||||
|--------|-------|------|
|
||||
| **Rester mono SQLite** | LAN / petits effectifs | Faible — documenter single-node |
|
||||
| **Postgres + Redis** (sessions, presence, timers) | Multi-instances / uptime | Moyen |
|
||||
| **Queue économie** (BullMQ) | Anti race balance/vault | Moyen |
|
||||
| **Séparer worker** (spins, vault ticks) | Charge Socket.IO | Élevé |
|
||||
| **Stripe live** | Monétisation réelle | Moyen + conformité |
|
||||
|
||||
---
|
||||
|
||||
## Hors scope de cette analyse
|
||||
|
||||
- Audit pentest complet / dépendances CVE détaillées
|
||||
- Refonte UI/UX visuelle
|
||||
- Implémentation Coinflip
|
||||
- Contenu (balancing drops / prix items)
|
||||
|
||||
---
|
||||
|
||||
*Document généré pour pilotage : aucune modification code associée. Prioriser P0 avant tout déploiement Docker “réel”.*
|
||||
+12
-18
@@ -1,4 +1,4 @@
|
||||
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import Layout from './components/Layout';
|
||||
import { useAuth } from './AuthContext';
|
||||
import Admin from './pages/Admin';
|
||||
@@ -9,10 +9,7 @@ import Leaderboard from './pages/Leaderboard';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import BattleHub from './pages/BattleHub';
|
||||
import MultiPlayersPage from './pages/MultiPlayersPage';
|
||||
import DuelListPage from './pages/DuelListPage';
|
||||
import DuelRoomPage from './pages/DuelRoomPage';
|
||||
import CoinflipStub from './pages/CoinflipStub';
|
||||
import BattleRoomPage from './pages/BattleRoomPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
import PlayerProfilePage from './pages/PlayerProfilePage';
|
||||
import CatalogPage from './pages/CatalogPage';
|
||||
@@ -28,11 +25,6 @@ function HomeRedirect() {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
function BetDuelRedirect() {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/battle/1vx/${id}`} replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -48,15 +40,17 @@ export default function App() {
|
||||
<Route path="/prestige" element={<PrestigePage />} />
|
||||
<Route path="/achievements" element={<AchievementsPage />} />
|
||||
<Route path="/battle" element={<BattleHub />} />
|
||||
<Route path="/battle/multi" element={<MultiPlayersPage />} />
|
||||
<Route path="/battle/1vx" element={<DuelListPage />} />
|
||||
<Route path="/battle/1vx/:id" element={<DuelRoomPage />} />
|
||||
<Route path="/battle/coinflip" element={<CoinflipStub />} />
|
||||
<Route path="/battle/create" element={<BattleRoomPage create />} />
|
||||
<Route path="/battle/multi" element={<Navigate to="/battle/create" replace />} />
|
||||
<Route path="/battle/1vx" element={<Navigate to="/battle" replace />} />
|
||||
<Route path="/battle/1vx/:id" element={<Navigate to="/battle" replace />} />
|
||||
<Route path="/battle/coinflip" element={<Navigate to="/battle" replace />} />
|
||||
<Route path="/battle/:id" element={<BattleRoomPage />} />
|
||||
<Route path="/bet" element={<Navigate to="/battle" replace />} />
|
||||
<Route path="/bet/multi" element={<Navigate to="/battle/multi" replace />} />
|
||||
<Route path="/bet/1vx" element={<Navigate to="/battle/1vx" replace />} />
|
||||
<Route path="/bet/1vx/:id" element={<BetDuelRedirect />} />
|
||||
<Route path="/bet/coinflip" element={<Navigate to="/battle/coinflip" replace />} />
|
||||
<Route path="/bet/multi" element={<Navigate to="/battle/create" replace />} />
|
||||
<Route path="/bet/1vx" element={<Navigate to="/battle" replace />} />
|
||||
<Route path="/bet/1vx/:id" element={<Navigate to="/battle" replace />} />
|
||||
<Route path="/bet/coinflip" element={<Navigate to="/battle" replace />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/player/:username" element={<PlayerProfilePage />} />
|
||||
<Route path="/shop" element={<ShopPage />} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { api } from './api';
|
||||
import { getSocket, refreshSocketSession } from './socket';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
@@ -22,6 +23,23 @@ export function AuthProvider({ children }) {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Keep Socket.IO presence in sync with HTTP session (login / logout / boot).
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
refreshSocketSession();
|
||||
}, [loading, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
const onReset = () => {
|
||||
refresh();
|
||||
};
|
||||
socket.on('server:prestige-reset', onReset);
|
||||
return () => {
|
||||
socket.off('server:prestige-reset', onReset);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const login = async (username, password) => {
|
||||
const data = await api.login(username, password);
|
||||
setUser(data.user);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api, formatCredits, centsJson, toCentsBigInt } from '../api';
|
||||
import ImageField from './ImageField';
|
||||
|
||||
const emptyCase = { name: '', price: 100, imageUrl: '', active: true };
|
||||
@@ -62,7 +62,7 @@ export default function AdminCases() {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await api.createCase({ ...caseForm, price: Number(caseForm.price) });
|
||||
await api.createCase({ ...caseForm, price: centsJson(toCentsBigInt(caseForm.price)) });
|
||||
setCaseForm(emptyCase);
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -112,7 +112,7 @@ export default function AdminCases() {
|
||||
try {
|
||||
await api.updateCase(editingId, {
|
||||
name: edit.name,
|
||||
price: Number(edit.price),
|
||||
price: centsJson(toCentsBigInt(edit.price)),
|
||||
imageUrl: edit.imageUrl,
|
||||
active: edit.active,
|
||||
});
|
||||
|
||||
@@ -78,6 +78,12 @@ export default function AdminDashboard() {
|
||||
<Link to="/admin/drops" className="btn secondary">
|
||||
Drop rates
|
||||
</Link>
|
||||
<Link to="/admin/shop" className="btn secondary">
|
||||
Shop osu packs
|
||||
</Link>
|
||||
<Link to="/admin/prestige-server" className="btn secondary">
|
||||
Prestige Server
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className="admin-block" style={{ marginTop: '1.25rem' }}>
|
||||
|
||||
+137
-13
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api, formatCredits, compareCents } from '../api';
|
||||
|
||||
function dropPercents(items) {
|
||||
const total = items.reduce((s, ci) => s + Number(ci.dropRate || 0), 0);
|
||||
@@ -7,12 +7,54 @@ function dropPercents(items) {
|
||||
return items.map((ci) => (Number(ci.dropRate) / total) * 100);
|
||||
}
|
||||
|
||||
function sortCatalog(list, sort) {
|
||||
const next = [...list];
|
||||
next.sort((a, b) => {
|
||||
if (sort === 'value-asc') return compareCents(a.marketValue, b.marketValue);
|
||||
if (sort === 'name') return a.name.localeCompare(b.name);
|
||||
if (sort === 'rarity') {
|
||||
const r = a.rarity.localeCompare(b.rarity);
|
||||
if (r !== 0) return r;
|
||||
return compareCents(b.marketValue, a.marketValue);
|
||||
}
|
||||
// value-desc (default)
|
||||
return compareCents(b.marketValue, a.marketValue);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function sortPool(list, sort) {
|
||||
const next = [...list];
|
||||
next.sort((a, b) => {
|
||||
if (sort === 'value-desc') {
|
||||
return compareCents(b.item.marketValue, a.item.marketValue);
|
||||
}
|
||||
if (sort === 'value-asc') {
|
||||
return compareCents(a.item.marketValue, b.item.marketValue);
|
||||
}
|
||||
if (sort === 'name') return a.item.name.localeCompare(b.item.name);
|
||||
if (sort === 'rarity') {
|
||||
const r = a.item.rarity.localeCompare(b.item.rarity);
|
||||
if (r !== 0) return r;
|
||||
return compareCents(b.item.marketValue, a.item.marketValue);
|
||||
}
|
||||
if (sort === 'rate-desc') return Number(b.dropRate) - Number(a.dropRate);
|
||||
// rate-asc (default)
|
||||
return Number(a.dropRate) - Number(b.dropRate);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function AdminDrops() {
|
||||
const [cases, setCases] = useState([]);
|
||||
const [items, setItems] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedCaseId, setSelectedCaseId] = useState('');
|
||||
const [dropForm, setDropForm] = useState({ itemId: '', dropRate: 1 });
|
||||
const [itemSort, setItemSort] = useState('value-desc');
|
||||
const [itemQuery, setItemQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
const [poolSort, setPoolSort] = useState('rate-asc');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [c, i] = await Promise.all([api.adminCases(), api.adminItems()]);
|
||||
@@ -22,10 +64,6 @@ export default function AdminDrops() {
|
||||
if (prev && c.cases.some((x) => String(x.id) === String(prev))) return prev;
|
||||
return c.cases[0] ? String(c.cases[0].id) : '';
|
||||
});
|
||||
setDropForm((f) => ({
|
||||
...f,
|
||||
itemId: f.itemId || (i.items[0] ? String(i.items[0].id) : ''),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,10 +75,43 @@ export default function AdminDrops() {
|
||||
[cases, selectedCaseId]
|
||||
);
|
||||
|
||||
const usedCategories = useMemo(() => {
|
||||
const set = new Set(items.map((item) => item.category || 'Misc'));
|
||||
return [...set].sort((a, b) => a.localeCompare(b));
|
||||
}, [items]);
|
||||
|
||||
const catalogItems = useMemo(() => {
|
||||
const q = itemQuery.trim().toLowerCase();
|
||||
const filtered = items.filter((item) => {
|
||||
const cat = item.category || 'Misc';
|
||||
if (categoryFilter !== 'all' && cat !== categoryFilter) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
item.name.toLowerCase().includes(q) ||
|
||||
item.rarity.toLowerCase().includes(q) ||
|
||||
cat.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
return sortCatalog(filtered, itemSort);
|
||||
}, [items, itemQuery, itemSort, categoryFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!catalogItems.length) {
|
||||
setDropForm((f) => (f.itemId ? { ...f, itemId: '' } : f));
|
||||
return;
|
||||
}
|
||||
setDropForm((f) => {
|
||||
if (f.itemId && catalogItems.some((item) => String(item.id) === String(f.itemId))) {
|
||||
return f;
|
||||
}
|
||||
return { ...f, itemId: String(catalogItems[0].id) };
|
||||
});
|
||||
}, [catalogItems]);
|
||||
|
||||
const poolItems = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
return [...selected.items].sort((a, b) => Number(a.dropRate) - Number(b.dropRate));
|
||||
}, [selected]);
|
||||
return sortPool(selected.items, poolSort);
|
||||
}, [selected, poolSort]);
|
||||
|
||||
const percents = useMemo(() => dropPercents(poolItems), [poolItems]);
|
||||
|
||||
@@ -76,6 +147,37 @@ export default function AdminDrops() {
|
||||
Add drop · {selected.name}{' '}
|
||||
<span className="muted">({formatCredits(selected.price)} cr)</span>
|
||||
</h3>
|
||||
<div className="admin-toolbar" style={{ marginBottom: '0.75rem' }}>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
aria-label="Filter category"
|
||||
>
|
||||
<option value="all">All categories</option>
|
||||
{usedCategories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={itemSort}
|
||||
onChange={(e) => setItemSort(e.target.value)}
|
||||
aria-label="Sort catalog items"
|
||||
>
|
||||
<option value="value-desc">Sort: value ↓</option>
|
||||
<option value="value-asc">Sort: value ↑</option>
|
||||
<option value="name">Sort: name</option>
|
||||
<option value="rarity">Sort: rarity</option>
|
||||
</select>
|
||||
<input
|
||||
className="admin-search"
|
||||
placeholder="Search items…"
|
||||
value={itemQuery}
|
||||
onChange={(e) => setItemQuery(e.target.value)}
|
||||
aria-label="Search catalog items"
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
className="admin-row"
|
||||
onSubmit={async (e) => {
|
||||
@@ -95,12 +197,18 @@ export default function AdminDrops() {
|
||||
<select
|
||||
value={dropForm.itemId}
|
||||
onChange={(e) => setDropForm({ ...dropForm, itemId: e.target.value })}
|
||||
disabled={!catalogItems.length}
|
||||
aria-label="Select item to add"
|
||||
>
|
||||
{items.map((item) => (
|
||||
{catalogItems.length === 0 ? (
|
||||
<option value="">No matching items</option>
|
||||
) : (
|
||||
catalogItems.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.rarity})
|
||||
{item.name} ({item.rarity}) · {formatCredits(item.marketValue)} cr
|
||||
</option>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
@@ -111,14 +219,28 @@ export default function AdminDrops() {
|
||||
placeholder="Weight"
|
||||
required
|
||||
/>
|
||||
<button className="btn" type="submit">
|
||||
<button className="btn" type="submit" disabled={!dropForm.itemId}>
|
||||
Add drop
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="admin-block">
|
||||
<h3>Pool ({poolItems.length})</h3>
|
||||
<div className="section-head" style={{ marginBottom: '0.75rem' }}>
|
||||
<h3 style={{ margin: 0 }}>Pool ({poolItems.length})</h3>
|
||||
<select
|
||||
value={poolSort}
|
||||
onChange={(e) => setPoolSort(e.target.value)}
|
||||
aria-label="Sort pool"
|
||||
>
|
||||
<option value="rate-asc">Sort: weight ↑</option>
|
||||
<option value="rate-desc">Sort: weight ↓</option>
|
||||
<option value="value-desc">Sort: value ↓</option>
|
||||
<option value="value-asc">Sort: value ↑</option>
|
||||
<option value="name">Sort: name</option>
|
||||
<option value="rarity">Sort: rarity</option>
|
||||
</select>
|
||||
</div>
|
||||
{poolItems.length === 0 ? (
|
||||
<div className="muted">No drops yet.</div>
|
||||
) : (
|
||||
@@ -130,7 +252,9 @@ export default function AdminDrops() {
|
||||
<img src={ci.item.imageUrl} alt="" className="drop-thumb" />
|
||||
) : null}
|
||||
{ci.item.name}{' '}
|
||||
<span className="muted">({ci.item.rarity})</span>
|
||||
<span className="muted">
|
||||
({ci.item.rarity}) · {formatCredits(ci.item.marketValue)} cr
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api, formatCredits, compareCents, centsJson, toCentsBigInt } from '../api';
|
||||
import ImageField from './ImageField';
|
||||
|
||||
const emptyItem = {
|
||||
@@ -10,23 +10,21 @@ const emptyItem = {
|
||||
imageUrl: '',
|
||||
};
|
||||
|
||||
function CategoryField({ value, onChange, categories, id }) {
|
||||
const listId = id || 'item-categories';
|
||||
function CategoryField({ value, onChange, categories }) {
|
||||
const options =
|
||||
value && !categories.includes(value) ? [...categories, value] : categories;
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
list={listId}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="e.g. Rifle, Knife…"
|
||||
required
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c} />
|
||||
))}
|
||||
</datalist>
|
||||
</>
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} required>
|
||||
{options.length === 0 ? (
|
||||
<option value="">No categories — create one first</option>
|
||||
) : (
|
||||
options.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,16 +49,23 @@ export default function AdminItems() {
|
||||
]);
|
||||
setItems(i.items);
|
||||
setRarities(r.rarities);
|
||||
setCategories(
|
||||
(c.names || c.categories || []).map((entry) =>
|
||||
const names = (c.names || c.categories || []).map((entry) =>
|
||||
typeof entry === 'string' ? entry : entry.name
|
||||
)
|
||||
);
|
||||
setItemForm((f) => ({
|
||||
setCategories(names);
|
||||
setItemForm((f) => {
|
||||
const nextCategory =
|
||||
f.category && names.includes(f.category)
|
||||
? f.category
|
||||
: names.includes('Misc')
|
||||
? 'Misc'
|
||||
: names[0] || '';
|
||||
return {
|
||||
...f,
|
||||
rarity: f.rarity || r.rarities[0] || 'MilSpec',
|
||||
category: f.category || 'Misc',
|
||||
}));
|
||||
category: nextCategory,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,7 +94,7 @@ export default function AdminItems() {
|
||||
list = [...list];
|
||||
list.sort((a, b) => {
|
||||
if (sort === 'name') return a.name.localeCompare(b.name);
|
||||
if (sort === 'value') return Number(b.marketValue) - Number(a.marketValue);
|
||||
if (sort === 'value') return compareCents(b.marketValue, a.marketValue);
|
||||
if (sort === 'rarity') return a.rarity.localeCompare(b.rarity);
|
||||
// category (default): group by category, then name
|
||||
const ca = (a.category || 'Misc').localeCompare(b.category || 'Misc');
|
||||
@@ -164,12 +169,14 @@ export default function AdminItems() {
|
||||
try {
|
||||
await api.createItem({
|
||||
...itemForm,
|
||||
marketValue: Number(itemForm.marketValue),
|
||||
marketValue: centsJson(toCentsBigInt(itemForm.marketValue)),
|
||||
});
|
||||
setItemForm({
|
||||
...emptyItem,
|
||||
rarity: rarities[0] || 'MilSpec',
|
||||
category: 'Misc',
|
||||
category: categories.includes('Misc')
|
||||
? 'Misc'
|
||||
: categories[0] || '',
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -188,7 +195,6 @@ export default function AdminItems() {
|
||||
<label>
|
||||
Category
|
||||
<CategoryField
|
||||
id="create-item-categories"
|
||||
value={itemForm.category}
|
||||
categories={categories}
|
||||
onChange={(category) => setItemForm({ ...itemForm, category })}
|
||||
@@ -247,7 +253,7 @@ export default function AdminItems() {
|
||||
name: edit.name,
|
||||
rarity: edit.rarity,
|
||||
category: edit.category,
|
||||
marketValue: Number(edit.marketValue),
|
||||
marketValue: centsJson(toCentsBigInt(edit.marketValue)),
|
||||
imageUrl: edit.imageUrl,
|
||||
});
|
||||
setEditingId(null);
|
||||
@@ -270,7 +276,6 @@ export default function AdminItems() {
|
||||
<label>
|
||||
Category
|
||||
<CategoryField
|
||||
id={`edit-item-categories-${item.id}`}
|
||||
value={edit.category}
|
||||
categories={categories}
|
||||
onChange={(category) => setEdit({ ...edit, category })}
|
||||
|
||||
@@ -8,6 +8,8 @@ const NAV = [
|
||||
{ to: '/admin/items', label: 'Items' },
|
||||
{ to: '/admin/categories', label: 'Categories' },
|
||||
{ to: '/admin/drops', label: 'Drops' },
|
||||
{ to: '/admin/shop', label: 'Shop' },
|
||||
{ to: '/admin/prestige-server', label: 'Prestige Server' },
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
|
||||
@@ -4,8 +4,105 @@ import {
|
||||
formatCredits,
|
||||
formatLastConnection,
|
||||
formatPlayTime,
|
||||
compareCents,
|
||||
creditsToCentsString,
|
||||
toCentsBigInt,
|
||||
} from '../api';
|
||||
|
||||
/** Credits delta (e.g. "15.5" or "-1000") → signed cents string. */
|
||||
function parseDeltaCents(raw) {
|
||||
const s = String(raw ?? '').trim().replace(',', '.');
|
||||
if (!s || s === '0' || s === '+0' || s === '-0') return null;
|
||||
const neg = s.startsWith('-');
|
||||
const body = s.startsWith('+') || s.startsWith('-') ? s.slice(1) : s;
|
||||
const cents = creditsToCentsString(body);
|
||||
if (cents == null || cents === '0') return null;
|
||||
return neg ? `-${cents}` : cents;
|
||||
}
|
||||
|
||||
/** Whole-unit delta (Pr / osu) — integers only. */
|
||||
function parseDeltaUnits(raw) {
|
||||
const s = String(raw ?? '').trim();
|
||||
if (!/^-?\d+$/.test(s) || s === '0' || s === '-0') return null;
|
||||
try {
|
||||
return BigInt(s);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function balanceAfterDelta(balance, delta) {
|
||||
try {
|
||||
const next = toCentsBigInt(balance) + toCentsBigInt(delta);
|
||||
return (next < 0n ? 0n : next).toString();
|
||||
} catch {
|
||||
return toCentsBigInt(balance).toString();
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact display for whole units (Pr / osu) via credit formatter. */
|
||||
function formatUnits(n) {
|
||||
try {
|
||||
return formatCredits((BigInt(n) * 100n).toString());
|
||||
} catch {
|
||||
return String(n);
|
||||
}
|
||||
}
|
||||
|
||||
function previewClass(delta) {
|
||||
if (delta == null) return 'admin-adjust-preview muted';
|
||||
const n = typeof delta === 'bigint' ? delta : BigInt(delta);
|
||||
return n > 0n ? 'admin-adjust-preview pos' : 'admin-adjust-preview neg';
|
||||
}
|
||||
|
||||
function formatSignedPreview(delta, suffix, isCents = false) {
|
||||
if (delta == null) return '—';
|
||||
const n = typeof delta === 'bigint' ? delta : BigInt(delta);
|
||||
const abs = n < 0n ? -n : n;
|
||||
const formatted = isCents ? formatCredits(abs.toString()) : formatUnits(abs);
|
||||
return `${n > 0n ? '+' : '−'}${formatted} ${suffix}`;
|
||||
}
|
||||
|
||||
function AdjustRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
step = '1',
|
||||
delta,
|
||||
preview,
|
||||
result,
|
||||
onSubmit,
|
||||
busy,
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
className="admin-adjust-row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
{label}
|
||||
<span className="admin-adjust-input-wrap">
|
||||
<input
|
||||
type="number"
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<span className={previewClass(delta)}>{preview}</span>
|
||||
</span>
|
||||
</label>
|
||||
{result != null && <span className="admin-adjust-result muted">{result}</span>}
|
||||
<button className="btn" type="submit" disabled={busy || delta == null}>
|
||||
Apply
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPlayers() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
@@ -13,8 +110,14 @@ export default function AdminPlayers() {
|
||||
const [sort, setSort] = useState('wealth');
|
||||
const [adjustId, setAdjustId] = useState(null);
|
||||
const [deltaCredits, setDeltaCredits] = useState('100');
|
||||
const [deltaPr, setDeltaPr] = useState('1000');
|
||||
const [deltaOsu, setDeltaOsu] = useState('100');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const deltaCents = useMemo(() => parseDeltaCents(deltaCredits), [deltaCredits]);
|
||||
const prDelta = useMemo(() => parseDeltaUnits(deltaPr), [deltaPr]);
|
||||
const osuDelta = useMemo(() => parseDeltaUnits(deltaOsu), [deltaOsu]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api.adminUsers();
|
||||
setUsers(data.users);
|
||||
@@ -39,23 +142,24 @@ export default function AdminPlayers() {
|
||||
new Date(a.lastConnection || 0).getTime()
|
||||
);
|
||||
}
|
||||
return Number(b.totalWealth) - Number(a.totalWealth);
|
||||
return compareCents(b.totalWealth, a.totalWealth);
|
||||
});
|
||||
return list;
|
||||
}, [users, query, sort]);
|
||||
|
||||
const applyDelta = async (userId, credits) => {
|
||||
const cents = Math.round(Number(credits) * 100);
|
||||
if (!Number.isInteger(cents) || cents === 0) {
|
||||
setError('Enter a non-zero credit amount (e.g. 100 or -50)');
|
||||
return;
|
||||
}
|
||||
const resetDeltas = () => {
|
||||
setDeltaCredits('100');
|
||||
setDeltaPr('1000');
|
||||
setDeltaOsu('100');
|
||||
};
|
||||
|
||||
const applyPatch = async (userId, body) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.adminUpdateUser(userId, { balanceDelta: cents });
|
||||
await api.adminUpdateUser(userId, body);
|
||||
setAdjustId(null);
|
||||
setDeltaCredits('100');
|
||||
resetDeltas();
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -64,12 +168,44 @@ export default function AdminPlayers() {
|
||||
}
|
||||
};
|
||||
|
||||
const applyCredits = async (userId) => {
|
||||
const cents = parseDeltaCents(deltaCredits);
|
||||
if (cents == null) {
|
||||
setError('Enter a non-zero credit amount (e.g. 100 or -50)');
|
||||
return;
|
||||
}
|
||||
await applyPatch(userId, { balanceDelta: cents });
|
||||
};
|
||||
|
||||
const applyPr = async (userId) => {
|
||||
const delta = parseDeltaUnits(deltaPr);
|
||||
if (delta == null) {
|
||||
setError('Enter a non-zero Pr amount (e.g. 1000 or -100)');
|
||||
return;
|
||||
}
|
||||
await applyPatch(userId, { prBalanceDelta: delta.toString() });
|
||||
};
|
||||
|
||||
const applyOsu = async (userId) => {
|
||||
const delta = parseDeltaUnits(deltaOsu);
|
||||
if (delta == null) {
|
||||
setError('Enter a non-zero osu amount (e.g. 100 or -50)');
|
||||
return;
|
||||
}
|
||||
// osuBalance is Int — stay within safe integer range for the JSON number API
|
||||
if (delta > BigInt(Number.MAX_SAFE_INTEGER) || delta < BigInt(Number.MIN_SAFE_INTEGER)) {
|
||||
setError('osu delta is too large');
|
||||
return;
|
||||
}
|
||||
await applyPatch(userId, { osuBalanceDelta: Number(delta) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Players</h1>
|
||||
<p className="muted">{users.length} accounts · adjust credits when needed</p>
|
||||
<p className="muted">{users.length} accounts · adjust credits, Pr & osu</p>
|
||||
</div>
|
||||
<div className="admin-toolbar">
|
||||
<select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">
|
||||
@@ -108,8 +244,9 @@ export default function AdminPlayers() {
|
||||
{u.inventoryCount})
|
||||
</div>
|
||||
<div className="muted">
|
||||
Opens {u.casesOpened} · prestige {u.prestigeCount} · achievements{' '}
|
||||
{u.achievements} · play {formatPlayTime(u.playTimeSeconds)} · last{' '}
|
||||
{formatUnits(u.prBalance ?? 0)} Pr · {formatUnits(u.osuBalance ?? 0)} osu · opens{' '}
|
||||
{u.casesOpened} · prestige {u.prestigeCount} · achievements {u.achievements} · play{' '}
|
||||
{formatPlayTime(u.playTimeSeconds)} · last{' '}
|
||||
{formatLastConnection(u.lastConnection)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,50 +255,62 @@ export default function AdminPlayers() {
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setAdjustId(adjustId === u.id ? null : u.id);
|
||||
setDeltaCredits('100');
|
||||
resetDeltas();
|
||||
}}
|
||||
>
|
||||
Adjust credits
|
||||
Adjust
|
||||
</button>
|
||||
</div>
|
||||
{adjustId === u.id && (
|
||||
<form
|
||||
className="admin-adjust-row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
applyDelta(u.id, deltaCredits);
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Δ credits (e.g. 100 or -50)
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
<div className="admin-adjust-panel">
|
||||
<AdjustRow
|
||||
label="Δ credits (e.g. 100 or -50)"
|
||||
value={deltaCredits}
|
||||
onChange={(e) => setDeltaCredits(e.target.value)}
|
||||
required
|
||||
onChange={setDeltaCredits}
|
||||
step="0.01"
|
||||
delta={deltaCents == null ? null : BigInt(deltaCents)}
|
||||
preview={formatSignedPreview(
|
||||
deltaCents == null ? null : BigInt(deltaCents),
|
||||
'cr',
|
||||
true
|
||||
)}
|
||||
result={
|
||||
deltaCents == null
|
||||
? null
|
||||
: `→ bal ${formatCredits(balanceAfterDelta(u.balance, deltaCents))} cr`
|
||||
}
|
||||
onSubmit={() => applyCredits(u.id)}
|
||||
busy={busy}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn" type="submit" disabled={busy}>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => applyDelta(u.id, '1000')}
|
||||
disabled={busy}
|
||||
>
|
||||
+1000
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
onClick={() => applyDelta(u.id, '-100')}
|
||||
disabled={busy}
|
||||
>
|
||||
−100
|
||||
</button>
|
||||
</form>
|
||||
<AdjustRow
|
||||
label="Δ Pr (e.g. 1000 or -100)"
|
||||
value={deltaPr}
|
||||
onChange={setDeltaPr}
|
||||
delta={prDelta}
|
||||
preview={formatSignedPreview(prDelta, 'Pr')}
|
||||
result={
|
||||
prDelta == null
|
||||
? null
|
||||
: `→ ${formatUnits(balanceAfterDelta(u.prBalance ?? 0, prDelta))} Pr`
|
||||
}
|
||||
onSubmit={() => applyPr(u.id)}
|
||||
busy={busy}
|
||||
/>
|
||||
<AdjustRow
|
||||
label="Δ osu (e.g. 100 or -50)"
|
||||
value={deltaOsu}
|
||||
onChange={setDeltaOsu}
|
||||
delta={osuDelta}
|
||||
preview={formatSignedPreview(osuDelta, 'osu')}
|
||||
result={
|
||||
osuDelta == null
|
||||
? null
|
||||
: `→ ${formatUnits(balanceAfterDelta(u.osuBalance ?? 0, osuDelta))} osu`
|
||||
}
|
||||
onSubmit={() => applyOsu(u.id)}
|
||||
busy={busy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default function ImageField({ value, onChange, disabled }) {
|
||||
{uploading ? '…' : 'Upload'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif"
|
||||
hidden
|
||||
onChange={onFile}
|
||||
disabled={disabled || uploading}
|
||||
|
||||
+145
-39
@@ -35,10 +35,10 @@ export const api = {
|
||||
catalog: () => request('/api/catalog'),
|
||||
prestige: () => request('/api/prestige'),
|
||||
doPrestige: () => request('/api/prestige', { method: 'POST', body: JSON.stringify({}) }),
|
||||
prestigeUpgrade: (skillId) =>
|
||||
prestigeUpgrade: (skillId, count = 1) =>
|
||||
request('/api/prestige/upgrade', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ skillId }),
|
||||
body: JSON.stringify({ skillId, count }),
|
||||
}),
|
||||
openCase: (id, count = 1) =>
|
||||
request(`/api/cases/${id}/open`, {
|
||||
@@ -56,7 +56,6 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
vaultClaim: () => request('/api/vault/claim', { method: 'POST' }),
|
||||
sellInventory: (inventoryItemIds) =>
|
||||
request('/api/inventory/sell', {
|
||||
method: 'POST',
|
||||
@@ -83,41 +82,65 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ packageId }),
|
||||
}),
|
||||
shopOsuCheckout: (packId) =>
|
||||
request('/api/shop/osu/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ packId }),
|
||||
}),
|
||||
shopOsuConfirm: (orderId) =>
|
||||
request('/api/shop/osu/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ orderId }),
|
||||
}),
|
||||
shopAd: () => request('/api/shop/ad', { method: 'POST' }),
|
||||
feed: () => request('/api/feed'),
|
||||
feedAnnounce: (inventoryItemIds, caseName) => {
|
||||
feedAnnounce: (inventoryItemIds, caseName, opts = {}) => {
|
||||
const ids = Array.isArray(inventoryItemIds)
|
||||
? inventoryItemIds
|
||||
: [inventoryItemIds];
|
||||
: inventoryItemIds != null
|
||||
? [inventoryItemIds]
|
||||
: [];
|
||||
const body = {
|
||||
inventoryItemIds: ids.filter(Boolean),
|
||||
caseName,
|
||||
respin: Boolean(opts.respin),
|
||||
};
|
||||
if (opts.snapshotBest) {
|
||||
body.snapshotBest = opts.snapshotBest;
|
||||
body.openCount = opts.openCount;
|
||||
body.count = opts.count;
|
||||
} else if (opts.snapshotItems) {
|
||||
body.snapshotItems = opts.snapshotItems;
|
||||
}
|
||||
return request('/api/feed/announce', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds: ids, caseName }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
leaderboard: () => request('/api/leaderboard'),
|
||||
jackpot: () => request('/api/jackpot'),
|
||||
jackpotBet: (inventoryItemIds) =>
|
||||
jackpotRound: (id) => request(`/api/jackpot/${id}`),
|
||||
jackpotCreateBet: (inventoryItemIds) =>
|
||||
request('/api/jackpot/bet', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
jackpotWithdraw: (inventoryItemIds) =>
|
||||
request('/api/jackpot/withdraw', {
|
||||
jackpotBet: (id, inventoryItemIds) =>
|
||||
request(`/api/jackpot/${id}/bet`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
duels: () => request('/api/duels'),
|
||||
duel: (id) => request(`/api/duels/${id}`),
|
||||
createDuel: (body) =>
|
||||
request('/api/duels', { method: 'POST', body: JSON.stringify(body) }),
|
||||
duelBet: (id, inventoryItemIds) =>
|
||||
request(`/api/duels/${id}/bet`, {
|
||||
jackpotWithdraw: (id, inventoryItemIds) =>
|
||||
request(`/api/jackpot/${id}/withdraw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
// legacy alias
|
||||
jackpotCreate: (inventoryItemIds) =>
|
||||
request('/api/jackpot/bet', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventoryItemIds }),
|
||||
}),
|
||||
duelLeave: (id) => request(`/api/duels/${id}/leave`, { method: 'POST' }),
|
||||
duelCancel: (id) => request(`/api/duels/${id}/cancel`, { method: 'POST' }),
|
||||
duelStart: (id) => request(`/api/duels/${id}/start`, { method: 'POST' }),
|
||||
profile: () => request('/api/profile'),
|
||||
playerProfile: (username) =>
|
||||
request(`/api/profile/player/${encodeURIComponent(username)}`),
|
||||
@@ -140,6 +163,12 @@ export const api = {
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
adminStats: () => request('/api/admin/stats'),
|
||||
adminPrestigeServerPreview: () => request('/api/admin/prestige-server'),
|
||||
adminPrestigeServer: (body) =>
|
||||
request('/api/admin/prestige-server', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
adminUsers: () => request('/api/admin/users'),
|
||||
adminUpdateUser: (id, body) =>
|
||||
request(`/api/admin/users/${id}`, {
|
||||
@@ -150,6 +179,17 @@ export const api = {
|
||||
adminItems: () => request('/api/admin/items'),
|
||||
adminRarities: () => request('/api/admin/rarities'),
|
||||
adminCategories: () => request('/api/admin/categories'),
|
||||
adminShopOsuPacks: () => request('/api/admin/shop/osu-packs'),
|
||||
adminShopOsuSection: (enabled) =>
|
||||
request('/api/admin/shop/osu-section', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
adminUpdateShopOsuPack: (id, body) =>
|
||||
request(`/api/admin/shop/osu-packs/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
createCategory: (body) =>
|
||||
request('/api/admin/categories', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateCategory: (id, body) =>
|
||||
@@ -186,17 +226,77 @@ export const api = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Exact credit display (cents → "1 234.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 → "1 234.56"), for tooltips / confirmations. */
|
||||
export function formatCreditsExact(cents) {
|
||||
const n = Number(cents);
|
||||
if (!Number.isFinite(n)) return '0.00';
|
||||
const fixed = (n / 100).toFixed(2);
|
||||
const neg = fixed.startsWith('-');
|
||||
const body = neg ? fixed.slice(1) : fixed;
|
||||
const [intPart, dec] = body.split('.');
|
||||
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f');
|
||||
return `${neg ? '-' : ''}${grouped}.${dec}`;
|
||||
let bi = toCentsBigInt(cents);
|
||||
const neg = bi < 0n;
|
||||
if (neg) bi = -bi;
|
||||
const whole = bi / 100n;
|
||||
const frac = (bi % 100n).toString().padStart(2, '0');
|
||||
const grouped = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f');
|
||||
return `${neg ? '-' : ''}${grouped}.${frac}`;
|
||||
}
|
||||
|
||||
/** Parse credits input (decimal string) → integer cents string. Null if invalid. */
|
||||
export function creditsToCentsString(raw) {
|
||||
if (raw == null || raw === '') return null;
|
||||
const s = String(raw).trim().replace(',', '.');
|
||||
const m = s.match(/^(\d+)(?:\.(\d{1,2})\d*)?$/);
|
||||
if (!m) return null;
|
||||
try {
|
||||
const whole = BigInt(m[1]);
|
||||
const frac = m[2] ? m[2].padEnd(2, '0') : '00';
|
||||
return (whole * 100n + BigInt(frac)).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cents → credits decimal string without Number precision loss. */
|
||||
export function centsToCreditsString(cents) {
|
||||
let bi = toCentsBigInt(cents);
|
||||
const neg = bi < 0n;
|
||||
const abs = neg ? -bi : bi;
|
||||
const whole = abs / 100n;
|
||||
const frac = (abs % 100n).toString().padStart(2, '0');
|
||||
const body =
|
||||
frac === '00' ? whole.toString() : `${whole}.${frac}`.replace(/0+$/, '').replace(/\.$/, '');
|
||||
return `${neg ? '-' : ''}${body}`;
|
||||
}
|
||||
|
||||
/** Alphabet suffixes after T: aa, ab, … az, ba, … (each step ×1000). */
|
||||
@@ -225,25 +325,31 @@ function tierSuffix(tier) {
|
||||
* K / M / B / T, then aa, ab, … (×1000 each). Input is cents.
|
||||
*/
|
||||
export function formatCredits(cents) {
|
||||
const n = Number(cents);
|
||||
if (!Number.isFinite(n)) return '0.00';
|
||||
const neg = n < 0;
|
||||
const credits = Math.abs(n) / 100;
|
||||
let bi = toCentsBigInt(cents);
|
||||
const neg = bi < 0n;
|
||||
if (neg) bi = -bi;
|
||||
|
||||
if (credits < 1000) {
|
||||
return `${neg ? '-' : ''}${credits.toFixed(2)}`;
|
||||
const creditsWhole = bi / 100n;
|
||||
const creditsFrac = bi % 100n;
|
||||
|
||||
if (creditsWhole < 1000n) {
|
||||
return `${neg ? '-' : ''}${creditsWhole}.${creditsFrac.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
let tier = 0;
|
||||
let scaled = credits;
|
||||
while (scaled >= 1000 && tier < 1000) {
|
||||
scaled /= 1000;
|
||||
let scaled = creditsWhole;
|
||||
let rem = 0n;
|
||||
while (scaled >= 1000n) {
|
||||
rem = scaled % 1000n;
|
||||
scaled /= 1000n;
|
||||
tier += 1;
|
||||
}
|
||||
|
||||
const suffix = tierSuffix(tier);
|
||||
const shown = String(Number(scaled.toFixed(2)));
|
||||
return `${neg ? '-' : ''}${shown}${suffix}`;
|
||||
// scaled < 1000 → safe as Number; rem gives ~3 decimal digits of precision
|
||||
const approx = Number(scaled) + Number(rem) / 1000;
|
||||
if (!Number.isFinite(approx)) return `${neg ? '-' : ''}0${tierSuffix(tier)}`;
|
||||
const shown = String(Number(approx.toFixed(2)));
|
||||
return `${neg ? '-' : ''}${shown}${tierSuffix(tier)}`;
|
||||
}
|
||||
|
||||
export function formatChance(pct) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -55,13 +55,13 @@ export default function DropFeed() {
|
||||
if (!entry?.id) return;
|
||||
setDrops((prev) => {
|
||||
if (prev.some((d) => d.id === entry.id)) return prev;
|
||||
// Also skip near-identical batch (same user + item + count) within a few seconds
|
||||
const nearDup = prev.find(
|
||||
(d) =>
|
||||
d.username === entry.username &&
|
||||
d.item?.id === entry.item?.id &&
|
||||
(d.count || 1) === (entry.count || 1) &&
|
||||
(d.openCount || 1) === (entry.openCount || 1) &&
|
||||
Boolean(d.respin) === Boolean(entry.respin) &&
|
||||
Math.abs(new Date(d.at) - new Date(entry.at)) < 15000
|
||||
);
|
||||
if (nearDup) return prev;
|
||||
@@ -69,7 +69,6 @@ export default function DropFeed() {
|
||||
});
|
||||
};
|
||||
|
||||
// Prefer socket history; HTTP is fallback if socket is slow
|
||||
socket.emit('feed:subscribe');
|
||||
socket.on('feed:history', onHistory);
|
||||
socket.on('feed:drop', onDrop);
|
||||
@@ -117,7 +116,11 @@ export default function DropFeed() {
|
||||
visible.map((d) => {
|
||||
const color = rarityColor(d.item?.rarity);
|
||||
return (
|
||||
<div key={d.id} className="drop-feed-row" style={{ borderLeftColor: color }}>
|
||||
<div
|
||||
key={d.id}
|
||||
className={`drop-feed-row${d.respin ? ' is-respin' : ''}`}
|
||||
style={{ borderLeftColor: color }}
|
||||
>
|
||||
<div className="drop-feed-item-visual" style={{ color }}>
|
||||
{d.item?.imageUrl ? (
|
||||
<img src={d.item.imageUrl} alt="" />
|
||||
@@ -148,16 +151,18 @@ export default function DropFeed() {
|
||||
{d.username}
|
||||
</>
|
||||
) : (
|
||||
d.username
|
||||
<>
|
||||
{d.respin ? (
|
||||
<span className="drop-feed-respin-label">Respin</span>
|
||||
) : null}
|
||||
{d.respin ? ' ' : null}
|
||||
{d.username}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="muted drop-feed-sub">
|
||||
{d.source === 'jackpot'
|
||||
? 'MultiPlayers Case · '
|
||||
: d.caseName
|
||||
? `${d.caseName} · `
|
||||
: ''}
|
||||
{d.caseName ? `${d.caseName} · ` : ''}
|
||||
{formatCredits(d.item?.marketValue || 0)} cr
|
||||
{d.openCount > 1
|
||||
? d.source === 'jackpot'
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { formatCredits, formatCreditsExact } from '../api';
|
||||
import { formatCredits, formatCreditsExact, toCentsBigInt } from '../api';
|
||||
import { rarityColor } from '../rarities';
|
||||
|
||||
/** Instance sell/bet price, else catalog FT reference */
|
||||
/** Instance sell/bet price, else catalog FT reference (BigInt cents). */
|
||||
export function itemValueCents(item) {
|
||||
if (!item) return 0;
|
||||
if (item.valueCents != null && Number.isFinite(Number(item.valueCents))) {
|
||||
return Number(item.valueCents);
|
||||
if (!item) return 0n;
|
||||
if (item.valueCents != null && item.valueCents !== '') {
|
||||
return toCentsBigInt(item.valueCents);
|
||||
}
|
||||
return Number(item.marketValue) || 0;
|
||||
return toCentsBigInt(item.marketValue);
|
||||
}
|
||||
|
||||
export function formatFloat(f) {
|
||||
@@ -41,6 +41,7 @@ export default function ItemTile({
|
||||
const titleParts = [];
|
||||
if (staked) titleParts.push('Vault');
|
||||
if (favorite) titleParts.push('Favorite');
|
||||
if (item.rarity) titleParts.push(item.rarity);
|
||||
if (floatTitle) titleParts.push(floatTitle);
|
||||
else titleParts.push(item.name);
|
||||
if (showValue) titleParts.push(`${formatCreditsExact(value)} cr`);
|
||||
@@ -76,16 +77,13 @@ export default function ItemTile({
|
||||
)}
|
||||
</div>
|
||||
<div className="item-name">{item.name}</div>
|
||||
<div className="item-meta" style={{ color }}>
|
||||
{item.rarity}
|
||||
</div>
|
||||
{showWear && wear ? (
|
||||
<div className="item-wear" title={floatTitle}>
|
||||
{wear}
|
||||
</div>
|
||||
) : null}
|
||||
{showValue && (
|
||||
<div className="item-meta" title={`${formatCreditsExact(value)} cr`}>
|
||||
<div className="item-meta item-price" title={`${formatCreditsExact(value)} cr`}>
|
||||
{formatCredits(value)} cr
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { api, formatCredits, formatCreditsExact } from '../api';
|
||||
import DropFeed from './DropFeed';
|
||||
import SessionLimitOverlay from './SessionLimitOverlay';
|
||||
import { onInventoryChanged } from '../inventoryEvents';
|
||||
import { getSocket, onSessionLimitChange } from '../socket';
|
||||
import { getSocket, onSessionLimitChange, getSessionLimitPayload } from '../socket';
|
||||
|
||||
const FEED_KEY = 'caseorion-feed-drop';
|
||||
|
||||
@@ -17,27 +17,20 @@ function ScrollToTop() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function countWaitingBattles(jackpotRound, duelOpen) {
|
||||
function countWaitingBattles(jackpotBattles) {
|
||||
let n = 0;
|
||||
|
||||
// MultiPlayers: only if someone has already bet and round still waits for more players
|
||||
const jpPlayers = jackpotRound?.players?.length ?? 0;
|
||||
const jpDeposits = jackpotRound?.deposits?.length ?? 0;
|
||||
for (const battle of jackpotBattles || []) {
|
||||
if (battle.status === 'spinning') continue;
|
||||
const jpPlayers = battle?.players?.length ?? 0;
|
||||
const jpDeposits = battle?.deposits?.length ?? 0;
|
||||
if (
|
||||
jackpotRound?.waitingForPlayers &&
|
||||
jackpotRound.status !== 'spinning' &&
|
||||
battle?.waitingForPlayers &&
|
||||
battle.status === 'open' &&
|
||||
(jpPlayers > 0 || jpDeposits > 0)
|
||||
) {
|
||||
n += 1;
|
||||
}
|
||||
|
||||
// 1vX: each open room that already has at least one player with a bet
|
||||
for (const room of duelOpen || []) {
|
||||
if (room.status !== 'open') continue;
|
||||
const hasBet =
|
||||
(room.playerCount ?? room.players?.length ?? 0) > 0 ||
|
||||
(room.deposits?.length ?? 0) > 0;
|
||||
if (hasBet) n += 1;
|
||||
}
|
||||
|
||||
return n;
|
||||
@@ -45,6 +38,7 @@ function countWaitingBattles(jackpotRound, duelOpen) {
|
||||
|
||||
export default function Layout() {
|
||||
const { user } = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
const [feedOpen, setFeedOpen] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(FEED_KEY) === '1';
|
||||
@@ -54,7 +48,12 @@ export default function Layout() {
|
||||
});
|
||||
const [waitingBattles, setWaitingBattles] = useState(0);
|
||||
const [prestigeVisible, setPrestigeVisible] = useState(false);
|
||||
const [sessionLimit, setSessionLimit] = useState(null);
|
||||
const [sessionLimit, setSessionLimit] = useState(() => getSessionLimitPayload());
|
||||
const [navHidden, setNavHidden] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setNavHidden(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -64,6 +63,47 @@ export default function Layout() {
|
||||
}
|
||||
}, [feedOpen]);
|
||||
|
||||
// Mobile: hide topnav while scrolling down, reveal on scroll up / near top
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 700px)');
|
||||
let lastY = window.scrollY || 0;
|
||||
let hidden = false;
|
||||
|
||||
const apply = (next) => {
|
||||
if (hidden === next) return;
|
||||
hidden = next;
|
||||
setNavHidden(next);
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (!mq.matches) {
|
||||
apply(false);
|
||||
lastY = window.scrollY || 0;
|
||||
return;
|
||||
}
|
||||
const y = window.scrollY || 0;
|
||||
const delta = y - lastY;
|
||||
lastY = y;
|
||||
if (y < 48) {
|
||||
apply(false);
|
||||
return;
|
||||
}
|
||||
if (delta > 6) apply(true);
|
||||
else if (delta < -6) apply(false);
|
||||
};
|
||||
|
||||
const onMq = () => {
|
||||
if (!mq.matches) apply(false);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
mq.addEventListener?.('change', onMq);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
mq.removeEventListener?.('change', onMq);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') {
|
||||
setPrestigeVisible(false);
|
||||
@@ -97,24 +137,22 @@ export default function Layout() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let jackpotRound = null;
|
||||
let duelOpen = [];
|
||||
let jackpotBattles = [];
|
||||
let cancelled = false;
|
||||
|
||||
const sync = () => {
|
||||
if (cancelled) return;
|
||||
setWaitingBattles((prev) => {
|
||||
const next = countWaitingBattles(jackpotRound, duelOpen);
|
||||
const next = countWaitingBattles(jackpotBattles);
|
||||
return prev === next ? prev : next;
|
||||
});
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
|
||||
const jp = await api.jackpot();
|
||||
if (cancelled) return;
|
||||
jackpotRound = jp.round;
|
||||
duelOpen = duels.open || [];
|
||||
jackpotBattles = jp.battles || [];
|
||||
sync();
|
||||
} catch {
|
||||
/* nav badge is optional */
|
||||
@@ -125,35 +163,22 @@ export default function Layout() {
|
||||
const offLimitChange = onSessionLimitChange((payload) => {
|
||||
setSessionLimit(payload);
|
||||
});
|
||||
const onJackpot = (state) => {
|
||||
jackpotRound = state;
|
||||
sync();
|
||||
};
|
||||
const onDuels = (list) => {
|
||||
duelOpen = list.open || [];
|
||||
const onJackpots = (payload) => {
|
||||
jackpotBattles = payload.battles || [];
|
||||
sync();
|
||||
};
|
||||
|
||||
socket.emit('jackpot:subscribe');
|
||||
socket.emit('duels:subscribe');
|
||||
socket.on('jackpot:state', onJackpot);
|
||||
socket.on('jackpot:spin', onJackpot);
|
||||
socket.on('jackpot:result', onJackpot);
|
||||
socket.on('duels:list', onDuels);
|
||||
socket.emit('jackpots:subscribe');
|
||||
socket.on('jackpots:list', onJackpots);
|
||||
socket.on('connect', () => {
|
||||
socket.emit('jackpot:subscribe');
|
||||
socket.emit('duels:subscribe');
|
||||
socket.emit('jackpots:subscribe');
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
offLimitChange();
|
||||
socket.emit('jackpot:unsubscribe');
|
||||
socket.emit('duels:unsubscribe');
|
||||
socket.off('jackpot:state', onJackpot);
|
||||
socket.off('jackpot:spin', onJackpot);
|
||||
socket.off('jackpot:result', onJackpot);
|
||||
socket.off('duels:list', onDuels);
|
||||
socket.emit('jackpots:unsubscribe');
|
||||
socket.off('jackpots:list', onJackpots);
|
||||
};
|
||||
}, [user?.id, user?.role]);
|
||||
|
||||
@@ -164,8 +189,8 @@ export default function Layout() {
|
||||
className={`app-shell${feedOpen ? ' feed-open' : ''}${sessionLimit ? ' session-locked' : ''}`}
|
||||
>
|
||||
<ScrollToTop />
|
||||
{sessionLimit && <SessionLimitOverlay max={sessionLimit.max || 10} />}
|
||||
<header className="topnav">
|
||||
{sessionLimit && <SessionLimitOverlay max={sessionLimit.max || 2} />}
|
||||
<header className={`topnav${navHidden ? ' topnav--hidden' : ''}`}>
|
||||
<div className="topnav-left">
|
||||
<NavLink to="/dashboard" className="brand">
|
||||
CaseOrion
|
||||
|
||||
@@ -2,8 +2,14 @@ export default function ProfilePrestigeStats({ prestige }) {
|
||||
if (!prestige || !prestige.count) return null;
|
||||
|
||||
const rows = [
|
||||
typeof prestige.prBalance === 'number' && prestige.prBalance > 0
|
||||
? { label: 'Pr', value: <strong>{prestige.prBalance}</strong> }
|
||||
(() => {
|
||||
try {
|
||||
return BigInt(String(prestige.prBalance ?? 0)) > 0n;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})()
|
||||
? { label: 'Pr', value: <strong>{String(prestige.prBalance)}</strong> }
|
||||
: null,
|
||||
{ label: 'Fortune', value: <strong>+{prestige.chanceBonus}%</strong> },
|
||||
{ label: 'Yield', value: <strong>+{prestige.gainBonus}%</strong> },
|
||||
@@ -15,9 +21,9 @@ export default function ProfilePrestigeStats({ prestige }) {
|
||||
label: 'Bulk',
|
||||
value: <strong>+{prestige.openBonus} opens</strong>,
|
||||
},
|
||||
(prestige.caseDiscount || 0) > 0 && {
|
||||
label: 'Discount',
|
||||
value: <strong>−{prestige.caseDiscount}%</strong>,
|
||||
(prestige.prBonus || 0) > 0 && {
|
||||
label: 'PR Farmer',
|
||||
value: <strong>+{prestige.prBonus}%</strong>,
|
||||
},
|
||||
(prestige.qualityBonus || 0) > 0 && {
|
||||
label: 'Quality',
|
||||
|
||||
@@ -18,7 +18,7 @@ const FAKE_ADS = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function SessionLimitOverlay({ max = 10 }) {
|
||||
export default function SessionLimitOverlay({ max = 2 }) {
|
||||
const [ad] = useState(
|
||||
() => FAKE_ADS[Math.floor(Math.random() * FAKE_ADS.length)]
|
||||
);
|
||||
|
||||
+493
-16
@@ -89,6 +89,20 @@ h3 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
transition: transform 280ms ease;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.topnav.topnav--hidden {
|
||||
transform: translateY(-110%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.topnav {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -321,11 +335,23 @@ h3 {
|
||||
|
||||
.drop-feed {
|
||||
width: 100%;
|
||||
max-height: 220px;
|
||||
max-height: none;
|
||||
position: static;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.drop-feed-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* One drop row visible; scroll for the rest */
|
||||
.drop-feed-list {
|
||||
flex: none;
|
||||
max-height: calc(1rem + 2 * 0.45rem + 5.75rem);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.balance-pill {
|
||||
@@ -384,9 +410,13 @@ a.balance-pill-link.active {
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: transparent;
|
||||
border-color: rgba(235, 75, 75, 0.5);
|
||||
color: var(--danger);
|
||||
background: var(--danger);
|
||||
border-color: transparent;
|
||||
color: #1a0606;
|
||||
}
|
||||
|
||||
.btn.danger:hover:not(:disabled) {
|
||||
background: #f06565;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
@@ -668,7 +698,7 @@ a.balance-pill-link.active {
|
||||
}
|
||||
|
||||
.item-wear {
|
||||
color: var(--gold);
|
||||
color: var(--accent);
|
||||
font-size: 0.72rem;
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.02em;
|
||||
@@ -693,6 +723,39 @@ a.balance-pill-link.active {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.case-hero-wrap {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 420ms ease;
|
||||
}
|
||||
|
||||
.case-hero-wrap > .case-hero {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
opacity 320ms ease,
|
||||
transform 420ms ease;
|
||||
}
|
||||
|
||||
.case-hero-wrap--hidden {
|
||||
grid-template-rows: 0fr;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.case-hero-wrap--hidden > .case-hero {
|
||||
opacity: 0;
|
||||
transform: translateY(-1.25rem);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.case-hero-wrap,
|
||||
.case-hero-wrap > .case-hero {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.case-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -734,7 +797,8 @@ a.balance-pill-link.active {
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
|
||||
.case-key-badge {
|
||||
.case-price,
|
||||
.item-price {
|
||||
color: var(--gold);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
@@ -768,6 +832,11 @@ a.balance-pill-link.active {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.multi-reel-hint {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.multi-reels.many {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
@@ -1703,6 +1772,15 @@ a.balance-pill-link.active {
|
||||
border-top: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.admin-adjust-panel .admin-adjust-row:first-child {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-adjust-panel .admin-adjust-row + .admin-adjust-row {
|
||||
margin-top: 0.35rem;
|
||||
padding-top: 0.55rem;
|
||||
}
|
||||
|
||||
.admin-adjust-row label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1719,6 +1797,35 @@ a.balance-pill-link.active {
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.admin-adjust-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.admin-adjust-preview {
|
||||
font-size: 0.9rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.admin-adjust-preview.pos {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-adjust-preview.neg {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.admin-adjust-result {
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
align-self: end;
|
||||
padding-bottom: 0.55rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1843,18 +1950,24 @@ a.balance-pill-link.active {
|
||||
|
||||
.item-vault-badge {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
left: 0.35rem;
|
||||
z-index: 1;
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
top: 0.3rem;
|
||||
left: 0.3rem;
|
||||
z-index: 2;
|
||||
min-width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
padding: 0 0.2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 0.2rem;
|
||||
background: rgba(80, 160, 220, 0.35);
|
||||
color: #9fd4ff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
border-radius: 0.25rem;
|
||||
background: #0a121c;
|
||||
color: #e8f6ff;
|
||||
border: 1.5px solid #7ec8ff;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.85),
|
||||
0 2px 6px rgba(0, 0, 0, 0.75);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1869,6 +1982,24 @@ a.balance-pill-link.active {
|
||||
|
||||
.vault-panel .section-head {
|
||||
margin-bottom: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.vault-timer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.15rem;
|
||||
min-width: 7.5rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.vault-timer strong {
|
||||
font-size: 1.35rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.vault-stats {
|
||||
@@ -1881,8 +2012,60 @@ a.balance-pill-link.active {
|
||||
|
||||
.vault-grid {
|
||||
margin-bottom: 0.5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 112px));
|
||||
gap: 0.55rem;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.vault-grid .item-tile {
|
||||
padding: 0.45rem;
|
||||
min-height: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.vault-grid .item-visual {
|
||||
min-height: 56px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.vault-grid .item-visual.has-img {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.vault-grid .item-visual img {
|
||||
max-height: 52px;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.vault-grid .item-name {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.15;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vault-grid .item-meta,
|
||||
.vault-grid .item-wear {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.vault-grid .item-vault-badge {
|
||||
min-width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.vault-grid .item-favorite-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.28rem;
|
||||
}
|
||||
|
||||
|
||||
.item-tile.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
@@ -1909,6 +2092,10 @@ a.balance-pill-link.active {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.mode-card-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.mode-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -2148,6 +2335,32 @@ a.mode-card:hover {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row-actions .btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.btn-ico {
|
||||
color: var(--gold);
|
||||
font-size: 0.95em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-ico-vault {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 1.1em;
|
||||
height: 1.1em;
|
||||
padding: 0 0.15em;
|
||||
border-radius: 0.2rem;
|
||||
background: #0a121c;
|
||||
color: #e8f6ff;
|
||||
border: 1px solid #7ec8ff;
|
||||
font-size: 0.72em;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.room-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2658,6 +2871,238 @@ a.achievement-badge:hover .achievement-badge-name {
|
||||
padding: 0.35rem 0.7rem;
|
||||
}
|
||||
|
||||
.shop-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.shop-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(220px, 0.7fr);
|
||||
gap: 1.5rem 2rem;
|
||||
align-items: end;
|
||||
padding: 0.25rem 0 0.5rem;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.shop-kicker {
|
||||
margin: 0 0 0.35rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-dim);
|
||||
}
|
||||
|
||||
.shop-hero h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: clamp(1.75rem, 3vw, 2.4rem);
|
||||
}
|
||||
|
||||
.shop-lede {
|
||||
margin: 0;
|
||||
max-width: 38rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.shop-lede strong {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shop-wallet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(61, 214, 198, 0.08), transparent 55%),
|
||||
var(--bg-panel);
|
||||
border: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.shop-wallet-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.shop-wallet-osu {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.shop-mock-note {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.shop-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.shop-section-head h2 {
|
||||
margin: 0 0 0.2rem;
|
||||
}
|
||||
|
||||
.shop-section-head .muted {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shop-osu-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.shop-osu-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 1rem 0.9rem 0.9rem;
|
||||
background: linear-gradient(180deg, var(--bg-elevated), var(--bg-panel));
|
||||
border: 1px solid var(--stroke);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.shop-osu-card.popular {
|
||||
border-color: rgba(61, 214, 198, 0.45);
|
||||
}
|
||||
|
||||
.shop-osu-card.highlight {
|
||||
border-color: rgba(228, 174, 57, 0.55);
|
||||
background:
|
||||
linear-gradient(160deg, rgba(228, 174, 57, 0.1), transparent 45%),
|
||||
linear-gradient(180deg, var(--bg-elevated), var(--bg-panel));
|
||||
}
|
||||
|
||||
.shop-badge {
|
||||
position: absolute;
|
||||
top: 0.55rem;
|
||||
right: 0.55rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bg-deep);
|
||||
background: var(--accent);
|
||||
padding: 0.15rem 0.4rem;
|
||||
}
|
||||
|
||||
.shop-osu-card.highlight .shop-badge {
|
||||
background: var(--gold);
|
||||
}
|
||||
|
||||
.shop-badge.subtle {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--stroke);
|
||||
position: static;
|
||||
align-self: flex-start;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.shop-osu-card h3,
|
||||
.shop-credit-card h3 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.shop-osu-amount {
|
||||
margin: 0.15rem 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.65rem;
|
||||
color: var(--accent);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.shop-osu-amount span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.shop-osu-blurb,
|
||||
.shop-osu-rate {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.3;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shop-buy-btn {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.shop-credit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.shop-credit-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.85rem;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--stroke);
|
||||
}
|
||||
|
||||
.shop-credit-card .pack-credits {
|
||||
margin: 0.1rem 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.shop-credit-cost {
|
||||
margin: 0.15rem 0 0.35rem;
|
||||
}
|
||||
|
||||
.shop-credit-scale {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.shop-credit-card .btn {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.shop-ad-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px dashed var(--stroke);
|
||||
background: rgba(61, 214, 198, 0.04);
|
||||
}
|
||||
|
||||
.shop-ad-panel h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.shop-ad-panel .muted {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.shop-hero {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.inv-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -2667,6 +3112,17 @@ a.achievement-badge:hover .achievement-badge-name {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.inv-section-head {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.inv-section-head h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inv-toolbar-left {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -2786,6 +3242,13 @@ a.achievement-badge:hover .achievement-badge-name {
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
|
||||
.pack-credits {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
color: var(--gold);
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
|
||||
.ad-panel {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@@ -3289,6 +3752,20 @@ a.achievement-badge:hover .achievement-badge-name {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.prestige-skill-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.prestige-skill-actions .btn {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.45rem 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.prestige-history {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
|
||||
@@ -8,6 +8,8 @@ import AdminItems from '../admin/AdminItems';
|
||||
import AdminCategories from '../admin/AdminCategories';
|
||||
import AdminDrops from '../admin/AdminDrops';
|
||||
import AdminPlayers from '../admin/AdminPlayers';
|
||||
import AdminPrestigeServer from '../admin/AdminPrestigeServer';
|
||||
import AdminShop from '../admin/AdminShop';
|
||||
|
||||
function AdminLogin() {
|
||||
const { adminLogin } = useAuth();
|
||||
@@ -74,6 +76,8 @@ export default function Admin() {
|
||||
<Route path="items" element={<AdminItems />} />
|
||||
<Route path="categories" element={<AdminCategories />} />
|
||||
<Route path="drops" element={<AdminDrops />} />
|
||||
<Route path="prestige-server" element={<AdminPrestigeServer />} />
|
||||
<Route path="shop" element={<AdminShop />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
+46
-203
@@ -1,43 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import CaseReel from '../components/CaseReel';
|
||||
import { useCountdown } from '../hooks/useCountdown';
|
||||
import { rarityColor } from '../rarities';
|
||||
import BattleLiveCard from '../components/BattleLiveCard';
|
||||
import { getSocket } from '../socket';
|
||||
|
||||
const TOP_ITEMS = 5;
|
||||
|
||||
function waitingDuelRooms(open) {
|
||||
return (open || []).filter(
|
||||
(r) =>
|
||||
r.status === 'open' &&
|
||||
((r.playerCount ?? r.players?.length ?? 0) > 0 || (r.deposits?.length ?? 0) > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function depositsToReelItems(deposits) {
|
||||
return (deposits || [])
|
||||
.filter((d) => d.item)
|
||||
.map((d) => ({
|
||||
...d.item,
|
||||
id: d.id,
|
||||
ownerUsername: d.username,
|
||||
ownerAvatarUrl: d.avatarUrl || '',
|
||||
}));
|
||||
}
|
||||
|
||||
export default function BattleHub() {
|
||||
const { user, loading } = useAuth();
|
||||
const [round, setRound] = useState(null);
|
||||
const [duelOpen, setDuelOpen] = useState([]);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const [spinActive, setSpinActive] = useState(false);
|
||||
const [spinDeposits, setSpinDeposits] = useState([]);
|
||||
const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
|
||||
const [lastWinner, setLastWinner] = useState(null);
|
||||
const [revealWinner, setRevealWinner] = useState(false);
|
||||
const [battles, setBattles] = useState([]);
|
||||
const [spinById, setSpinById] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
@@ -45,103 +16,53 @@ export default function BattleHub() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
|
||||
if (cancelled) return;
|
||||
setRound(jp.round);
|
||||
setDuelOpen(duels.open || []);
|
||||
if (jp.round?.status === 'spinning') {
|
||||
setSpinDeposits(jp.round.deposits || []);
|
||||
setSpinActive(true);
|
||||
setSpinning(true);
|
||||
setReelWinnerItemId(jp.round.reelWinnerItemId || null);
|
||||
setLastWinner(jp.round.winnerUsername || null);
|
||||
}
|
||||
const data = await api.jackpot();
|
||||
if (!cancelled) setBattles(data.battles || []);
|
||||
} catch {
|
||||
/* hub still usable without live preview */
|
||||
/* hub still usable */
|
||||
}
|
||||
})();
|
||||
|
||||
const socket = getSocket();
|
||||
const onState = (state) => setRound(state);
|
||||
const onSpin = (state) => {
|
||||
setRound(state);
|
||||
setSpinDeposits(state.deposits || []);
|
||||
setSpinActive(true);
|
||||
setSpinning(true);
|
||||
setRevealWinner(false);
|
||||
setReelWinnerItemId(state.reelWinnerItemId);
|
||||
setLastWinner(state.winnerUsername);
|
||||
const onList = (payload) => {
|
||||
setBattles(payload.battles || []);
|
||||
};
|
||||
const onSpin = (state) => {
|
||||
if (!state?.id) return;
|
||||
setSpinById((prev) => ({ ...prev, [state.id]: state }));
|
||||
setBattles((prev) => {
|
||||
const idx = prev.findIndex((b) => b.id === state.id);
|
||||
if (idx < 0) return [...prev, state];
|
||||
const next = [...prev];
|
||||
next[idx] = state;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const onResult = (state) => {
|
||||
const id = state?.previousRoundId || state?.id;
|
||||
if (!id) return;
|
||||
setSpinById((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const onResult = (state) => setRound(state);
|
||||
const onDuels = (list) => setDuelOpen(list.open || []);
|
||||
|
||||
socket.emit('jackpot:subscribe');
|
||||
socket.emit('duels:subscribe');
|
||||
socket.on('jackpot:state', onState);
|
||||
socket.emit('jackpots:subscribe');
|
||||
socket.on('jackpots:list', onList);
|
||||
socket.on('jackpot:spin', onSpin);
|
||||
socket.on('jackpot:result', onResult);
|
||||
socket.on('duels:list', onDuels);
|
||||
socket.on('connect', () => {
|
||||
socket.emit('jackpot:subscribe');
|
||||
socket.emit('duels:subscribe');
|
||||
});
|
||||
socket.on('connect', () => socket.emit('jackpots:subscribe'));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socket.emit('jackpot:unsubscribe');
|
||||
socket.emit('duels:unsubscribe');
|
||||
socket.off('jackpot:state', onState);
|
||||
socket.emit('jackpots:unsubscribe');
|
||||
socket.off('jackpots:list', onList);
|
||||
socket.off('jackpot:spin', onSpin);
|
||||
socket.off('jackpot:result', onResult);
|
||||
socket.off('duels:list', onDuels);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealWinner) return undefined;
|
||||
const t = setTimeout(() => {
|
||||
setSpinActive(false);
|
||||
setRevealWinner(false);
|
||||
}, 4000);
|
||||
return () => clearTimeout(t);
|
||||
}, [revealWinner]);
|
||||
|
||||
const topItems = useMemo(() => {
|
||||
const deposits = [...(round?.deposits || [])].filter((d) => d.item);
|
||||
deposits.sort((a, b) => (b.valueCents || 0) - (a.valueCents || 0));
|
||||
return deposits.slice(0, TOP_ITEMS);
|
||||
}, [round]);
|
||||
|
||||
const potItems = useMemo(
|
||||
() => depositsToReelItems(spinActive && spinDeposits.length ? spinDeposits : round?.deposits),
|
||||
[round, spinDeposits, spinActive]
|
||||
);
|
||||
|
||||
const showReel = spinActive && potItems.length > 0;
|
||||
const waitingRooms = useMemo(() => waitingDuelRooms(duelOpen), [duelOpen]);
|
||||
const waitingRoomCount = waitingRooms.length;
|
||||
|
||||
const liveTimerEndsAt =
|
||||
round?.status === 'open' && round?.endsAt && !round?.waitingForPlayers && !spinning
|
||||
? round.endsAt
|
||||
: null;
|
||||
const { label: liveTimerLabel } = useCountdown(liveTimerEndsAt);
|
||||
|
||||
const playerCount = round?.players?.length ?? 0;
|
||||
const potValue = round?.totalValue ?? 0;
|
||||
const itemCount = round?.deposits?.length ?? 0;
|
||||
const statusLabel = !round
|
||||
? '…'
|
||||
: round.status === 'spinning' || spinning
|
||||
? 'Spinning'
|
||||
: round.waitingForPlayers
|
||||
? 'Waiting for players'
|
||||
: round.status === 'open'
|
||||
? liveTimerEndsAt
|
||||
? `Live ${liveTimerLabel}`
|
||||
: 'Live'
|
||||
: round.status;
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
@@ -151,106 +72,28 @@ export default function BattleHub() {
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Battle</h1>
|
||||
<p className="muted">Multiplayer item gambling modes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mode-grid">
|
||||
<Link to="/battle/multi" className="mode-card mode-card-live">
|
||||
<Link to="/battle/create" className="mode-card mode-card-wide">
|
||||
<div className="mode-card-top">
|
||||
<h2>MultiPlayers Case</h2>
|
||||
<span className={`mode-status${round?.status === 'spinning' || spinning ? ' spinning' : ''}`}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<h2>Create a Battle</h2>
|
||||
</div>
|
||||
<p className="muted mode-blurb">
|
||||
Join the live pot. Bet up to {round?.maxItemsPerPlayer || 3} items — chance scales with
|
||||
value.
|
||||
Bet items to open a new pot. Others can join — chance scales with value. Timer starts at
|
||||
2 players.
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<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>
|
||||
{battles.map((b) => (
|
||||
<BattleLiveCard key={b.id} battle={b} spinEvent={spinById[b.id]} />
|
||||
))}
|
||||
{itemCount > topItems.length && (
|
||||
<span className="mode-pot-more muted">+{itemCount - topItems.length}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted mode-pot-empty">No items in the pot yet — be the first.</p>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<Link to="/battle/1vx" className="mode-card">
|
||||
<div className="mode-card-top">
|
||||
<h2>1vX Case</h2>
|
||||
{waitingRoomCount > 0 && (
|
||||
<span className="mode-status">
|
||||
{waitingRoomCount} waiting
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted mode-blurb">
|
||||
Create or join custom rooms. Set player/item limits and min value. Spectate live duels.
|
||||
{battles.length === 0 && (
|
||||
<p className="muted mode-pot-empty" style={{ gridColumn: '1 / -1' }}>
|
||||
No open battles — create one to get started.
|
||||
</p>
|
||||
<div className="mode-pot-stats">
|
||||
<div>
|
||||
<span className="muted">Open rooms</span>
|
||||
<strong>{waitingRoomCount}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="mode-card stub">
|
||||
<h2>Coinflip</h2>
|
||||
<p className="muted">Classic 1v1 item flip. Coming soon.</p>
|
||||
<span className="badge">Soon</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits, formatChance } from '../api';
|
||||
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
import { api, formatCredits, formatChance, compareCents } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ItemTile, { itemValueCents } from '../components/ItemTile';
|
||||
import CaseReel from '../components/CaseReel';
|
||||
@@ -8,7 +8,13 @@ import { useCountdown } from '../hooks/useCountdown';
|
||||
import { onInventoryChanged } from '../inventoryEvents';
|
||||
import { getSocket } from '../socket';
|
||||
|
||||
export default function MultiPlayersPage() {
|
||||
/**
|
||||
* Create mode (`/battle/create`) or join/spectate (`/battle/:id`).
|
||||
*/
|
||||
export default function BattleRoomPage({ create = false }) {
|
||||
const { id: paramId } = useParams();
|
||||
const battleId = create ? null : Number(paramId);
|
||||
const navigate = useNavigate();
|
||||
const { user, loading, refresh } = useAuth();
|
||||
const [round, setRound] = useState(null);
|
||||
const [inventory, setInventory] = useState([]);
|
||||
@@ -37,8 +43,8 @@ export default function MultiPlayersPage() {
|
||||
() =>
|
||||
[...inventory].sort(
|
||||
(a, b) =>
|
||||
itemValueCents(b.item) - itemValueCents(a.item) ||
|
||||
(b.valueCents ?? 0) - (a.valueCents ?? 0)
|
||||
compareCents(itemValueCents(b.item), itemValueCents(a.item)) ||
|
||||
compareCents(b.valueCents, a.valueCents)
|
||||
),
|
||||
[inventory]
|
||||
);
|
||||
@@ -46,23 +52,56 @@ export default function MultiPlayersPage() {
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [jp] = await Promise.all([api.jackpot(), loadInventory()]);
|
||||
if (!cancelled) setRound(jp.round);
|
||||
await loadInventory();
|
||||
if (create) {
|
||||
if (!cancelled) setRound(null);
|
||||
return;
|
||||
}
|
||||
if (!battleId) {
|
||||
if (!cancelled) setError('Invalid battle');
|
||||
return;
|
||||
}
|
||||
const data = await api.jackpotRound(battleId);
|
||||
if (cancelled) return;
|
||||
setRound(data.round);
|
||||
if (data.round?.status === 'spinning') {
|
||||
setSpinDeposits(data.round.deposits || []);
|
||||
setSpinActive(true);
|
||||
setSpinning(true);
|
||||
setLastWinner(data.round.winnerUsername || null);
|
||||
}
|
||||
if (data.round?.status === 'resolved' || data.round?.status === 'cancelled') {
|
||||
setLastWinner(data.round.winnerUsername || null);
|
||||
if (data.round.status === 'resolved') setRevealWinner(true);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
|
||||
if (create || !battleId) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const socket = getSocket();
|
||||
const onState = (state) => {
|
||||
if (state?.id && state.id !== battleId) return;
|
||||
setRound(state);
|
||||
if (state?.status === 'cancelled') {
|
||||
navigate('/battle', { replace: true });
|
||||
return;
|
||||
}
|
||||
if (state?.status === 'open') {
|
||||
loadInventory().catch(() => {});
|
||||
}
|
||||
};
|
||||
const onSpin = (state) => {
|
||||
if (state?.id && state.id !== battleId) return;
|
||||
setRound(state);
|
||||
setSpinDeposits(state.deposits || []);
|
||||
setSpinActive(true);
|
||||
@@ -72,25 +111,30 @@ export default function MultiPlayersPage() {
|
||||
setLastWinner(state.winnerUsername);
|
||||
};
|
||||
const onResult = (state) => {
|
||||
if (state?.previousRoundId && state.previousRoundId !== battleId) return;
|
||||
if (state?.id && state.id !== battleId && !state.previousRoundId) return;
|
||||
setRound(state);
|
||||
if (state.winnerUsername) setLastWinner(state.winnerUsername);
|
||||
setRevealWinner(true);
|
||||
setSpinning(false);
|
||||
loadInventory().catch(() => {});
|
||||
refresh().catch(() => {});
|
||||
};
|
||||
|
||||
socket.emit('jackpot:subscribe');
|
||||
socket.emit('jackpot:subscribe', battleId);
|
||||
socket.on('jackpot:state', onState);
|
||||
socket.on('jackpot:spin', onSpin);
|
||||
socket.on('jackpot:result', onResult);
|
||||
socket.on('connect', () => socket.emit('jackpot:subscribe'));
|
||||
socket.on('connect', () => socket.emit('jackpot:subscribe', battleId));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
socket.emit('jackpot:unsubscribe');
|
||||
socket.emit('jackpot:unsubscribe', battleId);
|
||||
socket.off('jackpot:state', onState);
|
||||
socket.off('jackpot:spin', onSpin);
|
||||
socket.off('jackpot:result', onResult);
|
||||
};
|
||||
}, [user, loadInventory, refresh]);
|
||||
}, [user, create, battleId, loadInventory, refresh, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
@@ -106,6 +150,8 @@ export default function MultiPlayersPage() {
|
||||
[round, user]
|
||||
);
|
||||
const mySlotsLeft = Math.max(0, maxItems - myDeposits.length);
|
||||
const canBet =
|
||||
create || round?.status === 'open' || (!round && create);
|
||||
|
||||
const potItems = useMemo(() => {
|
||||
const source = spinActive && spinDeposits.length ? spinDeposits : round?.deposits || [];
|
||||
@@ -140,7 +186,14 @@ export default function MultiPlayersPage() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.jackpotBet(selected);
|
||||
if (create && !round) {
|
||||
const data = await api.jackpotCreateBet(selected);
|
||||
setSelected([]);
|
||||
await loadInventory();
|
||||
navigate(`/battle/${data.round.id}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
const data = await api.jackpotBet(round.id, selected);
|
||||
setRound(data.round);
|
||||
setSelected([]);
|
||||
await loadInventory();
|
||||
@@ -152,10 +205,15 @@ export default function MultiPlayersPage() {
|
||||
};
|
||||
|
||||
const withdraw = async (inventoryItemIds) => {
|
||||
if (!round?.id) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.jackpotWithdraw(inventoryItemIds);
|
||||
const data = await api.jackpotWithdraw(round.id, inventoryItemIds);
|
||||
if (data.round?.status === 'cancelled') {
|
||||
navigate('/battle', { replace: true });
|
||||
return;
|
||||
}
|
||||
setRound(data.round);
|
||||
await loadInventory();
|
||||
} catch (err) {
|
||||
@@ -169,15 +227,22 @@ export default function MultiPlayersPage() {
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
|
||||
const title = create && !round ? 'Create a Battle' : round?.name || `Battle ${battleId || ''}`;
|
||||
const isOpen = create || round?.status === 'open';
|
||||
|
||||
return (
|
||||
<div className="bet-page">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>MultiPlayers Case</h1>
|
||||
<p className="muted">Live shared pot — win chance = your value / total pot.</p>
|
||||
<h1>{title}</h1>
|
||||
<p className="muted">
|
||||
{create && !round
|
||||
? 'Bet items to open a new battle. Chance = your value / total pot.'
|
||||
: 'Win chance = your value / total pot.'}
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/battle" className="btn secondary">
|
||||
Modes
|
||||
Back
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -185,25 +250,27 @@ export default function MultiPlayersPage() {
|
||||
|
||||
<div className="jackpot-status">
|
||||
<div className="timer-block">
|
||||
<span className="muted">Round</span>
|
||||
<span className="muted">Battle</span>
|
||||
<strong>#{round?.id || '—'}</strong>
|
||||
<span className={`status-pill ${round?.status || ''}`}>{round?.status || '…'}</span>
|
||||
<span className={`status-pill ${round?.status || ''}`}>
|
||||
{round?.status || (create ? 'new' : '…')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="timer-block">
|
||||
<span className="muted">Timer</span>
|
||||
<strong className="timer-digits">
|
||||
{round?.status === 'spinning' || spinning
|
||||
? 'SPIN'
|
||||
: round?.waitingForPlayers
|
||||
: round?.waitingForPlayers || (create && !round)
|
||||
? 'WAIT'
|
||||
: round?.status === 'open' && round?.endsAt
|
||||
? timerLabel
|
||||
: '—'}
|
||||
</strong>
|
||||
{round?.waitingForPlayers && round?.status === 'open' && (
|
||||
{((round?.waitingForPlayers && round?.status === 'open') || (create && !round)) && (
|
||||
<span className="muted">
|
||||
Need {round.minPlayers || 2} players ({round.players?.length || 0}/
|
||||
{round.minPlayers || 2})
|
||||
Need {round?.minPlayers || 2} players ({round?.players?.length || 0}/
|
||||
{round?.minPlayers || 2})
|
||||
</span>
|
||||
)}
|
||||
{round?.status === 'open' &&
|
||||
@@ -289,7 +356,9 @@ export default function MultiPlayersPage() {
|
||||
{myDeposits.length > 0 && round?.status === 'open' && (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<h2>Your bets ({myDeposits.length}/{maxItems})</h2>
|
||||
<h2>
|
||||
Your bets ({myDeposits.length}/{maxItems})
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
@@ -302,11 +371,11 @@ export default function MultiPlayersPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{round?.status === 'open' && (
|
||||
{isOpen && canBet && (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Bet from inventory</h2>
|
||||
<h2>{create && !round ? 'Bet to create' : 'Bet from inventory'}</h2>
|
||||
<p className="muted">
|
||||
Select up to {mySlotsLeft} more item{mySlotsLeft === 1 ? '' : 's'}.
|
||||
</p>
|
||||
@@ -317,7 +386,7 @@ export default function MultiPlayersPage() {
|
||||
disabled={busy || !selected.length || mySlotsLeft === 0}
|
||||
onClick={placeBet}
|
||||
>
|
||||
Place bet
|
||||
{create && !round ? 'Create battle' : 'Place bet'}
|
||||
</button>
|
||||
</div>
|
||||
{inventoryByValue.length === 0 ? (
|
||||
@@ -331,8 +400,12 @@ export default function MultiPlayersPage() {
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
favorite={inv.favorite}
|
||||
staked={inv.staked}
|
||||
selected={selected.includes(inv.id)}
|
||||
disabled={mySlotsLeft === 0 && !selected.includes(inv.id)}
|
||||
disabled={
|
||||
inv.staked ||
|
||||
(mySlotsLeft === 0 && !selected.includes(inv.id))
|
||||
}
|
||||
onClick={() => toggleSelect(inv.id)}
|
||||
/>
|
||||
))}
|
||||
+360
-112
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
import { api, formatCredits, compareCents, toCentsBigInt } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import CaseReel from '../components/CaseReel';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
import ItemTile, { itemValueCents } from '../components/ItemTile';
|
||||
import { notifyInventoryChanged } from '../inventoryEvents';
|
||||
import { rarityColor } from '../rarities';
|
||||
import { loadCaseOpenQty, resolveOpenQty, saveCaseOpenQty } from '../userPrefs';
|
||||
|
||||
const DEFAULT_MAX_OPENS = 10;
|
||||
/** Cap simultaneous reel animations to avoid lag on large multi-opens. */
|
||||
const MAX_VISIBLE_REELS = 5;
|
||||
|
||||
/** 1–5, then steps of 5 (10, 15…), plus max if not already listed. */
|
||||
function buildOpenQuantityOptions(maxOpens) {
|
||||
@@ -20,26 +22,69 @@ function buildOpenQuantityOptions(maxOpens) {
|
||||
return options;
|
||||
}
|
||||
|
||||
function dropValue(item) {
|
||||
return itemValueCents(item);
|
||||
}
|
||||
|
||||
/** Best drops first (by instance value), capped for reel display. */
|
||||
function pickShowcaseDrops(items, limit = MAX_VISIBLE_REELS) {
|
||||
return [...items]
|
||||
.sort((a, b) => compareCents(dropValue(b), dropValue(a)))
|
||||
.slice(0, Math.max(1, limit));
|
||||
}
|
||||
|
||||
/** Best drop by valueCents (BigInt-safe). */
|
||||
function pickBestDrop(items) {
|
||||
if (!items?.length) return null;
|
||||
let best = items[0];
|
||||
let bestVal = dropValue(best);
|
||||
for (let i = 1; i < items.length; i += 1) {
|
||||
const it = items[i];
|
||||
const v = dropValue(it);
|
||||
if (v > bestVal) {
|
||||
best = it;
|
||||
bestVal = v;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export default function CasePage() {
|
||||
const { id } = useParams();
|
||||
const { user, loading, updateBalance } = useAuth();
|
||||
const [crate, setCrate] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [keyMessage, setKeyMessage] = useState('');
|
||||
const [catalogMessage, setCatalogMessage] = useState('');
|
||||
const [openBonusMessage, setOpenBonusMessage] = useState('');
|
||||
const [animReduction, setAnimReduction] = useState(0);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [spins, setSpins] = useState([]);
|
||||
const [allDrops, setAllDrops] = useState([]);
|
||||
const [caseName, setCaseName] = useState('');
|
||||
/** True while paused on discarded winners before the respin reels restart. */
|
||||
const [respinRestarting, setRespinRestarting] = useState(false);
|
||||
const announcedRef = useRef(false);
|
||||
const preAnnouncedRef = useRef(false);
|
||||
const finishingRef = useRef(false);
|
||||
const pendingAnnounceRef = useRef({ ids: [], caseName: '' });
|
||||
const pendingAnnounceRef = useRef({ ids: [], caseName: '', respin: false });
|
||||
const pendingAutoSellRef = useRef([]);
|
||||
/** Auto-sell ids from the discarded first roll — applied when that reel finishes. */
|
||||
const pendingPreRespinAutoSellRef = useRef([]);
|
||||
/** When set, first reel pass is a discarded roll; then we restart with these final items. */
|
||||
const pendingRespinFinalRef = useRef(null);
|
||||
/** Snapshot of first-roll drops for feed (best item announced when first reels finish). */
|
||||
const pendingPreRespinAnnounceRef = useRef(null);
|
||||
/**
|
||||
* Respin animation phase (kept out of setState updaters — must be Strict-Mode safe):
|
||||
* null | 'pre' | 'restarting' | 'final'
|
||||
*/
|
||||
const respinPhaseRef = useRef(null);
|
||||
const quantityRef = useRef(1);
|
||||
const caseIdRef = useRef(null);
|
||||
const prefReadyRef = useRef(false);
|
||||
/** Tracks case maxOpens so we can bump qty when a key unlocks. */
|
||||
const prevMaxOpensRef = useRef(null);
|
||||
|
||||
const keyProgress = crate?.keyProgress ?? null;
|
||||
const maxOpens = keyProgress?.maxOpens ?? DEFAULT_MAX_OPENS;
|
||||
@@ -61,10 +106,15 @@ export default function CasePage() {
|
||||
const announcePendingDrop = async (useKeepalive = false) => {
|
||||
const ids = pendingAnnounceRef.current.ids.filter(Boolean);
|
||||
const announceCaseName = pendingAnnounceRef.current.caseName || caseName || crate?.name || '';
|
||||
const respin = Boolean(pendingAnnounceRef.current.respin);
|
||||
if (!ids.length || announcedRef.current) return;
|
||||
|
||||
announcedRef.current = true;
|
||||
const body = JSON.stringify({ inventoryItemIds: ids, caseName: announceCaseName });
|
||||
const body = JSON.stringify({
|
||||
inventoryItemIds: ids,
|
||||
caseName: announceCaseName,
|
||||
respin,
|
||||
});
|
||||
|
||||
if (useKeepalive) {
|
||||
fetch('/api/feed/announce', {
|
||||
@@ -80,15 +130,51 @@ export default function CasePage() {
|
||||
}
|
||||
|
||||
try {
|
||||
await api.feedAnnounce(ids, announceCaseName);
|
||||
await api.feedAnnounce(ids, announceCaseName, { respin });
|
||||
} catch {
|
||||
announcedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const announcePreRespinDrop = async () => {
|
||||
const pending = pendingPreRespinAnnounceRef.current;
|
||||
if (!pending?.items?.length || preAnnouncedRef.current) return;
|
||||
preAnnouncedRef.current = true;
|
||||
pendingPreRespinAnnounceRef.current = null;
|
||||
const best = pickBestDrop(pending.items);
|
||||
if (!best) {
|
||||
preAnnouncedRef.current = false;
|
||||
return;
|
||||
}
|
||||
const count = pending.items.filter((it) => it.id === best.id).length;
|
||||
try {
|
||||
await api.feedAnnounce([], pending.caseName || caseName || crate?.name || '', {
|
||||
snapshotBest: {
|
||||
id: best.id,
|
||||
name: best.name,
|
||||
imageUrl: best.imageUrl,
|
||||
rarity: best.rarity,
|
||||
valueCents: best.valueCents ?? best.marketValue,
|
||||
wear: best.wear,
|
||||
floatValue: best.floatValue,
|
||||
paintSeed: best.paintSeed,
|
||||
},
|
||||
openCount: pending.items.length,
|
||||
count,
|
||||
respin: false,
|
||||
});
|
||||
} catch {
|
||||
preAnnouncedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyPendingAutoSellKeepalive = () => {
|
||||
const ids = pendingAutoSellRef.current.filter(Boolean);
|
||||
const ids = [
|
||||
...pendingPreRespinAutoSellRef.current,
|
||||
...pendingAutoSellRef.current,
|
||||
].filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
pendingPreRespinAutoSellRef.current = [];
|
||||
pendingAutoSellRef.current = [];
|
||||
fetch('/api/inventory/auto-sell/apply', {
|
||||
method: 'POST',
|
||||
@@ -99,28 +185,11 @@ export default function CasePage() {
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const finalizeOpens = async (completedSpins, announceCaseName) => {
|
||||
if (finishingRef.current) return;
|
||||
finishingRef.current = true;
|
||||
|
||||
const ids = completedSpins.map((s) => s.item?.inventoryId).filter(Boolean);
|
||||
pendingAnnounceRef.current = { ids, caseName: announceCaseName };
|
||||
|
||||
try {
|
||||
await announcePendingDrop(false);
|
||||
|
||||
const toSell = completedSpins
|
||||
.filter((s) => s.item?.willAutoSell)
|
||||
.map((s) => s.item.inventoryId)
|
||||
.filter(Boolean);
|
||||
|
||||
pendingAutoSellRef.current = [];
|
||||
|
||||
if (toSell.length) {
|
||||
const data = await api.applyPendingAutoSell(toSell);
|
||||
updateBalance(data.user.balance);
|
||||
notifyInventoryChanged();
|
||||
const soldSet = new Set(data.soldIds);
|
||||
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)
|
||||
@@ -129,12 +198,66 @@ export default function CasePage() {
|
||||
item: {
|
||||
...s.item,
|
||||
autoSold: true,
|
||||
soldForCents: s.item.valueCents,
|
||||
soldForCents:
|
||||
payoutById.get(s.item.inventoryId) ?? s.item.valueCents,
|
||||
},
|
||||
}
|
||||
: s
|
||||
)
|
||||
);
|
||||
setAllDrops((curr) =>
|
||||
curr.map((item) =>
|
||||
soldSet.has(item.inventoryId)
|
||||
? {
|
||||
...item,
|
||||
autoSold: true,
|
||||
soldForCents: payoutById.get(item.inventoryId) ?? item.valueCents,
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/** Credit auto-sell for the discarded first roll once its reels finish. */
|
||||
const applyPreRespinAutoSell = async () => {
|
||||
const toSell = [...pendingPreRespinAutoSellRef.current];
|
||||
pendingPreRespinAutoSellRef.current = [];
|
||||
if (!toSell.length) return;
|
||||
try {
|
||||
const data = await api.applyPendingAutoSell(toSell);
|
||||
updateBalance(data.user.balance);
|
||||
notifyInventoryChanged();
|
||||
if (data.soldIds?.length) markSpinsAutoSold(data.soldIds, data.sold);
|
||||
} catch (err) {
|
||||
// Keep ids so unload keepalive can still flush if the request failed mid-respin.
|
||||
pendingPreRespinAutoSellRef.current = toSell;
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const finalizeOpens = async (announceCaseName) => {
|
||||
if (finishingRef.current) return;
|
||||
finishingRef.current = true;
|
||||
|
||||
// Keep full-batch ids already stored in pending refs (not only showcase reels)
|
||||
if (announceCaseName) {
|
||||
pendingAnnounceRef.current = {
|
||||
...pendingAnnounceRef.current,
|
||||
caseName: announceCaseName,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await announcePendingDrop(false);
|
||||
|
||||
const toSell = [...pendingAutoSellRef.current];
|
||||
pendingAutoSellRef.current = [];
|
||||
|
||||
if (toSell.length) {
|
||||
const data = await api.applyPendingAutoSell(toSell);
|
||||
updateBalance(data.user.balance);
|
||||
notifyInventoryChanged();
|
||||
if (data.soldIds?.length) markSpinsAutoSold(data.soldIds, data.sold);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -147,6 +270,7 @@ export default function CasePage() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
prefReadyRef.current = false;
|
||||
prevMaxOpensRef.current = null;
|
||||
caseIdRef.current = null;
|
||||
setCrate(null);
|
||||
(async () => {
|
||||
@@ -167,9 +291,16 @@ export default function CasePage() {
|
||||
|
||||
const maxAffordable = useMemo(() => {
|
||||
if (!crate || !user) return 1;
|
||||
if (crate.price <= 0) return maxOpens;
|
||||
const affordable = Math.floor(user.balance / crate.price);
|
||||
return Math.min(maxOpens, Math.max(affordable, 0));
|
||||
try {
|
||||
const price = BigInt(String(crate.price ?? 0).split('.')[0] || '0');
|
||||
if (price <= 0n) return maxOpens;
|
||||
const bal = BigInt(String(user.balance ?? 0).split('.')[0] || '0');
|
||||
const affordable = bal / price;
|
||||
const n = affordable > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(affordable);
|
||||
return Math.min(maxOpens, Math.max(n, 0));
|
||||
} catch {
|
||||
return 1;
|
||||
}
|
||||
}, [crate, user, maxOpens]);
|
||||
|
||||
const openOptions = useMemo(() => buildOpenQuantityOptions(maxOpens), [maxOpens]);
|
||||
@@ -182,18 +313,28 @@ export default function CasePage() {
|
||||
|
||||
if (!prefReadyRef.current) {
|
||||
prefReadyRef.current = true;
|
||||
prevMaxOpensRef.current = maxOpens;
|
||||
setQuantity(resolved);
|
||||
return;
|
||||
}
|
||||
|
||||
const prevMax = prevMaxOpensRef.current;
|
||||
prevMaxOpensRef.current = maxOpens;
|
||||
// New key unlocked → jump select to highest affordable tier (new max if credits allow).
|
||||
const unlockedHigher = prevMax != null && maxOpens > prevMax;
|
||||
|
||||
setQuantity((q) => {
|
||||
const allowed = openOptions.filter((n) => n <= Math.max(1, maxAffordable));
|
||||
if (!allowed.length) return 1;
|
||||
// Jump to newly unlocked max only when the balance can cover it.
|
||||
if (unlockedHigher && maxOpens <= Math.max(1, maxAffordable)) {
|
||||
return maxOpens;
|
||||
}
|
||||
if (allowed.includes(q)) return q;
|
||||
const below = allowed.filter((n) => n <= q);
|
||||
return below.length ? below[below.length - 1] : allowed[0];
|
||||
});
|
||||
}, [crate, maxAffordable, openOptions, user?.id]);
|
||||
}, [crate, maxAffordable, openOptions, user?.id, maxOpens]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefReadyRef.current || !user?.id || crate?.id == null) return;
|
||||
@@ -202,6 +343,37 @@ export default function CasePage() {
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Flush first-spin feed if page closes mid-respin
|
||||
const pre = pendingPreRespinAnnounceRef.current;
|
||||
if (pre?.items?.length && !preAnnouncedRef.current) {
|
||||
preAnnouncedRef.current = true;
|
||||
const best = pickBestDrop(pre.items);
|
||||
if (best) {
|
||||
const count = pre.items.filter((it) => it.id === best.id).length;
|
||||
fetch('/api/feed/announce', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
snapshotBest: {
|
||||
id: best.id,
|
||||
name: best.name,
|
||||
imageUrl: best.imageUrl,
|
||||
rarity: best.rarity,
|
||||
valueCents: best.valueCents ?? best.marketValue,
|
||||
wear: best.wear,
|
||||
floatValue: best.floatValue,
|
||||
paintSeed: best.paintSeed,
|
||||
},
|
||||
openCount: pre.items.length,
|
||||
count,
|
||||
caseName: pre.caseName || '',
|
||||
respin: false,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
announcePendingDrop(true);
|
||||
applyPendingAutoSellKeepalive();
|
||||
};
|
||||
@@ -213,7 +385,7 @@ export default function CasePage() {
|
||||
const ca = Number(a.chance ?? a.dropRate ?? 0);
|
||||
const cb = Number(b.chance ?? b.dropRate ?? 0);
|
||||
if (ca !== cb) return ca - cb;
|
||||
return (b.marketValue || 0) - (a.marketValue || 0);
|
||||
return compareCents(b.marketValue, a.marketValue);
|
||||
});
|
||||
}, [crate]);
|
||||
|
||||
@@ -225,31 +397,57 @@ export default function CasePage() {
|
||||
);
|
||||
}, [keyProgress]);
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
const totalCost = useMemo(() => {
|
||||
if (!crate) return '0';
|
||||
return (toCentsBigInt(crate.price) * BigInt(quantity)).toString();
|
||||
}, [crate, quantity]);
|
||||
|
||||
const totalCost = crate ? crate.price * quantity : 0;
|
||||
const canOpen =
|
||||
crate &&
|
||||
!opening &&
|
||||
crate.items.length > 0 &&
|
||||
quantity >= 1 &&
|
||||
quantity <= maxOpens &&
|
||||
user.balance >= totalCost;
|
||||
const canOpen = useMemo(() => {
|
||||
if (!user || !crate || opening || !crate.items?.length) return false;
|
||||
if (quantity < 1 || quantity > maxOpens) return false;
|
||||
return toCentsBigInt(user.balance) >= toCentsBigInt(totalCost);
|
||||
}, [user, crate, opening, quantity, maxOpens, totalCost]);
|
||||
|
||||
const allDone = spins.length > 0 && spins.every((s) => s.done);
|
||||
const allDone =
|
||||
spins.length > 0 &&
|
||||
spins.every((s) => s.done) &&
|
||||
!respinRestarting &&
|
||||
respinPhaseRef.current !== 'pre' &&
|
||||
respinPhaseRef.current !== 'restarting';
|
||||
|
||||
const heroHidden = opening || (spins.length > 0 && !allDone);
|
||||
|
||||
const beginReelSpins = (items) => {
|
||||
setAllDrops(items);
|
||||
const showcase = pickShowcaseDrops(items, MAX_VISIBLE_REELS);
|
||||
const next = showcase.map((item, idx) => ({
|
||||
key: `${item.inventoryId || item.id || 'x'}-${idx}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
item,
|
||||
spinning: false,
|
||||
done: false,
|
||||
}));
|
||||
setSpins(next);
|
||||
requestAnimationFrame(() => {
|
||||
setSpins((prev) => prev.map((s) => ({ ...s, spinning: true })));
|
||||
});
|
||||
};
|
||||
|
||||
const openCase = async () => {
|
||||
if (!canOpen) return;
|
||||
if (!canOpen || !crate) return;
|
||||
setError('');
|
||||
setKeyMessage('');
|
||||
setCatalogMessage('');
|
||||
setOpenBonusMessage('');
|
||||
setSpins([]);
|
||||
setAllDrops([]);
|
||||
setOpening(true);
|
||||
setRespinRestarting(false);
|
||||
announcedRef.current = false;
|
||||
preAnnouncedRef.current = false;
|
||||
finishingRef.current = false;
|
||||
pendingRespinFinalRef.current = null;
|
||||
pendingPreRespinAnnounceRef.current = null;
|
||||
pendingPreRespinAutoSellRef.current = [];
|
||||
respinPhaseRef.current = null;
|
||||
|
||||
try {
|
||||
const result = await api.openCase(crate.id, quantity);
|
||||
@@ -259,10 +457,9 @@ export default function CasePage() {
|
||||
if (result.animReduction != null) {
|
||||
setAnimReduction(result.animReduction);
|
||||
}
|
||||
const bonusParts = [];
|
||||
if (result.refunded) bonusParts.push('Refund! Case cost returned');
|
||||
if (result.respin) bonusParts.push('Respin! All drops re-rolled');
|
||||
if (bonusParts.length) setOpenBonusMessage(bonusParts.join(' · '));
|
||||
if (result.refunded) {
|
||||
setOpenBonusMessage('Refund! Case cost returned');
|
||||
}
|
||||
if (result.keyProgress) {
|
||||
setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
|
||||
}
|
||||
@@ -272,19 +469,6 @@ export default function CasePage() {
|
||||
);
|
||||
}
|
||||
if (result.catalog) {
|
||||
const parts = [];
|
||||
if (result.catalog.newlyFilled?.length) {
|
||||
const n = result.catalog.newlyFilled.length;
|
||||
parts.push(
|
||||
`${n} catalog slot${n === 1 ? '' : 's'} filled`
|
||||
);
|
||||
}
|
||||
if (result.catalog.chapterJustCompleted) {
|
||||
parts.push('chapter complete · +1% luck');
|
||||
} else if (result.catalog.luckPercent > 0 && result.catalog.newlyFilled?.length) {
|
||||
parts.push(`luck +${result.catalog.luckPercent}%`);
|
||||
}
|
||||
if (parts.length) setCatalogMessage(parts.join(' · '));
|
||||
if (result.catalog.luckPercent != null) {
|
||||
setCrate((prev) =>
|
||||
prev ? { ...prev, catalogLuckPercent: result.catalog.luckPercent } : prev
|
||||
@@ -308,24 +492,34 @@ export default function CasePage() {
|
||||
setCaseName(resolvedCaseName);
|
||||
const items = result.items?.length ? result.items : result.item ? [result.item] : [];
|
||||
const inventoryIds = items.map((item) => item?.inventoryId).filter(Boolean);
|
||||
const pre = result.respin && result.preRespinItems?.length ? result.preRespinItems : null;
|
||||
|
||||
pendingAnnounceRef.current = {
|
||||
ids: inventoryIds,
|
||||
caseName: resolvedCaseName || '',
|
||||
respin: Boolean(pre),
|
||||
};
|
||||
pendingAutoSellRef.current = items
|
||||
.filter((item) => item?.willAutoSell)
|
||||
.map((item) => item.inventoryId)
|
||||
.filter(Boolean);
|
||||
const next = items.map((item, idx) => ({
|
||||
key: `${item.inventoryId || item.id}-${idx}-${Date.now()}`,
|
||||
item,
|
||||
spinning: false,
|
||||
done: false,
|
||||
}));
|
||||
setSpins(next);
|
||||
requestAnimationFrame(() => {
|
||||
setSpins((prev) => prev.map((s) => ({ ...s, spinning: true })));
|
||||
});
|
||||
|
||||
if (pre) {
|
||||
pendingPreRespinAutoSellRef.current = pre
|
||||
.filter((item) => item?.willAutoSell && item?.inventoryId)
|
||||
.map((item) => item.inventoryId);
|
||||
pendingPreRespinAnnounceRef.current = {
|
||||
items: pre,
|
||||
caseName: resolvedCaseName || '',
|
||||
};
|
||||
pendingRespinFinalRef.current = items;
|
||||
respinPhaseRef.current = 'pre';
|
||||
beginReelSpins(pre);
|
||||
} else {
|
||||
pendingPreRespinAutoSellRef.current = [];
|
||||
respinPhaseRef.current = null;
|
||||
beginReelSpins(items);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setOpening(false);
|
||||
@@ -336,48 +530,82 @@ export default function CasePage() {
|
||||
setSpins((prev) => {
|
||||
const alreadyDone = prev.find((s) => s.key === key)?.done;
|
||||
if (alreadyDone) return prev;
|
||||
|
||||
const next = prev.map((s) =>
|
||||
return prev.map((s) =>
|
||||
s.key === key ? { ...s, spinning: false, done: true } : s
|
||||
);
|
||||
|
||||
const wasComplete = prev.length > 0 && prev.every((s) => s.done);
|
||||
const nowComplete = next.length > 0 && next.every((s) => s.done);
|
||||
|
||||
if (nowComplete && !wasComplete) {
|
||||
setOpening(false);
|
||||
void finalizeOpens(next, caseName || crate?.name || '');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 1) When first spin reels finish → feed line #1, start respin pause
|
||||
// 2) When final reels finish → feed line #2 (respin) via finalizeOpens
|
||||
useEffect(() => {
|
||||
if (!spins.length || spins.some((s) => !s.done)) return;
|
||||
|
||||
const phase = respinPhaseRef.current;
|
||||
|
||||
if (phase === 'pre') {
|
||||
if (!pendingRespinFinalRef.current) return;
|
||||
// Block re-entry while we credit first-roll auto-sell, then pause before restart.
|
||||
respinPhaseRef.current = 'restarting';
|
||||
void (async () => {
|
||||
await announcePreRespinDrop();
|
||||
await applyPreRespinAutoSell();
|
||||
setRespinRestarting(true);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === 'restarting') return;
|
||||
|
||||
if ((phase === 'final' || phase === null) && opening && !finishingRef.current) {
|
||||
respinPhaseRef.current = null;
|
||||
setOpening(false);
|
||||
void finalizeOpens(caseName || crate?.name || '');
|
||||
}
|
||||
}, [spins, opening, caseName, crate?.name]);
|
||||
|
||||
// After pause, restart reels with real loot (respin feed waits until those finish)
|
||||
useEffect(() => {
|
||||
if (!respinRestarting) return;
|
||||
const finalItems = pendingRespinFinalRef.current;
|
||||
if (!finalItems) return;
|
||||
|
||||
const t = setTimeout(() => {
|
||||
pendingRespinFinalRef.current = null;
|
||||
respinPhaseRef.current = 'final';
|
||||
setOpenBonusMessage((msg) =>
|
||||
msg?.includes('Respin')
|
||||
? msg
|
||||
: msg
|
||||
? `${msg} · Respin! Re-rolling…`
|
||||
: 'Respin! Re-rolling all drops…'
|
||||
);
|
||||
beginReelSpins(finalItems);
|
||||
setRespinRestarting(false);
|
||||
}, 550);
|
||||
|
||||
return () => clearTimeout(t);
|
||||
}, [respinRestarting]);
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
|
||||
return (
|
||||
<div className="case-page">
|
||||
<Link to="/cases" className="muted">
|
||||
← Back to cases
|
||||
</Link>
|
||||
|
||||
{!crate && !error && <p className="muted">Loading case…</p>}
|
||||
{error && <div className="error">{error}</div>}
|
||||
{keyMessage && <div className="ok-msg">{keyMessage}</div>}
|
||||
{catalogMessage && <div className="ok-msg">{catalogMessage}</div>}
|
||||
{openBonusMessage && <div className="ok-msg">{openBonusMessage}</div>}
|
||||
|
||||
{crate && (
|
||||
<>
|
||||
<div
|
||||
className={`case-hero-wrap${heroHidden ? ' case-hero-wrap--hidden' : ''}`}
|
||||
aria-hidden={heroHidden}
|
||||
>
|
||||
<div className="case-hero">
|
||||
<h1>{crate.name}</h1>
|
||||
<p className="muted">
|
||||
Cost {formatCredits(crate.price)} cr each
|
||||
{crate.basePrice != null && Number(crate.basePrice) !== Number(crate.price)
|
||||
? ` (was ${formatCredits(crate.basePrice)})`
|
||||
: ''}{' '}
|
||||
· Balance {formatCredits(user.balance)} cr
|
||||
{crate.catalogLuckPercent > 0
|
||||
? ` · Catalog luck +${crate.catalogLuckPercent}%`
|
||||
: ''}
|
||||
</p>
|
||||
|
||||
{keyProgress && (
|
||||
<div className="case-key-progress">
|
||||
@@ -436,9 +664,15 @@ export default function CasePage() {
|
||||
: `Open ${quantity}× for ${formatCredits(totalCost)} cr`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{spins.length > 0 && (
|
||||
<div className={`multi-reels${spins.length > 1 ? ' many' : ''}`}>
|
||||
{allDrops.length > spins.length && (
|
||||
<p className="muted multi-reel-hint">
|
||||
Showing top {spins.length} of {allDrops.length} drops
|
||||
</p>
|
||||
)}
|
||||
{spins.map((spin, idx) => (
|
||||
<div key={spin.key} className="multi-reel-slot">
|
||||
{spins.length > 1 && (
|
||||
@@ -464,14 +698,18 @@ export default function CasePage() {
|
||||
{spin.item.rarity}
|
||||
</div>
|
||||
<div className="muted">
|
||||
{[
|
||||
spin.item.wear || null,
|
||||
`${formatCredits(spin.item.valueCents ?? spin.item.marketValue)} cr`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
{spin.item.autoSold ? (
|
||||
<>Auto-sold for {formatCredits(spin.item.soldForCents)} cr</>
|
||||
) : (
|
||||
<>
|
||||
{formatCredits(spin.item.valueCents ?? spin.item.marketValue)} cr
|
||||
{spin.item.wear ? ` · ${spin.item.wear}` : ''}
|
||||
{' '}
|
||||
· Auto-sold for {formatCredits(spin.item.soldForCents)} cr
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -480,12 +718,22 @@ export default function CasePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allDone && spins.length > 1 && (
|
||||
{allDone && allDrops.length > 1 && (
|
||||
<div className="won-banner section">
|
||||
<div className="muted">You unboxed {spins.length} items</div>
|
||||
<div className="muted">
|
||||
You unboxed {allDrops.length} items
|
||||
{allDrops.length > spins.length
|
||||
? ` · reels showed the top ${spins.length} by value`
|
||||
: ''}
|
||||
</div>
|
||||
<div className="grid" style={{ marginTop: '0.75rem' }}>
|
||||
{spins.map((s) => (
|
||||
<ItemTile key={s.key} item={s.item} />
|
||||
{[...allDrops]
|
||||
.sort((a, b) => compareCents(dropValue(b), dropValue(a)))
|
||||
.map((item) => (
|
||||
<ItemTile
|
||||
key={item.inventoryId || `${item.id}-${item.floatValue}`}
|
||||
item={item}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,12 +30,6 @@ export default function CasesPage() {
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Cases</h1>
|
||||
<p className="muted">Pick a crate to open.</p>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<div className="grid case-grid">
|
||||
{cases.map((c) => (
|
||||
@@ -44,7 +38,7 @@ export default function CasesPage() {
|
||||
{c.imageUrl ? <img src={c.imageUrl} alt="" loading="lazy" /> : '▣'}
|
||||
</div>
|
||||
<div className="case-name">{c.name}</div>
|
||||
<div className="case-meta">{formatCredits(c.price)} cr</div>
|
||||
<div className="case-meta case-price">{formatCredits(c.price)} cr</div>
|
||||
<div className="case-meta">{c.items.length} possible drops</div>
|
||||
{c.keyProgress && (
|
||||
<div className="case-meta case-key-badge">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+190
-148
@@ -1,13 +1,22 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api, formatCredits, creditsToCentsString, centsToCreditsString, compareCents, toCentsBigInt } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
import { onInventoryChanged } from '../inventoryEvents';
|
||||
import { getSocket, onSocketSessionOk } from '../socket';
|
||||
import { loadInventorySort, saveInventorySort } from '../userPrefs';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function formatVaultCountdown(ms) {
|
||||
if (ms == null || !Number.isFinite(ms)) return '—';
|
||||
const totalSec = Math.max(0, Math.ceil(ms / 1000));
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, loading, patchUser } = useAuth();
|
||||
const [inventory, setInventory] = useState([]);
|
||||
@@ -21,6 +30,7 @@ export default function Dashboard() {
|
||||
const [maxValue, setMaxValue] = useState('10');
|
||||
const [autoSellEnabled, setAutoSellEnabled] = useState(false);
|
||||
const [settingsBusy, setSettingsBusy] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
@@ -32,7 +42,7 @@ export default function Dashboard() {
|
||||
if (!user) return;
|
||||
setAutoSellEnabled(Boolean(user.autoSellEnabled));
|
||||
if (user.autoSellThresholdCents != null) {
|
||||
setMaxValue((user.autoSellThresholdCents / 100).toFixed(2).replace(/\.?0+$/, '') || '0');
|
||||
setMaxValue(centsToCreditsString(user.autoSellThresholdCents));
|
||||
}
|
||||
}, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]);
|
||||
|
||||
@@ -64,13 +74,64 @@ export default function Dashboard() {
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return undefined;
|
||||
const socket = getSocket();
|
||||
const onYield = (payload) => {
|
||||
if (payload?.user) patchUser(payload.user);
|
||||
else if (payload?.balance != null) patchUser({ balance: payload.balance });
|
||||
if (payload?.vault) setVault(payload.vault);
|
||||
const credited = toCentsBigInt(payload?.credited);
|
||||
if (credited > 0n) {
|
||||
setVaultMessage(`Vault yield +${formatCredits(payload.credited)} cr`);
|
||||
}
|
||||
load().catch(() => {});
|
||||
};
|
||||
const onSession = () => {
|
||||
load().catch(() => {});
|
||||
};
|
||||
socket.on('vault:yield', onYield);
|
||||
const offSession = onSocketSessionOk(onSession);
|
||||
return () => {
|
||||
socket.off('vault:yield', onYield);
|
||||
offSession();
|
||||
};
|
||||
}, [user, patchUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(vault?.used > 0)) return undefined;
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [vault?.used]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(vault?.used > 0) || !vault?.nextYieldAt) return undefined;
|
||||
const dueAt = new Date(vault.nextYieldAt).getTime();
|
||||
const delay = Math.max(500, dueAt - Date.now() + 800);
|
||||
const t = setTimeout(() => {
|
||||
load().catch(() => {});
|
||||
}, delay);
|
||||
return () => clearTimeout(t);
|
||||
}, [vault?.nextYieldAt, vault?.used]);
|
||||
|
||||
const vaultCountdownMs = useMemo(() => {
|
||||
if (!(vault?.used > 0) || !vault?.nextYieldAt) return null;
|
||||
return new Date(vault.nextYieldAt).getTime() - now;
|
||||
}, [vault?.used, vault?.nextYieldAt, now]);
|
||||
|
||||
const vaultTickLabel = useMemo(() => {
|
||||
const ms = vault?.tickMs ?? (vault?.mode === 'offline' ? 600_000 : 300_000);
|
||||
return `${Math.round(ms / 60_000)} min`;
|
||||
}, [vault?.tickMs, vault?.mode]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const list = [...inventory];
|
||||
const list = inventory.filter((inv) => !inv.staked);
|
||||
if (sort === 'value') {
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
|
||||
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
|
||||
list.sort((a, b) =>
|
||||
compareCents(
|
||||
b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue,
|
||||
a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue
|
||||
)
|
||||
);
|
||||
} else {
|
||||
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
|
||||
@@ -93,28 +154,23 @@ export default function Dashboard() {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
|
||||
const selectedValue = useMemo(
|
||||
() =>
|
||||
inventory
|
||||
.filter((inv) => selected.includes(inv.id))
|
||||
.reduce((sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0), 0),
|
||||
[inventory, selected]
|
||||
);
|
||||
const selectedValue = useMemo(() => {
|
||||
let sum = 0n;
|
||||
for (const inv of inventory) {
|
||||
if (!selected.includes(inv.id)) continue;
|
||||
sum += toCentsBigInt(inv.valueCents ?? inv.item?.valueCents);
|
||||
}
|
||||
return sum.toString();
|
||||
}, [inventory, selected]);
|
||||
|
||||
const thresholdCents = useMemo(() => {
|
||||
const credits = Number(maxValue);
|
||||
if (!Number.isFinite(credits) || credits < 0) return null;
|
||||
return Math.round(credits * 100);
|
||||
}, [maxValue]);
|
||||
const thresholdCents = useMemo(() => creditsToCentsString(maxValue), [maxValue]);
|
||||
|
||||
const belowThreshold = useMemo(() => {
|
||||
if (thresholdCents == null) return [];
|
||||
return inventory.filter(
|
||||
(inv) =>
|
||||
!inv.favorite &&
|
||||
!inv.staked &&
|
||||
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
||||
);
|
||||
return inventory.filter((inv) => {
|
||||
if (inv.favorite || inv.staked) return false;
|
||||
return compareCents(inv.valueCents ?? inv.item?.valueCents, thresholdCents) < 0;
|
||||
});
|
||||
}, [inventory, thresholdCents]);
|
||||
|
||||
const selectedItems = useMemo(
|
||||
@@ -134,14 +190,13 @@ export default function Dashboard() {
|
||||
const canStake = selectedUnstaked.length > 0 && selectedUnstaked.length <= vaultFree;
|
||||
const canUnstake = selectedStaked.length > 0;
|
||||
|
||||
const belowValue = useMemo(
|
||||
() =>
|
||||
belowThreshold.reduce(
|
||||
(sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0),
|
||||
0
|
||||
),
|
||||
[belowThreshold]
|
||||
);
|
||||
const belowValue = useMemo(() => {
|
||||
let sum = 0n;
|
||||
for (const inv of belowThreshold) {
|
||||
sum += toCentsBigInt(inv.valueCents ?? inv.item?.valueCents);
|
||||
}
|
||||
return sum.toString();
|
||||
}, [belowThreshold]);
|
||||
|
||||
const toggle = (id) => {
|
||||
setSelected((prev) =>
|
||||
@@ -217,27 +272,6 @@ export default function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const claimVault = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setVaultMessage('');
|
||||
try {
|
||||
const data = await api.vaultClaim();
|
||||
if (data.user) patchUser({ balance: data.user.balance });
|
||||
if (data.vault) setVault(data.vault);
|
||||
setVaultMessage(
|
||||
data.credited > 0
|
||||
? `Vault yield +${formatCredits(data.credited)} cr`
|
||||
: 'No vault yield ready yet'
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setFavorite = async (favorite) => {
|
||||
if (!selected.length) return;
|
||||
setBusy(true);
|
||||
@@ -257,7 +291,8 @@ export default function Dashboard() {
|
||||
if (!ids.length || thresholdCents == null) return;
|
||||
const favSkipped = inventory.filter(
|
||||
(inv) =>
|
||||
inv.favorite && (inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
||||
inv.favorite &&
|
||||
compareCents(inv.valueCents ?? inv.item?.valueCents, thresholdCents) < 0
|
||||
).length;
|
||||
const ok = window.confirm(
|
||||
`Sell ${ids.length} item${ids.length === 1 ? '' : 's'} below ${formatCredits(thresholdCents)} cr for ${formatCredits(belowValue)} cr?` +
|
||||
@@ -282,19 +317,18 @@ export default function Dashboard() {
|
||||
|
||||
const onAutoSellToggle = async (enabled) => {
|
||||
setAutoSellEnabled(enabled);
|
||||
const credits = Number(maxValue);
|
||||
if (!Number.isFinite(credits) || credits < 0) return;
|
||||
await saveAutoSell(enabled, credits);
|
||||
if (creditsToCentsString(maxValue) == null) return;
|
||||
await saveAutoSell(enabled, maxValue);
|
||||
};
|
||||
|
||||
const onThresholdBlur = async () => {
|
||||
const credits = Number(maxValue);
|
||||
if (!Number.isFinite(credits) || credits < 0) return;
|
||||
const savedCents = user?.autoSellThresholdCents ?? 1000;
|
||||
if (Math.round(credits * 100) === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
|
||||
const cents = creditsToCentsString(maxValue);
|
||||
if (cents == null) return;
|
||||
const savedCents = String(user?.autoSellThresholdCents ?? 1000);
|
||||
if (cents === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
|
||||
return;
|
||||
}
|
||||
await saveAutoSell(autoSellEnabled, credits);
|
||||
await saveAutoSell(autoSellEnabled, maxValue);
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading…</p>;
|
||||
@@ -303,15 +337,56 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
|
||||
|
||||
{stakedItems.length > 0 && (
|
||||
<section className="section">
|
||||
<div className="vault-panel admin-block">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Inventory</h1>
|
||||
<p className="muted">Select items to sell at market value.</p>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="inv-toolbar">
|
||||
<section className="section">
|
||||
<div className="section-head inv-section-head">
|
||||
<h1>Inventory</h1>
|
||||
<div className="inv-toolbar-left">
|
||||
<div className="sort-toggle" role="group" aria-label="Sort inventory">
|
||||
<button
|
||||
@@ -335,17 +410,35 @@ export default function Dashboard() {
|
||||
Most recent
|
||||
</button>
|
||||
</div>
|
||||
{inventory.length > 0 && (
|
||||
{sorted.length > 0 && (
|
||||
<span className="muted inv-count">
|
||||
{inventory.length} item{inventory.length === 1 ? '' : 's'}
|
||||
{sorted.length} item{sorted.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
</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 · {formatCredits(selectedValue)} cr
|
||||
{selected.length} selected ·{' '}
|
||||
<span className="item-price">
|
||||
{formatCredits(selectedValue)} cr
|
||||
</span>
|
||||
{selectedHasFavorite ? ' · includes favorites' : ''}
|
||||
</span>
|
||||
<button
|
||||
@@ -363,6 +456,9 @@ export default function Dashboard() {
|
||||
onClick={() => setFavorite(true)}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="btn-ico" aria-hidden="true">
|
||||
★
|
||||
</span>
|
||||
Favorite
|
||||
</button>
|
||||
)}
|
||||
@@ -373,22 +469,12 @@ export default function Dashboard() {
|
||||
onClick={() => setFavorite(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="btn-ico" aria-hidden="true">
|
||||
★
|
||||
</span>
|
||||
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"
|
||||
@@ -396,6 +482,9 @@ export default function Dashboard() {
|
||||
onClick={stakeSelected}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="btn-ico btn-ico-vault" aria-hidden="true">
|
||||
V
|
||||
</span>
|
||||
Stake ({selectedUnstaked.length})
|
||||
</button>
|
||||
)}
|
||||
@@ -406,16 +495,18 @@ export default function Dashboard() {
|
||||
onClick={unstakeSelected}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="btn-ico btn-ico-vault" aria-hidden="true">
|
||||
V
|
||||
</span>
|
||||
Unstake ({selectedStaked.length})
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inv-bulk-sell">
|
||||
{inventory.length > 0 && (
|
||||
{sorted.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
@@ -440,11 +531,13 @@ export default function Dashboard() {
|
||||
</label>
|
||||
<span className="muted inv-bulk-preview">
|
||||
{belowThreshold.length} item{belowThreshold.length === 1 ? '' : 's'} ·{' '}
|
||||
<span className="item-price">
|
||||
{formatCredits(belowValue)} cr
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{inventory.length === 0 && (
|
||||
{sorted.length === 0 && (
|
||||
<label className="inv-bulk-label">
|
||||
Threshold
|
||||
<input
|
||||
@@ -471,76 +564,26 @@ export default function Dashboard() {
|
||||
<span>Auto-sell</span>
|
||||
</label>
|
||||
<span className="inv-auto-sell-at muted">
|
||||
at{' '}
|
||||
{formatCredits(
|
||||
Number.isFinite(Number(maxValue)) && Number(maxValue) >= 0
|
||||
? Math.round(Number(maxValue) * 100)
|
||||
: 0
|
||||
)}{' '}
|
||||
cr
|
||||
sell below {formatCredits(creditsToCentsString(maxValue) ?? 0)} cr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
|
||||
|
||||
<div className="vault-panel admin-block">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h2>Vault</h2>
|
||||
<p className="muted">
|
||||
Stake up to {vault?.slots ?? 3} items. Online: 10% / 10 min · Offline: 1% / 10 min
|
||||
(scales with wear & rarity).
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={claimVault}
|
||||
disabled={busy || !(vault?.used > 0)}
|
||||
>
|
||||
Claim yield
|
||||
</button>
|
||||
</div>
|
||||
<div className="vault-stats">
|
||||
<span>
|
||||
Slots <strong>{vault?.used ?? 0}/{vault?.slots ?? 3}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Online / tick{' '}
|
||||
<strong className="gold">{formatCredits(vault?.onlinePerTick ?? 0)} cr</strong>
|
||||
</span>
|
||||
<span>
|
||||
Offline / tick{' '}
|
||||
<strong>{formatCredits(vault?.offlinePerTick ?? 0)} cr</strong>
|
||||
</span>
|
||||
</div>
|
||||
{stakedItems.length > 0 ? (
|
||||
<div className="grid vault-grid">
|
||||
{stakedItems.map((inv) => (
|
||||
<ItemTile
|
||||
key={`vault-${inv.id}`}
|
||||
item={inv.item}
|
||||
favorite={inv.favorite}
|
||||
staked
|
||||
selected={selected.includes(inv.id)}
|
||||
onClick={() => toggle(inv.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">No items staked — select items below and Stake.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{inventory.length === 0 ? (
|
||||
{sorted.length === 0 ? (
|
||||
<div className="empty">
|
||||
{stakedItems.length > 0 ? (
|
||||
'All items are in the vault.'
|
||||
) : (
|
||||
<>
|
||||
Empty for now —{' '}
|
||||
<Link to="/cases" style={{ color: 'var(--accent)' }}>
|
||||
open a case
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -550,7 +593,6 @@ export default function Dashboard() {
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
favorite={inv.favorite}
|
||||
staked={inv.staked}
|
||||
selected={selected.includes(inv.id)}
|
||||
onClick={() => toggle(inv.id)}
|
||||
/>
|
||||
|
||||
@@ -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 (2–10)
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={10}
|
||||
value={form.maxPlayers}
|
||||
onChange={(e) => setForm({ ...form, maxPlayers: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Max items / player (1–10)
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={form.maxItemsPerPlayer}
|
||||
onChange={(e) => setForm({ ...form, maxItemsPerPlayer: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Min item value (cr)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={form.minItemValue}
|
||||
onChange={(e) => setForm({ ...form, minItemValue: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Min players to start
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
max={10}
|
||||
value={form.minPlayersToStart}
|
||||
onChange={(e) => setForm({ ...form, minPlayersToStart: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn" disabled={busy}>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2>Open rooms</h2>
|
||||
{open.length === 0 ? (
|
||||
<div className="empty">No open rooms.</div>
|
||||
) : (
|
||||
<div className="room-list">
|
||||
{open.map((r) => (
|
||||
<Link key={r.id} to={`/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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api, formatCredits, formatLastConnection, formatPlayTime } from '../api';
|
||||
import { api, formatCredits, formatLastConnection, formatPlayTime, compareCents } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import ItemTile from '../components/ItemTile';
|
||||
import ProfileCaseStats from '../components/ProfileCaseStats';
|
||||
@@ -38,10 +38,11 @@ export default function PlayerProfilePage() {
|
||||
const sorted = useMemo(() => {
|
||||
const list = [...inventory];
|
||||
if (sort === 'value') {
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
|
||||
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
|
||||
list.sort((a, b) =>
|
||||
compareCents(
|
||||
b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue,
|
||||
a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue
|
||||
)
|
||||
);
|
||||
} else {
|
||||
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
|
||||
@@ -161,10 +162,7 @@ export default function PlayerProfilePage() {
|
||||
Cases opened <strong>{profile.casesOpened}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Jackpot wins <strong>{profile.jackpotWins}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Duel wins <strong>{profile.duelWins}</strong>
|
||||
Battle wins <strong>{profile.jackpotWins}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<ProfileAchievementBadges achievements={profile.achievements} />
|
||||
@@ -223,7 +221,12 @@ export default function PlayerProfilePage() {
|
||||
<>
|
||||
<div className="grid">
|
||||
{pageItems.map((inv) => (
|
||||
<ItemTile key={inv.id} item={inv.item} favorite={inv.favorite} />
|
||||
<ItemTile
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
favorite={inv.favorite}
|
||||
staked={inv.staked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api, formatCredits, toCentsBigInt } from '../api';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
function formatSkillValue(skill, value) {
|
||||
@@ -67,7 +67,7 @@ export default function PrestigePage() {
|
||||
patchUser(result.user);
|
||||
await refresh();
|
||||
setMessage(
|
||||
`Prestige #${result.prestige.count} · +${result.prGranted} Pr. Balance, inventory, keys & catalog reset.`
|
||||
`Prestige #${result.prestige.count} · +${result.prGranted} Pr. Balance, inventory & keys reset.`
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -77,14 +77,14 @@ export default function PrestigePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onUpgrade = async (skillId) => {
|
||||
const onUpgrade = async (skillId, count = 1) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await api.prestigeUpgrade(skillId);
|
||||
const result = await api.prestigeUpgrade(skillId, count);
|
||||
patchUser(result.user);
|
||||
setMessage(`Upgraded ${skillId}.`);
|
||||
setMessage(count > 1 ? `Upgraded ${skillId} ×${count}.` : `Upgraded ${skillId}.`);
|
||||
setData((prev) =>
|
||||
prev
|
||||
? {
|
||||
@@ -101,7 +101,10 @@ export default function PrestigePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const shortfall = Math.max(0, cost - (Number(user.balance) || 0));
|
||||
const shortfall =
|
||||
toCentsBigInt(cost) > toCentsBigInt(user.balance)
|
||||
? toCentsBigInt(cost) - toCentsBigInt(user.balance)
|
||||
: 0n;
|
||||
const statusLabel = !data?.canAfford
|
||||
? `Need ${formatCredits(shortfall)} cr more`
|
||||
: data?.lockedItems > 0
|
||||
@@ -178,8 +181,8 @@ export default function PrestigePage() {
|
||||
<strong>+{p.openBonus || 0}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Discount</span>
|
||||
<strong>−{p.caseDiscount || 0}%</strong>
|
||||
<span className="muted">PR Farmer</span>
|
||||
<strong>+{p.prBonus || 0}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Quality</span>
|
||||
@@ -251,6 +254,7 @@ export default function PrestigePage() {
|
||||
<li>All Pr already earned</li>
|
||||
<li>Skill-tree bonuses</li>
|
||||
<li>Account & prestige count</li>
|
||||
<li>Catalog progress</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@@ -258,7 +262,7 @@ export default function PrestigePage() {
|
||||
<ul>
|
||||
<li>Balance</li>
|
||||
<li>Inventory & vault stakes</li>
|
||||
<li>Case keys & catalog progress</li>
|
||||
<li>Case keys</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,21 +312,43 @@ export default function PrestigePage() {
|
||||
<p className="muted">{skill.description}</p>
|
||||
<div className="prestige-skill-meta">
|
||||
<span>
|
||||
Lv {skill.level} · now{' '}
|
||||
Lv {skill.level}
|
||||
{skill.maxLevel != null ? `/${skill.maxLevel}` : ''}
|
||||
{' · now '}
|
||||
<strong>{formatSkillValue(skill, skill.current)}</strong>
|
||||
</span>
|
||||
{!skill.atMax && (
|
||||
<span className="prestige-delta">
|
||||
→ {formatSkillValue(skill, skill.nextValue)}
|
||||
</span>
|
||||
)}
|
||||
{skill.atMax && <span className="prestige-delta">MAX</span>}
|
||||
</div>
|
||||
<div className="prestige-skill-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
disabled={busy || !skill.canAfford}
|
||||
onClick={() => onUpgrade(skill.id)}
|
||||
disabled={busy || skill.atMax || !skill.canAfford}
|
||||
onClick={() => onUpgrade(skill.id, 1)}
|
||||
>
|
||||
Buy · {skill.cost} Pr
|
||||
{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>
|
||||
</article>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -6,6 +6,7 @@ import ProfileAchievementBadges from '../components/ProfileAchievementBadges';
|
||||
import ProfilePrestigeStats from '../components/ProfilePrestigeStats';
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { onSocketSessionOk } from '../socket';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, loading, refresh, logout } = useAuth();
|
||||
@@ -21,7 +22,7 @@ export default function ProfilePage() {
|
||||
useEffect(() => {
|
||||
if (!user || user.role === 'admin') return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const data = await api.profile();
|
||||
if (!cancelled) {
|
||||
@@ -31,9 +32,14 @@ export default function ProfilePage() {
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.message);
|
||||
}
|
||||
})();
|
||||
};
|
||||
loadProfile();
|
||||
const offSession = onSocketSessionOk(() => {
|
||||
loadProfile().catch(() => {});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
offSession();
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
@@ -130,7 +136,13 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
<label className="btn secondary file-btn">
|
||||
Change photo
|
||||
<input type="file" accept="image/*" hidden onChange={onAvatar} disabled={busy} />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif"
|
||||
hidden
|
||||
onChange={onAvatar}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<div className="muted" style={{ marginTop: '0.75rem' }}>
|
||||
Balance {formatCredits(user.balance)} cr
|
||||
@@ -178,10 +190,7 @@ export default function ProfilePage() {
|
||||
Cases opened <strong>{profile.casesOpened}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Jackpot wins <strong>{profile.jackpotWins}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Duel wins <strong>{profile.duelWins}</strong>
|
||||
Battle wins <strong>{profile.jackpotWins}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<ProfileAchievementBadges
|
||||
|
||||
+153
-47
@@ -23,20 +23,33 @@ function useAdCountdown(nextAdAt) {
|
||||
return remaining;
|
||||
}
|
||||
|
||||
function formatCountdown(sec) {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function ShopPage() {
|
||||
const { user, loading, patchUser } = useAuth();
|
||||
const [packages, setPackages] = useState([]);
|
||||
const [osuTopups, setOsuTopups] = useState([]);
|
||||
const [osuTopupsEnabled, setOsuTopupsEnabled] = useState(true);
|
||||
const [payments, setPayments] = useState(null);
|
||||
const [ad, setAd] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [watching, setWatching] = useState(false);
|
||||
const [confirmingId, setConfirmingId] = useState(null);
|
||||
|
||||
const remaining = useAdCountdown(ad?.ready ? null : ad?.nextAdAt);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const data = await api.shop();
|
||||
setPackages(data.packages || []);
|
||||
setOsuTopups(data.osuTopups || []);
|
||||
setOsuTopupsEnabled(data.osuTopupsEnabled !== false && (data.osuTopups || []).length > 0);
|
||||
setPayments(data.payments || null);
|
||||
setAd(data.ad);
|
||||
patchUser({
|
||||
balance: data.user.balance,
|
||||
@@ -60,7 +73,7 @@ export default function ShopPage() {
|
||||
};
|
||||
}, [user?.id, user?.role, load]);
|
||||
|
||||
const buy = async (packageId) => {
|
||||
const buyCredits = async (packageId) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
@@ -70,7 +83,7 @@ export default function ShopPage() {
|
||||
balance: data.user.balance,
|
||||
osuBalance: data.user.osuBalance,
|
||||
});
|
||||
setMessage(`Bought ${data.package.name} — +${formatCredits(data.package.credits)} cr`);
|
||||
setMessage(`Exchanged — +${formatCredits(data.package.credits)} cr`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -79,12 +92,41 @@ export default function ShopPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const buyOsu = async (packId) => {
|
||||
setBusy(true);
|
||||
setConfirmingId(packId);
|
||||
setError('');
|
||||
setMessage('');
|
||||
try {
|
||||
const checkout = await api.shopOsuCheckout(packId);
|
||||
if (checkout.mode === 'mock' && checkout.confirmable) {
|
||||
const data = await api.shopOsuConfirm(checkout.order.id);
|
||||
patchUser({
|
||||
balance: data.user.balance,
|
||||
osuBalance: data.user.osuBalance,
|
||||
});
|
||||
setMessage(
|
||||
data.alreadyPaid
|
||||
? 'Order already completed'
|
||||
: `+${data.osuGranted} osu added to your wallet`
|
||||
);
|
||||
await load();
|
||||
} else {
|
||||
setError('Payment provider is not ready for live checkout yet');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setConfirmingId(null);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const watchAd = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
setWatching(true);
|
||||
// Simulated short ad
|
||||
await new Promise((r) => setTimeout(r, 1800));
|
||||
try {
|
||||
const data = await api.shopAd();
|
||||
@@ -115,30 +157,122 @@ export default function ShopPage() {
|
||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||
|
||||
const adReady = ad?.ready || (ad?.nextAdAt && remaining === 0);
|
||||
const osuBal = user.osuBalance ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
<h1>Shop</h1>
|
||||
<p className="muted">Spend osu for credit packs, or watch an ad every minute.</p>
|
||||
<div className="shop-page">
|
||||
<header className="shop-hero">
|
||||
<div className="shop-hero-copy">
|
||||
<p className="shop-kicker">CaseOrion Shop</p>
|
||||
<h1>Fuel your runs</h1>
|
||||
<p className="shop-lede">
|
||||
<strong>osu</strong> is the premium wallet — bought with real money, spent on credit
|
||||
packs. Credits are for opening cases, vaulting, and battling.
|
||||
</p>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<span className="osu-pill">{user.osuBalance ?? 0} osu</span>
|
||||
<span className="balance-pill">{formatCredits(user.balance)} cr</span>
|
||||
<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>}
|
||||
|
||||
<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.
|
||||
{osuTopupsEnabled && osuTopups.length > 0 && (
|
||||
<section className="shop-section">
|
||||
<div className="shop-section-head">
|
||||
<h2>Get osu</h2>
|
||||
<p className="muted">Premium top-ups · better rate on larger packs</p>
|
||||
</div>
|
||||
<div className="shop-osu-grid">
|
||||
{osuTopups.map((pack) => (
|
||||
<article
|
||||
key={pack.id}
|
||||
className={`shop-osu-card${pack.badge ? ' has-badge' : ''}${
|
||||
pack.badge === 'Best value' ? ' highlight' : ''
|
||||
}${pack.badge === 'Popular' ? ' popular' : ''}`}
|
||||
>
|
||||
{pack.badge ? <span className="shop-badge">{pack.badge}</span> : null}
|
||||
<h3>{pack.name}</h3>
|
||||
<p className="shop-osu-amount">
|
||||
{pack.osu} <span>osu</span>
|
||||
</p>
|
||||
<p className="muted shop-osu-blurb">{pack.blurb}</p>
|
||||
<p className="shop-osu-rate muted">{pack.perOsuLabel}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn shop-buy-btn"
|
||||
disabled={busy}
|
||||
onClick={() => buyOsu(pack.id)}
|
||||
>
|
||||
{confirmingId === pack.id
|
||||
? 'Processing…'
|
||||
: `Buy · ${pack.displayPrice || `€${(pack.priceCents / 100).toFixed(2)}`}`}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="shop-section">
|
||||
<div className="shop-section-head">
|
||||
<h2>Exchange for credits</h2>
|
||||
<p className="muted">
|
||||
Spend osu · payout scales with your credit balance (min floor early game)
|
||||
</p>
|
||||
</div>
|
||||
<div className="shop-credit-grid">
|
||||
{packages.map((p) => {
|
||||
const canAfford = osuBal >= p.osuCost;
|
||||
return (
|
||||
<article key={p.id} className="shop-credit-card">
|
||||
{p.tag ? <span className="shop-badge subtle">{p.tag}</span> : null}
|
||||
<h3>{p.name}</h3>
|
||||
<p className="pack-credits">+{formatCredits(p.credits)} cr</p>
|
||||
<p className="muted shop-credit-scale">
|
||||
{p.balancePct != null ? `${p.balancePct}% of balance` : null}
|
||||
{p.scaled === false ? ' · floor' : ''}
|
||||
</p>
|
||||
<p className="shop-credit-cost">
|
||||
<span className="osu-pill">{p.osuCost} osu</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
disabled={busy || !canAfford}
|
||||
onClick={() => buyCredits(p.id)}
|
||||
title={canAfford ? undefined : 'Not enough osu'}
|
||||
>
|
||||
{canAfford ? 'Exchange' : 'Need osu'}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="shop-section shop-free">
|
||||
<div className="shop-ad-panel">
|
||||
<div>
|
||||
<h2>Free credits</h2>
|
||||
<p className="muted">
|
||||
Watch a short ad every minute for {formatCredits(ad?.rewardMin || 10000)}–
|
||||
{formatCredits(ad?.rewardMax || 20000)} cr.
|
||||
</p>
|
||||
</div>
|
||||
{watching ? (
|
||||
<div className="ad-sim">
|
||||
<div className="ad-bar" />
|
||||
@@ -147,43 +281,15 @@ export default function ShopPage() {
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
className="btn secondary"
|
||||
disabled={busy || !adReady}
|
||||
onClick={watchAd}
|
||||
>
|
||||
{adReady
|
||||
? 'Watch ad'
|
||||
: `Available in ${String(Math.floor(remaining / 60)).padStart(2, '0')}:${String(
|
||||
remaining % 60
|
||||
).padStart(2, '0')}`}
|
||||
{adReady ? 'Watch ad' : `Ready in ${formatCountdown(remaining)}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2>Credit packs</h2>
|
||||
<p className="muted" style={{ marginBottom: '1rem' }}>
|
||||
Buy credits with osu (demo currency).
|
||||
</p>
|
||||
<div className="mode-grid">
|
||||
{packages.map((p) => (
|
||||
<div key={p.id} className="mode-card shop-pack">
|
||||
<h2>{p.name}</h2>
|
||||
<p className="pack-credits">{p.blurb}</p>
|
||||
<p className="muted">{p.osuCost} osu</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy || (user.osuBalance ?? 0) < p.osuCost}
|
||||
onClick={() => buy(p.id)}
|
||||
>
|
||||
Buy
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+107
-3
@@ -4,10 +4,14 @@ let socket = null;
|
||||
let limited = false;
|
||||
let limitPayload = null;
|
||||
const limitListeners = new Set();
|
||||
const sessionOkListeners = new Set();
|
||||
let unloadBound = false;
|
||||
let connectTimer = null;
|
||||
let reconnectSeq = 0;
|
||||
|
||||
function notifyLimit(payload) {
|
||||
limited = true;
|
||||
limitPayload = payload || { max: 10 };
|
||||
limitPayload = payload || { max: 2 };
|
||||
for (const fn of limitListeners) {
|
||||
try {
|
||||
fn(limitPayload);
|
||||
@@ -29,32 +33,125 @@ function notifyOk() {
|
||||
}
|
||||
}
|
||||
|
||||
function notifySessionOk(payload) {
|
||||
for (const fn of sessionOkListeners) {
|
||||
try {
|
||||
fn(payload);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearConnectTimer() {
|
||||
if (connectTimer != null) {
|
||||
clearTimeout(connectTimer);
|
||||
connectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureConnected() {
|
||||
if (!socket || limited) return;
|
||||
socket.io.reconnection(true);
|
||||
if (!socket.connected) {
|
||||
clearConnectTimer();
|
||||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null;
|
||||
if (!socket || limited || socket.connected) return;
|
||||
socket.connect();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
function bindUnloadOnce() {
|
||||
if (unloadBound || typeof window === 'undefined') return;
|
||||
unloadBound = true;
|
||||
const release = () => {
|
||||
clearConnectTimer();
|
||||
if (socket?.connected) {
|
||||
// Manual disconnect frees the presence slot; pageshow/visibility will reconnect.
|
||||
socket.disconnect();
|
||||
}
|
||||
};
|
||||
// pagehide covers tab close, bfcache, and mobile backgrounding better than unload
|
||||
window.addEventListener('pagehide', release);
|
||||
window.addEventListener('pageshow', () => {
|
||||
ensureConnected();
|
||||
});
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') ensureConnected();
|
||||
});
|
||||
}
|
||||
|
||||
export function getSocket() {
|
||||
if (!socket) {
|
||||
// Connect only after auth is known (see refreshSocketSession) to avoid
|
||||
// anonymous+auth double handshakes that stack slots on rapid refresh.
|
||||
socket = io({
|
||||
path: '/socket.io',
|
||||
withCredentials: true,
|
||||
autoConnect: true,
|
||||
autoConnect: false,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 500,
|
||||
reconnectionAttempts: Infinity,
|
||||
});
|
||||
|
||||
bindUnloadOnce();
|
||||
|
||||
socket.on('session:limit', (payload) => {
|
||||
socket.io.reconnection(false);
|
||||
notifyLimit(payload);
|
||||
});
|
||||
|
||||
socket.on('session:ok', () => {
|
||||
socket.on('session:replaced', () => {
|
||||
// Another tab/refresh took our slot — stay quiet, allow a later reconnect
|
||||
// without showing the hard limit overlay.
|
||||
socket.io.reconnection(false);
|
||||
});
|
||||
|
||||
socket.on('session:ok', (payload) => {
|
||||
if (!socket.io.reconnection()) {
|
||||
socket.io.reconnection(true);
|
||||
}
|
||||
notifyOk();
|
||||
notifySessionOk(payload);
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a new Socket.IO handshake so express-session reloads the auth cookie.
|
||||
* Needed after login/register/logout — otherwise an anonymous socket stays "offline".
|
||||
* Serialized so spam refresh cannot open many overlapping sockets from this tab.
|
||||
*/
|
||||
export function refreshSocketSession() {
|
||||
const s = getSocket();
|
||||
if (limited) return s;
|
||||
|
||||
const seq = ++reconnectSeq;
|
||||
clearConnectTimer();
|
||||
s.io.reconnection(true);
|
||||
|
||||
const connectNow = () => {
|
||||
if (seq !== reconnectSeq || limited) return;
|
||||
if (!s.connected) s.connect();
|
||||
};
|
||||
|
||||
if (s.connected) {
|
||||
s.once('disconnect', () => {
|
||||
if (seq !== reconnectSeq) return;
|
||||
clearConnectTimer();
|
||||
connectTimer = setTimeout(connectNow, 40);
|
||||
});
|
||||
s.disconnect();
|
||||
} else {
|
||||
clearConnectTimer();
|
||||
connectTimer = setTimeout(connectNow, 0);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function isSessionLimited() {
|
||||
return limited;
|
||||
}
|
||||
@@ -70,10 +167,17 @@ export function onSessionLimitChange(fn) {
|
||||
return () => limitListeners.delete(fn);
|
||||
}
|
||||
|
||||
/** Fired when the server confirms an authenticated presence session. */
|
||||
export function onSocketSessionOk(fn) {
|
||||
sessionOkListeners.add(fn);
|
||||
return () => sessionOkListeners.delete(fn);
|
||||
}
|
||||
|
||||
/** Allow reconnect after the user closes other tabs and reloads. */
|
||||
export function clearSessionLimit() {
|
||||
notifyOk();
|
||||
if (socket) {
|
||||
socket.io.reconnection(true);
|
||||
if (!socket.connected) socket.connect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
port: 5888,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
|
||||
@@ -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" "$@"
|
||||
@@ -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" "$@"
|
||||
@@ -5,30 +5,38 @@
|
||||
# Usage:
|
||||
# ./scripts/local-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS
|
||||
# ./scripts/local-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
|
||||
# ./scripts/local-db-import.sh ./backups/….tar.gz <username> # player-only merge
|
||||
#
|
||||
# Typical flow (prod → laptop):
|
||||
# # on prod:
|
||||
# ./scripts/docker-db-export.sh
|
||||
# # copy the .tar.gz here, then:
|
||||
# ./scripts/local-db-import.sh ./backups/casegambling-….tar.gz
|
||||
# # or only one player into the existing local DB:
|
||||
# ./scripts/local-db-import.sh ./backups/casegambling-….tar.gz SomePlayer
|
||||
#
|
||||
# Stop `npm run dev` before importing so SQLite is not locked.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
MERGE_PLAYER_SCRIPT="$ROOT/scripts/merge-player-db.sh"
|
||||
|
||||
if [[ -z "${1:-}" ]]; then
|
||||
echo "Usage: $0 <backup-dir-or-tar.gz>" >&2
|
||||
if [[ -z "${1:-}" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
echo "Usage: $0 <backup-dir-or-tar.gz> [username]" >&2
|
||||
echo "Example: $0 ./backups/casegambling-20260116-203000.tar.gz" >&2
|
||||
echo " $0 ./backups/casegambling-20260116-203000.tar.gz SomePlayer" >&2
|
||||
echo "Create a backup from Docker: ./scripts/docker-db-export.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKUP_ARG="$1"
|
||||
USERNAME="${2:-}"
|
||||
|
||||
# Resolve backup path from the caller's cwd
|
||||
if [[ "$1" = /* ]]; then
|
||||
SRC="$1"
|
||||
if [[ "$BACKUP_ARG" = /* ]]; then
|
||||
SRC="$BACKUP_ARG"
|
||||
else
|
||||
SRC="$(pwd)/$1"
|
||||
SRC="$(pwd)/$BACKUP_ARG"
|
||||
fi
|
||||
|
||||
LOCAL_DB="${LOCAL_DB:-$ROOT/server/prisma/dev.db}"
|
||||
@@ -49,6 +57,25 @@ is_archive() {
|
||||
esac
|
||||
}
|
||||
|
||||
copy_player_avatar() {
|
||||
local avatar_url="$1"
|
||||
local uploads_src="$2"
|
||||
local uploads_dst="$3"
|
||||
[[ -z "$avatar_url" || ! -d "$uploads_src" ]] && return 0
|
||||
local rel="${avatar_url#/}"
|
||||
rel="${rel#uploads/}"
|
||||
rel="${rel#./}"
|
||||
case "$rel" in
|
||||
''|http:*|https:*|*..*) return 0 ;;
|
||||
esac
|
||||
local src_file="$uploads_src/$rel"
|
||||
if [[ -f "$src_file" ]]; then
|
||||
mkdir -p "$uploads_dst/$(dirname "$rel")"
|
||||
cp -f "$src_file" "$uploads_dst/$rel"
|
||||
echo "==> Copied avatar upload: $rel"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -f "$SRC" ]] && is_archive "$SRC"; then
|
||||
echo "==> Extracting $(basename "$SRC")"
|
||||
TMP="$(mktemp -d)"
|
||||
@@ -84,6 +111,35 @@ if [[ -z "$DB" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Player-only merge into existing local DB ─────────────────────────────────
|
||||
if [[ -n "$USERNAME" ]]; then
|
||||
if [[ ! -f "$LOCAL_DB" ]]; then
|
||||
echo "Error: local DB not found: $LOCAL_DB" >&2
|
||||
echo "Tip: run a full import first (without username), or start the app once." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$MERGE_PLAYER_SCRIPT" ]]; then
|
||||
echo "Error: merge helper missing or not executable: $MERGE_PLAYER_SCRIPT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Drop WAL/SHM so we merge against the real file contents
|
||||
rm -f "${LOCAL_DB}-wal" "${LOCAL_DB}-shm"
|
||||
|
||||
"$MERGE_PLAYER_SCRIPT" "$DB" "$LOCAL_DB" "$USERNAME"
|
||||
AVATAR_URL="$(sqlite3 "$DB" "SELECT avatarUrl FROM User WHERE username = '${USERNAME//\'/\'\'}' LIMIT 1;" 2>/dev/null || true)"
|
||||
if [[ -d "$SRC/uploads" ]]; then
|
||||
copy_player_avatar "${AVATAR_URL:-}" "$SRC/uploads" "$LOCAL_UPLOADS"
|
||||
fi
|
||||
|
||||
echo "==> Done (player-only)."
|
||||
echo " db: $LOCAL_DB"
|
||||
echo " Tip: stop npm run dev before import if the DB was locked; then restart:"
|
||||
echo " npm run dev"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Full import (replace local DB + uploads) ─────────────────────────────────
|
||||
mkdir -p "$(dirname "$LOCAL_DB")"
|
||||
|
||||
echo "==> Importing database → $LOCAL_DB"
|
||||
|
||||
Executable
+251
@@ -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)"
|
||||
@@ -3,8 +3,9 @@
|
||||
# Run this on the machine that runs docker compose.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/docker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS
|
||||
# ./scripts/docker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
|
||||
# ./scripts/xdocker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS
|
||||
# ./scripts/xdocker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
|
||||
# ./scripts/xdocker-db-import.sh ./backups/….tar.gz <username> # player-only merge
|
||||
#
|
||||
# Expects a backup from docker-db-export.sh or local-db-export.sh
|
||||
# (prod.db or dev.db + uploads/).
|
||||
@@ -24,18 +25,24 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${1:-}" ]]; then
|
||||
echo "Usage: $0 <backup-dir-or-tar.gz>" >&2
|
||||
MERGE_PLAYER_SCRIPT="$ROOT/scripts/merge-player-db.sh"
|
||||
|
||||
if [[ -z "${1:-}" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
echo "Usage: $0 <backup-dir-or-tar.gz> [username]" >&2
|
||||
echo "Example: $0 ./backups/casegambling-20260116-203000.tar.gz" >&2
|
||||
echo " $0 ./backups/casegambling-20260116-203000.tar.gz SomePlayer" >&2
|
||||
echo "Create a backup first: ./scripts/docker-db-export.sh (or local-db-export.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKUP_ARG="$1"
|
||||
USERNAME="${2:-}"
|
||||
|
||||
# Resolve backup path from the caller's cwd (before any cd)
|
||||
if [[ "$1" = /* ]]; then
|
||||
SRC="$1"
|
||||
if [[ "$BACKUP_ARG" = /* ]]; then
|
||||
SRC="$BACKUP_ARG"
|
||||
else
|
||||
SRC="$(pwd)/$1"
|
||||
SRC="$(pwd)/$BACKUP_ARG"
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
@@ -52,10 +59,14 @@ fi
|
||||
SERVICE="${SERVICE:-casegambling}"
|
||||
|
||||
TMP=""
|
||||
WORK=""
|
||||
cleanup() {
|
||||
if [[ -n "$TMP" && -d "$TMP" ]]; then
|
||||
rm -rf "$TMP"
|
||||
fi
|
||||
if [[ -n "$WORK" && -d "$WORK" ]]; then
|
||||
rm -rf "$WORK"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -66,6 +77,25 @@ is_archive() {
|
||||
esac
|
||||
}
|
||||
|
||||
copy_player_avatar_docker() {
|
||||
local avatar_url="$1"
|
||||
local uploads_src="$2"
|
||||
[[ -z "$avatar_url" || ! -d "$uploads_src" ]] && return 0
|
||||
local rel="${avatar_url#/}"
|
||||
rel="${rel#uploads/}"
|
||||
rel="${rel#./}"
|
||||
case "$rel" in
|
||||
''|http:*|https:*|*..*) return 0 ;;
|
||||
esac
|
||||
local src_file="$uploads_src/$rel"
|
||||
if [[ -f "$src_file" ]]; then
|
||||
"${COMPOSE[@]}" run --rm --no-deps --entrypoint sh "$SERVICE" -c \
|
||||
"mkdir -p /app/uploads/$(dirname "$rel")" >/dev/null
|
||||
"${COMPOSE[@]}" cp "$src_file" "$SERVICE:/app/uploads/$rel"
|
||||
echo "==> Copied avatar upload: $rel"
|
||||
fi
|
||||
}
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Error: docker not found" >&2
|
||||
exit 1
|
||||
@@ -109,6 +139,58 @@ fi
|
||||
echo "==> Ensuring compose service exists"
|
||||
"${COMPOSE[@]}" up -d --no-start "$SERVICE" >/dev/null
|
||||
|
||||
# ── Player-only merge into existing Docker DB ────────────────────────────────
|
||||
if [[ -n "$USERNAME" ]]; then
|
||||
if [[ ! -x "$MERGE_PLAYER_SCRIPT" ]]; then
|
||||
echo "Error: merge helper missing or not executable: $MERGE_PLAYER_SCRIPT" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v sqlite3 >/dev/null 2>&1; then
|
||||
echo "Error: sqlite3 is required on the host for player-only import" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Stopping $SERVICE (safe SQLite merge)"
|
||||
"${COMPOSE[@]}" stop "$SERVICE" >/dev/null 2>&1 || true
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
TARGET_DB="$WORK/prod.db"
|
||||
|
||||
echo "==> Fetching current /data/prod.db"
|
||||
if ! "${COMPOSE[@]}" cp "$SERVICE:/data/prod.db" "$TARGET_DB" 2>/dev/null; then
|
||||
echo "Error: could not copy /data/prod.db from $SERVICE" >&2
|
||||
echo "Tip: run a full import once first so the volume has a database." >&2
|
||||
exit 1
|
||||
fi
|
||||
"${COMPOSE[@]}" run --rm --no-deps --entrypoint sh "$SERVICE" -c \
|
||||
'rm -f /data/prod.db-wal /data/prod.db-shm' >/dev/null
|
||||
|
||||
"$MERGE_PLAYER_SCRIPT" "$DB" "$TARGET_DB" "$USERNAME"
|
||||
|
||||
echo "==> Writing merged database → /data/prod.db"
|
||||
"${COMPOSE[@]}" cp "$TARGET_DB" "$SERVICE:/data/prod.db"
|
||||
|
||||
AVATAR_URL="$(sqlite3 "$DB" "SELECT avatarUrl FROM User WHERE username = '${USERNAME//\'/\'\'}' LIMIT 1;" 2>/dev/null || true)"
|
||||
if [[ -d "$SRC/uploads" ]]; then
|
||||
copy_player_avatar_docker "${AVATAR_URL:-}" "$SRC/uploads"
|
||||
fi
|
||||
|
||||
echo "==> Starting $SERVICE"
|
||||
"${COMPOSE[@]}" start "$SERVICE" 2>/dev/null || "${COMPOSE[@]}" up -d "$SERVICE"
|
||||
|
||||
HOST_PORT_VAL="${HOST_PORT:-}"
|
||||
if [[ -z "$HOST_PORT_VAL" && -f "$ENV_FILE" ]]; then
|
||||
HOST_PORT_VAL="$(grep -E '^HOST_PORT=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- | tr -d '"' || true)"
|
||||
fi
|
||||
HOST_PORT_VAL="${HOST_PORT_VAL:-3009}"
|
||||
|
||||
echo "==> Done (player-only)."
|
||||
echo " App: http://localhost:${HOST_PORT_VAL}"
|
||||
echo " Logs: ${COMPOSE[*]} logs -f --tail=50"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Full import (replace Docker DB + uploads) ────────────────────────────────
|
||||
echo "==> Stopping $SERVICE (safe SQLite replace)"
|
||||
"${COMPOSE[@]}" stop "$SERVICE" >/dev/null 2>&1 || true
|
||||
|
||||
|
||||
Generated
+508
@@ -15,10 +15,448 @@
|
||||
"express-session": "^1.18.1",
|
||||
"multer": "^2.2.0",
|
||||
"prisma": "^6.11.1",
|
||||
"sharp": "^0.34.4",
|
||||
"socket.io": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz",
|
||||
"integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz",
|
||||
"integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz",
|
||||
"integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz",
|
||||
"integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz",
|
||||
"integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz",
|
||||
"integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz",
|
||||
"integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz",
|
||||
"integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz",
|
||||
"integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz",
|
||||
"integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz",
|
||||
"integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz",
|
||||
"integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz",
|
||||
"integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz",
|
||||
"integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz",
|
||||
"integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz",
|
||||
"integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz",
|
||||
"integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz",
|
||||
"integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz",
|
||||
"integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz",
|
||||
"integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz",
|
||||
"integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz",
|
||||
"integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||
@@ -457,6 +895,15 @@
|
||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@@ -1420,6 +1867,18 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
@@ -1471,6 +1930,48 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz",
|
||||
"integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.0",
|
||||
"semver": "^7.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.4",
|
||||
"@img/sharp-darwin-x64": "0.34.4",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.3",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.3",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.3",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.3",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.3",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.3",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.3",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.3",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.3",
|
||||
"@img/sharp-linux-arm": "0.34.4",
|
||||
"@img/sharp-linux-arm64": "0.34.4",
|
||||
"@img/sharp-linux-ppc64": "0.34.4",
|
||||
"@img/sharp-linux-s390x": "0.34.4",
|
||||
"@img/sharp-linux-x64": "0.34.4",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.4",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.4",
|
||||
"@img/sharp-wasm32": "0.34.4",
|
||||
"@img/sharp-win32-arm64": "0.34.4",
|
||||
"@img/sharp-win32-ia32": "0.34.4",
|
||||
"@img/sharp-win32-x64": "0.34.4"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
@@ -1671,6 +2172,13 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@
|
||||
"express-session": "^1.18.1",
|
||||
"multer": "^2.2.0",
|
||||
"prisma": "^6.11.1",
|
||||
"sharp": "^0.34.4",
|
||||
"socket.io": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
+58
-52
@@ -11,29 +11,21 @@ model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
passwordHash String
|
||||
balance BigInt @default(0)
|
||||
balance String @default("0")
|
||||
osuBalance Int @default(0)
|
||||
prBalance Int @default(0)
|
||||
prBalance String @default("0")
|
||||
lastAdAt DateTime?
|
||||
role String @default("user")
|
||||
avatarUrl String @default("")
|
||||
bio String @default("")
|
||||
autoSellEnabled Boolean @default(false)
|
||||
autoSellThresholdCents BigInt @default(1000)
|
||||
autoSellThresholdCents String @default("1000")
|
||||
prestigeCount Int @default(0)
|
||||
prestigeChanceBonus Int @default(0)
|
||||
prestigeGainBonus Int @default(0)
|
||||
prestigeKeyOpenReduction Int @default(0)
|
||||
prestigeStartBonus Int @default(0)
|
||||
prestigeAdBonus Int @default(0)
|
||||
prestigeAdCooldownReduction Int @default(0)
|
||||
prestigeWearBonus Int @default(0)
|
||||
prestigePrBonus Int @default(0)
|
||||
prestigeShopDiscount Int @default(0)
|
||||
prestigeStartOsu Int @default(0)
|
||||
prestigeDropValueBonus Int @default(0)
|
||||
prestigeOpenBonus Int @default(0)
|
||||
prestigeCaseDiscount Int @default(0)
|
||||
prestigeQualityBonus Int @default(0)
|
||||
prestigeAnimReduction Int @default(0)
|
||||
prestigeRefundChance Int @default(0)
|
||||
@@ -41,27 +33,46 @@ model User {
|
||||
prestigeVaultSlots Int @default(0)
|
||||
prestigeOfflineYield Int @default(0)
|
||||
prestigeOnlineYield Int @default(0)
|
||||
vaultYieldAt DateTime?
|
||||
lastConnection DateTime?
|
||||
playTimeSeconds BigInt @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
inventory InventoryItem[]
|
||||
transactions Transaction[]
|
||||
jackpotWins JackpotRound[] @relation("JackpotWinner")
|
||||
jackpotCreated JackpotRound[] @relation("JackpotCreator")
|
||||
jackpotDeposits JackpotDeposit[]
|
||||
duelRoomsCreated DuelRoom[] @relation("DuelCreator")
|
||||
duelWins DuelRoom[] @relation("DuelWinner")
|
||||
duelDeposits DuelDeposit[]
|
||||
caseKeyProgress CaseKeyProgress[]
|
||||
catalogEntries CatalogEntry[]
|
||||
prestigeLogs PrestigeLog[]
|
||||
achievements UserAchievement[]
|
||||
paymentOrders PaymentOrder[]
|
||||
}
|
||||
|
||||
model PaymentOrder {
|
||||
id String @id @default(cuid())
|
||||
userId Int
|
||||
packId String
|
||||
osuAmount Int
|
||||
priceCents Int
|
||||
currency String @default("eur")
|
||||
status String @default("pending")
|
||||
provider String @default("mock")
|
||||
providerRef String @default("")
|
||||
meta String @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
paidAt DateTime?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, status])
|
||||
@@index([providerRef])
|
||||
}
|
||||
|
||||
model Case {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
imageUrl String @default("")
|
||||
price BigInt
|
||||
price String @default("0")
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
items CaseItem[]
|
||||
@@ -81,7 +92,7 @@ model Item {
|
||||
imageUrl String @default("")
|
||||
rarity String
|
||||
category String @default("Misc")
|
||||
marketValue BigInt
|
||||
marketValue String @default("0")
|
||||
createdAt DateTime @default(now())
|
||||
cases CaseItem[]
|
||||
inventory InventoryItem[]
|
||||
@@ -110,20 +121,19 @@ model InventoryItem {
|
||||
floatValue Float @default(0.265)
|
||||
wear String @default("Field-Tested")
|
||||
paintSeed Int @default(0)
|
||||
valueCents BigInt @default(0)
|
||||
valueCents String @default("0")
|
||||
favorite Boolean @default(false)
|
||||
obtainedAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
jackpotDeposits JackpotDeposit[]
|
||||
duelDeposits DuelDeposit[]
|
||||
}
|
||||
|
||||
model Transaction {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
type String
|
||||
amount BigInt
|
||||
amount String
|
||||
meta String @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -133,11 +143,15 @@ model JackpotRound {
|
||||
id Int @id @default(autoincrement())
|
||||
status String @default("open")
|
||||
endsAt DateTime
|
||||
creatorId Int?
|
||||
winnerUserId Int?
|
||||
createdAt DateTime @default(now())
|
||||
resolvedAt DateTime?
|
||||
creator User? @relation("JackpotCreator", fields: [creatorId], references: [id])
|
||||
winner User? @relation("JackpotWinner", fields: [winnerUserId], references: [id])
|
||||
deposits JackpotDeposit[]
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model JackpotDeposit {
|
||||
@@ -145,7 +159,7 @@ model JackpotDeposit {
|
||||
roundId Int
|
||||
userId Int
|
||||
inventoryItemId Int
|
||||
valueCents BigInt
|
||||
valueCents String
|
||||
createdAt DateTime @default(now())
|
||||
round JackpotRound @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -154,37 +168,6 @@ model JackpotDeposit {
|
||||
@@unique([roundId, inventoryItemId])
|
||||
}
|
||||
|
||||
model DuelRoom {
|
||||
id Int @id @default(autoincrement())
|
||||
creatorId Int
|
||||
status String @default("open")
|
||||
maxPlayers Int
|
||||
maxItemsPerPlayer Int
|
||||
minItemValue BigInt @default(0)
|
||||
minPlayersToStart Int @default(2)
|
||||
winnerUserId Int?
|
||||
createdAt DateTime @default(now())
|
||||
startedAt DateTime?
|
||||
resolvedAt DateTime?
|
||||
creator User @relation("DuelCreator", fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
winner User? @relation("DuelWinner", fields: [winnerUserId], references: [id])
|
||||
deposits DuelDeposit[]
|
||||
}
|
||||
|
||||
model DuelDeposit {
|
||||
id Int @id @default(autoincrement())
|
||||
roomId Int
|
||||
userId Int
|
||||
inventoryItemId Int
|
||||
valueCents BigInt
|
||||
createdAt DateTime @default(now())
|
||||
room DuelRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
inventoryItem InventoryItem @relation(fields: [inventoryItemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([roomId, inventoryItemId])
|
||||
}
|
||||
|
||||
model CaseKeyProgress {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
@@ -215,7 +198,7 @@ model PrestigeLog {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
choice String
|
||||
prGranted Int @default(0)
|
||||
prGranted String @default("0")
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
@@ -241,3 +224,26 @@ model UserAchievement {
|
||||
|
||||
@@unique([userId, achievementId])
|
||||
}
|
||||
|
||||
/** Key/value app settings (e.g. shop.osuTopupsEnabled). */
|
||||
model AppSetting {
|
||||
key String @id
|
||||
value String
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/** Configurable real-money → osu packs for the shop. */
|
||||
model ShopOsuPack {
|
||||
id String @id
|
||||
name String
|
||||
osu Int
|
||||
priceCents Int
|
||||
currency String @default("eur")
|
||||
badge String @default("")
|
||||
blurb String @default("")
|
||||
perOsuLabel String @default("")
|
||||
enabled Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
+23
-16
@@ -1,6 +1,7 @@
|
||||
import { prisma } from './db.js';
|
||||
import { countCompletedChapters } from './catalog.js';
|
||||
import { livePlayTimeSeconds } from './presence.js';
|
||||
import { isPrestigeSkillTreeMaxed } from './prestige.js';
|
||||
|
||||
/** First batch of achievements — upserted on seed / boot. */
|
||||
export const ACHIEVEMENT_DEFS = [
|
||||
@@ -34,44 +35,44 @@ export const ACHIEVEMENT_DEFS = [
|
||||
},
|
||||
{
|
||||
key: 'jackpot_win',
|
||||
name: 'Pot Lucky',
|
||||
description: 'Win a MultiPlayers Case round.',
|
||||
name: 'Battle Winner',
|
||||
description: 'Win a Battle pot.',
|
||||
icon: '♠',
|
||||
sortOrder: 5,
|
||||
},
|
||||
{
|
||||
key: 'duel_win',
|
||||
name: 'Duel Victor',
|
||||
description: 'Win a 1vX Case battle.',
|
||||
icon: '⚔',
|
||||
sortOrder: 6,
|
||||
},
|
||||
{
|
||||
key: 'catalog_chapter',
|
||||
name: 'Chapter Complete',
|
||||
description: 'Finish a full catalog chapter.',
|
||||
icon: '◆',
|
||||
sortOrder: 7,
|
||||
sortOrder: 6,
|
||||
},
|
||||
{
|
||||
key: 'first_prestige',
|
||||
name: 'Prestige Bound',
|
||||
description: 'Prestige for the first time.',
|
||||
icon: '✦',
|
||||
sortOrder: 8,
|
||||
sortOrder: 7,
|
||||
},
|
||||
{
|
||||
key: 'shop_customer',
|
||||
name: 'Shop Regular',
|
||||
description: 'Buy a shop pack or claim an ad reward.',
|
||||
icon: '◈',
|
||||
sortOrder: 9,
|
||||
sortOrder: 8,
|
||||
},
|
||||
{
|
||||
key: 'playtime_hour',
|
||||
name: 'Hourglass',
|
||||
description: 'Spend 1 hour playing on CaseOrion.',
|
||||
icon: '⌛',
|
||||
sortOrder: 9,
|
||||
},
|
||||
{
|
||||
key: 'prestige_skills_max',
|
||||
name: 'Skill Absolute',
|
||||
description: 'Reach max level on every Prestige skill tree branch.',
|
||||
icon: '♛',
|
||||
sortOrder: 10,
|
||||
},
|
||||
];
|
||||
@@ -89,6 +90,11 @@ export async function ensureAchievements() {
|
||||
},
|
||||
});
|
||||
}
|
||||
// Removed game modes (e.g. 1vX duels) — drop obsolete achievement defs
|
||||
await prisma.userAchievement.deleteMany({
|
||||
where: { achievement: { key: 'duel_win' } },
|
||||
});
|
||||
await prisma.achievement.deleteMany({ where: { key: 'duel_win' } });
|
||||
}
|
||||
|
||||
function serializeAchievement(row, unlockedAt = null) {
|
||||
@@ -160,16 +166,15 @@ export async function checkAchievements(userId) {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user || user.role === 'admin') return [];
|
||||
|
||||
const [casesOpened, jackpotWins, duelWins, chapters, shopOrAd, owned] =
|
||||
const [casesOpened, jackpotWins, chapters, shopOrAd, owned] =
|
||||
await Promise.all([
|
||||
prisma.transaction.count({ where: { userId, type: 'open_case' } }),
|
||||
prisma.transaction.count({ where: { userId, type: 'jackpot_win' } }),
|
||||
prisma.transaction.count({ where: { userId, type: 'duel_win' } }),
|
||||
countCompletedChapters(prisma, userId),
|
||||
prisma.transaction.count({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: ['shop_purchase', 'ad_reward'] },
|
||||
type: { in: ['shop_purchase', 'ad_reward', 'osu_purchase'] },
|
||||
},
|
||||
}),
|
||||
prisma.userAchievement.findMany({
|
||||
@@ -186,7 +191,6 @@ export async function checkAchievements(userId) {
|
||||
if (casesOpened >= 25 && !have.has('case_25')) candidates.push('case_25');
|
||||
if (casesOpened >= 100 && !have.has('case_100')) candidates.push('case_100');
|
||||
if (jackpotWins >= 1 && !have.has('jackpot_win')) candidates.push('jackpot_win');
|
||||
if (duelWins >= 1 && !have.has('duel_win')) candidates.push('duel_win');
|
||||
if (chapters >= 1 && !have.has('catalog_chapter')) candidates.push('catalog_chapter');
|
||||
if ((user.prestigeCount ?? 0) >= 1 && !have.has('first_prestige')) {
|
||||
candidates.push('first_prestige');
|
||||
@@ -195,6 +199,9 @@ export async function checkAchievements(userId) {
|
||||
if (livePlayTimeSeconds(user) >= 3600 && !have.has('playtime_hour')) {
|
||||
candidates.push('playtime_hour');
|
||||
}
|
||||
if (isPrestigeSkillTreeMaxed(user) && !have.has('prestige_skills_max')) {
|
||||
candidates.push('prestige_skills_max');
|
||||
}
|
||||
|
||||
return unlockMany(userId, candidates);
|
||||
}
|
||||
|
||||
+42
-28
@@ -4,48 +4,62 @@
|
||||
* Prestige gain bonus multiplies credited amount (not the sell threshold check).
|
||||
*/
|
||||
import { applyGainMultiplier } from './prestige.js';
|
||||
import { toCentsBigInt, centsJson } from './cents.js';
|
||||
|
||||
export function shouldAutoSell(user, valueCents) {
|
||||
if (!user?.autoSellEnabled) return false;
|
||||
const threshold = user.autoSellThresholdCents ?? 0;
|
||||
return valueCents < threshold;
|
||||
const threshold = toCentsBigInt(user.autoSellThresholdCents ?? 0);
|
||||
return toCentsBigInt(valueCents) < threshold;
|
||||
}
|
||||
|
||||
export async function applyAutoSell(tx, user, inventoryItems) {
|
||||
/** Compute auto-sell payout without mutating inventory (for deferred claims). */
|
||||
export function computeAutoSell(user, inventoryItems) {
|
||||
if (!user?.autoSellEnabled) {
|
||||
return { sold: [], balanceDelta: 0 };
|
||||
return { sold: [], balanceDelta: 0n };
|
||||
}
|
||||
|
||||
const threshold = user.autoSellThresholdCents ?? 0;
|
||||
const threshold = toCentsBigInt(user.autoSellThresholdCents ?? 0);
|
||||
const gainBonus = user.prestigeGainBonus ?? 0;
|
||||
const sold = [];
|
||||
let balanceDelta = 0;
|
||||
let balanceDelta = 0n;
|
||||
|
||||
for (const inv of inventoryItems) {
|
||||
if (!inv || inv.locked || inv.staked || inv.favorite || inv.valueCents >= threshold) continue;
|
||||
|
||||
const payout = applyGainMultiplier(inv.valueCents, gainBonus);
|
||||
|
||||
await tx.inventoryItem.delete({ where: { id: inv.id } });
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'sell_item',
|
||||
amount: payout,
|
||||
meta: JSON.stringify({
|
||||
inventoryItemId: inv.id,
|
||||
itemId: inv.itemId,
|
||||
autoSell: true,
|
||||
valueCents: inv.valueCents,
|
||||
prestigeGainBonus: gainBonus,
|
||||
payout,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!inv || inv.locked || inv.staked || inv.favorite) continue;
|
||||
const value = toCentsBigInt(inv.valueCents);
|
||||
if (value >= threshold) continue;
|
||||
sold.push(inv);
|
||||
balanceDelta += payout;
|
||||
balanceDelta += applyGainMultiplier(value, gainBonus);
|
||||
}
|
||||
|
||||
return { sold, balanceDelta };
|
||||
}
|
||||
|
||||
export async function applyAutoSell(tx, user, inventoryItems) {
|
||||
const { sold, balanceDelta } = computeAutoSell(user, inventoryItems);
|
||||
const gainBonus = user.prestigeGainBonus ?? 0;
|
||||
|
||||
if (!sold.length) {
|
||||
return { sold: [], balanceDelta: 0n };
|
||||
}
|
||||
|
||||
const soldIds = sold.map((inv) => inv.id);
|
||||
await tx.inventoryItem.deleteMany({
|
||||
where: { id: { in: soldIds }, userId: user.id },
|
||||
});
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'sell_item',
|
||||
amount: centsJson(balanceDelta),
|
||||
meta: JSON.stringify({
|
||||
count: sold.length,
|
||||
inventoryItemIds: soldIds,
|
||||
autoSell: true,
|
||||
prestigeGainBonus: gainBonus,
|
||||
payout: centsJson(balanceDelta),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return { sold, balanceDelta };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from './db.js';
|
||||
import { applyCatalogLuck, normalizeRates } from './weighted.js';
|
||||
import { toCentsBigInt } from './cents.js';
|
||||
|
||||
/**
|
||||
* Count how many active cases the user has fully collected.
|
||||
@@ -103,7 +104,13 @@ export async function getCatalogState(userId) {
|
||||
orderBy: { dropRate: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { price: 'asc' },
|
||||
});
|
||||
cases.sort((a, b) => {
|
||||
const pa = toCentsBigInt(a.price);
|
||||
const pb = toCentsBigInt(b.price);
|
||||
if (pa < pb) return -1;
|
||||
if (pa > pb) return 1;
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
const entries = await prisma.catalogEntry.findMany({
|
||||
|
||||
@@ -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
@@ -1,23 +1,12 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/** Prisma BigInt → Number so the rest of the app can keep using number arithmetic / JSON. */
|
||||
function reviveMoney(value) {
|
||||
if (typeof value === 'bigint') return Number(value);
|
||||
if (value == null || typeof value !== 'object') return value;
|
||||
if (value instanceof Date) return value;
|
||||
if (Array.isArray(value)) return value.map(reviveMoney);
|
||||
for (const key of Object.keys(value)) {
|
||||
value[key] = reviveMoney(value[key]);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const base = new PrismaClient();
|
||||
|
||||
export const prisma = base.$extends({
|
||||
query: {
|
||||
$allOperations({ args, query }) {
|
||||
return query(args).then(reviveMoney);
|
||||
},
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Plain Prisma client — no BigInt→Number revive.
|
||||
*
|
||||
* Credits / Pr / prices are stored as decimal **strings** and parsed with
|
||||
* `toCentsBigInt` / `centsJson` so values can grow past Number.MAX_SAFE_INTEGER.
|
||||
* Any remaining Prisma BigInt fields (e.g. playTimeSeconds) stay as bigint until
|
||||
* an API boundary stringifies them (`res.json` replacer in index.js) or an
|
||||
* explicit Number()/toString() conversion.
|
||||
*/
|
||||
export const prisma = new PrismaClient();
|
||||
|
||||
+11
-1
@@ -13,9 +13,11 @@ function fingerprint(entry) {
|
||||
return [
|
||||
entry.username || '',
|
||||
entry.item?.id || '',
|
||||
String(entry.item?.valueCents ?? entry.item?.marketValue ?? ''),
|
||||
entry.count || 1,
|
||||
entry.openCount || 1,
|
||||
ids.join(','),
|
||||
entry.respin ? 'respin' : 'open',
|
||||
ids.join(',') || `snap:${entry.item?.name || ''}`,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
@@ -47,3 +49,11 @@ export function pushDrop(entry) {
|
||||
const { _fp, ...publicEvent } = event;
|
||||
return publicEvent;
|
||||
}
|
||||
|
||||
export function clearDropFeed() {
|
||||
feed.length = 0;
|
||||
const io = getIO();
|
||||
if (io) {
|
||||
io.to('feed').emit('feed:history', []);
|
||||
}
|
||||
}
|
||||
|
||||
+61
-50
@@ -13,7 +13,6 @@ import inventoryRoutes from './routes/inventory.js';
|
||||
import leaderboardRoutes from './routes/leaderboard.js';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
import jackpotRoutes from './routes/jackpot.js';
|
||||
import duelsRoutes from './routes/duels.js';
|
||||
import profileRoutes from './routes/profile.js';
|
||||
import shopRoutes from './routes/shop.js';
|
||||
import feedRoutes from './routes/feed.js';
|
||||
@@ -23,12 +22,11 @@ import achievementsRoutes from './routes/achievements.js';
|
||||
import vaultRoutes from './routes/vault.js';
|
||||
import { setIO } from './realtime.js';
|
||||
import { getDropFeed } from './feed.js';
|
||||
import { ensureJackpotRound } from './services/jackpot.js';
|
||||
import { recoverDuelSpins, getDuelRoom, listDuelRooms, startEmptyDuelRoomPurge } from './services/duel.js';
|
||||
import { getJackpotState } from './services/jackpot.js';
|
||||
import { ensureJackpotRound, listJackpotBattles, getJackpotRound } from './services/jackpot.js';
|
||||
import { ensureAdminFromEnv } from './ensureAdmin.js';
|
||||
import { ensureAchievements, checkAchievements } from './achievements.js';
|
||||
import { ensureCategories } from './categories.js';
|
||||
import { ensureShopOsuPacks } from './shopOsu.js';
|
||||
import { trackConnect, trackDisconnect, settleOnlineVaultForConnected } from './presence.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -40,7 +38,7 @@ if (!fs.existsSync(uploadsDir)) {
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const PORT = Number(process.env.PORT || 3001);
|
||||
const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173';
|
||||
const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5888';
|
||||
const TRUST_PROXY = process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1';
|
||||
|
||||
/** true | false | 'auto' (secure cookie only on HTTPS) */
|
||||
@@ -96,6 +94,19 @@ const sessionMiddleware = session({
|
||||
app.use(cors(corsOptions));
|
||||
app.use(express.json());
|
||||
app.use(sessionMiddleware);
|
||||
// Serialize BigInt fields (balance / valueCents) as strings for JSON responses
|
||||
app.use((_req, res, next) => {
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = (body) =>
|
||||
originalJson(
|
||||
JSON.parse(
|
||||
JSON.stringify(body, (_key, value) =>
|
||||
typeof value === 'bigint' ? value.toString() : value
|
||||
)
|
||||
)
|
||||
);
|
||||
next();
|
||||
});
|
||||
app.use('/uploads', express.static(uploadsDir));
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
@@ -108,7 +119,6 @@ app.use('/api/inventory', inventoryRoutes);
|
||||
app.use('/api/leaderboard', leaderboardRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/jackpot', jackpotRoutes);
|
||||
app.use('/api/duels', duelsRoutes);
|
||||
app.use('/api/profile', profileRoutes);
|
||||
app.use('/api/shop', shopRoutes);
|
||||
app.use('/api/feed', feedRoutes);
|
||||
@@ -137,7 +147,7 @@ io.on('connection', async (socket) => {
|
||||
|
||||
if (userId) {
|
||||
try {
|
||||
const result = await trackConnect(userId, socket.id);
|
||||
const result = await trackConnect(userId, socket.id, io);
|
||||
if (!result.ok) {
|
||||
socket.emit('session:limit', {
|
||||
max: result.max,
|
||||
@@ -147,6 +157,7 @@ io.on('connection', async (socket) => {
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
// Soft-replace of older sockets no longer emits session:limit (refresh-safe).
|
||||
socket.join(`user:${userId}`);
|
||||
socket.emit('session:ok', {
|
||||
count: result.count,
|
||||
@@ -158,52 +169,52 @@ io.on('connection', async (socket) => {
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('jackpot:subscribe', async () => {
|
||||
socket.on('jackpots:subscribe', async () => {
|
||||
socket.join('jackpots');
|
||||
try {
|
||||
const battles = await listJackpotBattles();
|
||||
socket.emit('jackpots:list', { battles });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('jackpots:unsubscribe', () => {
|
||||
socket.leave('jackpots');
|
||||
});
|
||||
|
||||
socket.on('jackpot:subscribe', async (roundId) => {
|
||||
const id = Number(roundId);
|
||||
if (id) {
|
||||
socket.join(`jackpot:${id}`);
|
||||
try {
|
||||
const state = await getJackpotRound(id);
|
||||
if (state) socket.emit('jackpot:state', state);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Legacy: join global room and send list head
|
||||
socket.join('jackpot');
|
||||
try {
|
||||
const state = await getJackpotState();
|
||||
socket.emit('jackpot:state', state);
|
||||
const battles = await listJackpotBattles();
|
||||
socket.emit('jackpots:list', { battles });
|
||||
if (battles[0]) socket.emit('jackpot:state', battles[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('jackpot:unsubscribe', () => {
|
||||
socket.on('jackpot:unsubscribe', (roundId) => {
|
||||
const id = Number(roundId);
|
||||
if (id) {
|
||||
socket.leave(`jackpot:${id}`);
|
||||
return;
|
||||
}
|
||||
socket.leave('jackpot');
|
||||
});
|
||||
|
||||
socket.on('duels:subscribe', async () => {
|
||||
socket.join('duels');
|
||||
try {
|
||||
const list = await listDuelRooms();
|
||||
socket.emit('duels:list', list);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('duels:unsubscribe', () => {
|
||||
socket.leave('duels');
|
||||
});
|
||||
|
||||
socket.on('duel:subscribe', async (roomId) => {
|
||||
const id = Number(roomId);
|
||||
if (!id) return;
|
||||
socket.join(`duel:${id}`);
|
||||
try {
|
||||
const room = await getDuelRoom(id);
|
||||
if (room) socket.emit('duel:state', room);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('duel:unsubscribe', (roomId) => {
|
||||
const id = Number(roomId);
|
||||
if (!id) return;
|
||||
socket.leave(`duel:${id}`);
|
||||
});
|
||||
|
||||
socket.on('feed:subscribe', () => {
|
||||
socket.join('feed');
|
||||
socket.emit('feed:history', getDropFeed());
|
||||
@@ -214,9 +225,10 @@ io.on('connection', async (socket) => {
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
if (userId) {
|
||||
trackDisconnect(userId, socket.id)
|
||||
.then(() => checkAchievements(userId))
|
||||
const uid = socket.data.userId || userId;
|
||||
if (uid) {
|
||||
trackDisconnect(uid, socket.id)
|
||||
.then(() => checkAchievements(uid))
|
||||
.catch((err) => console.error(err));
|
||||
}
|
||||
});
|
||||
@@ -244,6 +256,7 @@ async function bootstrap() {
|
||||
await ensureAdminFromEnv();
|
||||
await ensureAchievements();
|
||||
await ensureCategories();
|
||||
await ensureShopOsuPacks();
|
||||
} catch (err) {
|
||||
console.error(err.message || err);
|
||||
process.exit(1);
|
||||
@@ -253,14 +266,12 @@ async function bootstrap() {
|
||||
console.log(`API listening on http://localhost:${PORT}`);
|
||||
try {
|
||||
await ensureJackpotRound();
|
||||
await recoverDuelSpins();
|
||||
startEmptyDuelRoomPurge();
|
||||
// Online vault dividends: check every 60s for completed 10-min ticks
|
||||
// Online vault dividends: check every 30s for completed ticks (5 min online)
|
||||
setInterval(() => {
|
||||
settleOnlineVaultForConnected().catch((err) =>
|
||||
console.error('vault online interval', err)
|
||||
);
|
||||
}, 60_000);
|
||||
}, 30_000);
|
||||
} catch (err) {
|
||||
console.error('Failed to bootstrap game services', err);
|
||||
}
|
||||
|
||||
@@ -19,21 +19,24 @@ export function publicUser(user) {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
balance: user.balance,
|
||||
balance: user.balance == null ? '0' : user.balance.toString(),
|
||||
osuBalance: user.osuBalance ?? 0,
|
||||
prBalance: user.prBalance ?? 0,
|
||||
prBalance: user.prBalance == null ? '0' : String(user.prBalance),
|
||||
lastAdAt: user.lastAdAt || null,
|
||||
role: user.role,
|
||||
avatarUrl: user.avatarUrl || '',
|
||||
bio: user.bio || '',
|
||||
autoSellEnabled: Boolean(user.autoSellEnabled),
|
||||
autoSellThresholdCents: user.autoSellThresholdCents ?? 1000,
|
||||
autoSellThresholdCents:
|
||||
user.autoSellThresholdCents == null
|
||||
? '1000'
|
||||
: user.autoSellThresholdCents.toString(),
|
||||
prestigeCount: user.prestigeCount ?? 0,
|
||||
prestigeChanceBonus: user.prestigeChanceBonus ?? 0,
|
||||
prestigeGainBonus: user.prestigeGainBonus ?? 0,
|
||||
prestigeWearBonus: user.prestigeWearBonus ?? 0,
|
||||
prestigeOpenBonus: user.prestigeOpenBonus ?? 0,
|
||||
prestigeCaseDiscount: user.prestigeCaseDiscount ?? 0,
|
||||
prestigePrBonus: user.prestigePrBonus ?? 0,
|
||||
prestigeQualityBonus: user.prestigeQualityBonus ?? 0,
|
||||
prestigeAnimReduction: user.prestigeAnimReduction ?? 0,
|
||||
prestigeRefundChance: user.prestigeRefundChance ?? 0,
|
||||
|
||||
@@ -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
@@ -3,7 +3,7 @@ import { settleVaultYield } from './vault.js';
|
||||
|
||||
export const MAX_SOCKETS_PER_USER = Math.max(
|
||||
1,
|
||||
Number(process.env.MAX_SOCKETS_PER_USER || 10)
|
||||
Number(process.env.MAX_SOCKETS_PER_USER || 2)
|
||||
);
|
||||
|
||||
/** @type {Map<number, { sockets: Set<string>, sessionStartedAt: number, lastVaultCredit?: number }>} */
|
||||
@@ -35,11 +35,13 @@ export function totalSocketCount() {
|
||||
|
||||
/**
|
||||
* Track an authenticated socket. Updates lastConnection on first socket for this user.
|
||||
* Rejects when the account already has MAX_SOCKETS_PER_USER live windows.
|
||||
* Caps concurrent sockets at MAX_SOCKETS_PER_USER. When full, the oldest socket is
|
||||
* replaced (covers refresh spam / ghost connections) instead of blocking the new tab.
|
||||
* Slot claim is synchronous so concurrent connects cannot exceed the max.
|
||||
* @returns {Promise<{ ok: true, count: number, max: number, vaultCredited?: number } | { ok: false, count: number, max: number }>}
|
||||
* @param {import('socket.io').Server | null} [io]
|
||||
* @returns {Promise<{ ok: true, count: number, max: number, vaultCredited?: string, replaced?: string[] } | { ok: false, count: number, max: number }>}
|
||||
*/
|
||||
export async function trackConnect(userId, socketId) {
|
||||
export async function trackConnect(userId, socketId, io = null) {
|
||||
const id = Number(userId);
|
||||
const max = MAX_SOCKETS_PER_USER;
|
||||
if (!id || !socketId) {
|
||||
@@ -52,18 +54,42 @@ export async function trackConnect(userId, socketId) {
|
||||
online.set(id, entry);
|
||||
}
|
||||
|
||||
// Drop ids that are no longer connected (HMR / crashed tabs leave ghosts otherwise)
|
||||
pruneDeadSockets(entry, io);
|
||||
|
||||
if (entry.sockets.size === 0) {
|
||||
entry.sessionStartedAt = Date.now();
|
||||
}
|
||||
|
||||
if (entry.sockets.has(socketId)) {
|
||||
return { ok: true, count: entry.sockets.size, max };
|
||||
}
|
||||
|
||||
if (entry.sockets.size >= max) {
|
||||
return { ok: false, count: entry.sockets.size, max };
|
||||
// At capacity: free the oldest slot(s) so a refresh never soft-locks the only open tab
|
||||
const replaced = [];
|
||||
while (entry.sockets.size >= max) {
|
||||
const oldest = entry.sockets.values().next().value;
|
||||
if (!oldest) break;
|
||||
entry.sockets.delete(oldest);
|
||||
replaced.push(oldest);
|
||||
const oldSock = io?.sockets?.sockets?.get(oldest);
|
||||
if (oldSock) {
|
||||
try {
|
||||
oldSock.emit('session:replaced', {
|
||||
reason: 'slot_taken',
|
||||
max,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
oldSock.disconnect(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Claim slot before any await (avoids race past the limit)
|
||||
entry.sockets.add(socketId);
|
||||
const isFirst = entry.sockets.size === 1;
|
||||
let vaultCredited = 0;
|
||||
const isFirst = entry.sockets.size === 1 && replaced.length === 0;
|
||||
let vaultCredited = '0';
|
||||
|
||||
if (isFirst) {
|
||||
try {
|
||||
@@ -77,13 +103,23 @@ export async function trackConnect(userId, socketId) {
|
||||
// Offline yield accrued while disconnected
|
||||
try {
|
||||
const settled = await settleVaultYield(id, 'offline');
|
||||
vaultCredited = settled.credited;
|
||||
vaultCredited = settled.credited || '0';
|
||||
} catch (err) {
|
||||
console.error('presence vault offline settle', err);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, count: entry.sockets.size, max, vaultCredited };
|
||||
return { ok: true, count: entry.sockets.size, max, vaultCredited, replaced };
|
||||
}
|
||||
|
||||
function pruneDeadSockets(entry, io) {
|
||||
if (!entry?.sockets?.size) return;
|
||||
if (!io?.sockets?.sockets) return;
|
||||
for (const sid of [...entry.sockets]) {
|
||||
if (!io.sockets.sockets.has(sid)) {
|
||||
entry.sockets.delete(sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +159,17 @@ export async function trackDisconnect(userId, socketId) {
|
||||
|
||||
/** Live playtime including the current open session (if any). */
|
||||
export function livePlayTimeSeconds(user) {
|
||||
const stored = Number(user?.playTimeSeconds ?? 0);
|
||||
let stored = 0;
|
||||
try {
|
||||
// playTimeSeconds is Prisma BigInt — avoid Number() on the raw value path
|
||||
// for consistency; seconds stay well within safe integer range for decades.
|
||||
stored = Number(typeof user?.playTimeSeconds === 'bigint'
|
||||
? user.playTimeSeconds
|
||||
: BigInt(user?.playTimeSeconds ?? 0));
|
||||
} catch {
|
||||
stored = 0;
|
||||
}
|
||||
if (!Number.isFinite(stored) || stored < 0) stored = 0;
|
||||
const entry = online.get(Number(user?.id));
|
||||
if (!entry) return stored;
|
||||
const live = Math.max(0, Math.floor((Date.now() - entry.sessionStartedAt) / 1000));
|
||||
@@ -143,14 +189,14 @@ export function serializePresence(user) {
|
||||
/** Settle online vault yield for all currently connected users. */
|
||||
export async function settleOnlineVaultForConnected() {
|
||||
const ids = onlineUserIds();
|
||||
let total = 0;
|
||||
let total = 0n;
|
||||
for (const uid of ids) {
|
||||
try {
|
||||
const settled = await settleVaultYield(uid, 'online');
|
||||
total += settled.credited;
|
||||
total += BigInt(settled.credited || '0');
|
||||
} catch (err) {
|
||||
console.error('vault online tick', uid, err);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
return total.toString();
|
||||
}
|
||||
|
||||
+134
-57
@@ -1,17 +1,19 @@
|
||||
import { applyPercentFloor, toCentsBigInt } from './cents.js';
|
||||
|
||||
/** 1_000_000_000_000.00 cr in cents */
|
||||
export const PRESTIGE_COST_CENTS = 100_000_000_000_000n;
|
||||
|
||||
/** Page unlock: 10_000_000_000.00 cr total wealth (balance + unlocked inventory). */
|
||||
export const PRESTIGE_VISIBLE_AT_CENTS = 1_000_000_000_000;
|
||||
export const PRESTIGE_VISIBLE_AT_CENTS = 1_000_000_000_000n;
|
||||
|
||||
/** Base Pr granted on every prestige. */
|
||||
export const PR_BASE_REWARD = 1000;
|
||||
|
||||
/**
|
||||
* For every 100_000_000_000.00 cr above the prestige cost, +1000 Pr on average.
|
||||
* For every 250_000_000_000.00 cr above the prestige cost, +1000 Pr on average.
|
||||
* (Stored in cents.)
|
||||
*/
|
||||
export const PR_EXCESS_CHUNK_CENTS = 10_000_000_000_000n;
|
||||
export const PR_EXCESS_CHUNK_CENTS = 25_000_000_000_000n;
|
||||
export const PR_PER_EXCESS_CHUNK = 1000;
|
||||
|
||||
const BASE_AD_COOLDOWN_MS = 60_000;
|
||||
@@ -19,12 +21,14 @@ const BASE_AD_COOLDOWN_MS = 60_000;
|
||||
/**
|
||||
* Skill tree upgrades bought with Pr (stackable branches).
|
||||
* level = how many times this branch has been purchased.
|
||||
* maxLevel = hard cap on purchases.
|
||||
*/
|
||||
export const PRESTIGE_SKILLS = {
|
||||
chance: {
|
||||
id: 'chance',
|
||||
field: 'prestigeChanceBonus',
|
||||
delta: 1,
|
||||
maxLevel: 100,
|
||||
title: 'Fortune',
|
||||
description: '+1% drop luck on case opens (permanent)',
|
||||
unit: '%',
|
||||
@@ -34,6 +38,7 @@ export const PRESTIGE_SKILLS = {
|
||||
id: 'gain',
|
||||
field: 'prestigeGainBonus',
|
||||
delta: 2,
|
||||
maxLevel: 20,
|
||||
title: 'Yield',
|
||||
description: '+2% credits on sell / auto-sell (permanent)',
|
||||
unit: '%',
|
||||
@@ -43,6 +48,7 @@ export const PRESTIGE_SKILLS = {
|
||||
id: 'polish',
|
||||
field: 'prestigeWearBonus',
|
||||
delta: 1,
|
||||
maxLevel: 100,
|
||||
title: 'Polish',
|
||||
description: 'Better float / wear rolls on case opens',
|
||||
unit: '',
|
||||
@@ -52,25 +58,27 @@ export const PRESTIGE_SKILLS = {
|
||||
id: 'bulk',
|
||||
field: 'prestigeOpenBonus',
|
||||
delta: 1,
|
||||
maxLevel: 20,
|
||||
title: 'Bulk',
|
||||
description: '+1 max simultaneous case opens (permanent)',
|
||||
unit: '',
|
||||
branch: 'drops',
|
||||
},
|
||||
discount: {
|
||||
id: 'discount',
|
||||
field: 'prestigeCaseDiscount',
|
||||
prFarmer: {
|
||||
id: 'prFarmer',
|
||||
field: 'prestigePrBonus',
|
||||
delta: 2,
|
||||
title: 'Discount',
|
||||
description: '−2% case open price (permanent)',
|
||||
maxLevel: 50,
|
||||
title: 'PR Farmer',
|
||||
description: '+2% Pr obtained when you prestige (permanent)',
|
||||
unit: '%',
|
||||
prefix: '−',
|
||||
branch: 'economy',
|
||||
},
|
||||
quality: {
|
||||
id: 'quality',
|
||||
field: 'prestigeQualityBonus',
|
||||
delta: 1,
|
||||
maxLevel: 100,
|
||||
title: 'Quality',
|
||||
description: 'Better float / wear rolls on case opens (items below Field-Tested get upgraded)',
|
||||
unit: '',
|
||||
@@ -80,6 +88,7 @@ export const PRESTIGE_SKILLS = {
|
||||
id: 'noTime',
|
||||
field: 'prestigeAnimReduction',
|
||||
delta: 10,
|
||||
maxLevel: 100,
|
||||
title: 'noTime',
|
||||
description: 'Reduced case opening animation time',
|
||||
unit: '%',
|
||||
@@ -90,6 +99,7 @@ export const PRESTIGE_SKILLS = {
|
||||
id: 'refund',
|
||||
field: 'prestigeRefundChance',
|
||||
delta: 2,
|
||||
maxLevel: 20,
|
||||
title: 'Refund',
|
||||
description: 'Chance to get a free case open refund',
|
||||
unit: '%',
|
||||
@@ -99,8 +109,9 @@ export const PRESTIGE_SKILLS = {
|
||||
id: 'respin',
|
||||
field: 'prestigeRespinChance',
|
||||
delta: 1,
|
||||
maxLevel: 20,
|
||||
title: 'Respin',
|
||||
description: 'Chance to get a free case respin (respins all cases if multi-opening)',
|
||||
description: 'Chance to free-respin the open (shows first result, then reels restart)',
|
||||
unit: '%',
|
||||
branch: 'drops',
|
||||
},
|
||||
@@ -108,6 +119,7 @@ export const PRESTIGE_SKILLS = {
|
||||
id: 'vaultCap',
|
||||
field: 'prestigeVaultSlots',
|
||||
delta: 1,
|
||||
maxLevel: 10,
|
||||
title: 'Vault Capacity',
|
||||
description: '+1 max staking slot in the vault',
|
||||
unit: '',
|
||||
@@ -118,6 +130,7 @@ export const PRESTIGE_SKILLS = {
|
||||
id: 'offlineYield',
|
||||
field: 'prestigeOfflineYield',
|
||||
delta: 10,
|
||||
maxLevel: 100,
|
||||
title: 'Offline Yield',
|
||||
description: 'Increased dividends for staked items while offline',
|
||||
unit: '%',
|
||||
@@ -127,6 +140,7 @@ export const PRESTIGE_SKILLS = {
|
||||
id: 'onlineYield',
|
||||
field: 'prestigeOnlineYield',
|
||||
delta: 10,
|
||||
maxLevel: 100,
|
||||
title: 'Online Yield',
|
||||
description: 'Increased dividends for staked items while online',
|
||||
unit: '%',
|
||||
@@ -142,6 +156,37 @@ export function skillUpgradeCost(skillId, level = 0) {
|
||||
return Math.round((1000 + lv * 500) * mult);
|
||||
}
|
||||
|
||||
/** Total Pr cost to buy `count` levels starting at `level`. */
|
||||
export function skillUpgradeCostBulk(skillId, level = 0, count = 1) {
|
||||
const n = Math.max(1, Math.min(100, Math.floor(Number(count) || 1)));
|
||||
const start = Math.max(0, Number(level) || 0);
|
||||
let total = 0;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
total += skillUpgradeCost(skillId, start + i);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function skillMaxLevel(skillId) {
|
||||
const skill = PRESTIGE_SKILLS[skillId];
|
||||
const max = Number(skill?.maxLevel);
|
||||
return Number.isFinite(max) && max > 0 ? Math.floor(max) : Infinity;
|
||||
}
|
||||
|
||||
/** How many levels can still be bought before the cap. */
|
||||
export function skillLevelsRemaining(user, skillId) {
|
||||
const max = skillMaxLevel(skillId);
|
||||
if (!Number.isFinite(max)) return Infinity;
|
||||
return Math.max(0, max - skillLevelFromUser(user, skillId));
|
||||
}
|
||||
|
||||
/** True when every prestige skill is at its max level. */
|
||||
export function isPrestigeSkillTreeMaxed(user) {
|
||||
return Object.keys(PRESTIGE_SKILLS).every(
|
||||
(id) => skillLevelFromUser(user, id) >= skillMaxLevel(id)
|
||||
);
|
||||
}
|
||||
|
||||
export function skillLevelFromUser(user, skillId) {
|
||||
const skill = PRESTIGE_SKILLS[skillId];
|
||||
if (!skill) return 0;
|
||||
@@ -150,35 +195,64 @@ export function skillLevelFromUser(user, skillId) {
|
||||
return Math.floor(value / delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected Pr (no RNG) for UI preview.
|
||||
*/
|
||||
export function estimatePrReward(balanceCents) {
|
||||
const balance = BigInt(balanceCents);
|
||||
const cost = PRESTIGE_COST_CENTS;
|
||||
if (balance < cost) return 0;
|
||||
const excess = balance - cost;
|
||||
const chunk = PR_EXCESS_CHUNK_CENTS;
|
||||
const exact = Number(excess) / Number(chunk);
|
||||
return Math.round(PR_BASE_REWARD + exact * PR_PER_EXCESS_CHUNK);
|
||||
/** Parse Pr amounts (stored as decimal strings; may exceed i64). */
|
||||
export function toPrBigInt(value) {
|
||||
return toCentsBigInt(value);
|
||||
}
|
||||
|
||||
export function prJson(value) {
|
||||
return toPrBigInt(value).toString();
|
||||
}
|
||||
|
||||
/** Apply PR Farmer % to a base Pr reward (floor). Returns BigInt. */
|
||||
export function applyPrBonus(basePr, prBonusPct = 0) {
|
||||
const base = toPrBigInt(basePr);
|
||||
const pct = Math.max(0, Math.floor(Number(prBonusPct) || 0));
|
||||
if (base <= 0n) return 0n;
|
||||
if (pct <= 0) return base;
|
||||
return (base * BigInt(100 + pct)) / 100n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pr reward: 1000 base + 1000 per full 100B cr excess, plus a fractional
|
||||
* roll so partial chunks average to the remaining Pr.
|
||||
* Expected Pr (no RNG) for UI preview. Returns string.
|
||||
*/
|
||||
export function computePrReward(balanceCents) {
|
||||
const balance = BigInt(balanceCents);
|
||||
export function estimatePrReward(balanceCents, prBonusPct = 0) {
|
||||
const balance = toCentsBigInt(balanceCents);
|
||||
const cost = PRESTIGE_COST_CENTS;
|
||||
if (balance < cost) return '0';
|
||||
const excess = balance - cost;
|
||||
const chunk = PR_EXCESS_CHUNK_CENTS;
|
||||
const full = excess / chunk;
|
||||
const rem = excess % chunk;
|
||||
// Round partial chunk: rem/chunk >= 0.5 → +1 chunk of Pr
|
||||
const half = chunk / 2n;
|
||||
const roundedChunks = full + (rem >= half ? 1n : 0n);
|
||||
const base = BigInt(PR_BASE_REWARD) + roundedChunks * BigInt(PR_PER_EXCESS_CHUNK);
|
||||
return prJson(applyPrBonus(base, prBonusPct));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pr reward: 1000 base + 1000 per full 250B cr excess, plus a fractional
|
||||
* roll so partial chunks average to the remaining Pr.
|
||||
* PR Farmer adds +N% to the final grant.
|
||||
* Returns string (may exceed Number.MAX_SAFE_INTEGER).
|
||||
*/
|
||||
export function computePrReward(balanceCents, prBonusPct = 0) {
|
||||
const balance = toCentsBigInt(balanceCents);
|
||||
const cost = PRESTIGE_COST_CENTS;
|
||||
const excess = balance > cost ? balance - cost : 0n;
|
||||
const chunk = PR_EXCESS_CHUNK_CENTS;
|
||||
const full = Number(excess / chunk);
|
||||
const full = excess / chunk;
|
||||
const rem = excess % chunk;
|
||||
const frac = Number(rem) / Number(chunk);
|
||||
const bonus =
|
||||
full * PR_PER_EXCESS_CHUNK +
|
||||
(Math.random() < frac ? PR_PER_EXCESS_CHUNK : 0);
|
||||
return PR_BASE_REWARD + bonus;
|
||||
// Math.random() < rem/chunk without Number overflow
|
||||
let extra = 0n;
|
||||
if (rem > 0n) {
|
||||
const scale = 1_000_000_000n;
|
||||
const roll = BigInt(Math.floor(Math.random() * 1e9));
|
||||
if (roll * chunk < rem * scale) extra = BigInt(PR_PER_EXCESS_CHUNK);
|
||||
}
|
||||
const base = BigInt(PR_BASE_REWARD) + full * BigInt(PR_PER_EXCESS_CHUNK) + extra;
|
||||
return prJson(applyPrBonus(base, prBonusPct));
|
||||
}
|
||||
|
||||
export function adCooldownMs(_user) {
|
||||
@@ -192,7 +266,7 @@ export function serializePrestige(user) {
|
||||
gainBonus: user?.prestigeGainBonus ?? 0,
|
||||
wearBonus: user?.prestigeWearBonus ?? 0,
|
||||
openBonus: user?.prestigeOpenBonus ?? 0,
|
||||
caseDiscount: user?.prestigeCaseDiscount ?? 0,
|
||||
prBonus: user?.prestigePrBonus ?? 0,
|
||||
qualityBonus: user?.prestigeQualityBonus ?? 0,
|
||||
animReduction: user?.prestigeAnimReduction ?? 0,
|
||||
refundChance: user?.prestigeRefundChance ?? 0,
|
||||
@@ -200,7 +274,7 @@ export function serializePrestige(user) {
|
||||
vaultSlots: user?.prestigeVaultSlots ?? 0,
|
||||
offlineYield: user?.prestigeOfflineYield ?? 0,
|
||||
onlineYield: user?.prestigeOnlineYield ?? 0,
|
||||
prBalance: user?.prBalance ?? 0,
|
||||
prBalance: prJson(user?.prBalance ?? 0),
|
||||
costCents: Number(PRESTIGE_COST_CENTS),
|
||||
visibleAtCents: PRESTIGE_VISIBLE_AT_CENTS,
|
||||
prBaseReward: PR_BASE_REWARD,
|
||||
@@ -210,10 +284,17 @@ export function serializePrestige(user) {
|
||||
}
|
||||
|
||||
export function serializeSkillTree(user) {
|
||||
const pr = toPrBigInt(user?.prBalance ?? 0);
|
||||
return Object.values(PRESTIGE_SKILLS).map((skill) => {
|
||||
const level = skillLevelFromUser(user, skill.id);
|
||||
const maxLevel = skillMaxLevel(skill.id);
|
||||
const room = Math.max(0, maxLevel - level);
|
||||
const atMax = room <= 0;
|
||||
const current = Number(user?.[skill.field] ?? 0);
|
||||
const cost = skillUpgradeCost(skill.id, level);
|
||||
const buyCount = atMax ? 0 : 1;
|
||||
const buyCount10 = atMax ? 0 : Math.min(10, room);
|
||||
const cost = buyCount ? skillUpgradeCost(skill.id, level) : 0;
|
||||
const cost10 = buyCount10 ? skillUpgradeCostBulk(skill.id, level, buyCount10) : 0;
|
||||
return {
|
||||
id: skill.id,
|
||||
title: skill.title,
|
||||
@@ -223,28 +304,24 @@ export function serializeSkillTree(user) {
|
||||
prefix: skill.prefix || (skill.unit === '%' || skill.unit === 'cr-cents' ? '+' : ''),
|
||||
branch: skill.branch || 'misc',
|
||||
level,
|
||||
maxLevel: Number.isFinite(maxLevel) ? maxLevel : null,
|
||||
atMax,
|
||||
room,
|
||||
buyCount10,
|
||||
current,
|
||||
nextValue: current + skill.delta,
|
||||
nextValue: atMax ? current : current + skill.delta,
|
||||
nextValue10: atMax ? current : current + skill.delta * buyCount10,
|
||||
cost,
|
||||
canAfford: (user?.prBalance ?? 0) >= cost,
|
||||
cost10,
|
||||
canAfford: !atMax && pr >= BigInt(cost),
|
||||
canAfford10: buyCount10 >= 2 && pr >= BigInt(cost10),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply case price discount (%). Min 1 cent. */
|
||||
export function applyCaseDiscount(priceCents, discountPct = 0) {
|
||||
const base = Math.max(0, Math.floor(Number(priceCents) || 0));
|
||||
const pct = Math.max(0, Number(discountPct) || 0);
|
||||
if (pct <= 0) return Math.max(1, base);
|
||||
return Math.max(1, Math.floor(base * (1 - pct / 100)));
|
||||
}
|
||||
|
||||
/** Apply lifetime gain % to a credit amount (cents). */
|
||||
/** Apply lifetime gain % to a credit amount (cents). Returns BigInt. */
|
||||
export function applyGainMultiplier(cents, gainBonusPct = 0) {
|
||||
const base = Number(cents) || 0;
|
||||
const pct = Number(gainBonusPct) || 0;
|
||||
if (pct <= 0) return Math.floor(base);
|
||||
return Math.floor(base * (1 + pct / 100));
|
||||
return applyPercentFloor(cents, gainBonusPct);
|
||||
}
|
||||
|
||||
export function totalLuckPercent(catalogLuck = 0, prestigeChanceBonus = 0) {
|
||||
@@ -255,17 +332,17 @@ export function isValidSkill(skillId) {
|
||||
return Boolean(PRESTIGE_SKILLS[skillId]);
|
||||
}
|
||||
|
||||
/** Visible if already prestiged, or total wealth reaches the unlock threshold. */
|
||||
export function isPrestigePageVisible(user, totalCents) {
|
||||
if ((user?.prestigeCount ?? 0) > 0) return true;
|
||||
if ((user?.prBalance ?? 0) > 0) return true;
|
||||
return (Number(totalCents) || 0) >= PRESTIGE_VISIBLE_AT_CENTS;
|
||||
}
|
||||
|
||||
export async function getUnlockedInventoryValueCents(db, userId) {
|
||||
const rows = await db.inventoryItem.findMany({
|
||||
where: { userId, locked: false },
|
||||
select: { valueCents: true },
|
||||
});
|
||||
return rows.reduce((sum, r) => sum + (Number(r.valueCents) || 0), 0);
|
||||
return rows.reduce((sum, r) => sum + toCentsBigInt(r.valueCents), 0n);
|
||||
}
|
||||
|
||||
/** Visible if already prestiged, or total wealth reaches the unlock threshold. */
|
||||
export function isPrestigePageVisible(user, totalCents) {
|
||||
if ((user?.prestigeCount ?? 0) > 0) return true;
|
||||
if (toPrBigInt(user?.prBalance) > 0n) return true;
|
||||
return toCentsBigInt(totalCents) >= PRESTIGE_VISIBLE_AT_CENTS;
|
||||
}
|
||||
|
||||
+12
-8
@@ -8,17 +8,21 @@ export function getIO() {
|
||||
return io;
|
||||
}
|
||||
|
||||
export function emitJackpot(event, payload) {
|
||||
export function emitJackpot(roundId, event, payload) {
|
||||
if (!io) return;
|
||||
const id = Number(roundId);
|
||||
if (id) {
|
||||
io.to(`jackpot:${id}`).emit(event, payload);
|
||||
}
|
||||
// Legacy global room
|
||||
io.to('jackpot').emit(event, payload);
|
||||
// Hub list subscribers need spin/result for live cards
|
||||
if (event === 'jackpot:spin' || event === 'jackpot:result') {
|
||||
io.to('jackpots').emit(event, payload);
|
||||
}
|
||||
}
|
||||
|
||||
export function emitDuel(roomId, event, payload) {
|
||||
export function emitJackpotsList(payload) {
|
||||
if (!io) return;
|
||||
io.to(`duel:${roomId}`).emit(event, payload);
|
||||
}
|
||||
|
||||
export function emitDuelsList(payload) {
|
||||
if (!io) return;
|
||||
io.to('duels').emit('duels:list', payload);
|
||||
io.to('jackpots').emit('jackpots:list', payload);
|
||||
}
|
||||
|
||||
+173
-25
@@ -3,7 +3,7 @@ import bcrypt from 'bcrypt';
|
||||
import { prisma } from '../db.js';
|
||||
import { requireAdmin, publicUser } from '../middleware.js';
|
||||
import { isValidRarity, RARITIES } from '../rarities.js';
|
||||
import { createUploader } from '../upload.js';
|
||||
import { createUploader, processUploadedImage } from '../upload.js';
|
||||
import { onlineUserCount, totalSocketCount } from '../presence.js';
|
||||
import { ACHIEVEMENT_DEFS } from '../achievements.js';
|
||||
import {
|
||||
@@ -12,10 +12,24 @@ import {
|
||||
ensureCategory,
|
||||
listCategoriesWithCounts,
|
||||
} from '../categories.js';
|
||||
import { toCentsBigInt, centsJson } from '../cents.js';
|
||||
import { toPrBigInt, prJson } from '../prestige.js';
|
||||
import {
|
||||
listShopOsuPacksAdmin,
|
||||
updateShopOsuPack,
|
||||
setOsuTopupsSectionEnabled,
|
||||
} from '../shopOsu.js';
|
||||
|
||||
const router = Router();
|
||||
const upload = createUploader('asset');
|
||||
|
||||
/** Parse admin money input (cents) → non-negative decimal string for Prisma. */
|
||||
function parseCentsField(raw) {
|
||||
const bi = toCentsBigInt(raw);
|
||||
if (bi < 0n) return null;
|
||||
return centsJson(bi);
|
||||
}
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const username = String(req.body.username || '').trim();
|
||||
@@ -45,6 +59,39 @@ router.get('/rarities', requireAdmin, (_req, res) => {
|
||||
res.json({ rarities: RARITIES });
|
||||
});
|
||||
|
||||
router.get('/shop/osu-packs', requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const data = await listShopOsuPacksAdmin();
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to load osu packs' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/shop/osu-section', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const enabled = Boolean(req.body.enabled);
|
||||
const sectionEnabled = await setOsuTopupsSectionEnabled(enabled);
|
||||
const data = await listShopOsuPacksAdmin();
|
||||
res.json({ ...data, sectionEnabled });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to update shop section' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/shop/osu-packs/:id', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const pack = await updateShopOsuPack(req.params.id, req.body || {});
|
||||
res.json({ pack });
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
if (status >= 500) console.error(err);
|
||||
res.status(status).json({ error: err.message || 'Failed to update pack' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/categories', requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const categories = await listCategoriesWithCounts();
|
||||
@@ -218,16 +265,21 @@ router.get('/users', requireAdmin, async (_req, res) => {
|
||||
|
||||
res.json({
|
||||
users: users.map((u) => {
|
||||
const inventoryValue = u.inventory.reduce((s, inv) => s + inv.valueCents, 0);
|
||||
const inventoryValue = u.inventory.reduce(
|
||||
(s, inv) => s + toCentsBigInt(inv.valueCents),
|
||||
0n
|
||||
);
|
||||
const balance = toCentsBigInt(u.balance);
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatarUrl: u.avatarUrl || '',
|
||||
balance: u.balance,
|
||||
balance: centsJson(balance),
|
||||
osuBalance: u.osuBalance ?? 0,
|
||||
inventoryValue,
|
||||
prBalance: prJson(u.prBalance ?? 0),
|
||||
inventoryValue: centsJson(inventoryValue),
|
||||
inventoryCount: u.inventory.length,
|
||||
totalWealth: u.balance + inventoryValue,
|
||||
totalWealth: centsJson(balance + inventoryValue),
|
||||
casesOpened: opensByUser.get(u.id) || 0,
|
||||
achievements: u._count.achievements,
|
||||
prestigeCount: u.prestigeCount ?? 0,
|
||||
@@ -253,22 +305,19 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
|
||||
|
||||
const data = {};
|
||||
if (req.body.balance !== undefined) {
|
||||
const balance = Number(req.body.balance);
|
||||
if (!Number.isInteger(balance) || balance < 0) {
|
||||
const balance = String(req.body.balance).trim();
|
||||
if (!/^\d+$/.test(balance)) {
|
||||
return res.status(400).json({ error: 'balance must be a non-negative integer (cents)' });
|
||||
}
|
||||
data.balance = balance;
|
||||
}
|
||||
if (req.body.balanceDelta !== undefined) {
|
||||
const delta = Number(req.body.balanceDelta);
|
||||
if (!Number.isInteger(delta)) {
|
||||
const deltaRaw = String(req.body.balanceDelta).trim();
|
||||
if (!/^-?\d+$/.test(deltaRaw)) {
|
||||
return res.status(400).json({ error: 'balanceDelta must be an integer (cents)' });
|
||||
}
|
||||
const next = Number(user.balance) + delta;
|
||||
if (next < 0) {
|
||||
return res.status(400).json({ error: 'Resulting balance cannot be negative' });
|
||||
}
|
||||
data.balance = next;
|
||||
const next = toCentsBigInt(user.balance) + BigInt(deltaRaw);
|
||||
data.balance = (next < 0n ? 0n : next).toString();
|
||||
}
|
||||
if (req.body.osuBalance !== undefined) {
|
||||
const osu = Number(req.body.osuBalance);
|
||||
@@ -277,6 +326,21 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
|
||||
}
|
||||
data.osuBalance = osu;
|
||||
}
|
||||
if (req.body.osuBalanceDelta !== undefined) {
|
||||
const delta = Number(req.body.osuBalanceDelta);
|
||||
if (!Number.isInteger(delta) || delta === 0) {
|
||||
return res.status(400).json({ error: 'osuBalanceDelta must be a non-zero integer' });
|
||||
}
|
||||
data.osuBalance = Math.max(0, (user.osuBalance ?? 0) + delta);
|
||||
}
|
||||
if (req.body.prBalanceDelta !== undefined) {
|
||||
const deltaRaw = String(req.body.prBalanceDelta).trim();
|
||||
if (!/^-?\d+$/.test(deltaRaw) || deltaRaw === '0' || deltaRaw === '-0') {
|
||||
return res.status(400).json({ error: 'prBalanceDelta must be a non-zero integer' });
|
||||
}
|
||||
const next = toPrBigInt(user.prBalance) + BigInt(deltaRaw);
|
||||
data.prBalance = prJson(next < 0n ? 0n : next);
|
||||
}
|
||||
|
||||
if (!Object.keys(data).length) {
|
||||
return res.status(400).json({ error: 'No changes provided' });
|
||||
@@ -284,12 +348,15 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const next = await tx.user.update({ where: { id }, data });
|
||||
if (data.balance !== undefined && Number(data.balance) !== Number(user.balance)) {
|
||||
if (
|
||||
data.balance !== undefined &&
|
||||
toCentsBigInt(data.balance) !== toCentsBigInt(user.balance)
|
||||
) {
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: id,
|
||||
type: 'admin_adjust',
|
||||
amount: Number(data.balance) - Number(user.balance),
|
||||
amount: centsJson(toCentsBigInt(data.balance) - toCentsBigInt(user.balance)),
|
||||
meta: JSON.stringify({ reason: 'admin_panel', by: req.session.userId }),
|
||||
},
|
||||
});
|
||||
@@ -305,14 +372,20 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
|
||||
});
|
||||
|
||||
router.post('/upload', requireAdmin, (req, res) => {
|
||||
upload.single('image')(req, res, (err) => {
|
||||
upload.single('image')(req, res, async (err) => {
|
||||
if (err) {
|
||||
return res.status(400).json({ error: err.message || 'Upload failed' });
|
||||
}
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
try {
|
||||
await processUploadedImage(req.file);
|
||||
res.status(201).json({ url: `/uploads/${req.file.filename}` });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
res.status(400).json({ error: 'Image processing failed' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -356,11 +429,11 @@ router.post('/cases', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const imageUrl = String(req.body.imageUrl || '');
|
||||
const price = Number(req.body.price);
|
||||
const price = parseCentsField(req.body.price);
|
||||
const active = req.body.active !== false;
|
||||
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
if (!Number.isInteger(price) || price < 0) {
|
||||
if (price == null) {
|
||||
return res.status(400).json({ error: 'Price must be a non-negative integer (cents)' });
|
||||
}
|
||||
|
||||
@@ -381,8 +454,8 @@ router.put('/cases/:id', requireAdmin, async (req, res) => {
|
||||
if (req.body.name !== undefined) data.name = String(req.body.name).trim();
|
||||
if (req.body.imageUrl !== undefined) data.imageUrl = String(req.body.imageUrl);
|
||||
if (req.body.price !== undefined) {
|
||||
const price = Number(req.body.price);
|
||||
if (!Number.isInteger(price) || price < 0) {
|
||||
const price = parseCentsField(req.body.price);
|
||||
if (price == null) {
|
||||
return res.status(400).json({ error: 'Invalid price' });
|
||||
}
|
||||
data.price = price;
|
||||
@@ -425,13 +498,13 @@ router.post('/items', requireAdmin, async (req, res) => {
|
||||
const imageUrl = String(req.body.imageUrl || '');
|
||||
const rarity = String(req.body.rarity || '');
|
||||
const category = await ensureCategory(req.body.category);
|
||||
const marketValue = Number(req.body.marketValue);
|
||||
const marketValue = parseCentsField(req.body.marketValue);
|
||||
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
if (!isValidRarity(rarity)) {
|
||||
return res.status(400).json({ error: `Invalid rarity. Use: ${RARITIES.join(', ')}` });
|
||||
}
|
||||
if (!Number.isInteger(marketValue) || marketValue < 0) {
|
||||
if (marketValue == null) {
|
||||
return res.status(400).json({ error: 'marketValue must be a non-negative integer (cents)' });
|
||||
}
|
||||
|
||||
@@ -461,8 +534,8 @@ router.put('/items/:id', requireAdmin, async (req, res) => {
|
||||
data.category = await ensureCategory(req.body.category);
|
||||
}
|
||||
if (req.body.marketValue !== undefined) {
|
||||
const marketValue = Number(req.body.marketValue);
|
||||
if (!Number.isInteger(marketValue) || marketValue < 0) {
|
||||
const marketValue = parseCentsField(req.body.marketValue);
|
||||
if (marketValue == null) {
|
||||
return res.status(400).json({ error: 'Invalid marketValue' });
|
||||
}
|
||||
data.marketValue = marketValue;
|
||||
@@ -545,4 +618,79 @@ router.delete('/case-items/:id', requireAdmin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Preview counts before a server prestige wipe.
|
||||
*/
|
||||
router.get('/prestige-server', requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const [players, inventory, prestigeLogs, opensKept] = await Promise.all([
|
||||
prisma.user.count({ where: { role: { not: 'admin' } } }),
|
||||
prisma.inventoryItem.count({
|
||||
where: { user: { role: { not: 'admin' } } },
|
||||
}),
|
||||
prisma.prestigeLog.count({
|
||||
where: { user: { role: { not: 'admin' } } },
|
||||
}),
|
||||
prisma.transaction.count({
|
||||
where: {
|
||||
user: { role: { not: 'admin' } },
|
||||
type: { in: ['open_case', 'jackpot_win'] },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
res.json({
|
||||
preview: {
|
||||
players,
|
||||
inventoryItems: inventory,
|
||||
prestigeLogs,
|
||||
statsTransactionsKept: opensKept,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to load prestige-server preview' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Full player wipe (server prestige). Requires admin password confirmation.
|
||||
* Achievement fields must be configured before activation.
|
||||
*/
|
||||
router.post('/prestige-server', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const password = String(req.body.password || '');
|
||||
if (!password) {
|
||||
return res.status(400).json({ error: 'Admin password required' });
|
||||
}
|
||||
|
||||
const admin = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
||||
if (!admin || admin.role !== 'admin') {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(password, admin.passwordHash);
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: 'Invalid admin password' });
|
||||
}
|
||||
|
||||
const ach = req.body.achievement || {};
|
||||
const { runServerPrestige } = await import('../serverPrestige.js');
|
||||
const result = await runServerPrestige({
|
||||
achievementKey: ach.key,
|
||||
achievementName: ach.name,
|
||||
achievementDescription: ach.description,
|
||||
achievementIcon: ach.icon,
|
||||
});
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
...result,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const status = err.status || 500;
|
||||
res.status(status).json({ error: err.message || 'Server prestige failed' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -33,7 +33,7 @@ router.post('/register', async (req, res) => {
|
||||
data: {
|
||||
username,
|
||||
passwordHash,
|
||||
balance: STARTING_BALANCE,
|
||||
balance: String(STARTING_BALANCE),
|
||||
osuBalance: STARTING_OSU,
|
||||
role: 'user',
|
||||
},
|
||||
@@ -43,7 +43,7 @@ router.post('/register', async (req, res) => {
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'seed_credit',
|
||||
amount: STARTING_BALANCE,
|
||||
amount: String(STARTING_BALANCE),
|
||||
meta: JSON.stringify({ reason: 'starting_balance' }),
|
||||
},
|
||||
});
|
||||
@@ -52,7 +52,7 @@ router.post('/register', async (req, res) => {
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'seed_osu',
|
||||
amount: STARTING_OSU,
|
||||
amount: String(STARTING_OSU),
|
||||
meta: JSON.stringify({ reason: 'starting_osu' }),
|
||||
},
|
||||
});
|
||||
@@ -97,20 +97,20 @@ router.post('/login', async (req, res) => {
|
||||
});
|
||||
|
||||
// Offline vault yield if user is not already socket-connected
|
||||
let vaultCredited = 0;
|
||||
let vaultCredited = '0';
|
||||
try {
|
||||
const { isConnected } = await import('../presence.js');
|
||||
const { settleVaultYield } = await import('../vault.js');
|
||||
if (!isConnected(user.id)) {
|
||||
const settled = await settleVaultYield(user.id, 'offline');
|
||||
vaultCredited = settled.credited;
|
||||
vaultCredited = settled.credited || '0';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('login vault settle', err);
|
||||
}
|
||||
|
||||
const refreshed =
|
||||
vaultCredited > 0
|
||||
vaultCredited !== '0' && vaultCredited !== 0
|
||||
? await prisma.user.findUnique({ where: { id: user.id } })
|
||||
: withConn;
|
||||
|
||||
|
||||
+58
-46
@@ -15,7 +15,8 @@ import {
|
||||
fillCatalogSlots,
|
||||
formatCaseItemsWithLuck,
|
||||
} from '../catalog.js';
|
||||
import { totalLuckPercent, applyCaseDiscount } from '../prestige.js';
|
||||
import { totalLuckPercent } from '../prestige.js';
|
||||
import { toCentsBigInt, adjustUserBalance, spendUserBalance, centsJson } from '../cents.js';
|
||||
import { checkAchievements } from '../achievements.js';
|
||||
|
||||
const router = Router();
|
||||
@@ -47,7 +48,6 @@ async function loadUserLuckAndKeys(userId) {
|
||||
return {
|
||||
luckPercent: 0,
|
||||
openBonus: 0,
|
||||
caseDiscount: 0,
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
@@ -59,7 +59,6 @@ async function loadUserLuckAndKeys(userId) {
|
||||
prestigeChanceBonus: true,
|
||||
prestigeWearBonus: true,
|
||||
prestigeOpenBonus: true,
|
||||
prestigeCaseDiscount: true,
|
||||
prestigeQualityBonus: true,
|
||||
prestigeAnimReduction: true,
|
||||
prestigeRefundChance: true,
|
||||
@@ -70,7 +69,6 @@ async function loadUserLuckAndKeys(userId) {
|
||||
return {
|
||||
luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus),
|
||||
openBonus: user?.prestigeOpenBonus ?? 0,
|
||||
caseDiscount: user?.prestigeCaseDiscount ?? 0,
|
||||
user,
|
||||
};
|
||||
}
|
||||
@@ -102,17 +100,20 @@ router.get('/', async (req, res) => {
|
||||
include: {
|
||||
items: { include: { item: true } },
|
||||
},
|
||||
orderBy: { price: 'asc' },
|
||||
});
|
||||
cases.sort((a, b) => {
|
||||
const pa = toCentsBigInt(a.price);
|
||||
const pb = toCentsBigInt(b.price);
|
||||
if (pa < pb) return -1;
|
||||
if (pa > pb) return 1;
|
||||
return a.id - b.id;
|
||||
});
|
||||
const progressMap = await loadKeyProgressMap(userId);
|
||||
const { luckPercent, openBonus, caseDiscount } = await loadUserLuckAndKeys(userId);
|
||||
const { luckPercent, openBonus } = await loadUserLuckAndKeys(userId);
|
||||
res.json({
|
||||
catalogLuckPercent: luckPercent,
|
||||
cases: cases.map((c) => {
|
||||
const unitPrice = userId
|
||||
? applyCaseDiscount(c.price, caseDiscount)
|
||||
: Number(c.price);
|
||||
const formatted = formatCase(c, luckPercent, unitPrice);
|
||||
const formatted = formatCase(c, luckPercent, userId ? c.price : Number(c.price));
|
||||
if (!userId) return formatted;
|
||||
const progress = progressMap.get(c.id);
|
||||
return {
|
||||
@@ -140,10 +141,8 @@ router.get('/:id', async (req, res) => {
|
||||
if (!c || !c.active) {
|
||||
return res.status(404).json({ error: 'Case not found' });
|
||||
}
|
||||
const { luckPercent, openBonus, caseDiscount } = await loadUserLuckAndKeys(userId);
|
||||
const unitPrice = userId
|
||||
? applyCaseDiscount(c.price, caseDiscount)
|
||||
: Number(c.price);
|
||||
const { luckPercent, openBonus } = await loadUserLuckAndKeys(userId);
|
||||
const unitPrice = userId ? c.price : Number(c.price);
|
||||
const formatted = formatCase(c, luckPercent, unitPrice);
|
||||
if (userId) {
|
||||
const progress = await prisma.caseKeyProgress.findUnique({
|
||||
@@ -201,13 +200,10 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const unitPrice = applyCaseDiscount(c.price, user.prestigeCaseDiscount ?? 0);
|
||||
const totalCost = unitPrice * count;
|
||||
if (user.balance < totalCost) {
|
||||
const err = new Error('Insufficient balance');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
const unitPrice = toCentsBigInt(c.price);
|
||||
const totalCost = unitPrice * BigInt(count);
|
||||
// Charge up front (atomic WHERE balance >= cost). Refunded opens credit back later.
|
||||
await spendUserBalance(tx, userId, totalCost);
|
||||
|
||||
const catalogLuck = await countCompletedChapters(tx, userId);
|
||||
const luckPercent = totalLuckPercent(catalogLuck, user.prestigeChanceBonus);
|
||||
@@ -231,7 +227,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
floatValue: rolled.floatValue,
|
||||
wear: rolled.wear,
|
||||
paintSeed: rolled.paintSeed,
|
||||
valueCents: rolled.valueCents,
|
||||
valueCents: centsJson(rolled.valueCents),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -241,7 +237,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
data: {
|
||||
userId,
|
||||
type: 'open_case',
|
||||
amount: -unitPrice,
|
||||
amount: centsJson(-unitPrice),
|
||||
meta: JSON.stringify({
|
||||
caseId: c.id,
|
||||
caseName: c.name,
|
||||
@@ -251,12 +247,12 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
floatValue: rolled.floatValue,
|
||||
wear: rolled.wear,
|
||||
paintSeed: rolled.paintSeed,
|
||||
valueCents: rolled.valueCents,
|
||||
valueCents: centsJson(rolled.valueCents),
|
||||
willAutoSell,
|
||||
openIndex: i,
|
||||
openCount: batchCount,
|
||||
unitPrice,
|
||||
basePrice: Number(c.price),
|
||||
unitPrice: centsJson(unitPrice),
|
||||
basePrice: centsJson(c.price),
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -268,11 +264,11 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
name: item.name,
|
||||
imageUrl: item.imageUrl,
|
||||
rarity: item.rarity,
|
||||
marketValue: item.marketValue,
|
||||
marketValue: toCentsBigInt(item.marketValue).toString(),
|
||||
floatValue: rolled.floatValue,
|
||||
wear: rolled.wear,
|
||||
paintSeed: rolled.paintSeed,
|
||||
valueCents: rolled.valueCents,
|
||||
valueCents: toCentsBigInt(rolled.valueCents).toString(),
|
||||
willAutoSell,
|
||||
});
|
||||
}
|
||||
@@ -281,12 +277,32 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
|
||||
let { drops, createdTxIds } = await createDrops(count);
|
||||
|
||||
// Respin: re-roll all drops for free (replace first batch)
|
||||
// Respin: re-roll all drops for free (replace first batch).
|
||||
// preRespinItems animate the discarded roll. Auto-sell-eligible items stay in
|
||||
// inventory (with inventoryId) so the client can credit them when the first
|
||||
// reels finish; the rest are discarded immediately.
|
||||
let preRespinItems = null;
|
||||
const respinChance = Math.max(0, Number(user.prestigeRespinChance) || 0);
|
||||
const didRespin = respinChance > 0 && Math.random() * 100 < respinChance;
|
||||
if (didRespin) {
|
||||
const oldIds = drops.map((d) => d.inventoryId);
|
||||
await tx.inventoryItem.deleteMany({ where: { id: { in: oldIds } } });
|
||||
const discardIds = drops
|
||||
.filter((d) => !d.willAutoSell)
|
||||
.map((d) => d.inventoryId)
|
||||
.filter(Boolean);
|
||||
const pendingAutoSellIds = drops
|
||||
.filter((d) => d.willAutoSell)
|
||||
.map((d) => d.inventoryId)
|
||||
.filter(Boolean);
|
||||
|
||||
preRespinItems = drops.map((d) => ({
|
||||
...d,
|
||||
inventoryId: d.willAutoSell ? d.inventoryId : null,
|
||||
preRespin: true,
|
||||
}));
|
||||
|
||||
if (discardIds.length) {
|
||||
await tx.inventoryItem.deleteMany({ where: { id: { in: discardIds } } });
|
||||
}
|
||||
await tx.transaction.deleteMany({ where: { id: { in: createdTxIds } } });
|
||||
const again = await createDrops(count);
|
||||
drops = again.drops;
|
||||
@@ -295,11 +311,12 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
data: {
|
||||
userId,
|
||||
type: 'case_respin',
|
||||
amount: 0,
|
||||
amount: '0',
|
||||
meta: JSON.stringify({
|
||||
caseId: c.id,
|
||||
openCount: count,
|
||||
inventoryItemIds: drops.map((d) => d.inventoryId),
|
||||
preRespinPendingAutoSellIds: pendingAutoSellIds,
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -326,41 +343,35 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
let balanceDelta = -totalCost;
|
||||
let updatedUser = await tx.user.findUnique({ where: { id: userId } });
|
||||
if (didRefund) {
|
||||
balanceDelta += totalCost;
|
||||
updatedUser = await adjustUserBalance(tx, userId, updatedUser.balance, totalCost);
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId,
|
||||
type: 'case_refund',
|
||||
amount: totalCost,
|
||||
amount: centsJson(totalCost),
|
||||
meta: JSON.stringify({
|
||||
caseId: c.id,
|
||||
caseName: c.name,
|
||||
openCount: count,
|
||||
unitPrice,
|
||||
totalCost,
|
||||
unitPrice: centsJson(unitPrice),
|
||||
totalCost: centsJson(totalCost),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedUser = await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
balance: { increment: balanceDelta },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user: publicUser(updatedUser),
|
||||
caseName: c.name,
|
||||
items: drops,
|
||||
preRespinItems,
|
||||
keyProgress: serializeKeyProgress(updatedProgress, 0, openBonus),
|
||||
keysGained: applied.keysGained,
|
||||
catalog,
|
||||
unitPrice,
|
||||
totalCost,
|
||||
unitPrice: centsJson(unitPrice),
|
||||
totalCost: centsJson(totalCost),
|
||||
refunded: didRefund,
|
||||
respin: didRespin,
|
||||
animReduction: user.prestigeAnimReduction ?? 0,
|
||||
@@ -374,6 +385,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
caseName: result.caseName,
|
||||
count: result.items.length,
|
||||
items: result.items,
|
||||
preRespinItems: result.preRespinItems,
|
||||
keyProgress: result.keyProgress,
|
||||
keysGained: result.keysGained,
|
||||
catalog: result.catalog,
|
||||
|
||||
@@ -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;
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import { prisma } from '../db.js';
|
||||
import { requireAuth } from '../middleware.js';
|
||||
import { getDropFeed, pushDrop } from '../feed.js';
|
||||
import { toCentsBigInt } from '../cents.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -13,9 +14,89 @@ router.get('/', (_req, res) => {
|
||||
* Announce case drop(s) to the live feed (after client spin finishes).
|
||||
* For multi-opens: send all inventoryItemIds — only the best drop is shown,
|
||||
* with count of how many times that item appeared (e.g. x3).
|
||||
*
|
||||
* Respin flow:
|
||||
* - First spin (discarded): body.snapshotItems (no inventory left)
|
||||
* - Final spin: body.inventoryItemIds + body.respin=true
|
||||
*/
|
||||
router.post('/announce', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const caseName = String(req.body.caseName || '').slice(0, 80);
|
||||
const respin = Boolean(req.body.respin);
|
||||
const snapshotItems = Array.isArray(req.body.snapshotItems)
|
||||
? req.body.snapshotItems
|
||||
: null;
|
||||
|
||||
// Discarded first roll (respin preview) — announce from client snapshot.
|
||||
// Prefer compact payload: snapshotBest + openCount (+ optional count).
|
||||
if (snapshotItems?.length || req.body.snapshotBest) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.session.userId },
|
||||
select: { username: true, avatarUrl: true },
|
||||
});
|
||||
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
const normalizeOne = (raw) => ({
|
||||
id: Number(raw?.id) || 0,
|
||||
name: String(raw?.name || '').slice(0, 120),
|
||||
imageUrl: String(raw?.imageUrl || '').slice(0, 500),
|
||||
rarity: String(raw?.rarity || '').slice(0, 40),
|
||||
valueCents: String(raw?.valueCents ?? raw?.marketValue ?? '0'),
|
||||
wear: raw?.wear ? String(raw.wear).slice(0, 40) : '',
|
||||
floatValue: Number(raw?.floatValue) || 0,
|
||||
paintSeed: Number(raw?.paintSeed) || 0,
|
||||
});
|
||||
|
||||
let best;
|
||||
let openCount;
|
||||
let count;
|
||||
|
||||
if (req.body.snapshotBest) {
|
||||
best = normalizeOne(req.body.snapshotBest);
|
||||
if (!best.id || !best.name) {
|
||||
return res.status(400).json({ error: 'Invalid snapshot best item' });
|
||||
}
|
||||
openCount = Math.max(1, Math.floor(Number(req.body.openCount) || 1));
|
||||
count = Math.max(1, Math.min(openCount, Math.floor(Number(req.body.count) || 1)));
|
||||
} else {
|
||||
const normalized = snapshotItems.map(normalizeOne).filter((it) => it.id && it.name);
|
||||
if (!normalized.length) {
|
||||
return res.status(400).json({ error: 'Invalid snapshot items' });
|
||||
}
|
||||
best = normalized[0];
|
||||
for (const it of normalized) {
|
||||
if (toCentsBigInt(it.valueCents) > toCentsBigInt(best.valueCents)) best = it;
|
||||
}
|
||||
openCount = normalized.length;
|
||||
count = normalized.filter((it) => it.id === best.id).length;
|
||||
}
|
||||
|
||||
const drop = pushDrop({
|
||||
source: 'case',
|
||||
caseName,
|
||||
respin: false,
|
||||
count,
|
||||
openCount,
|
||||
inventoryIds: [],
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl || '',
|
||||
item: {
|
||||
inventoryId: null,
|
||||
id: best.id,
|
||||
name: best.name,
|
||||
imageUrl: best.imageUrl,
|
||||
rarity: best.rarity,
|
||||
marketValue: best.valueCents,
|
||||
valueCents: best.valueCents,
|
||||
wear: best.wear,
|
||||
floatValue: best.floatValue,
|
||||
paintSeed: best.paintSeed,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({ drop });
|
||||
}
|
||||
|
||||
let ids = [];
|
||||
if (Array.isArray(req.body.inventoryItemIds)) {
|
||||
ids = req.body.inventoryItemIds.map(Number).filter(Boolean);
|
||||
@@ -51,15 +132,15 @@ router.post('/announce', requireAuth, async (req, res) => {
|
||||
// Best drop = highest instance value (tie-break: first in batch)
|
||||
let best = invItems[0];
|
||||
for (const inv of invItems) {
|
||||
if (inv.valueCents > best.valueCents) best = inv;
|
||||
if (toCentsBigInt(inv.valueCents) > toCentsBigInt(best.valueCents)) best = inv;
|
||||
}
|
||||
|
||||
const count = invItems.filter((inv) => inv.itemId === best.itemId).length;
|
||||
const caseName = String(req.body.caseName || '').slice(0, 80);
|
||||
|
||||
const drop = pushDrop({
|
||||
source: 'case',
|
||||
caseName,
|
||||
respin,
|
||||
count,
|
||||
openCount: invItems.length,
|
||||
inventoryIds: invItems.map((i) => i.id),
|
||||
|
||||
@@ -3,10 +3,9 @@ import { prisma } from '../db.js';
|
||||
import { requireAuth, publicUser } from '../middleware.js';
|
||||
import { applyAutoSell } from '../autoSell.js';
|
||||
import { applyGainMultiplier } from '../prestige.js';
|
||||
import { toCentsBigInt, adjustUserBalance, centsJson, creditsToCents } from '../cents.js';
|
||||
import {
|
||||
maxVaultSlots,
|
||||
summarizeVaultRates,
|
||||
estimatePendingYield,
|
||||
summarizeVaultState,
|
||||
} from '../vault.js';
|
||||
import { isConnected } from '../presence.js';
|
||||
|
||||
@@ -54,16 +53,10 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
|
||||
const staked = items.filter((i) => i.staked);
|
||||
const mode = isConnected(userId) ? 'online' : 'offline';
|
||||
const rates = summarizeVaultRates(staked, user);
|
||||
|
||||
res.json({
|
||||
inventory: items.map(serializeInv),
|
||||
vault: {
|
||||
used: staked.length,
|
||||
slots: maxVaultSlots(user),
|
||||
...rates,
|
||||
pendingYield: estimatePendingYield(staked, user, mode),
|
||||
},
|
||||
vault: summarizeVaultState(user, staked, mode),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -78,11 +71,11 @@ router.patch('/auto-sell', requireAuth, async (req, res) => {
|
||||
data.autoSellEnabled = Boolean(req.body.enabled);
|
||||
}
|
||||
if (req.body.thresholdCredits != null) {
|
||||
const credits = Number(req.body.thresholdCredits);
|
||||
if (!Number.isFinite(credits) || credits < 0) {
|
||||
const cents = creditsToCents(req.body.thresholdCredits);
|
||||
if (cents == null) {
|
||||
return res.status(400).json({ error: 'Invalid threshold' });
|
||||
}
|
||||
data.autoSellThresholdCents = Math.round(credits * 100);
|
||||
data.autoSellThresholdCents = centsJson(cents);
|
||||
}
|
||||
|
||||
if (!Object.keys(data).length) {
|
||||
@@ -108,7 +101,8 @@ router.post('/auto-sell/apply', requireAuth, async (req, res) => {
|
||||
return res.json({ user: publicUser(await prisma.user.findUnique({ where: { id: req.session.userId } })), soldIds: [], total: 0 });
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const result = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
||||
if (!user) {
|
||||
const err = new Error('User not found');
|
||||
@@ -126,26 +120,37 @@ router.post('/auto-sell/apply', requireAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
const { sold, balanceDelta } = await applyAutoSell(tx, user, invItems);
|
||||
if (!balanceDelta) {
|
||||
return { user, soldIds: [], total: 0 };
|
||||
if (balanceDelta === 0n) {
|
||||
return { user, soldIds: [], total: 0n, sold: [] };
|
||||
}
|
||||
|
||||
const updatedUser = await tx.user.update({
|
||||
where: { id: req.session.userId },
|
||||
data: { balance: { increment: balanceDelta } },
|
||||
});
|
||||
const updatedUser = await adjustUserBalance(
|
||||
tx,
|
||||
req.session.userId,
|
||||
user.balance,
|
||||
balanceDelta
|
||||
);
|
||||
|
||||
const gainBonus = user.prestigeGainBonus ?? 0;
|
||||
return {
|
||||
user: updatedUser,
|
||||
soldIds: sold.map((inv) => inv.id),
|
||||
total: balanceDelta,
|
||||
sold: sold.map((inv) => ({
|
||||
id: inv.id,
|
||||
valueCents: centsJson(inv.valueCents),
|
||||
payoutCents: centsJson(applyGainMultiplier(inv.valueCents, gainBonus)),
|
||||
})),
|
||||
};
|
||||
});
|
||||
},
|
||||
{ timeout: 60_000, maxWait: 15_000 }
|
||||
);
|
||||
|
||||
res.json({
|
||||
user: publicUser(result.user),
|
||||
soldIds: result.soldIds,
|
||||
total: result.total,
|
||||
total: centsJson(result.total),
|
||||
sold: result.sold || [],
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
@@ -161,7 +166,8 @@ router.post('/sell', requireAuth, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Select at least one item to sell' });
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const result = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
||||
if (!user) {
|
||||
const err = new Error('User not found');
|
||||
@@ -176,7 +182,12 @@ router.post('/sell', requireAuth, async (req, res) => {
|
||||
locked: false,
|
||||
staked: false,
|
||||
},
|
||||
include: { item: true },
|
||||
select: {
|
||||
id: true,
|
||||
itemId: true,
|
||||
valueCents: true,
|
||||
favorite: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (invItems.length !== ids.length) {
|
||||
@@ -192,43 +203,49 @@ router.post('/sell', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const gainBonus = user.prestigeGainBonus ?? 0;
|
||||
let total = 0;
|
||||
|
||||
let total = 0n;
|
||||
for (const inv of invItems) {
|
||||
const payout = applyGainMultiplier(inv.valueCents, gainBonus);
|
||||
total += payout;
|
||||
await tx.inventoryItem.delete({ where: { id: inv.id } });
|
||||
total += applyGainMultiplier(inv.valueCents, gainBonus);
|
||||
}
|
||||
|
||||
await tx.inventoryItem.deleteMany({
|
||||
where: {
|
||||
id: { in: ids },
|
||||
userId: req.session.userId,
|
||||
locked: false,
|
||||
staked: false,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: req.session.userId,
|
||||
type: 'sell_item',
|
||||
amount: payout,
|
||||
amount: centsJson(total),
|
||||
meta: JSON.stringify({
|
||||
inventoryItemId: inv.id,
|
||||
itemId: inv.itemId,
|
||||
itemName: inv.item.name,
|
||||
valueCents: inv.valueCents,
|
||||
floatValue: inv.floatValue,
|
||||
wear: inv.wear,
|
||||
paintSeed: inv.paintSeed,
|
||||
count: invItems.length,
|
||||
inventoryItemIds: ids,
|
||||
prestigeGainBonus: gainBonus,
|
||||
payout,
|
||||
payout: centsJson(total),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedUser = await tx.user.update({
|
||||
where: { id: req.session.userId },
|
||||
data: { balance: { increment: total } },
|
||||
});
|
||||
const updatedUser = await adjustUserBalance(
|
||||
tx,
|
||||
req.session.userId,
|
||||
user.balance,
|
||||
total
|
||||
);
|
||||
|
||||
return { user: updatedUser, total, soldCount: invItems.length };
|
||||
});
|
||||
},
|
||||
{ timeout: 60_000, maxWait: 15_000 }
|
||||
);
|
||||
|
||||
res.json({
|
||||
user: publicUser(result.user),
|
||||
total: result.total,
|
||||
total: centsJson(result.total),
|
||||
soldCount: result.soldCount,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../middleware.js';
|
||||
import {
|
||||
getJackpotState,
|
||||
listJackpotBattles,
|
||||
getJackpotRound,
|
||||
createJackpotBattle,
|
||||
placeJackpotBet,
|
||||
withdrawJackpotBet,
|
||||
} from '../services/jackpot.js';
|
||||
@@ -16,25 +18,58 @@ function handleError(res, err) {
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const state = await getJackpotState();
|
||||
res.json({ round: state });
|
||||
const battles = await listJackpotBattles();
|
||||
res.json({ battles });
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const round = await getJackpotRound(req.params.id);
|
||||
if (!round) {
|
||||
return res.status(404).json({ error: 'Battle not found' });
|
||||
}
|
||||
res.json({ round });
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Create a battle by placing the first bet (no round id). */
|
||||
router.post('/bet', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const state = await placeJackpotBet(req.session.userId, req.body.inventoryItemIds);
|
||||
const state = await createJackpotBattle(
|
||||
req.session.userId,
|
||||
req.body.inventoryItemIds
|
||||
);
|
||||
res.json({ round: state });
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/withdraw', requireAuth, async (req, res) => {
|
||||
router.post('/:id/bet', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const state = await withdrawJackpotBet(req.session.userId, req.body.inventoryItemIds);
|
||||
const state = await placeJackpotBet(
|
||||
req.params.id,
|
||||
req.session.userId,
|
||||
req.body.inventoryItemIds
|
||||
);
|
||||
res.json({ round: state });
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/withdraw', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const state = await withdrawJackpotBet(
|
||||
req.params.id,
|
||||
req.session.userId,
|
||||
req.body.inventoryItemIds
|
||||
);
|
||||
res.json({ round: state });
|
||||
} catch (err) {
|
||||
handleError(res, err);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../db.js';
|
||||
import { toCentsBigInt, centsJson } from '../cents.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -17,22 +18,28 @@ router.get('/', async (_req, res) => {
|
||||
const ranked = users
|
||||
.map((u) => {
|
||||
const inventoryValue = u.inventory.reduce(
|
||||
(sum, inv) => sum + inv.valueCents,
|
||||
0
|
||||
(sum, inv) => sum + toCentsBigInt(inv.valueCents),
|
||||
0n
|
||||
);
|
||||
const balance = toCentsBigInt(u.balance);
|
||||
const totalWealth = balance + inventoryValue;
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
avatarUrl: u.avatarUrl || '',
|
||||
balance: u.balance,
|
||||
inventoryValue,
|
||||
totalWealth: u.balance + inventoryValue,
|
||||
balance: centsJson(balance),
|
||||
inventoryValue: centsJson(inventoryValue),
|
||||
totalWealth,
|
||||
itemCount: u.inventory.length,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.totalWealth - a.totalWealth)
|
||||
.sort((a, b) => (a.totalWealth < b.totalWealth ? 1 : a.totalWealth > b.totalWealth ? -1 : 0))
|
||||
.slice(0, 50)
|
||||
.map((entry, index) => ({ rank: index + 1, ...entry }));
|
||||
.map((entry, index) => ({
|
||||
rank: index + 1,
|
||||
...entry,
|
||||
totalWealth: centsJson(entry.totalWealth),
|
||||
}));
|
||||
|
||||
res.json({ leaderboard: ranked });
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,9 +12,13 @@ import {
|
||||
serializePrestige,
|
||||
serializeSkillTree,
|
||||
skillLevelFromUser,
|
||||
skillUpgradeCost,
|
||||
skillLevelsRemaining,
|
||||
skillUpgradeCostBulk,
|
||||
toPrBigInt,
|
||||
prJson,
|
||||
} from '../prestige.js';
|
||||
import { checkAchievements } from '../achievements.js';
|
||||
import { centsJson, toCentsBigInt, addCents, spendUserBalance } from '../cents.js';
|
||||
|
||||
const router = Router();
|
||||
const COST = PRESTIGE_COST_CENTS;
|
||||
@@ -36,18 +40,20 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const balance = BigInt(user.balance);
|
||||
const totalCents = Number(user.balance) + inventoryValue;
|
||||
const balance = toCentsBigInt(user.balance);
|
||||
const totalCents = balance + inventoryValue;
|
||||
const pageVisible = isPrestigePageVisible(user, totalCents);
|
||||
const canAfford = balance >= COST;
|
||||
const canPrestige = pageVisible && canAfford && lockedCount === 0;
|
||||
const previewPr = canAfford ? estimatePrReward(user.balance) : 0;
|
||||
const previewPr = canAfford
|
||||
? estimatePrReward(user.balance, user.prestigePrBonus ?? 0)
|
||||
: 0;
|
||||
|
||||
res.json({
|
||||
prestige: serializePrestige(user),
|
||||
skills: serializeSkillTree(user),
|
||||
pageVisible,
|
||||
totalCents,
|
||||
totalCents: totalCents.toString(),
|
||||
canAfford,
|
||||
lockedItems: lockedCount,
|
||||
canPrestige,
|
||||
@@ -55,7 +61,7 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
history: logs.map((l) => ({
|
||||
id: l.id,
|
||||
choice: l.choice,
|
||||
prGranted: l.prGranted ?? 0,
|
||||
prGranted: prJson(l.prGranted ?? 0),
|
||||
createdAt: l.createdAt,
|
||||
})),
|
||||
});
|
||||
@@ -85,11 +91,6 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
if (BigInt(user.balance) < COST) {
|
||||
const err = new Error('Insufficient balance for prestige');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const lockedCount = await tx.inventoryItem.count({
|
||||
where: { userId: user.id, locked: true },
|
||||
@@ -102,22 +103,28 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const prGranted = computePrReward(user.balance);
|
||||
// Lock funds atomically (balance >= COST). Remainder wiped with the reset below.
|
||||
await spendUserBalance(tx, user.id, COST, {
|
||||
message: 'Insufficient balance for prestige',
|
||||
});
|
||||
|
||||
const fresh = await tx.user.findUnique({ where: { id: user.id } });
|
||||
const previousBalance = toCentsBigInt(user.balance);
|
||||
const prGranted = computePrReward(previousBalance, user.prestigePrBonus ?? 0);
|
||||
const prestigeCount = (user.prestigeCount ?? 0) + 1;
|
||||
|
||||
await tx.inventoryItem.deleteMany({ where: { userId: user.id } });
|
||||
await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } });
|
||||
await tx.catalogEntry.deleteMany({ where: { userId: user.id } });
|
||||
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'prestige',
|
||||
amount: -Number(COST),
|
||||
amount: centsJson(-COST),
|
||||
meta: JSON.stringify({
|
||||
prestigeCount,
|
||||
prGranted,
|
||||
previousBalance: Number(user.balance),
|
||||
previousBalance: centsJson(previousBalance),
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -126,20 +133,22 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
data: {
|
||||
userId: user.id,
|
||||
choice: 'reset',
|
||||
prGranted,
|
||||
prGranted: prJson(prGranted),
|
||||
},
|
||||
});
|
||||
|
||||
// Wipe leftover credits after the COST spend (prestige resets economy).
|
||||
const updated = await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
prestigeCount,
|
||||
balance: 0,
|
||||
prBalance: { increment: prGranted },
|
||||
balance: '0',
|
||||
prBalance: addCents(fresh.prBalance, prGranted),
|
||||
vaultYieldAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
return { user: updated, prGranted };
|
||||
return { user: updated, prGranted: prJson(prGranted) };
|
||||
});
|
||||
|
||||
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
|
||||
@@ -164,6 +173,10 @@ router.post('/upgrade', requireAuth, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Unknown skill' });
|
||||
}
|
||||
|
||||
let count = Number(req.body?.count ?? 1);
|
||||
if (!Number.isFinite(count)) count = 1;
|
||||
count = Math.max(1, Math.min(10, Math.floor(count)));
|
||||
|
||||
const skill = PRESTIGE_SKILLS[skillId];
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
@@ -180,8 +193,16 @@ router.post('/upgrade', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const level = skillLevelFromUser(user, skillId);
|
||||
const cost = skillUpgradeCost(skillId, level);
|
||||
if ((user.prBalance ?? 0) < cost) {
|
||||
const room = skillLevelsRemaining(user, skillId);
|
||||
if (room <= 0) {
|
||||
const err = new Error('Skill is already at max level');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
count = Math.min(count, room);
|
||||
|
||||
const cost = skillUpgradeCostBulk(skillId, level, count);
|
||||
if (toPrBigInt(user.prBalance) < BigInt(cost)) {
|
||||
const err = new Error('Not enough Pr');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
@@ -190,8 +211,8 @@ router.post('/upgrade', requireAuth, async (req, res) => {
|
||||
const updated = await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
prBalance: { decrement: cost },
|
||||
[skill.field]: { increment: skill.delta },
|
||||
prBalance: addCents(user.prBalance, -cost),
|
||||
[skill.field]: { increment: skill.delta * count },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -199,11 +220,12 @@ router.post('/upgrade', requireAuth, async (req, res) => {
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'prestige_upgrade',
|
||||
amount: 0,
|
||||
amount: '0',
|
||||
meta: JSON.stringify({
|
||||
skillId,
|
||||
cost,
|
||||
delta: skill.delta,
|
||||
count,
|
||||
delta: skill.delta * count,
|
||||
field: skill.field,
|
||||
}),
|
||||
},
|
||||
@@ -212,6 +234,8 @@ router.post('/upgrade', requireAuth, async (req, res) => {
|
||||
return updated;
|
||||
});
|
||||
|
||||
checkAchievements(result.id).catch((err) => console.error('achievements', err));
|
||||
|
||||
res.json({
|
||||
user: publicUser(result),
|
||||
prestige: serializePrestige(result),
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { prisma } from '../db.js';
|
||||
import { requireAuth, publicUser } from '../middleware.js';
|
||||
import { getCompletedCatalogBadges } from '../catalog.js';
|
||||
@@ -13,32 +9,15 @@ import {
|
||||
checkAchievements,
|
||||
getUnlockedAchievements,
|
||||
} from '../achievements.js';
|
||||
import { toCentsBigInt, centsJson } from '../cents.js';
|
||||
import { createUploader, processUploadedImage, safeExt } from '../upload.js';
|
||||
|
||||
const router = Router();
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const uploadsDir = path.join(__dirname, '../../uploads');
|
||||
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadsDir),
|
||||
const upload = createUploader('avatar', {
|
||||
maxBytes: 2 * 1024 * 1024,
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase() || '.png';
|
||||
const safe = ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext) ? ext : '.png';
|
||||
cb(null, `avatar-${req.session.userId}-${Date.now()}${safe}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!file.mimetype.startsWith('image/')) {
|
||||
return cb(new Error('Only images are allowed'));
|
||||
}
|
||||
cb(null, true);
|
||||
cb(null, `avatar-${req.session.userId}-${Date.now()}${safeExt(file.originalname, file.mimetype)}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -70,6 +49,7 @@ function serializeInv(inv) {
|
||||
paintSeed: inv.paintSeed,
|
||||
valueCents: inv.valueCents,
|
||||
favorite: Boolean(inv.favorite),
|
||||
staked: Boolean(inv.staked),
|
||||
item: {
|
||||
id: inv.item.id,
|
||||
name: inv.item.name,
|
||||
@@ -103,18 +83,18 @@ router.get('/player/:username', async (req, res) => {
|
||||
});
|
||||
if (!user) return res.status(404).json({ error: 'Player not found' });
|
||||
|
||||
const inventoryValue = user.inventory.reduce((sum, inv) => sum + inv.valueCents, 0);
|
||||
const inventoryValue = user.inventory.reduce(
|
||||
(sum, inv) => sum + toCentsBigInt(inv.valueCents),
|
||||
0n
|
||||
);
|
||||
|
||||
await checkAchievements(user.id);
|
||||
|
||||
const [jackpotWins, duelWins, casesOpened, caseOpens, catalog, achievements] =
|
||||
const [jackpotWins, casesOpened, caseOpens, catalog, achievements] =
|
||||
await Promise.all([
|
||||
prisma.transaction.count({
|
||||
where: { userId: user.id, type: 'jackpot_win' },
|
||||
}),
|
||||
prisma.transaction.count({
|
||||
where: { userId: user.id, type: 'duel_win' },
|
||||
}),
|
||||
prisma.transaction.count({
|
||||
where: { userId: user.id, type: 'open_case' },
|
||||
}),
|
||||
@@ -127,10 +107,9 @@ router.get('/player/:username', async (req, res) => {
|
||||
user: publicUser(user),
|
||||
profile: {
|
||||
createdAt: user.createdAt,
|
||||
inventoryValue,
|
||||
inventoryValue: centsJson(inventoryValue),
|
||||
inventoryCount: user.inventory.length,
|
||||
jackpotWins,
|
||||
duelWins,
|
||||
casesOpened,
|
||||
caseOpens,
|
||||
catalogCompleted: catalog.completed,
|
||||
@@ -158,20 +137,17 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const inventoryValue = user.inventory.reduce(
|
||||
(sum, inv) => sum + (inv.locked ? 0 : inv.valueCents),
|
||||
0
|
||||
(sum, inv) => sum + (inv.locked ? 0n : toCentsBigInt(inv.valueCents)),
|
||||
0n
|
||||
);
|
||||
|
||||
await checkAchievements(user.id);
|
||||
|
||||
const [jackpotWins, duelWins, casesOpened, caseOpens, catalog, achievements] =
|
||||
const [jackpotWins, casesOpened, caseOpens, catalog, achievements] =
|
||||
await Promise.all([
|
||||
prisma.transaction.count({
|
||||
where: { userId: user.id, type: 'jackpot_win' },
|
||||
}),
|
||||
prisma.transaction.count({
|
||||
where: { userId: user.id, type: 'duel_win' },
|
||||
}),
|
||||
prisma.transaction.count({
|
||||
where: { userId: user.id, type: 'open_case' },
|
||||
}),
|
||||
@@ -184,10 +160,9 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
user: publicUser(user),
|
||||
profile: {
|
||||
createdAt: user.createdAt,
|
||||
inventoryValue,
|
||||
inventoryValue: centsJson(inventoryValue),
|
||||
inventoryCount: user.inventory.filter((i) => !i.locked).length,
|
||||
jackpotWins,
|
||||
duelWins,
|
||||
casesOpened,
|
||||
caseOpens,
|
||||
catalogCompleted: catalog.completed,
|
||||
@@ -256,6 +231,7 @@ router.post('/avatar', requireAuth, (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await processUploadedImage(req.file);
|
||||
const avatarUrl = `/uploads/${req.file.filename}`;
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.session.userId },
|
||||
|
||||
+79
-28
@@ -3,17 +3,18 @@ import { prisma } from '../db.js';
|
||||
import { requireAuth, publicUser } from '../middleware.js';
|
||||
import { checkAchievements } from '../achievements.js';
|
||||
import { adCooldownMs } from '../prestige.js';
|
||||
import { centsJson, creditUserBalance } from '../cents.js';
|
||||
import {
|
||||
getCreditPack,
|
||||
paymentsProvider,
|
||||
scaleCreditPack,
|
||||
scaleCreditPacks,
|
||||
} from '../shopCatalog.js';
|
||||
import { listEnabledShopOsuPacks, isOsuTopupsSectionEnabled } from '../shopOsu.js';
|
||||
import { confirmMockOsuPayment, createOsuCheckout } from '../payments.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Packages: spend osu → receive credits (cents). */
|
||||
export const SHOP_PACKAGES = [
|
||||
{ id: 'starter', name: 'Starter Pack', osuCost: 10, credits: 5000, blurb: '50.00 cr' },
|
||||
{ id: 'boost', name: 'Boost Pack', osuCost: 25, credits: 15000, blurb: '150.00 cr' },
|
||||
{ id: 'pro', name: 'Pro Pack', osuCost: 50, credits: 35000, blurb: '350.00 cr' },
|
||||
{ id: 'whale', name: 'Whale Pack', osuCost: 100, credits: 80000, blurb: '800.00 cr' },
|
||||
];
|
||||
|
||||
const AD_REWARD_MIN = 10000; // 100.00 cr
|
||||
const AD_REWARD_MAX = 20000; // 200.00 cr
|
||||
|
||||
@@ -38,13 +39,19 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const packages = SHOP_PACKAGES.map((pack) => ({
|
||||
...pack,
|
||||
baseOsuCost: pack.osuCost,
|
||||
}));
|
||||
const packages = scaleCreditPacks(user.balance);
|
||||
const sectionEnabled = await isOsuTopupsSectionEnabled();
|
||||
const osuTopups = sectionEnabled ? await listEnabledShopOsuPacks() : [];
|
||||
|
||||
res.json({
|
||||
packages,
|
||||
osuTopups,
|
||||
osuTopupsEnabled: sectionEnabled && osuTopups.length > 0,
|
||||
payments: {
|
||||
provider: paymentsProvider(),
|
||||
currency: 'eur',
|
||||
mock: paymentsProvider() === 'mock',
|
||||
},
|
||||
user: publicUser(user),
|
||||
ad: adPayload(user),
|
||||
});
|
||||
@@ -54,11 +61,12 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** Spend osu → credits (amount scaled to live balance at purchase time). */
|
||||
router.post('/buy', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const packageId = String(req.body.packageId || '');
|
||||
const pack = SHOP_PACKAGES.find((p) => p.id === packageId);
|
||||
if (!pack) {
|
||||
const packDef = getCreditPack(packageId);
|
||||
if (!packDef) {
|
||||
return res.status(400).json({ error: 'Unknown package' });
|
||||
}
|
||||
|
||||
@@ -69,6 +77,7 @@ router.post('/buy', requireAuth, async (req, res) => {
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
const pack = scaleCreditPack(packDef, user.balance);
|
||||
const osuCost = pack.osuCost;
|
||||
if (user.osuBalance < osuCost) {
|
||||
const err = new Error('Not enough osu');
|
||||
@@ -76,35 +85,36 @@ router.post('/buy', requireAuth, async (req, res) => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const updated = await tx.user.update({
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
osuBalance: { decrement: osuCost },
|
||||
balance: { increment: pack.credits },
|
||||
},
|
||||
data: { osuBalance: { decrement: osuCost } },
|
||||
});
|
||||
const updated = await creditUserBalance(tx, user.id, pack.credits);
|
||||
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'shop_purchase',
|
||||
amount: pack.credits,
|
||||
amount: centsJson(pack.credits),
|
||||
meta: JSON.stringify({
|
||||
packageId: pack.id,
|
||||
osuCost,
|
||||
credits: pack.credits,
|
||||
balancePct: pack.balancePct,
|
||||
balanceBefore: String(user.balance),
|
||||
scaled: pack.scaled,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return { user: updated, osuCost };
|
||||
return { user: updated, osuCost, pack };
|
||||
});
|
||||
|
||||
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
|
||||
|
||||
res.json({
|
||||
user: publicUser(result.user),
|
||||
package: { ...pack, osuCost: result.osuCost },
|
||||
package: result.pack,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
@@ -113,6 +123,49 @@ router.post('/buy', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** Start real-money → osu checkout (mock or future Stripe). */
|
||||
router.post('/osu/checkout', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const packId = String(req.body.packId || '');
|
||||
const checkout = await createOsuCheckout(req.session.userId, packId);
|
||||
res.json(checkout);
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
if (status >= 500) console.error(err);
|
||||
res.status(status).json({ error: err.message || 'Checkout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
/** Complete mock payment and credit osu. */
|
||||
router.post('/osu/confirm', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const orderId = String(req.body.orderId || '');
|
||||
const result = await confirmMockOsuPayment(req.session.userId, orderId);
|
||||
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
|
||||
res.json({
|
||||
user: publicUser(result.user),
|
||||
order: result.order,
|
||||
alreadyPaid: result.alreadyPaid,
|
||||
osuGranted: result.order.osuAmount,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
if (status >= 500) console.error(err);
|
||||
res.status(status).json({ error: err.message || 'Payment confirmation failed' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Stripe webhook stub — verify signature & fulfill when PAYMENTS_PROVIDER=stripe.
|
||||
* Keep raw body parsing in mind when wiring Stripe.
|
||||
*/
|
||||
router.post('/osu/webhook', async (req, res) => {
|
||||
if (paymentsProvider() !== 'stripe') {
|
||||
return res.status(404).json({ error: 'Stripe webhooks not enabled' });
|
||||
}
|
||||
res.status(501).json({ error: 'Stripe webhook not implemented yet' });
|
||||
});
|
||||
|
||||
router.post('/ad', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
@@ -138,19 +191,17 @@ router.post('/ad', requireAuth, async (req, res) => {
|
||||
AD_REWARD_MIN + Math.floor(Math.random() * (AD_REWARD_MAX - AD_REWARD_MIN + 1));
|
||||
const now = new Date();
|
||||
|
||||
const updated = await tx.user.update({
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
balance: { increment: reward },
|
||||
lastAdAt: now,
|
||||
},
|
||||
data: { lastAdAt: now },
|
||||
});
|
||||
const updated = await creditUserBalance(tx, user.id, reward);
|
||||
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: 'ad_reward',
|
||||
amount: reward,
|
||||
amount: centsJson(reward),
|
||||
meta: JSON.stringify({ reward }),
|
||||
},
|
||||
});
|
||||
|
||||
+37
-46
@@ -4,8 +4,7 @@ import { requireAuth, publicUser } from '../middleware.js';
|
||||
import {
|
||||
maxVaultSlots,
|
||||
settleVaultYield,
|
||||
summarizeVaultRates,
|
||||
estimatePendingYield,
|
||||
summarizeVaultState,
|
||||
} from '../vault.js';
|
||||
import { isConnected } from '../presence.js';
|
||||
|
||||
@@ -18,6 +17,8 @@ router.post('/stake', requireAuth, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Select at least one item to stake' });
|
||||
}
|
||||
|
||||
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
||||
if (!user) {
|
||||
@@ -61,16 +62,31 @@ router.post('/stake', requireAuth, async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
return { user, stakedIds: ids, maxSlots, used: used + ids.length };
|
||||
// Shared timer: start only when vault was empty
|
||||
let updatedUser = user;
|
||||
if (used === 0 || !user.vaultYieldAt) {
|
||||
updatedUser = await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: { vaultYieldAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
const staked = await tx.inventoryItem.findMany({
|
||||
where: { userId: user.id, staked: true },
|
||||
include: { item: true },
|
||||
});
|
||||
|
||||
return {
|
||||
user: updatedUser,
|
||||
stakedIds: ids,
|
||||
vault: summarizeVaultState(updatedUser, staked, mode),
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
user: publicUser(result.user),
|
||||
stakedIds: result.stakedIds,
|
||||
vault: {
|
||||
used: result.used,
|
||||
slots: result.maxSlots,
|
||||
},
|
||||
vault: result.vault,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
@@ -86,7 +102,6 @@ router.post('/unstake', requireAuth, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Select at least one item to unstake' });
|
||||
}
|
||||
|
||||
// Settle yield before unstake (online if connected, else offline)
|
||||
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
|
||||
const settled = await settleVaultYield(req.session.userId, mode);
|
||||
|
||||
@@ -120,16 +135,25 @@ router.post('/unstake', requireAuth, async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
const used = await tx.inventoryItem.count({
|
||||
const remaining = await tx.inventoryItem.findMany({
|
||||
where: { userId: user.id, staked: true },
|
||||
include: { item: true },
|
||||
});
|
||||
|
||||
const refreshed = await tx.user.findUnique({ where: { id: user.id } });
|
||||
let refreshed = user;
|
||||
if (remaining.length === 0 && user.vaultYieldAt) {
|
||||
refreshed = await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: { vaultYieldAt: null },
|
||||
});
|
||||
} else {
|
||||
refreshed = await tx.user.findUnique({ where: { id: user.id } });
|
||||
}
|
||||
|
||||
return {
|
||||
user: refreshed,
|
||||
unstakedIds: ids,
|
||||
maxSlots: maxVaultSlots(refreshed),
|
||||
used,
|
||||
vault: summarizeVaultState(refreshed, remaining, mode),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -137,10 +161,7 @@ router.post('/unstake', requireAuth, async (req, res) => {
|
||||
user: publicUser(result.user),
|
||||
unstakedIds: result.unstakedIds,
|
||||
yieldCredited: settled.credited,
|
||||
vault: {
|
||||
used: result.used,
|
||||
slots: result.maxSlots,
|
||||
},
|
||||
vault: result.vault,
|
||||
});
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
@@ -149,34 +170,4 @@ router.post('/unstake', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/claim', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
|
||||
const settled = await settleVaultYield(req.session.userId, mode);
|
||||
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const staked = await prisma.inventoryItem.findMany({
|
||||
where: { userId: user.id, staked: true },
|
||||
include: { item: true },
|
||||
});
|
||||
const rates = summarizeVaultRates(staked, user);
|
||||
|
||||
res.json({
|
||||
user: publicUser(user),
|
||||
credited: settled.credited,
|
||||
ticks: settled.ticks,
|
||||
vault: {
|
||||
used: staked.length,
|
||||
slots: maxVaultSlots(user),
|
||||
...rates,
|
||||
pendingYield: estimatePendingYield(staked, user, mode),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message || 'Failed to claim vault yield' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+286
-210
@@ -1,9 +1,10 @@
|
||||
import { prisma } from '../db.js';
|
||||
import { pickWeighted } from '../weighted.js';
|
||||
import { applyAutoSell } from '../autoSell.js';
|
||||
import { emitJackpot } from '../realtime.js';
|
||||
import { emitJackpot, emitJackpotsList } from '../realtime.js';
|
||||
import { pushDrop } from '../feed.js';
|
||||
import { checkAchievements } from '../achievements.js';
|
||||
import { toCentsBigInt, centsJson, creditUserBalance } from '../cents.js';
|
||||
|
||||
const ROUND_SECONDS = Number(process.env.JACKPOT_ROUND_SECONDS || 30);
|
||||
const MAX_ITEMS = Number(process.env.JACKPOT_MAX_ITEMS || 3);
|
||||
@@ -12,8 +13,8 @@ const SPIN_MS = 5500;
|
||||
/** Sentinel endsAt while waiting for enough players (timer not armed). */
|
||||
const WAITING_ENDS_AT = new Date('2099-01-01T00:00:00.000Z');
|
||||
|
||||
let resolveTimer = null;
|
||||
let spinFollowUp = null;
|
||||
const resolveTimers = new Map();
|
||||
const spinFollowUps = new Map();
|
||||
|
||||
function isTimerWaiting(endsAt) {
|
||||
return new Date(endsAt).getFullYear() >= 2099;
|
||||
@@ -30,6 +31,14 @@ function depositInclude() {
|
||||
};
|
||||
}
|
||||
|
||||
function roundInclude() {
|
||||
return {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true, avatarUrl: true } },
|
||||
creator: { select: { id: true, username: true, avatarUrl: true } },
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeJackpotRound(round) {
|
||||
if (!round) return null;
|
||||
|
||||
@@ -56,24 +65,27 @@ export function serializeJackpotRound(round) {
|
||||
}));
|
||||
|
||||
const byUser = new Map();
|
||||
let totalValue = 0;
|
||||
let totalValue = 0n;
|
||||
for (const d of deposits) {
|
||||
totalValue += d.valueCents;
|
||||
const v = toCentsBigInt(d.valueCents);
|
||||
totalValue += v;
|
||||
const prev = byUser.get(d.userId) || {
|
||||
userId: d.userId,
|
||||
username: d.username,
|
||||
avatarUrl: d.avatarUrl,
|
||||
valueCents: 0,
|
||||
valueCents: 0n,
|
||||
itemCount: 0,
|
||||
};
|
||||
prev.valueCents += d.valueCents;
|
||||
prev.valueCents += v;
|
||||
prev.itemCount += 1;
|
||||
byUser.set(d.userId, prev);
|
||||
}
|
||||
|
||||
const players = [...byUser.values()].map((p) => ({
|
||||
...p,
|
||||
chance: totalValue > 0 ? (p.valueCents / totalValue) * 100 : 0,
|
||||
valueCents: centsJson(p.valueCents),
|
||||
chance:
|
||||
totalValue > 0n ? Number((p.valueCents * 10000n) / totalValue) / 100 : 0,
|
||||
}));
|
||||
|
||||
const waitingForPlayers =
|
||||
@@ -82,6 +94,9 @@ export function serializeJackpotRound(round) {
|
||||
return {
|
||||
id: round.id,
|
||||
status: round.status,
|
||||
creatorId: round.creatorId || null,
|
||||
creatorUsername: round.creator?.username || null,
|
||||
name: `Battle ${round.id}`,
|
||||
endsAt: waitingForPlayers ? null : round.endsAt,
|
||||
waitingForPlayers,
|
||||
roundSeconds: ROUND_SECONDS,
|
||||
@@ -90,102 +105,110 @@ export function serializeJackpotRound(round) {
|
||||
resolvedAt: round.resolvedAt || null,
|
||||
winnerUserId: round.winnerUserId || null,
|
||||
winnerUsername: round.winner?.username || null,
|
||||
totalValue,
|
||||
totalValue: centsJson(totalValue),
|
||||
maxItemsPerPlayer: MAX_ITEMS,
|
||||
players,
|
||||
deposits,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOpenOrLatest() {
|
||||
const open = await prisma.jackpotRound.findFirst({
|
||||
where: { status: { in: ['open', 'spinning'] } },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
if (open) return open;
|
||||
|
||||
return prisma.jackpotRound.findFirst({
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
async function loadRound(id) {
|
||||
return prisma.jackpotRound.findUnique({
|
||||
where: { id: Number(id) },
|
||||
include: roundInclude(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getJackpotRound(id) {
|
||||
return serializeJackpotRound(await loadRound(id));
|
||||
}
|
||||
|
||||
export async function listJackpotBattles() {
|
||||
const rounds = await prisma.jackpotRound.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ status: 'spinning' },
|
||||
{ status: 'open', deposits: { some: {} } },
|
||||
],
|
||||
},
|
||||
include: roundInclude(),
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
return rounds.map(serializeJackpotRound);
|
||||
}
|
||||
|
||||
export async function getJackpotState() {
|
||||
const round = await loadOpenOrLatest();
|
||||
return serializeJackpotRound(round);
|
||||
export async function broadcastJackpotsList() {
|
||||
const battles = await listJackpotBattles();
|
||||
emitJackpotsList({ battles });
|
||||
return battles;
|
||||
}
|
||||
|
||||
export async function broadcastJackpot(extra = {}) {
|
||||
const state = await getJackpotState();
|
||||
emitJackpot('jackpot:state', { ...state, ...extra });
|
||||
return state;
|
||||
}
|
||||
|
||||
async function createOpenRound() {
|
||||
return prisma.jackpotRound.create({
|
||||
data: { status: 'open', endsAt: WAITING_ENDS_AT },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function clearResolveTimer() {
|
||||
if (resolveTimer) {
|
||||
clearTimeout(resolveTimer);
|
||||
resolveTimer = null;
|
||||
function clearResolveTimer(roundId) {
|
||||
const t = resolveTimers.get(roundId);
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
resolveTimers.delete(roundId);
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
clearResolveTimer();
|
||||
if (spinFollowUp) {
|
||||
clearTimeout(spinFollowUp);
|
||||
spinFollowUp = null;
|
||||
function clearSpinFollowUp(roundId) {
|
||||
const t = spinFollowUps.get(roundId);
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
spinFollowUps.delete(roundId);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleResolve(endsAt) {
|
||||
clearResolveTimer();
|
||||
function clearTimers(roundId) {
|
||||
clearResolveTimer(roundId);
|
||||
clearSpinFollowUp(roundId);
|
||||
}
|
||||
|
||||
function scheduleResolve(roundId, endsAt) {
|
||||
clearResolveTimer(roundId);
|
||||
if (isTimerWaiting(endsAt)) return;
|
||||
const delay = Math.max(0, new Date(endsAt).getTime() - Date.now());
|
||||
resolveTimer = setTimeout(() => {
|
||||
resolveJackpotRound().catch((err) => console.error('jackpot resolve', err));
|
||||
}, delay);
|
||||
resolveTimers.set(
|
||||
roundId,
|
||||
setTimeout(() => {
|
||||
resolveJackpotRound(roundId).catch((err) =>
|
||||
console.error('jackpot resolve', err)
|
||||
);
|
||||
}, delay)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover timers for all active battles (boot). Does not create empty rounds.
|
||||
* Cancels orphan open battles with zero deposits (legacy singleton leftovers).
|
||||
*/
|
||||
export async function ensureJackpotRound() {
|
||||
let round = await prisma.jackpotRound.findFirst({
|
||||
where: { status: 'open' },
|
||||
include: { deposits: true },
|
||||
orderBy: { id: 'desc' },
|
||||
await prisma.jackpotRound.updateMany({
|
||||
where: {
|
||||
status: 'open',
|
||||
deposits: { none: {} },
|
||||
},
|
||||
data: { status: 'cancelled', resolvedAt: new Date() },
|
||||
});
|
||||
|
||||
if (!round) {
|
||||
const spinning = await prisma.jackpotRound.findFirst({
|
||||
where: { status: 'spinning' },
|
||||
orderBy: { id: 'desc' },
|
||||
const rounds = await prisma.jackpotRound.findMany({
|
||||
where: { status: { in: ['open', 'spinning'] } },
|
||||
include: { deposits: true },
|
||||
});
|
||||
if (spinning) {
|
||||
spinFollowUp = setTimeout(() => {
|
||||
finishJackpotSpin(spinning.id).catch((err) => console.error(err));
|
||||
}, 1000);
|
||||
return getJackpotState();
|
||||
}
|
||||
round = await createOpenRound();
|
||||
return getJackpotState();
|
||||
|
||||
for (const round of rounds) {
|
||||
if (round.status === 'spinning') {
|
||||
clearSpinFollowUp(round.id);
|
||||
spinFollowUps.set(
|
||||
round.id,
|
||||
setTimeout(() => {
|
||||
finishJackpotSpin(round.id).catch((err) => console.error(err));
|
||||
}, 1000)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const players = uniquePlayerCount(round.deposits);
|
||||
|
||||
if (players < MIN_PLAYERS) {
|
||||
if (!isTimerWaiting(round.endsAt)) {
|
||||
await prisma.jackpotRound.update({
|
||||
@@ -193,8 +216,8 @@ export async function ensureJackpotRound() {
|
||||
data: { endsAt: WAITING_ENDS_AT },
|
||||
});
|
||||
}
|
||||
clearResolveTimer();
|
||||
return getJackpotState();
|
||||
clearResolveTimer(round.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isTimerWaiting(round.endsAt)) {
|
||||
@@ -203,42 +226,24 @@ export async function ensureJackpotRound() {
|
||||
where: { id: round.id },
|
||||
data: { endsAt },
|
||||
});
|
||||
scheduleResolve(endsAt);
|
||||
return getJackpotState();
|
||||
scheduleResolve(round.id, endsAt);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (new Date(round.endsAt).getTime() <= Date.now()) {
|
||||
await resolveJackpotRound();
|
||||
return getJackpotState();
|
||||
await resolveJackpotRound(round.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
scheduleResolve(round.endsAt);
|
||||
return getJackpotState();
|
||||
scheduleResolve(round.id, round.endsAt);
|
||||
}
|
||||
|
||||
return listJackpotBattles();
|
||||
}
|
||||
|
||||
export async function placeJackpotBet(userId, inventoryItemIds) {
|
||||
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
|
||||
if (!ids.length) {
|
||||
throw Object.assign(new Error('Select at least one item'), { status: 400 });
|
||||
}
|
||||
if (ids.length > MAX_ITEMS) {
|
||||
throw Object.assign(new Error(`Max ${MAX_ITEMS} items per bet`), { status: 400 });
|
||||
}
|
||||
|
||||
const round = await prisma.$transaction(async (tx) => {
|
||||
const open = await tx.jackpotRound.findFirst({
|
||||
where: { status: 'open' },
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
if (!open) {
|
||||
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
|
||||
}
|
||||
if (!isTimerWaiting(open.endsAt) && new Date(open.endsAt).getTime() <= Date.now()) {
|
||||
throw Object.assign(new Error('Round is closing'), { status: 409 });
|
||||
}
|
||||
|
||||
async function applyDeposits(tx, roundId, userId, ids) {
|
||||
const existingCount = await tx.jackpotDeposit.count({
|
||||
where: { roundId: open.id, userId },
|
||||
where: { roundId, userId },
|
||||
});
|
||||
if (existingCount + ids.length > MAX_ITEMS) {
|
||||
throw Object.assign(
|
||||
@@ -252,7 +257,9 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
|
||||
include: { item: true },
|
||||
});
|
||||
if (invItems.length !== ids.length) {
|
||||
throw Object.assign(new Error('Invalid, locked, or staked inventory items'), { status: 400 });
|
||||
throw Object.assign(new Error('Invalid, locked, or staked inventory items'), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
for (const inv of invItems) {
|
||||
@@ -262,7 +269,7 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
|
||||
});
|
||||
await tx.jackpotDeposit.create({
|
||||
data: {
|
||||
roundId: open.id,
|
||||
roundId,
|
||||
userId,
|
||||
inventoryItemId: inv.id,
|
||||
valueCents: inv.valueCents,
|
||||
@@ -272,28 +279,85 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
|
||||
data: {
|
||||
userId,
|
||||
type: 'jackpot_bet',
|
||||
amount: 0,
|
||||
amount: '0',
|
||||
meta: JSON.stringify({
|
||||
roundId: open.id,
|
||||
roundId,
|
||||
inventoryItemId: inv.id,
|
||||
itemId: inv.itemId,
|
||||
valueCents: inv.valueCents,
|
||||
valueCents: centsJson(inv.valueCents),
|
||||
wear: inv.wear,
|
||||
floatValue: inv.floatValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let next = await tx.jackpotRound.findUnique({
|
||||
where: { id: open.id },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
/**
|
||||
* Create a new battle by placing the first bet.
|
||||
*/
|
||||
export async function createJackpotBattle(userId, inventoryItemIds) {
|
||||
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
|
||||
if (!ids.length) {
|
||||
throw Object.assign(new Error('Select at least one item'), { status: 400 });
|
||||
}
|
||||
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 created = await tx.jackpotRound.create({
|
||||
data: {
|
||||
status: 'open',
|
||||
endsAt: WAITING_ENDS_AT,
|
||||
creatorId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Arm countdown once we have enough distinct players
|
||||
await applyDeposits(tx, created.id, userId, ids);
|
||||
|
||||
return tx.jackpotRound.findUnique({
|
||||
where: { id: created.id },
|
||||
include: roundInclude(),
|
||||
});
|
||||
});
|
||||
|
||||
const state = serializeJackpotRound(round);
|
||||
emitJackpot(round.id, 'jackpot:state', state);
|
||||
await broadcastJackpotsList();
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function placeJackpotBet(roundId, userId, inventoryItemIds) {
|
||||
const id = Number(roundId);
|
||||
if (!id) {
|
||||
throw Object.assign(new Error('Invalid battle'), { status: 400 });
|
||||
}
|
||||
|
||||
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
|
||||
if (!ids.length) {
|
||||
throw Object.assign(new Error('Select at least one item'), { status: 400 });
|
||||
}
|
||||
if (ids.length > MAX_ITEMS) {
|
||||
throw Object.assign(new Error(`Max ${MAX_ITEMS} items per bet`), { status: 400 });
|
||||
}
|
||||
|
||||
const round = await prisma.$transaction(async (tx) => {
|
||||
const open = await tx.jackpotRound.findUnique({ where: { id } });
|
||||
if (!open || open.status !== 'open') {
|
||||
throw Object.assign(new Error('Battle is not open'), { status: 409 });
|
||||
}
|
||||
if (!isTimerWaiting(open.endsAt) && new Date(open.endsAt).getTime() <= Date.now()) {
|
||||
throw Object.assign(new Error('Round is closing'), { status: 409 });
|
||||
}
|
||||
|
||||
await applyDeposits(tx, open.id, userId, ids);
|
||||
|
||||
let next = await tx.jackpotRound.findUnique({
|
||||
where: { id: open.id },
|
||||
include: roundInclude(),
|
||||
});
|
||||
|
||||
if (
|
||||
uniquePlayerCount(next.deposits) >= MIN_PLAYERS &&
|
||||
isTimerWaiting(next.endsAt)
|
||||
@@ -302,10 +366,7 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
|
||||
next = await tx.jackpotRound.update({
|
||||
where: { id: next.id },
|
||||
data: { endsAt },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
include: roundInclude(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -316,29 +377,35 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
|
||||
uniquePlayerCount(round.deposits) >= MIN_PLAYERS &&
|
||||
!isTimerWaiting(round.endsAt)
|
||||
) {
|
||||
scheduleResolve(round.endsAt);
|
||||
scheduleResolve(round.id, round.endsAt);
|
||||
}
|
||||
|
||||
const state = serializeJackpotRound(round);
|
||||
emitJackpot('jackpot:state', state);
|
||||
emitJackpot(round.id, 'jackpot:state', state);
|
||||
await broadcastJackpotsList();
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function withdrawJackpotBet(userId, inventoryItemIds) {
|
||||
export async function withdrawJackpotBet(roundId, userId, inventoryItemIds) {
|
||||
const id = Number(roundId);
|
||||
if (!id) {
|
||||
throw Object.assign(new Error('Invalid battle'), { status: 400 });
|
||||
}
|
||||
|
||||
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
|
||||
if (!ids.length) {
|
||||
throw Object.assign(new Error('Select items to withdraw'), { status: 400 });
|
||||
}
|
||||
|
||||
const round = await prisma.$transaction(async (tx) => {
|
||||
const open = await tx.jackpotRound.findFirst({
|
||||
where: { status: 'open' },
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
if (!open) {
|
||||
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
|
||||
const open = await tx.jackpotRound.findUnique({ where: { id } });
|
||||
if (!open || open.status !== 'open') {
|
||||
throw Object.assign(new Error('Battle is not open'), { status: 409 });
|
||||
}
|
||||
if (!isTimerWaiting(open.endsAt) && new Date(open.endsAt).getTime() - Date.now() < 3000) {
|
||||
if (
|
||||
!isTimerWaiting(open.endsAt) &&
|
||||
new Date(open.endsAt).getTime() - Date.now() < 3000
|
||||
) {
|
||||
throw Object.assign(new Error('Too late to withdraw'), { status: 409 });
|
||||
}
|
||||
|
||||
@@ -363,33 +430,44 @@ export async function withdrawJackpotBet(userId, inventoryItemIds) {
|
||||
|
||||
let next = await tx.jackpotRound.findUnique({
|
||||
where: { id: open.id },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
include: roundInclude(),
|
||||
});
|
||||
|
||||
// Disarm timer if we drop below min players
|
||||
if (uniquePlayerCount(next.deposits) < MIN_PLAYERS && !isTimerWaiting(next.endsAt)) {
|
||||
next = await tx.jackpotRound.update({
|
||||
where: { id: next.id },
|
||||
data: { endsAt: WAITING_ENDS_AT },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
include: roundInclude(),
|
||||
});
|
||||
}
|
||||
|
||||
// Creator left alone with no deposits → cancel empty battle
|
||||
if (!next.deposits.length) {
|
||||
next = await tx.jackpotRound.update({
|
||||
where: { id: next.id },
|
||||
data: { status: 'cancelled', resolvedAt: new Date() },
|
||||
include: roundInclude(),
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
if (round.status === 'cancelled') {
|
||||
clearTimers(round.id);
|
||||
const state = serializeJackpotRound(round);
|
||||
emitJackpot(round.id, 'jackpot:state', state);
|
||||
await broadcastJackpotsList();
|
||||
return state;
|
||||
}
|
||||
|
||||
if (isTimerWaiting(round.endsAt) || uniquePlayerCount(round.deposits) < MIN_PLAYERS) {
|
||||
clearResolveTimer();
|
||||
clearResolveTimer(round.id);
|
||||
}
|
||||
|
||||
const state = serializeJackpotRound(round);
|
||||
emitJackpot('jackpot:state', state);
|
||||
emitJackpot(round.id, 'jackpot:state', state);
|
||||
await broadcastJackpotsList();
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -400,10 +478,7 @@ async function finishJackpotSpin(roundId) {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const round = await tx.jackpotRound.findUnique({
|
||||
where: { id: roundId },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true, avatarUrl: true } },
|
||||
},
|
||||
include: roundInclude(),
|
||||
});
|
||||
if (!round || round.status !== 'spinning') return null;
|
||||
|
||||
@@ -418,7 +493,7 @@ async function finishJackpotSpin(roundId) {
|
||||
const acquiredIds = round.deposits
|
||||
.filter((d) => d.userId !== winnerId)
|
||||
.map((d) => d.inventoryItemId);
|
||||
let autoSellTotal = 0;
|
||||
let autoSellTotal = 0n;
|
||||
if (acquiredIds.length) {
|
||||
const winner = await tx.user.findUnique({ where: { id: winnerId } });
|
||||
const acquiredItems = await tx.inventoryItem.findMany({
|
||||
@@ -428,39 +503,39 @@ async function finishJackpotSpin(roundId) {
|
||||
autoSellTotal = balanceDelta;
|
||||
}
|
||||
|
||||
const totalValue = round.deposits.reduce((s, d) => s + d.valueCents, 0);
|
||||
const totalValue = round.deposits.reduce(
|
||||
(s, d) => s + toCentsBigInt(d.valueCents),
|
||||
0n
|
||||
);
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: winnerId,
|
||||
type: 'jackpot_win',
|
||||
amount: totalValue,
|
||||
amount: centsJson(totalValue),
|
||||
meta: JSON.stringify({
|
||||
roundId: round.id,
|
||||
depositIds: round.deposits.map((d) => d.id),
|
||||
autoSellTotal,
|
||||
autoSellTotal: centsJson(autoSellTotal),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (autoSellTotal > 0) {
|
||||
await tx.user.update({
|
||||
where: { id: winnerId },
|
||||
data: { balance: { increment: autoSellTotal } },
|
||||
});
|
||||
if (autoSellTotal > 0n) {
|
||||
await creditUserBalance(tx, winnerId, autoSellTotal);
|
||||
}
|
||||
|
||||
await tx.jackpotRound.update({
|
||||
const resolved = await tx.jackpotRound.update({
|
||||
where: { id: round.id },
|
||||
data: { status: 'resolved', resolvedAt: new Date() },
|
||||
include: roundInclude(),
|
||||
});
|
||||
|
||||
// Best item in the pot (highest instance value) for Feed Drop, with xN count
|
||||
let best = null;
|
||||
for (const d of round.deposits) {
|
||||
const inv = d.inventoryItem;
|
||||
const item = inv?.item;
|
||||
if (!item) continue;
|
||||
if (!best || d.valueCents > best.deposit.valueCents) {
|
||||
if (!best || toCentsBigInt(d.valueCents) > toCentsBigInt(best.deposit.valueCents)) {
|
||||
best = { deposit: d, item, inv };
|
||||
}
|
||||
}
|
||||
@@ -476,7 +551,7 @@ async function finishJackpotSpin(roundId) {
|
||||
).length;
|
||||
feedEntry = {
|
||||
source: 'jackpot',
|
||||
caseName: 'MultiPlayers Case',
|
||||
caseName: `Battle ${round.id}`,
|
||||
count,
|
||||
openCount: round.deposits.length,
|
||||
inventoryIds: round.deposits.map((d) => d.inventoryItemId).filter(Boolean),
|
||||
@@ -497,9 +572,11 @@ async function finishJackpotSpin(roundId) {
|
||||
};
|
||||
}
|
||||
|
||||
return createOpenRoundInTx(tx);
|
||||
return resolved;
|
||||
});
|
||||
|
||||
clearTimers(roundId);
|
||||
|
||||
if (!result) return;
|
||||
|
||||
if (winnerId) {
|
||||
@@ -515,72 +592,56 @@ async function finishJackpotSpin(roundId) {
|
||||
}
|
||||
|
||||
const state = serializeJackpotRound(result);
|
||||
// New round waits for players — do not arm timer yet
|
||||
clearResolveTimer();
|
||||
emitJackpot('jackpot:result', {
|
||||
emitJackpot(roundId, 'jackpot:result', {
|
||||
previousRoundId: roundId,
|
||||
...state,
|
||||
});
|
||||
emitJackpot('jackpot:state', state);
|
||||
await broadcastJackpotsList();
|
||||
}
|
||||
|
||||
async function createOpenRoundInTx(tx) {
|
||||
return tx.jackpotRound.create({
|
||||
data: { status: 'open', endsAt: WAITING_ENDS_AT },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveJackpotRound() {
|
||||
clearTimers();
|
||||
export async function resolveJackpotRound(roundId) {
|
||||
const id = Number(roundId);
|
||||
clearTimers(id);
|
||||
|
||||
const spun = await prisma.$transaction(async (tx) => {
|
||||
const open = await tx.jackpotRound.findFirst({
|
||||
where: { status: 'open' },
|
||||
const open = await tx.jackpotRound.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
if (!open) return null;
|
||||
if (!open || open.status !== 'open') return null;
|
||||
|
||||
const players = uniquePlayerCount(open.deposits);
|
||||
if (players < MIN_PLAYERS) {
|
||||
if (players < MIN_PLAYERS || !open.deposits.length) {
|
||||
const waiting = await tx.jackpotRound.update({
|
||||
where: { id: open.id },
|
||||
data: { endsAt: WAITING_ENDS_AT },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
});
|
||||
return { kind: 'waiting', updated: waiting };
|
||||
}
|
||||
|
||||
if (!open.deposits.length) {
|
||||
const waiting = await tx.jackpotRound.update({
|
||||
where: { id: open.id },
|
||||
data: { endsAt: WAITING_ENDS_AT },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
include: roundInclude(),
|
||||
});
|
||||
return { kind: 'waiting', updated: waiting };
|
||||
}
|
||||
|
||||
const byUser = new Map();
|
||||
for (const d of open.deposits) {
|
||||
byUser.set(d.userId, (byUser.get(d.userId) || 0) + d.valueCents);
|
||||
byUser.set(
|
||||
d.userId,
|
||||
(byUser.get(d.userId) || 0n) + toCentsBigInt(d.valueCents)
|
||||
);
|
||||
}
|
||||
const entries = [...byUser.entries()].map(([userId, dropRate]) => ({
|
||||
const maxV = [...byUser.values()].reduce((a, b) => (a > b ? a : b), 0n);
|
||||
const weightEntries =
|
||||
maxV > BigInt(Number.MAX_SAFE_INTEGER)
|
||||
? [...byUser.entries()].map(([userId, v]) => ({
|
||||
userId,
|
||||
dropRate,
|
||||
dropRate: Number((v * 1_000_000n) / maxV) || 1,
|
||||
}))
|
||||
: [...byUser.entries()].map(([userId, v]) => ({
|
||||
userId,
|
||||
dropRate: Number(v),
|
||||
}));
|
||||
const winner = pickWeighted(entries);
|
||||
|
||||
const winner = pickWeighted(weightEntries);
|
||||
|
||||
const winnerDeposits = open.deposits.filter((d) => d.userId === winner.userId);
|
||||
const reelDepositId = winnerDeposits[0]?.id || null;
|
||||
@@ -588,10 +649,7 @@ export async function resolveJackpotRound() {
|
||||
const updated = await tx.jackpotRound.update({
|
||||
where: { id: open.id },
|
||||
data: { status: 'spinning', winnerUserId: winner.userId },
|
||||
include: {
|
||||
deposits: { include: depositInclude() },
|
||||
winner: { select: { id: true, username: true } },
|
||||
},
|
||||
include: roundInclude(),
|
||||
});
|
||||
|
||||
return { kind: 'spin', updated, reelItemId: reelDepositId };
|
||||
@@ -599,23 +657,41 @@ export async function resolveJackpotRound() {
|
||||
|
||||
if (!spun) return;
|
||||
|
||||
if (spun.kind === 'waiting' || spun.kind === 'extend') {
|
||||
if (spun.kind === 'waiting') {
|
||||
const state = serializeJackpotRound(spun.updated);
|
||||
clearResolveTimer();
|
||||
emitJackpot('jackpot:state', state);
|
||||
clearResolveTimer(id);
|
||||
emitJackpot(id, 'jackpot:state', state);
|
||||
await broadcastJackpotsList();
|
||||
return state;
|
||||
}
|
||||
|
||||
const { updated, reelItemId } = spun;
|
||||
const state = serializeJackpotRound(updated);
|
||||
emitJackpot('jackpot:spin', {
|
||||
emitJackpot(id, 'jackpot:spin', {
|
||||
...state,
|
||||
reelWinnerItemId: reelItemId,
|
||||
});
|
||||
await broadcastJackpotsList();
|
||||
|
||||
spinFollowUp = setTimeout(() => {
|
||||
finishJackpotSpin(updated.id).catch((err) => console.error('jackpot finish', err));
|
||||
}, SPIN_MS + 200);
|
||||
spinFollowUps.set(
|
||||
updated.id,
|
||||
setTimeout(() => {
|
||||
finishJackpotSpin(updated.id).catch((err) =>
|
||||
console.error('jackpot finish', err)
|
||||
);
|
||||
}, SPIN_MS + 200)
|
||||
);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** @deprecated use listJackpotBattles / getJackpotRound */
|
||||
export async function getJackpotState() {
|
||||
const battles = await listJackpotBattles();
|
||||
return battles[0] || null;
|
||||
}
|
||||
|
||||
export async function broadcastJackpot(extra = {}) {
|
||||
const battles = await broadcastJackpotsList();
|
||||
return battles[0] ? { ...battles[0], ...extra } : null;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
@@ -1,31 +1,126 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import sharp from 'sharp';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const uploadsDir = path.join(__dirname, '../uploads');
|
||||
|
||||
/** Max stored dimensions (aspect ratio preserved, never upscaled). */
|
||||
export const MAX_IMAGE_WIDTH = 1920;
|
||||
export const MAX_IMAGE_HEIGHT = 1080;
|
||||
|
||||
/** Raster only — no SVG (stored XSS if served as image/*). */
|
||||
export const ALLOWED_IMAGE_MIMES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
]);
|
||||
|
||||
export const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
|
||||
|
||||
/** sharp `metadata().format` values we accept */
|
||||
const ALLOWED_SHARP_FORMATS = new Set(['png', 'jpeg', 'jpg', 'webp', 'gif']);
|
||||
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
export function createUploader(prefix) {
|
||||
export function safeExt(originalname, mimetype = '') {
|
||||
const fromName = path.extname(originalname || '').toLowerCase();
|
||||
if (ALLOWED_IMAGE_EXTS.has(fromName)) return fromName === '.jpeg' ? '.jpg' : fromName;
|
||||
if (mimetype === 'image/png') return '.png';
|
||||
if (mimetype === 'image/webp') return '.webp';
|
||||
if (mimetype === 'image/gif') return '.gif';
|
||||
if (mimetype === 'image/jpeg' || mimetype === 'image/jpg') return '.jpg';
|
||||
return '.png';
|
||||
}
|
||||
|
||||
function isAllowedUpload(file) {
|
||||
const mime = String(file?.mimetype || '').toLowerCase();
|
||||
const ext = path.extname(file?.originalname || '').toLowerCase();
|
||||
if (!ALLOWED_IMAGE_MIMES.has(mime)) return false;
|
||||
// If the client sent an extension, it must match the allowlist (blocks .svg renamed tricks loosely)
|
||||
if (ext && !ALLOWED_IMAGE_EXTS.has(ext)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downscale an uploaded image if larger than 1920×1080, keeping aspect ratio.
|
||||
* Also rejects non-raster / spoofed files (e.g. SVG with a fake image/* type).
|
||||
* Overwrites the file in place. No-ops when already within bounds.
|
||||
*/
|
||||
export async function processUploadedImage(file) {
|
||||
if (!file?.path) return;
|
||||
|
||||
const filePath = file.path;
|
||||
let meta;
|
||||
try {
|
||||
const animated = file.mimetype === 'image/gif';
|
||||
meta = await sharp(filePath, { animated, failOn: 'none' }).metadata();
|
||||
} catch {
|
||||
await fs.promises.unlink(filePath).catch(() => {});
|
||||
throw new Error('Invalid or unsupported image file');
|
||||
}
|
||||
|
||||
const format = String(meta.format || '').toLowerCase();
|
||||
if (!ALLOWED_SHARP_FORMATS.has(format)) {
|
||||
await fs.promises.unlink(filePath).catch(() => {});
|
||||
throw new Error('Only PNG, JPEG, WebP, and GIF images are allowed');
|
||||
}
|
||||
|
||||
let width = meta.width || 0;
|
||||
let height = meta.height || 0;
|
||||
if (!width || !height) return;
|
||||
|
||||
// EXIF orientations 5–8 swap display width/height
|
||||
const orientation = meta.orientation || 1;
|
||||
if (orientation >= 5 && orientation <= 8) {
|
||||
[width, height] = [height, width];
|
||||
}
|
||||
if (width <= MAX_IMAGE_WIDTH && height <= MAX_IMAGE_HEIGHT) return;
|
||||
|
||||
const tmpPath = `${filePath}.resized`;
|
||||
try {
|
||||
const animated = format === 'gif';
|
||||
await sharp(filePath, { animated, failOn: 'none' })
|
||||
.rotate() // bake EXIF orientation before resize
|
||||
.resize({
|
||||
width: MAX_IMAGE_WIDTH,
|
||||
height: MAX_IMAGE_HEIGHT,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.toFile(tmpPath);
|
||||
await fs.promises.rename(tmpPath, filePath);
|
||||
} catch (err) {
|
||||
await fs.promises.unlink(tmpPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function createUploader(prefix, { maxBytes = 3 * 1024 * 1024, filename } = {}) {
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadsDir),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase() || '.png';
|
||||
const safe = ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext) ? ext : '.png';
|
||||
cb(null, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${safe}`);
|
||||
},
|
||||
filename:
|
||||
filename ||
|
||||
((_req, file, cb) => {
|
||||
cb(
|
||||
null,
|
||||
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${safeExt(file.originalname, file.mimetype)}`
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
return multer({
|
||||
storage,
|
||||
limits: { fileSize: 3 * 1024 * 1024 },
|
||||
limits: { fileSize: maxBytes },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!file.mimetype.startsWith('image/')) {
|
||||
return cb(new Error('Only images are allowed'));
|
||||
if (!isAllowedUpload(file)) {
|
||||
return cb(new Error('Only PNG, JPEG, WebP, and GIF images are allowed'));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
|
||||
+160
-48
@@ -1,10 +1,18 @@
|
||||
import { prisma } from './db.js';
|
||||
import { applyRateFloor, toCentsBigInt, minBigInt, centsJson, creditUserBalance } from './cents.js';
|
||||
import { getIO } from './realtime.js';
|
||||
import { publicUser } from './middleware.js';
|
||||
|
||||
export const BASE_VAULT_SLOTS = 3;
|
||||
export const VAULT_TICK_MS = 600_000; // 10 minutes
|
||||
export const ONLINE_TICK_MS = 300_000; // 5 minutes
|
||||
export const OFFLINE_TICK_MS = 600_000; // 10 minutes
|
||||
/** @deprecated use ONLINE_TICK_MS / OFFLINE_TICK_MS */
|
||||
export const VAULT_TICK_MS = OFFLINE_TICK_MS;
|
||||
export const ONLINE_BASE_RATE = 0.1; // 10% of value per tick
|
||||
export const OFFLINE_BASE_RATE = 0.01; // 1% of value per tick
|
||||
export const WEAR_FLOAT_K = 3;
|
||||
/** Soft cap: at most 500% of item value per tick */
|
||||
export const MAX_TICK_VALUE_MULT = 5n;
|
||||
|
||||
/** Rarity → yield multiplier (seed rarities + fallbacks). */
|
||||
export const RARITY_YIELD_MULT = {
|
||||
@@ -17,6 +25,10 @@ export const RARITY_YIELD_MULT = {
|
||||
Extraordinary: 1.8,
|
||||
};
|
||||
|
||||
export function vaultTickMs(mode) {
|
||||
return mode === 'online' ? ONLINE_TICK_MS : OFFLINE_TICK_MS;
|
||||
}
|
||||
|
||||
export function maxVaultSlots(user) {
|
||||
return BASE_VAULT_SLOTS + Math.max(0, Math.floor(Number(user?.prestigeVaultSlots) || 0));
|
||||
}
|
||||
@@ -41,117 +53,217 @@ export function skillYieldMult(user, mode) {
|
||||
}
|
||||
|
||||
export function tickPayoutCents(inv, user, mode) {
|
||||
const value = Math.max(0, Math.floor(Number(inv.valueCents) || 0));
|
||||
if (value <= 0) return 0;
|
||||
const value = toCentsBigInt(inv.valueCents);
|
||||
if (value <= 0n) return 0n;
|
||||
const baseRate = mode === 'online' ? ONLINE_BASE_RATE : OFFLINE_BASE_RATE;
|
||||
const wear = wearYieldMult(inv.floatValue);
|
||||
const rarity = rarityYieldMult(inv.item?.rarity ?? inv.rarity);
|
||||
const skill = skillYieldMult(user, mode);
|
||||
const payout = Math.floor(value * baseRate * wear * rarity * skill);
|
||||
// Soft cap: at most 100% of item value per tick
|
||||
return Math.min(value, Math.max(0, payout));
|
||||
const combined = baseRate * wear * rarity * skill;
|
||||
const payout = applyRateFloor(value, combined);
|
||||
return minBigInt(value * MAX_TICK_VALUE_MULT, payout);
|
||||
}
|
||||
|
||||
export function estimateTickYield(inv, user, mode) {
|
||||
return tickPayoutCents(inv, user, mode);
|
||||
}
|
||||
|
||||
/** Shared vault timer anchor (user-level). Falls back to oldest item stake/yield. */
|
||||
export function resolveVaultAnchor(user, items, now = Date.now()) {
|
||||
if (user?.vaultYieldAt) {
|
||||
return new Date(user.vaultYieldAt).getTime();
|
||||
}
|
||||
let earliest = null;
|
||||
for (const inv of items || []) {
|
||||
const raw = inv.lastYieldAt || inv.stakedAt;
|
||||
if (!raw) continue;
|
||||
const ms = new Date(raw).getTime();
|
||||
if (!Number.isFinite(ms)) continue;
|
||||
if (earliest == null || ms < earliest) earliest = ms;
|
||||
}
|
||||
return earliest ?? now;
|
||||
}
|
||||
|
||||
export function nextYieldAtMs(user, items, mode, now = Date.now()) {
|
||||
if (!items?.length) return null;
|
||||
const tickMs = vaultTickMs(mode);
|
||||
const anchor = resolveVaultAnchor(user, items, now);
|
||||
const ticksDone = Math.floor(Math.max(0, now - anchor) / tickMs);
|
||||
return anchor + (ticksDone + 1) * tickMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle complete vault ticks for a user.
|
||||
* Settle complete vault ticks for a user (shared timer across all staked items).
|
||||
* @param {'online'|'offline'} mode
|
||||
* @param {Date|number} [until] settle up to this time (default now)
|
||||
* @returns {{ credited: number, ticks: number, itemIds: number[] }}
|
||||
* @returns {{ credited: string, ticks: number, itemIds: number[], balance?: string, vault?: object }}
|
||||
*/
|
||||
export async function settleVaultYield(userId, mode, until = Date.now()) {
|
||||
const id = Number(userId);
|
||||
if (!id) return { credited: 0, ticks: 0, itemIds: [] };
|
||||
if (!id) return { credited: '0', ticks: 0, itemIds: [] };
|
||||
|
||||
const untilMs = until instanceof Date ? until.getTime() : Number(until) || Date.now();
|
||||
const tickMs = vaultTickMs(mode);
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findUnique({ where: { id } });
|
||||
if (!user) return { credited: 0, ticks: 0, itemIds: [] };
|
||||
if (!user) return { credited: '0', ticks: 0, itemIds: [], user: null, items: [] };
|
||||
|
||||
const items = await tx.inventoryItem.findMany({
|
||||
where: { userId: id, staked: true },
|
||||
include: { item: true },
|
||||
});
|
||||
if (!items.length) return { credited: 0, ticks: 0, itemIds: [] };
|
||||
if (!items.length) {
|
||||
if (user.vaultYieldAt) {
|
||||
await tx.user.update({ where: { id }, data: { vaultYieldAt: null } });
|
||||
}
|
||||
return { credited: '0', ticks: 0, itemIds: [], user, items: [] };
|
||||
}
|
||||
|
||||
let totalCredit = 0;
|
||||
let maxTicks = 0;
|
||||
const itemIds = [];
|
||||
const now = new Date(untilMs);
|
||||
|
||||
for (const inv of items) {
|
||||
const anchor = inv.lastYieldAt || inv.stakedAt || now;
|
||||
const anchorMs = new Date(anchor).getTime();
|
||||
const anchorMs = resolveVaultAnchor(user, items, untilMs);
|
||||
const elapsed = Math.max(0, untilMs - anchorMs);
|
||||
const ticks = Math.floor(elapsed / VAULT_TICK_MS);
|
||||
if (ticks <= 0) continue;
|
||||
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 = [];
|
||||
for (const inv of items) {
|
||||
const perTick = tickPayoutCents(inv, user, mode);
|
||||
const credit = perTick * ticks;
|
||||
if (credit > 0) {
|
||||
const credit = perTick * BigInt(ticks);
|
||||
if (credit > 0n) {
|
||||
totalCredit += credit;
|
||||
itemIds.push(inv.id);
|
||||
}
|
||||
maxTicks = Math.max(maxTicks, ticks);
|
||||
|
||||
const advanced = new Date(anchorMs + ticks * VAULT_TICK_MS);
|
||||
await tx.inventoryItem.update({
|
||||
where: { id: inv.id },
|
||||
data: { lastYieldAt: advanced },
|
||||
data: { lastYieldAt: new Date(anchorMs + ticks * tickMs) },
|
||||
});
|
||||
}
|
||||
|
||||
if (totalCredit <= 0) {
|
||||
return { credited: 0, ticks: maxTicks, itemIds: [] };
|
||||
}
|
||||
|
||||
await tx.user.update({
|
||||
const advanced = new Date(anchorMs + ticks * tickMs);
|
||||
let updatedUser;
|
||||
if (totalCredit > 0n) {
|
||||
await creditUserBalance(tx, id, totalCredit);
|
||||
updatedUser = await tx.user.update({
|
||||
where: { id },
|
||||
data: { balance: { increment: totalCredit } },
|
||||
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;
|
||||
}
|
||||
|
||||
if (totalCredit > 0n) {
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: id,
|
||||
type: 'vault_yield',
|
||||
amount: totalCredit,
|
||||
amount: centsJson(totalCredit),
|
||||
meta: JSON.stringify({
|
||||
mode,
|
||||
ticks: maxTicks,
|
||||
ticks,
|
||||
inventoryItemIds: itemIds,
|
||||
tickMs,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { credited: totalCredit, ticks: maxTicks, itemIds };
|
||||
return {
|
||||
credited: totalCredit.toString(),
|
||||
ticks,
|
||||
itemIds,
|
||||
user: updatedUser,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
const vault = result.user
|
||||
? summarizeVaultState(result.user, result.items || [], mode, untilMs)
|
||||
: null;
|
||||
|
||||
if (result.credited !== '0' && result.user) {
|
||||
emitVaultYield(id, {
|
||||
credited: result.credited,
|
||||
ticks: result.ticks,
|
||||
balance: result.user.balance?.toString?.() ?? String(result.user.balance),
|
||||
user: publicUser(result.user),
|
||||
vault,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
credited: result.credited,
|
||||
ticks: result.ticks,
|
||||
itemIds: result.itemIds,
|
||||
balance: result.user?.balance?.toString?.() ?? undefined,
|
||||
vault,
|
||||
};
|
||||
}
|
||||
|
||||
function emitVaultYield(userId, payload) {
|
||||
const io = getIO();
|
||||
if (!io) return;
|
||||
io.to(`user:${userId}`).emit('vault:yield', payload);
|
||||
}
|
||||
|
||||
/** Estimate pending yield for UI (incomplete ticks not included). */
|
||||
export function estimatePendingYield(items, user, mode, now = Date.now()) {
|
||||
let total = 0;
|
||||
if (!items?.length) return '0';
|
||||
const tickMs = vaultTickMs(mode);
|
||||
const anchor = resolveVaultAnchor(user, items, now);
|
||||
const ticks = Math.floor(Math.max(0, now - anchor) / tickMs);
|
||||
if (ticks <= 0) return '0';
|
||||
let total = 0n;
|
||||
for (const inv of items) {
|
||||
const anchor = inv.lastYieldAt || inv.stakedAt;
|
||||
if (!anchor) continue;
|
||||
const elapsed = Math.max(0, now - new Date(anchor).getTime());
|
||||
const ticks = Math.floor(elapsed / VAULT_TICK_MS);
|
||||
if (ticks <= 0) continue;
|
||||
total += tickPayoutCents(inv, user, mode) * ticks;
|
||||
total += tickPayoutCents(inv, user, mode) * BigInt(ticks);
|
||||
}
|
||||
return total;
|
||||
return total.toString();
|
||||
}
|
||||
|
||||
/** Per-tick rate summary for UI (one tick online / offline). */
|
||||
export function summarizeVaultRates(items, user) {
|
||||
let onlinePerTick = 0;
|
||||
let offlinePerTick = 0;
|
||||
let onlinePerTick = 0n;
|
||||
let offlinePerTick = 0n;
|
||||
for (const inv of items) {
|
||||
onlinePerTick += tickPayoutCents(inv, user, 'online');
|
||||
offlinePerTick += tickPayoutCents(inv, user, 'offline');
|
||||
}
|
||||
return { onlinePerTick, offlinePerTick, tickMs: VAULT_TICK_MS };
|
||||
return {
|
||||
onlinePerTick: onlinePerTick.toString(),
|
||||
offlinePerTick: offlinePerTick.toString(),
|
||||
tickMsOnline: ONLINE_TICK_MS,
|
||||
tickMsOffline: OFFLINE_TICK_MS,
|
||||
tickMs: ONLINE_TICK_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeVaultState(user, items, mode, now = Date.now()) {
|
||||
const rates = summarizeVaultRates(items, user);
|
||||
const nextMs = nextYieldAtMs(user, items, mode, now);
|
||||
return {
|
||||
used: items.length,
|
||||
slots: maxVaultSlots(user),
|
||||
...rates,
|
||||
mode,
|
||||
tickMs: vaultTickMs(mode),
|
||||
vaultYieldAt: user.vaultYieldAt ? new Date(user.vaultYieldAt).toISOString() : null,
|
||||
nextYieldAt: nextMs != null ? new Date(nextMs).toISOString() : null,
|
||||
pendingYield: estimatePendingYield(items, user, mode, now),
|
||||
};
|
||||
}
|
||||
|
||||
+10
-4
@@ -5,6 +5,8 @@
|
||||
* Per-drop float f ∈ [0, 1) drives wear grade and price multiplier.
|
||||
*/
|
||||
|
||||
import { toCentsBigInt } from './cents.js';
|
||||
|
||||
export const WEAR_BANDS = [
|
||||
{
|
||||
wear: 'Factory New',
|
||||
@@ -96,10 +98,14 @@ export function priceMultiplier(floatValue) {
|
||||
}
|
||||
|
||||
export function computeValueCents(baseMarketValue, floatValue) {
|
||||
const base = Math.max(0, Math.floor(Number(baseMarketValue) || 0));
|
||||
if (base <= 0) return 0;
|
||||
const cents = Math.round(base * priceMultiplier(floatValue));
|
||||
return Math.max(1, cents);
|
||||
// Always BigInt-parse — Number() silently corrupts values above ~9e15
|
||||
let base = toCentsBigInt(baseMarketValue);
|
||||
if (base < 0n) base = 0n;
|
||||
if (base <= 0n) return 0n;
|
||||
// Fixed-point multiplier (×10000) to avoid Number precision loss on huge bases
|
||||
const multBp = Math.round(priceMultiplier(floatValue) * 10000);
|
||||
const cents = (base * BigInt(Math.max(0, multBp)) + 5000n) / 10000n;
|
||||
return cents < 1n ? 1n : cents;
|
||||
}
|
||||
|
||||
export function rollFloat() {
|
||||
|
||||
Reference in New Issue
Block a user