update nice visuel
This commit is contained in:
+9
-1
@@ -18,8 +18,16 @@ ADMIN_USERNAME=admin
|
|||||||
ADMIN_PASSWORD=change-me-strong-password
|
ADMIN_PASSWORD=change-me-strong-password
|
||||||
|
|
||||||
# Starting balances for new accounts (credits in cents, osu as units)
|
# Starting balances for new accounts (credits in cents, osu as units)
|
||||||
|
# In production, keep STARTING_OSU low/0 — osu is the real-money currency.
|
||||||
STARTING_BALANCE=10000
|
STARTING_BALANCE=10000
|
||||||
STARTING_OSU=100
|
STARTING_OSU=25
|
||||||
|
|
||||||
|
# Payments (osu top-ups)
|
||||||
|
# mock = instant confirm for local/dev (default)
|
||||||
|
# stripe = requires STRIPE_SECRET_KEY (+ webhook wiring)
|
||||||
|
PAYMENTS_PROVIDER=mock
|
||||||
|
# STRIPE_SECRET_KEY=sk_live_...
|
||||||
|
# STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
|
||||||
# Jackpot
|
# Jackpot
|
||||||
JACKPOT_ROUND_SECONDS=30
|
JACKPOT_ROUND_SECONDS=30
|
||||||
|
|||||||
@@ -16,16 +16,16 @@ npm run db:seed
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- App: http://localhost:5173
|
- App: http://localhost:5888
|
||||||
- API: http://localhost:3001
|
- API: http://localhost:3001
|
||||||
- LAN: http://\<your-lan-ip\>:5173 (Vite binds to `0.0.0.0`)
|
- LAN: http://\<your-lan-ip\>:5888 (Vite binds to `0.0.0.0`)
|
||||||
|
|
||||||
Create `server/.env` if missing:
|
Create `server/.env` if missing:
|
||||||
|
|
||||||
```
|
```
|
||||||
DATABASE_URL="file:./dev.db"
|
DATABASE_URL="file:./dev.db"
|
||||||
PORT=3001
|
PORT=3001
|
||||||
CLIENT_ORIGIN=http://localhost:5173
|
CLIENT_ORIGIN=http://localhost:5888
|
||||||
SESSION_SECRET=dev-secret
|
SESSION_SECRET=dev-secret
|
||||||
STARTING_BALANCE=10000
|
STARTING_BALANCE=10000
|
||||||
STARTING_OSU=100
|
STARTING_OSU=100
|
||||||
@@ -47,21 +47,20 @@ New registrations receive **100.00** starting credits and **100 osu** (`STARTING
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Login / register (username + password)
|
- Login / register (username + password)
|
||||||
- **Inventory**: sort by recent or value, multi-select sell at **instance** value (float/wear), bulk **Sell below** threshold, **favorites** (★ — cannot be sold or auto-sold; can still be bet in battles), **Vault** staking (up to **3** slots by default; +1 per Vault Capacity skill) — staked items earn dividends every **10 min** (10% value online / 1% offline, scaled by float² wear and rarity)
|
- **Inventory**: sort by recent or value, multi-select sell at **instance** value (float/wear), bulk **Sell below** threshold, **favorites** (★ — cannot be sold or auto-sold; can still be bet in battles), **Vault** staking (up to **3** slots by default; +1 per Vault Capacity skill) — shared timer; dividends auto-pay **every 5 min online** (10% value) / **10 min offline** (1%), scaled by float² wear and rarity (cap **500%** value / tick)
|
||||||
- **Auto-sell**: optional threshold (shared with bulk sell); on **case opens**, credits are applied after the reel finishes; on **battle wins**, items taken from other players below the threshold are sold immediately
|
- **Auto-sell**: optional threshold (shared with bulk sell); on **case opens**, credits are applied after the reel finishes; on **battle wins**, items taken from other players below the threshold are sold immediately
|
||||||
- **Wear (float)**: each case drop gets a random float `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)
|
- **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
|
- **Feed Drop**: toggle in nav — live left column of latest case unboxes + MultiPlayers Battle winners
|
||||||
- Cases page: browse and open crates (CS-style reel, result rolled server-side)
|
- Cases page: browse and open crates (CS-style reel, result rolled server-side)
|
||||||
- **Case keys** (per crate): **1 key** → **x10**; each **100 opens** of that case earns +1 key up to **x20** (keys 1→11, +1 open per key: x11…x20); from **x20** onward each next key costs **200, 300, 400…** opens (100 × tier) and adds +1 max open (x21, x22…) — no cap
|
- **Case keys** (per crate): **1 key** → **x10**; each **100 opens** of that case earns +1 key up to **x20** (keys 1→11, +1 open per key: x11…x20); from **x20** onward each next key costs **200, 300, 400…** opens (100 × tier) and adds +1 max open (x21, x22…) — no cap
|
||||||
- **Battle** hub (`/battle`):
|
- **Battle** hub (`/battle`):
|
||||||
- **MultiPlayers Case** — live shared pot preview on the hub (top items, pot total, players), up to 3 items, win chance by value, Socket.IO sync
|
- Create / join MultiPlayers pots — live shared pot, up to 3 items, win chance by value, Socket.IO sync
|
||||||
- **1vX Case** — custom rooms (player/item limits, min value), spectate in-progress
|
- Spectate in-progress battles from the hub
|
||||||
- **Coinflip** — stub (coming soon)
|
- **Shop** (`/shop`): buy **osu** with real money (mock checkout in dev, Stripe-ready), exchange osu for credit packs, or watch an ad every 1 min for 100.00–200.00 cr
|
||||||
- **Shop** (nav balance pill → `/shop`): spend osu on credit packs; watch an ad every 1 min for 100.00–200.00 cr
|
|
||||||
- Profile (username in nav): avatar, bio, password, stats, logout
|
- Profile (username in nav): avatar, bio, password, stats, logout
|
||||||
- **Presence**: online status (Socket.IO), total play time, last connection — shown on own and public profiles
|
- **Presence**: online status (Socket.IO), total play time, last connection — shown on own and public profiles
|
||||||
- **Achievements** (`/achievements`, also from profile): 10 starter badges unlocked by play (cases, battles, catalog, prestige, shop, playtime); earned badges appear on the profile
|
- **Achievements** (`/achievements`, also from profile): starter badges unlocked by play (cases, battles, catalog, prestige, shop, playtime); earned badges appear on the profile
|
||||||
- **Prestige**: pay **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)
|
- **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
|
- **Leaderboard**: richest players (balance + inventory value); click a row to open a public **player profile** (`/player/:username`) with stats and inventory
|
||||||
- Admin: overview dashboard, players (credit adjust), cases / items / drops (split pages, search filters)
|
- Admin: overview dashboard, players (credit adjust), cases / items / drops (split pages, search filters)
|
||||||
|
|
||||||
@@ -114,12 +113,12 @@ Typical **prod → local** flow: `docker-db-export.sh` on the server → `scp` t
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Balances and prices are integers in **cents**; **osu** is a separate demo currency for the shop
|
- Balances and prices are integers in **cents**; **osu** is the premium (real-money) currency — top-ups create `PaymentOrder` rows (`PAYMENTS_PROVIDER=mock|stripe`)
|
||||||
- Catalog item price = Field-Tested reference; each inventory row stores `floatValue`, `wear`, `paintSeed`, `valueCents`, `favorite`, optional vault `staked` / `stakedAt` / `lastYieldAt`
|
- Catalog item price = Field-Tested reference; each inventory row stores `floatValue`, `wear`, `paintSeed`, `valueCents`, `favorite`, optional vault `staked` / `stakedAt` / `lastYieldAt`
|
||||||
- Per-user auto-sell settings: `autoSellEnabled`, `autoSellThresholdCents` (default threshold **10.00** cr)
|
- Per-user auto-sell settings: `autoSellEnabled`, `autoSellThresholdCents` (default threshold **10.00** cr)
|
||||||
- Case key progress stored in `CaseKeyProgress` (per user + case); backfill: `npm run db:backfill-case-keys --prefix server`
|
- Case key progress stored in `CaseKeyProgress` (per user + case); backfill: `npm run db:backfill-case-keys --prefix server`
|
||||||
- Inventory items locked while deposited in a bet; unlocked or transferred on resolve; staked vault items cannot be sold, auto-sold, or bet
|
- Inventory items locked while deposited in a bet; unlocked or transferred on resolve; staked vault items cannot be sold, auto-sold, or bet
|
||||||
- Avatars / uploads under `server/uploads/` (volume in Docker)
|
- Avatars / uploads under `server/uploads/` (volume in Docker)
|
||||||
- Real-time via Socket.IO; Docker Hub image name is lowercase: `foufure/casegambling`
|
- Real-time via Socket.IO; Docker Hub image name is lowercase: `foufure/casegambling`
|
||||||
- Max **10** simultaneous browser windows/tabs per account (Socket.IO); override with `MAX_SOCKETS_PER_USER`
|
- Max **2** simultaneous browser windows/tabs per account (Socket.IO); override with `MAX_SOCKETS_PER_USER`
|
||||||
- Swap `DATABASE_URL` to PostgreSQL later without changing app logic
|
- Swap `DATABASE_URL` to PostgreSQL later without changing app logic
|
||||||
|
|||||||
+255
@@ -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 Layout from './components/Layout';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
import Admin from './pages/Admin';
|
import Admin from './pages/Admin';
|
||||||
@@ -9,10 +9,7 @@ import Leaderboard from './pages/Leaderboard';
|
|||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
import BattleHub from './pages/BattleHub';
|
import BattleHub from './pages/BattleHub';
|
||||||
import MultiPlayersPage from './pages/MultiPlayersPage';
|
import BattleRoomPage from './pages/BattleRoomPage';
|
||||||
import DuelListPage from './pages/DuelListPage';
|
|
||||||
import DuelRoomPage from './pages/DuelRoomPage';
|
|
||||||
import CoinflipStub from './pages/CoinflipStub';
|
|
||||||
import ProfilePage from './pages/ProfilePage';
|
import ProfilePage from './pages/ProfilePage';
|
||||||
import PlayerProfilePage from './pages/PlayerProfilePage';
|
import PlayerProfilePage from './pages/PlayerProfilePage';
|
||||||
import CatalogPage from './pages/CatalogPage';
|
import CatalogPage from './pages/CatalogPage';
|
||||||
@@ -28,11 +25,6 @@ function HomeRedirect() {
|
|||||||
return <Navigate to="/dashboard" replace />;
|
return <Navigate to="/dashboard" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function BetDuelRedirect() {
|
|
||||||
const { id } = useParams();
|
|
||||||
return <Navigate to={`/battle/1vx/${id}`} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -48,15 +40,17 @@ export default function App() {
|
|||||||
<Route path="/prestige" element={<PrestigePage />} />
|
<Route path="/prestige" element={<PrestigePage />} />
|
||||||
<Route path="/achievements" element={<AchievementsPage />} />
|
<Route path="/achievements" element={<AchievementsPage />} />
|
||||||
<Route path="/battle" element={<BattleHub />} />
|
<Route path="/battle" element={<BattleHub />} />
|
||||||
<Route path="/battle/multi" element={<MultiPlayersPage />} />
|
<Route path="/battle/create" element={<BattleRoomPage create />} />
|
||||||
<Route path="/battle/1vx" element={<DuelListPage />} />
|
<Route path="/battle/multi" element={<Navigate to="/battle/create" replace />} />
|
||||||
<Route path="/battle/1vx/:id" element={<DuelRoomPage />} />
|
<Route path="/battle/1vx" element={<Navigate to="/battle" replace />} />
|
||||||
<Route path="/battle/coinflip" element={<CoinflipStub />} />
|
<Route path="/battle/1vx/:id" element={<Navigate to="/battle" replace />} />
|
||||||
|
<Route path="/battle/coinflip" element={<Navigate to="/battle" replace />} />
|
||||||
|
<Route path="/battle/:id" element={<BattleRoomPage />} />
|
||||||
<Route path="/bet" element={<Navigate to="/battle" replace />} />
|
<Route path="/bet" element={<Navigate to="/battle" replace />} />
|
||||||
<Route path="/bet/multi" element={<Navigate to="/battle/multi" replace />} />
|
<Route path="/bet/multi" element={<Navigate to="/battle/create" replace />} />
|
||||||
<Route path="/bet/1vx" element={<Navigate to="/battle/1vx" replace />} />
|
<Route path="/bet/1vx" element={<Navigate to="/battle" replace />} />
|
||||||
<Route path="/bet/1vx/:id" element={<BetDuelRedirect />} />
|
<Route path="/bet/1vx/:id" element={<Navigate to="/battle" replace />} />
|
||||||
<Route path="/bet/coinflip" element={<Navigate to="/battle/coinflip" replace />} />
|
<Route path="/bet/coinflip" element={<Navigate to="/battle" replace />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
<Route path="/player/:username" element={<PlayerProfilePage />} />
|
<Route path="/player/:username" element={<PlayerProfilePage />} />
|
||||||
<Route path="/shop" element={<ShopPage />} />
|
<Route path="/shop" element={<ShopPage />} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
import { getSocket, refreshSocketSession } from './socket';
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
@@ -22,6 +23,23 @@ export function AuthProvider({ children }) {
|
|||||||
refresh();
|
refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
// Keep Socket.IO presence in sync with HTTP session (login / logout / boot).
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
refreshSocketSession();
|
||||||
|
}, [loading, user?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = getSocket();
|
||||||
|
const onReset = () => {
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
socket.on('server:prestige-reset', onReset);
|
||||||
|
return () => {
|
||||||
|
socket.off('server:prestige-reset', onReset);
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
const login = async (username, password) => {
|
const login = async (username, password) => {
|
||||||
const data = await api.login(username, password);
|
const data = await api.login(username, password);
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { api, formatCredits } from '../api';
|
import { api, formatCredits, centsJson, toCentsBigInt } from '../api';
|
||||||
import ImageField from './ImageField';
|
import ImageField from './ImageField';
|
||||||
|
|
||||||
const emptyCase = { name: '', price: 100, imageUrl: '', active: true };
|
const emptyCase = { name: '', price: 100, imageUrl: '', active: true };
|
||||||
@@ -62,7 +62,7 @@ export default function AdminCases() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.createCase({ ...caseForm, price: Number(caseForm.price) });
|
await api.createCase({ ...caseForm, price: centsJson(toCentsBigInt(caseForm.price)) });
|
||||||
setCaseForm(emptyCase);
|
setCaseForm(emptyCase);
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -112,7 +112,7 @@ export default function AdminCases() {
|
|||||||
try {
|
try {
|
||||||
await api.updateCase(editingId, {
|
await api.updateCase(editingId, {
|
||||||
name: edit.name,
|
name: edit.name,
|
||||||
price: Number(edit.price),
|
price: centsJson(toCentsBigInt(edit.price)),
|
||||||
imageUrl: edit.imageUrl,
|
imageUrl: edit.imageUrl,
|
||||||
active: edit.active,
|
active: edit.active,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ export default function AdminDashboard() {
|
|||||||
<Link to="/admin/drops" className="btn secondary">
|
<Link to="/admin/drops" className="btn secondary">
|
||||||
Drop rates
|
Drop rates
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/admin/shop" className="btn secondary">
|
||||||
|
Shop osu packs
|
||||||
|
</Link>
|
||||||
|
<Link to="/admin/prestige-server" className="btn secondary">
|
||||||
|
Prestige Server
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-block" style={{ marginTop: '1.25rem' }}>
|
<section className="admin-block" style={{ marginTop: '1.25rem' }}>
|
||||||
|
|||||||
+139
-15
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { api, formatCredits } from '../api';
|
import { api, formatCredits, compareCents } from '../api';
|
||||||
|
|
||||||
function dropPercents(items) {
|
function dropPercents(items) {
|
||||||
const total = items.reduce((s, ci) => s + Number(ci.dropRate || 0), 0);
|
const total = items.reduce((s, ci) => s + Number(ci.dropRate || 0), 0);
|
||||||
@@ -7,12 +7,54 @@ function dropPercents(items) {
|
|||||||
return items.map((ci) => (Number(ci.dropRate) / total) * 100);
|
return items.map((ci) => (Number(ci.dropRate) / total) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortCatalog(list, sort) {
|
||||||
|
const next = [...list];
|
||||||
|
next.sort((a, b) => {
|
||||||
|
if (sort === 'value-asc') return compareCents(a.marketValue, b.marketValue);
|
||||||
|
if (sort === 'name') return a.name.localeCompare(b.name);
|
||||||
|
if (sort === 'rarity') {
|
||||||
|
const r = a.rarity.localeCompare(b.rarity);
|
||||||
|
if (r !== 0) return r;
|
||||||
|
return compareCents(b.marketValue, a.marketValue);
|
||||||
|
}
|
||||||
|
// value-desc (default)
|
||||||
|
return compareCents(b.marketValue, a.marketValue);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortPool(list, sort) {
|
||||||
|
const next = [...list];
|
||||||
|
next.sort((a, b) => {
|
||||||
|
if (sort === 'value-desc') {
|
||||||
|
return compareCents(b.item.marketValue, a.item.marketValue);
|
||||||
|
}
|
||||||
|
if (sort === 'value-asc') {
|
||||||
|
return compareCents(a.item.marketValue, b.item.marketValue);
|
||||||
|
}
|
||||||
|
if (sort === 'name') return a.item.name.localeCompare(b.item.name);
|
||||||
|
if (sort === 'rarity') {
|
||||||
|
const r = a.item.rarity.localeCompare(b.item.rarity);
|
||||||
|
if (r !== 0) return r;
|
||||||
|
return compareCents(b.item.marketValue, a.item.marketValue);
|
||||||
|
}
|
||||||
|
if (sort === 'rate-desc') return Number(b.dropRate) - Number(a.dropRate);
|
||||||
|
// rate-asc (default)
|
||||||
|
return Number(a.dropRate) - Number(b.dropRate);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminDrops() {
|
export default function AdminDrops() {
|
||||||
const [cases, setCases] = useState([]);
|
const [cases, setCases] = useState([]);
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [selectedCaseId, setSelectedCaseId] = useState('');
|
const [selectedCaseId, setSelectedCaseId] = useState('');
|
||||||
const [dropForm, setDropForm] = useState({ itemId: '', dropRate: 1 });
|
const [dropForm, setDropForm] = useState({ itemId: '', dropRate: 1 });
|
||||||
|
const [itemSort, setItemSort] = useState('value-desc');
|
||||||
|
const [itemQuery, setItemQuery] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||||
|
const [poolSort, setPoolSort] = useState('rate-asc');
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const [c, i] = await Promise.all([api.adminCases(), api.adminItems()]);
|
const [c, i] = await Promise.all([api.adminCases(), api.adminItems()]);
|
||||||
@@ -22,10 +64,6 @@ export default function AdminDrops() {
|
|||||||
if (prev && c.cases.some((x) => String(x.id) === String(prev))) return prev;
|
if (prev && c.cases.some((x) => String(x.id) === String(prev))) return prev;
|
||||||
return c.cases[0] ? String(c.cases[0].id) : '';
|
return c.cases[0] ? String(c.cases[0].id) : '';
|
||||||
});
|
});
|
||||||
setDropForm((f) => ({
|
|
||||||
...f,
|
|
||||||
itemId: f.itemId || (i.items[0] ? String(i.items[0].id) : ''),
|
|
||||||
}));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,10 +75,43 @@ export default function AdminDrops() {
|
|||||||
[cases, selectedCaseId]
|
[cases, selectedCaseId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const usedCategories = useMemo(() => {
|
||||||
|
const set = new Set(items.map((item) => item.category || 'Misc'));
|
||||||
|
return [...set].sort((a, b) => a.localeCompare(b));
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const catalogItems = useMemo(() => {
|
||||||
|
const q = itemQuery.trim().toLowerCase();
|
||||||
|
const filtered = items.filter((item) => {
|
||||||
|
const cat = item.category || 'Misc';
|
||||||
|
if (categoryFilter !== 'all' && cat !== categoryFilter) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
item.name.toLowerCase().includes(q) ||
|
||||||
|
item.rarity.toLowerCase().includes(q) ||
|
||||||
|
cat.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return sortCatalog(filtered, itemSort);
|
||||||
|
}, [items, itemQuery, itemSort, categoryFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!catalogItems.length) {
|
||||||
|
setDropForm((f) => (f.itemId ? { ...f, itemId: '' } : f));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDropForm((f) => {
|
||||||
|
if (f.itemId && catalogItems.some((item) => String(item.id) === String(f.itemId))) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
return { ...f, itemId: String(catalogItems[0].id) };
|
||||||
|
});
|
||||||
|
}, [catalogItems]);
|
||||||
|
|
||||||
const poolItems = useMemo(() => {
|
const poolItems = useMemo(() => {
|
||||||
if (!selected) return [];
|
if (!selected) return [];
|
||||||
return [...selected.items].sort((a, b) => Number(a.dropRate) - Number(b.dropRate));
|
return sortPool(selected.items, poolSort);
|
||||||
}, [selected]);
|
}, [selected, poolSort]);
|
||||||
|
|
||||||
const percents = useMemo(() => dropPercents(poolItems), [poolItems]);
|
const percents = useMemo(() => dropPercents(poolItems), [poolItems]);
|
||||||
|
|
||||||
@@ -76,6 +147,37 @@ export default function AdminDrops() {
|
|||||||
Add drop · {selected.name}{' '}
|
Add drop · {selected.name}{' '}
|
||||||
<span className="muted">({formatCredits(selected.price)} cr)</span>
|
<span className="muted">({formatCredits(selected.price)} cr)</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
<div className="admin-toolbar" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||||
|
aria-label="Filter category"
|
||||||
|
>
|
||||||
|
<option value="all">All categories</option>
|
||||||
|
{usedCategories.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={itemSort}
|
||||||
|
onChange={(e) => setItemSort(e.target.value)}
|
||||||
|
aria-label="Sort catalog items"
|
||||||
|
>
|
||||||
|
<option value="value-desc">Sort: value ↓</option>
|
||||||
|
<option value="value-asc">Sort: value ↑</option>
|
||||||
|
<option value="name">Sort: name</option>
|
||||||
|
<option value="rarity">Sort: rarity</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className="admin-search"
|
||||||
|
placeholder="Search items…"
|
||||||
|
value={itemQuery}
|
||||||
|
onChange={(e) => setItemQuery(e.target.value)}
|
||||||
|
aria-label="Search catalog items"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<form
|
<form
|
||||||
className="admin-row"
|
className="admin-row"
|
||||||
onSubmit={async (e) => {
|
onSubmit={async (e) => {
|
||||||
@@ -95,12 +197,18 @@ export default function AdminDrops() {
|
|||||||
<select
|
<select
|
||||||
value={dropForm.itemId}
|
value={dropForm.itemId}
|
||||||
onChange={(e) => setDropForm({ ...dropForm, itemId: e.target.value })}
|
onChange={(e) => setDropForm({ ...dropForm, itemId: e.target.value })}
|
||||||
|
disabled={!catalogItems.length}
|
||||||
|
aria-label="Select item to add"
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{catalogItems.length === 0 ? (
|
||||||
<option key={item.id} value={item.id}>
|
<option value="">No matching items</option>
|
||||||
{item.name} ({item.rarity})
|
) : (
|
||||||
</option>
|
catalogItems.map((item) => (
|
||||||
))}
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name} ({item.rarity}) · {formatCredits(item.marketValue)} cr
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -111,14 +219,28 @@ export default function AdminDrops() {
|
|||||||
placeholder="Weight"
|
placeholder="Weight"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button className="btn" type="submit">
|
<button className="btn" type="submit" disabled={!dropForm.itemId}>
|
||||||
Add drop
|
Add drop
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-block">
|
<div className="admin-block">
|
||||||
<h3>Pool ({poolItems.length})</h3>
|
<div className="section-head" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<h3 style={{ margin: 0 }}>Pool ({poolItems.length})</h3>
|
||||||
|
<select
|
||||||
|
value={poolSort}
|
||||||
|
onChange={(e) => setPoolSort(e.target.value)}
|
||||||
|
aria-label="Sort pool"
|
||||||
|
>
|
||||||
|
<option value="rate-asc">Sort: weight ↑</option>
|
||||||
|
<option value="rate-desc">Sort: weight ↓</option>
|
||||||
|
<option value="value-desc">Sort: value ↓</option>
|
||||||
|
<option value="value-asc">Sort: value ↑</option>
|
||||||
|
<option value="name">Sort: name</option>
|
||||||
|
<option value="rarity">Sort: rarity</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
{poolItems.length === 0 ? (
|
{poolItems.length === 0 ? (
|
||||||
<div className="muted">No drops yet.</div>
|
<div className="muted">No drops yet.</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -130,7 +252,9 @@ export default function AdminDrops() {
|
|||||||
<img src={ci.item.imageUrl} alt="" className="drop-thumb" />
|
<img src={ci.item.imageUrl} alt="" className="drop-thumb" />
|
||||||
) : null}
|
) : null}
|
||||||
{ci.item.name}{' '}
|
{ci.item.name}{' '}
|
||||||
<span className="muted">({ci.item.rarity})</span>
|
<span className="muted">
|
||||||
|
({ci.item.rarity}) · {formatCredits(ci.item.marketValue)} cr
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { api, formatCredits } from '../api';
|
import { api, formatCredits, compareCents, centsJson, toCentsBigInt } from '../api';
|
||||||
import ImageField from './ImageField';
|
import ImageField from './ImageField';
|
||||||
|
|
||||||
const emptyItem = {
|
const emptyItem = {
|
||||||
@@ -10,23 +10,21 @@ const emptyItem = {
|
|||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
function CategoryField({ value, onChange, categories, id }) {
|
function CategoryField({ value, onChange, categories }) {
|
||||||
const listId = id || 'item-categories';
|
const options =
|
||||||
|
value && !categories.includes(value) ? [...categories, value] : categories;
|
||||||
return (
|
return (
|
||||||
<>
|
<select value={value} onChange={(e) => onChange(e.target.value)} required>
|
||||||
<input
|
{options.length === 0 ? (
|
||||||
list={listId}
|
<option value="">No categories — create one first</option>
|
||||||
value={value}
|
) : (
|
||||||
onChange={(e) => onChange(e.target.value)}
|
options.map((c) => (
|
||||||
placeholder="e.g. Rifle, Knife…"
|
<option key={c} value={c}>
|
||||||
required
|
{c}
|
||||||
/>
|
</option>
|
||||||
<datalist id={listId}>
|
))
|
||||||
{categories.map((c) => (
|
)}
|
||||||
<option key={c} value={c} />
|
</select>
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,16 +49,23 @@ export default function AdminItems() {
|
|||||||
]);
|
]);
|
||||||
setItems(i.items);
|
setItems(i.items);
|
||||||
setRarities(r.rarities);
|
setRarities(r.rarities);
|
||||||
setCategories(
|
const names = (c.names || c.categories || []).map((entry) =>
|
||||||
(c.names || c.categories || []).map((entry) =>
|
typeof entry === 'string' ? entry : entry.name
|
||||||
typeof entry === 'string' ? entry : entry.name
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
setItemForm((f) => ({
|
setCategories(names);
|
||||||
...f,
|
setItemForm((f) => {
|
||||||
rarity: f.rarity || r.rarities[0] || 'MilSpec',
|
const nextCategory =
|
||||||
category: f.category || 'Misc',
|
f.category && names.includes(f.category)
|
||||||
}));
|
? f.category
|
||||||
|
: names.includes('Misc')
|
||||||
|
? 'Misc'
|
||||||
|
: names[0] || '';
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
rarity: f.rarity || r.rarities[0] || 'MilSpec',
|
||||||
|
category: nextCategory,
|
||||||
|
};
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,7 +94,7 @@ export default function AdminItems() {
|
|||||||
list = [...list];
|
list = [...list];
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
if (sort === 'name') return a.name.localeCompare(b.name);
|
if (sort === 'name') return a.name.localeCompare(b.name);
|
||||||
if (sort === 'value') return Number(b.marketValue) - Number(a.marketValue);
|
if (sort === 'value') return compareCents(b.marketValue, a.marketValue);
|
||||||
if (sort === 'rarity') return a.rarity.localeCompare(b.rarity);
|
if (sort === 'rarity') return a.rarity.localeCompare(b.rarity);
|
||||||
// category (default): group by category, then name
|
// category (default): group by category, then name
|
||||||
const ca = (a.category || 'Misc').localeCompare(b.category || 'Misc');
|
const ca = (a.category || 'Misc').localeCompare(b.category || 'Misc');
|
||||||
@@ -164,12 +169,14 @@ export default function AdminItems() {
|
|||||||
try {
|
try {
|
||||||
await api.createItem({
|
await api.createItem({
|
||||||
...itemForm,
|
...itemForm,
|
||||||
marketValue: Number(itemForm.marketValue),
|
marketValue: centsJson(toCentsBigInt(itemForm.marketValue)),
|
||||||
});
|
});
|
||||||
setItemForm({
|
setItemForm({
|
||||||
...emptyItem,
|
...emptyItem,
|
||||||
rarity: rarities[0] || 'MilSpec',
|
rarity: rarities[0] || 'MilSpec',
|
||||||
category: 'Misc',
|
category: categories.includes('Misc')
|
||||||
|
? 'Misc'
|
||||||
|
: categories[0] || '',
|
||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -188,7 +195,6 @@ export default function AdminItems() {
|
|||||||
<label>
|
<label>
|
||||||
Category
|
Category
|
||||||
<CategoryField
|
<CategoryField
|
||||||
id="create-item-categories"
|
|
||||||
value={itemForm.category}
|
value={itemForm.category}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
onChange={(category) => setItemForm({ ...itemForm, category })}
|
onChange={(category) => setItemForm({ ...itemForm, category })}
|
||||||
@@ -247,7 +253,7 @@ export default function AdminItems() {
|
|||||||
name: edit.name,
|
name: edit.name,
|
||||||
rarity: edit.rarity,
|
rarity: edit.rarity,
|
||||||
category: edit.category,
|
category: edit.category,
|
||||||
marketValue: Number(edit.marketValue),
|
marketValue: centsJson(toCentsBigInt(edit.marketValue)),
|
||||||
imageUrl: edit.imageUrl,
|
imageUrl: edit.imageUrl,
|
||||||
});
|
});
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
@@ -270,7 +276,6 @@ export default function AdminItems() {
|
|||||||
<label>
|
<label>
|
||||||
Category
|
Category
|
||||||
<CategoryField
|
<CategoryField
|
||||||
id={`edit-item-categories-${item.id}`}
|
|
||||||
value={edit.category}
|
value={edit.category}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
onChange={(category) => setEdit({ ...edit, category })}
|
onChange={(category) => setEdit({ ...edit, category })}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const NAV = [
|
|||||||
{ to: '/admin/items', label: 'Items' },
|
{ to: '/admin/items', label: 'Items' },
|
||||||
{ to: '/admin/categories', label: 'Categories' },
|
{ to: '/admin/categories', label: 'Categories' },
|
||||||
{ to: '/admin/drops', label: 'Drops' },
|
{ to: '/admin/drops', label: 'Drops' },
|
||||||
|
{ to: '/admin/shop', label: 'Shop' },
|
||||||
|
{ to: '/admin/prestige-server', label: 'Prestige Server' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
|
|||||||
@@ -4,8 +4,105 @@ import {
|
|||||||
formatCredits,
|
formatCredits,
|
||||||
formatLastConnection,
|
formatLastConnection,
|
||||||
formatPlayTime,
|
formatPlayTime,
|
||||||
|
compareCents,
|
||||||
|
creditsToCentsString,
|
||||||
|
toCentsBigInt,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
|
|
||||||
|
/** Credits delta (e.g. "15.5" or "-1000") → signed cents string. */
|
||||||
|
function parseDeltaCents(raw) {
|
||||||
|
const s = String(raw ?? '').trim().replace(',', '.');
|
||||||
|
if (!s || s === '0' || s === '+0' || s === '-0') return null;
|
||||||
|
const neg = s.startsWith('-');
|
||||||
|
const body = s.startsWith('+') || s.startsWith('-') ? s.slice(1) : s;
|
||||||
|
const cents = creditsToCentsString(body);
|
||||||
|
if (cents == null || cents === '0') return null;
|
||||||
|
return neg ? `-${cents}` : cents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole-unit delta (Pr / osu) — integers only. */
|
||||||
|
function parseDeltaUnits(raw) {
|
||||||
|
const s = String(raw ?? '').trim();
|
||||||
|
if (!/^-?\d+$/.test(s) || s === '0' || s === '-0') return null;
|
||||||
|
try {
|
||||||
|
return BigInt(s);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function balanceAfterDelta(balance, delta) {
|
||||||
|
try {
|
||||||
|
const next = toCentsBigInt(balance) + toCentsBigInt(delta);
|
||||||
|
return (next < 0n ? 0n : next).toString();
|
||||||
|
} catch {
|
||||||
|
return toCentsBigInt(balance).toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact display for whole units (Pr / osu) via credit formatter. */
|
||||||
|
function formatUnits(n) {
|
||||||
|
try {
|
||||||
|
return formatCredits((BigInt(n) * 100n).toString());
|
||||||
|
} catch {
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewClass(delta) {
|
||||||
|
if (delta == null) return 'admin-adjust-preview muted';
|
||||||
|
const n = typeof delta === 'bigint' ? delta : BigInt(delta);
|
||||||
|
return n > 0n ? 'admin-adjust-preview pos' : 'admin-adjust-preview neg';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignedPreview(delta, suffix, isCents = false) {
|
||||||
|
if (delta == null) return '—';
|
||||||
|
const n = typeof delta === 'bigint' ? delta : BigInt(delta);
|
||||||
|
const abs = n < 0n ? -n : n;
|
||||||
|
const formatted = isCents ? formatCredits(abs.toString()) : formatUnits(abs);
|
||||||
|
return `${n > 0n ? '+' : '−'}${formatted} ${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdjustRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
step = '1',
|
||||||
|
delta,
|
||||||
|
preview,
|
||||||
|
result,
|
||||||
|
onSubmit,
|
||||||
|
busy,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="admin-adjust-row"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
{label}
|
||||||
|
<span className="admin-adjust-input-wrap">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span className={previewClass(delta)}>{preview}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{result != null && <span className="admin-adjust-result muted">{result}</span>}
|
||||||
|
<button className="btn" type="submit" disabled={busy || delta == null}>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminPlayers() {
|
export default function AdminPlayers() {
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -13,8 +110,14 @@ export default function AdminPlayers() {
|
|||||||
const [sort, setSort] = useState('wealth');
|
const [sort, setSort] = useState('wealth');
|
||||||
const [adjustId, setAdjustId] = useState(null);
|
const [adjustId, setAdjustId] = useState(null);
|
||||||
const [deltaCredits, setDeltaCredits] = useState('100');
|
const [deltaCredits, setDeltaCredits] = useState('100');
|
||||||
|
const [deltaPr, setDeltaPr] = useState('1000');
|
||||||
|
const [deltaOsu, setDeltaOsu] = useState('100');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const deltaCents = useMemo(() => parseDeltaCents(deltaCredits), [deltaCredits]);
|
||||||
|
const prDelta = useMemo(() => parseDeltaUnits(deltaPr), [deltaPr]);
|
||||||
|
const osuDelta = useMemo(() => parseDeltaUnits(deltaOsu), [deltaOsu]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const data = await api.adminUsers();
|
const data = await api.adminUsers();
|
||||||
setUsers(data.users);
|
setUsers(data.users);
|
||||||
@@ -39,23 +142,24 @@ export default function AdminPlayers() {
|
|||||||
new Date(a.lastConnection || 0).getTime()
|
new Date(a.lastConnection || 0).getTime()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Number(b.totalWealth) - Number(a.totalWealth);
|
return compareCents(b.totalWealth, a.totalWealth);
|
||||||
});
|
});
|
||||||
return list;
|
return list;
|
||||||
}, [users, query, sort]);
|
}, [users, query, sort]);
|
||||||
|
|
||||||
const applyDelta = async (userId, credits) => {
|
const resetDeltas = () => {
|
||||||
const cents = Math.round(Number(credits) * 100);
|
setDeltaCredits('100');
|
||||||
if (!Number.isInteger(cents) || cents === 0) {
|
setDeltaPr('1000');
|
||||||
setError('Enter a non-zero credit amount (e.g. 100 or -50)');
|
setDeltaOsu('100');
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
|
const applyPatch = async (userId, body) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.adminUpdateUser(userId, { balanceDelta: cents });
|
await api.adminUpdateUser(userId, body);
|
||||||
setAdjustId(null);
|
setAdjustId(null);
|
||||||
setDeltaCredits('100');
|
resetDeltas();
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -64,12 +168,44 @@ export default function AdminPlayers() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyCredits = async (userId) => {
|
||||||
|
const cents = parseDeltaCents(deltaCredits);
|
||||||
|
if (cents == null) {
|
||||||
|
setError('Enter a non-zero credit amount (e.g. 100 or -50)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await applyPatch(userId, { balanceDelta: cents });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPr = async (userId) => {
|
||||||
|
const delta = parseDeltaUnits(deltaPr);
|
||||||
|
if (delta == null) {
|
||||||
|
setError('Enter a non-zero Pr amount (e.g. 1000 or -100)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await applyPatch(userId, { prBalanceDelta: delta.toString() });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyOsu = async (userId) => {
|
||||||
|
const delta = parseDeltaUnits(deltaOsu);
|
||||||
|
if (delta == null) {
|
||||||
|
setError('Enter a non-zero osu amount (e.g. 100 or -50)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// osuBalance is Int — stay within safe integer range for the JSON number API
|
||||||
|
if (delta > BigInt(Number.MAX_SAFE_INTEGER) || delta < BigInt(Number.MIN_SAFE_INTEGER)) {
|
||||||
|
setError('osu delta is too large');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await applyPatch(userId, { osuBalanceDelta: Number(delta) });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>Players</h1>
|
<h1>Players</h1>
|
||||||
<p className="muted">{users.length} accounts · adjust credits when needed</p>
|
<p className="muted">{users.length} accounts · adjust credits, Pr & osu</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-toolbar">
|
<div className="admin-toolbar">
|
||||||
<select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">
|
<select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">
|
||||||
@@ -108,8 +244,9 @@ export default function AdminPlayers() {
|
|||||||
{u.inventoryCount})
|
{u.inventoryCount})
|
||||||
</div>
|
</div>
|
||||||
<div className="muted">
|
<div className="muted">
|
||||||
Opens {u.casesOpened} · prestige {u.prestigeCount} · achievements{' '}
|
{formatUnits(u.prBalance ?? 0)} Pr · {formatUnits(u.osuBalance ?? 0)} osu · opens{' '}
|
||||||
{u.achievements} · play {formatPlayTime(u.playTimeSeconds)} · last{' '}
|
{u.casesOpened} · prestige {u.prestigeCount} · achievements {u.achievements} · play{' '}
|
||||||
|
{formatPlayTime(u.playTimeSeconds)} · last{' '}
|
||||||
{formatLastConnection(u.lastConnection)}
|
{formatLastConnection(u.lastConnection)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,50 +255,62 @@ export default function AdminPlayers() {
|
|||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAdjustId(adjustId === u.id ? null : u.id);
|
setAdjustId(adjustId === u.id ? null : u.id);
|
||||||
setDeltaCredits('100');
|
resetDeltas();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Adjust credits
|
Adjust
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{adjustId === u.id && (
|
{adjustId === u.id && (
|
||||||
<form
|
<div className="admin-adjust-panel">
|
||||||
className="admin-adjust-row"
|
<AdjustRow
|
||||||
onSubmit={(e) => {
|
label="Δ credits (e.g. 100 or -50)"
|
||||||
e.preventDefault();
|
value={deltaCredits}
|
||||||
applyDelta(u.id, deltaCredits);
|
onChange={setDeltaCredits}
|
||||||
}}
|
step="0.01"
|
||||||
>
|
delta={deltaCents == null ? null : BigInt(deltaCents)}
|
||||||
<label>
|
preview={formatSignedPreview(
|
||||||
Δ credits (e.g. 100 or -50)
|
deltaCents == null ? null : BigInt(deltaCents),
|
||||||
<input
|
'cr',
|
||||||
type="number"
|
true
|
||||||
step="0.01"
|
)}
|
||||||
value={deltaCredits}
|
result={
|
||||||
onChange={(e) => setDeltaCredits(e.target.value)}
|
deltaCents == null
|
||||||
required
|
? null
|
||||||
/>
|
: `→ bal ${formatCredits(balanceAfterDelta(u.balance, deltaCents))} cr`
|
||||||
</label>
|
}
|
||||||
<button className="btn" type="submit" disabled={busy}>
|
onSubmit={() => applyCredits(u.id)}
|
||||||
Apply
|
busy={busy}
|
||||||
</button>
|
/>
|
||||||
<button
|
<AdjustRow
|
||||||
type="button"
|
label="Δ Pr (e.g. 1000 or -100)"
|
||||||
className="btn ghost"
|
value={deltaPr}
|
||||||
onClick={() => applyDelta(u.id, '1000')}
|
onChange={setDeltaPr}
|
||||||
disabled={busy}
|
delta={prDelta}
|
||||||
>
|
preview={formatSignedPreview(prDelta, 'Pr')}
|
||||||
+1000
|
result={
|
||||||
</button>
|
prDelta == null
|
||||||
<button
|
? null
|
||||||
type="button"
|
: `→ ${formatUnits(balanceAfterDelta(u.prBalance ?? 0, prDelta))} Pr`
|
||||||
className="btn ghost"
|
}
|
||||||
onClick={() => applyDelta(u.id, '-100')}
|
onSubmit={() => applyPr(u.id)}
|
||||||
disabled={busy}
|
busy={busy}
|
||||||
>
|
/>
|
||||||
−100
|
<AdjustRow
|
||||||
</button>
|
label="Δ osu (e.g. 100 or -50)"
|
||||||
</form>
|
value={deltaOsu}
|
||||||
|
onChange={setDeltaOsu}
|
||||||
|
delta={osuDelta}
|
||||||
|
preview={formatSignedPreview(osuDelta, 'osu')}
|
||||||
|
result={
|
||||||
|
osuDelta == null
|
||||||
|
? null
|
||||||
|
: `→ ${formatUnits(balanceAfterDelta(u.osuBalance ?? 0, osuDelta))} osu`
|
||||||
|
}
|
||||||
|
onSubmit={() => applyOsu(u.id)}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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'}
|
{uploading ? '…' : 'Upload'}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/png,image/jpeg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif"
|
||||||
hidden
|
hidden
|
||||||
onChange={onFile}
|
onChange={onFile}
|
||||||
disabled={disabled || uploading}
|
disabled={disabled || uploading}
|
||||||
|
|||||||
+145
-39
@@ -35,10 +35,10 @@ export const api = {
|
|||||||
catalog: () => request('/api/catalog'),
|
catalog: () => request('/api/catalog'),
|
||||||
prestige: () => request('/api/prestige'),
|
prestige: () => request('/api/prestige'),
|
||||||
doPrestige: () => request('/api/prestige', { method: 'POST', body: JSON.stringify({}) }),
|
doPrestige: () => request('/api/prestige', { method: 'POST', body: JSON.stringify({}) }),
|
||||||
prestigeUpgrade: (skillId) =>
|
prestigeUpgrade: (skillId, count = 1) =>
|
||||||
request('/api/prestige/upgrade', {
|
request('/api/prestige/upgrade', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ skillId }),
|
body: JSON.stringify({ skillId, count }),
|
||||||
}),
|
}),
|
||||||
openCase: (id, count = 1) =>
|
openCase: (id, count = 1) =>
|
||||||
request(`/api/cases/${id}/open`, {
|
request(`/api/cases/${id}/open`, {
|
||||||
@@ -56,7 +56,6 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ inventoryItemIds }),
|
body: JSON.stringify({ inventoryItemIds }),
|
||||||
}),
|
}),
|
||||||
vaultClaim: () => request('/api/vault/claim', { method: 'POST' }),
|
|
||||||
sellInventory: (inventoryItemIds) =>
|
sellInventory: (inventoryItemIds) =>
|
||||||
request('/api/inventory/sell', {
|
request('/api/inventory/sell', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -83,41 +82,65 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ packageId }),
|
body: JSON.stringify({ packageId }),
|
||||||
}),
|
}),
|
||||||
|
shopOsuCheckout: (packId) =>
|
||||||
|
request('/api/shop/osu/checkout', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ packId }),
|
||||||
|
}),
|
||||||
|
shopOsuConfirm: (orderId) =>
|
||||||
|
request('/api/shop/osu/confirm', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ orderId }),
|
||||||
|
}),
|
||||||
shopAd: () => request('/api/shop/ad', { method: 'POST' }),
|
shopAd: () => request('/api/shop/ad', { method: 'POST' }),
|
||||||
feed: () => request('/api/feed'),
|
feed: () => request('/api/feed'),
|
||||||
feedAnnounce: (inventoryItemIds, caseName) => {
|
feedAnnounce: (inventoryItemIds, caseName, opts = {}) => {
|
||||||
const ids = Array.isArray(inventoryItemIds)
|
const ids = Array.isArray(inventoryItemIds)
|
||||||
? inventoryItemIds
|
? inventoryItemIds
|
||||||
: [inventoryItemIds];
|
: inventoryItemIds != null
|
||||||
|
? [inventoryItemIds]
|
||||||
|
: [];
|
||||||
|
const body = {
|
||||||
|
inventoryItemIds: ids.filter(Boolean),
|
||||||
|
caseName,
|
||||||
|
respin: Boolean(opts.respin),
|
||||||
|
};
|
||||||
|
if (opts.snapshotBest) {
|
||||||
|
body.snapshotBest = opts.snapshotBest;
|
||||||
|
body.openCount = opts.openCount;
|
||||||
|
body.count = opts.count;
|
||||||
|
} else if (opts.snapshotItems) {
|
||||||
|
body.snapshotItems = opts.snapshotItems;
|
||||||
|
}
|
||||||
return request('/api/feed/announce', {
|
return request('/api/feed/announce', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ inventoryItemIds: ids, caseName }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
leaderboard: () => request('/api/leaderboard'),
|
leaderboard: () => request('/api/leaderboard'),
|
||||||
jackpot: () => request('/api/jackpot'),
|
jackpot: () => request('/api/jackpot'),
|
||||||
jackpotBet: (inventoryItemIds) =>
|
jackpotRound: (id) => request(`/api/jackpot/${id}`),
|
||||||
|
jackpotCreateBet: (inventoryItemIds) =>
|
||||||
request('/api/jackpot/bet', {
|
request('/api/jackpot/bet', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ inventoryItemIds }),
|
body: JSON.stringify({ inventoryItemIds }),
|
||||||
}),
|
}),
|
||||||
jackpotWithdraw: (inventoryItemIds) =>
|
jackpotBet: (id, inventoryItemIds) =>
|
||||||
request('/api/jackpot/withdraw', {
|
request(`/api/jackpot/${id}/bet`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ inventoryItemIds }),
|
body: JSON.stringify({ inventoryItemIds }),
|
||||||
}),
|
}),
|
||||||
duels: () => request('/api/duels'),
|
jackpotWithdraw: (id, inventoryItemIds) =>
|
||||||
duel: (id) => request(`/api/duels/${id}`),
|
request(`/api/jackpot/${id}/withdraw`, {
|
||||||
createDuel: (body) =>
|
method: 'POST',
|
||||||
request('/api/duels', { method: 'POST', body: JSON.stringify(body) }),
|
body: JSON.stringify({ inventoryItemIds }),
|
||||||
duelBet: (id, inventoryItemIds) =>
|
}),
|
||||||
request(`/api/duels/${id}/bet`, {
|
// legacy alias
|
||||||
|
jackpotCreate: (inventoryItemIds) =>
|
||||||
|
request('/api/jackpot/bet', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ inventoryItemIds }),
|
body: JSON.stringify({ inventoryItemIds }),
|
||||||
}),
|
}),
|
||||||
duelLeave: (id) => request(`/api/duels/${id}/leave`, { method: 'POST' }),
|
|
||||||
duelCancel: (id) => request(`/api/duels/${id}/cancel`, { method: 'POST' }),
|
|
||||||
duelStart: (id) => request(`/api/duels/${id}/start`, { method: 'POST' }),
|
|
||||||
profile: () => request('/api/profile'),
|
profile: () => request('/api/profile'),
|
||||||
playerProfile: (username) =>
|
playerProfile: (username) =>
|
||||||
request(`/api/profile/player/${encodeURIComponent(username)}`),
|
request(`/api/profile/player/${encodeURIComponent(username)}`),
|
||||||
@@ -140,6 +163,12 @@ export const api = {
|
|||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
}),
|
}),
|
||||||
adminStats: () => request('/api/admin/stats'),
|
adminStats: () => request('/api/admin/stats'),
|
||||||
|
adminPrestigeServerPreview: () => request('/api/admin/prestige-server'),
|
||||||
|
adminPrestigeServer: (body) =>
|
||||||
|
request('/api/admin/prestige-server', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
adminUsers: () => request('/api/admin/users'),
|
adminUsers: () => request('/api/admin/users'),
|
||||||
adminUpdateUser: (id, body) =>
|
adminUpdateUser: (id, body) =>
|
||||||
request(`/api/admin/users/${id}`, {
|
request(`/api/admin/users/${id}`, {
|
||||||
@@ -150,6 +179,17 @@ export const api = {
|
|||||||
adminItems: () => request('/api/admin/items'),
|
adminItems: () => request('/api/admin/items'),
|
||||||
adminRarities: () => request('/api/admin/rarities'),
|
adminRarities: () => request('/api/admin/rarities'),
|
||||||
adminCategories: () => request('/api/admin/categories'),
|
adminCategories: () => request('/api/admin/categories'),
|
||||||
|
adminShopOsuPacks: () => request('/api/admin/shop/osu-packs'),
|
||||||
|
adminShopOsuSection: (enabled) =>
|
||||||
|
request('/api/admin/shop/osu-section', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
}),
|
||||||
|
adminUpdateShopOsuPack: (id, body) =>
|
||||||
|
request(`/api/admin/shop/osu-packs/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
createCategory: (body) =>
|
createCategory: (body) =>
|
||||||
request('/api/admin/categories', { method: 'POST', body: JSON.stringify(body) }),
|
request('/api/admin/categories', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
updateCategory: (id, body) =>
|
updateCategory: (id, body) =>
|
||||||
@@ -186,17 +226,77 @@ export const api = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exact credit display (cents → "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) {
|
export function formatCreditsExact(cents) {
|
||||||
const n = Number(cents);
|
let bi = toCentsBigInt(cents);
|
||||||
if (!Number.isFinite(n)) return '0.00';
|
const neg = bi < 0n;
|
||||||
const fixed = (n / 100).toFixed(2);
|
if (neg) bi = -bi;
|
||||||
const neg = fixed.startsWith('-');
|
const whole = bi / 100n;
|
||||||
const body = neg ? fixed.slice(1) : fixed;
|
const frac = (bi % 100n).toString().padStart(2, '0');
|
||||||
const [intPart, dec] = body.split('.');
|
const grouped = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f');
|
||||||
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f');
|
return `${neg ? '-' : ''}${grouped}.${frac}`;
|
||||||
return `${neg ? '-' : ''}${grouped}.${dec}`;
|
}
|
||||||
|
|
||||||
|
/** Parse credits input (decimal string) → integer cents string. Null if invalid. */
|
||||||
|
export function creditsToCentsString(raw) {
|
||||||
|
if (raw == null || raw === '') return null;
|
||||||
|
const s = String(raw).trim().replace(',', '.');
|
||||||
|
const m = s.match(/^(\d+)(?:\.(\d{1,2})\d*)?$/);
|
||||||
|
if (!m) return null;
|
||||||
|
try {
|
||||||
|
const whole = BigInt(m[1]);
|
||||||
|
const frac = m[2] ? m[2].padEnd(2, '0') : '00';
|
||||||
|
return (whole * 100n + BigInt(frac)).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cents → credits decimal string without Number precision loss. */
|
||||||
|
export function centsToCreditsString(cents) {
|
||||||
|
let bi = toCentsBigInt(cents);
|
||||||
|
const neg = bi < 0n;
|
||||||
|
const abs = neg ? -bi : bi;
|
||||||
|
const whole = abs / 100n;
|
||||||
|
const frac = (abs % 100n).toString().padStart(2, '0');
|
||||||
|
const body =
|
||||||
|
frac === '00' ? whole.toString() : `${whole}.${frac}`.replace(/0+$/, '').replace(/\.$/, '');
|
||||||
|
return `${neg ? '-' : ''}${body}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Alphabet suffixes after T: aa, ab, … az, ba, … (each step ×1000). */
|
/** Alphabet suffixes after T: aa, ab, … az, ba, … (each step ×1000). */
|
||||||
@@ -225,25 +325,31 @@ function tierSuffix(tier) {
|
|||||||
* K / M / B / T, then aa, ab, … (×1000 each). Input is cents.
|
* K / M / B / T, then aa, ab, … (×1000 each). Input is cents.
|
||||||
*/
|
*/
|
||||||
export function formatCredits(cents) {
|
export function formatCredits(cents) {
|
||||||
const n = Number(cents);
|
let bi = toCentsBigInt(cents);
|
||||||
if (!Number.isFinite(n)) return '0.00';
|
const neg = bi < 0n;
|
||||||
const neg = n < 0;
|
if (neg) bi = -bi;
|
||||||
const credits = Math.abs(n) / 100;
|
|
||||||
|
|
||||||
if (credits < 1000) {
|
const creditsWhole = bi / 100n;
|
||||||
return `${neg ? '-' : ''}${credits.toFixed(2)}`;
|
const creditsFrac = bi % 100n;
|
||||||
|
|
||||||
|
if (creditsWhole < 1000n) {
|
||||||
|
return `${neg ? '-' : ''}${creditsWhole}.${creditsFrac.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let tier = 0;
|
let tier = 0;
|
||||||
let scaled = credits;
|
let scaled = creditsWhole;
|
||||||
while (scaled >= 1000 && tier < 1000) {
|
let rem = 0n;
|
||||||
scaled /= 1000;
|
while (scaled >= 1000n) {
|
||||||
|
rem = scaled % 1000n;
|
||||||
|
scaled /= 1000n;
|
||||||
tier += 1;
|
tier += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const suffix = tierSuffix(tier);
|
// scaled < 1000 → safe as Number; rem gives ~3 decimal digits of precision
|
||||||
const shown = String(Number(scaled.toFixed(2)));
|
const approx = Number(scaled) + Number(rem) / 1000;
|
||||||
return `${neg ? '-' : ''}${shown}${suffix}`;
|
if (!Number.isFinite(approx)) return `${neg ? '-' : ''}0${tierSuffix(tier)}`;
|
||||||
|
const shown = String(Number(approx.toFixed(2)));
|
||||||
|
return `${neg ? '-' : ''}${shown}${tierSuffix(tier)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatChance(pct) {
|
export function formatChance(pct) {
|
||||||
|
|||||||
@@ -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;
|
if (!entry?.id) return;
|
||||||
setDrops((prev) => {
|
setDrops((prev) => {
|
||||||
if (prev.some((d) => d.id === entry.id)) return prev;
|
if (prev.some((d) => d.id === entry.id)) return prev;
|
||||||
// Also skip near-identical batch (same user + item + count) within a few seconds
|
|
||||||
const nearDup = prev.find(
|
const nearDup = prev.find(
|
||||||
(d) =>
|
(d) =>
|
||||||
d.username === entry.username &&
|
d.username === entry.username &&
|
||||||
d.item?.id === entry.item?.id &&
|
d.item?.id === entry.item?.id &&
|
||||||
(d.count || 1) === (entry.count || 1) &&
|
(d.count || 1) === (entry.count || 1) &&
|
||||||
(d.openCount || 1) === (entry.openCount || 1) &&
|
(d.openCount || 1) === (entry.openCount || 1) &&
|
||||||
|
Boolean(d.respin) === Boolean(entry.respin) &&
|
||||||
Math.abs(new Date(d.at) - new Date(entry.at)) < 15000
|
Math.abs(new Date(d.at) - new Date(entry.at)) < 15000
|
||||||
);
|
);
|
||||||
if (nearDup) return prev;
|
if (nearDup) return prev;
|
||||||
@@ -69,7 +69,6 @@ export default function DropFeed() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prefer socket history; HTTP is fallback if socket is slow
|
|
||||||
socket.emit('feed:subscribe');
|
socket.emit('feed:subscribe');
|
||||||
socket.on('feed:history', onHistory);
|
socket.on('feed:history', onHistory);
|
||||||
socket.on('feed:drop', onDrop);
|
socket.on('feed:drop', onDrop);
|
||||||
@@ -117,7 +116,11 @@ export default function DropFeed() {
|
|||||||
visible.map((d) => {
|
visible.map((d) => {
|
||||||
const color = rarityColor(d.item?.rarity);
|
const color = rarityColor(d.item?.rarity);
|
||||||
return (
|
return (
|
||||||
<div key={d.id} className="drop-feed-row" style={{ borderLeftColor: color }}>
|
<div
|
||||||
|
key={d.id}
|
||||||
|
className={`drop-feed-row${d.respin ? ' is-respin' : ''}`}
|
||||||
|
style={{ borderLeftColor: color }}
|
||||||
|
>
|
||||||
<div className="drop-feed-item-visual" style={{ color }}>
|
<div className="drop-feed-item-visual" style={{ color }}>
|
||||||
{d.item?.imageUrl ? (
|
{d.item?.imageUrl ? (
|
||||||
<img src={d.item.imageUrl} alt="" />
|
<img src={d.item.imageUrl} alt="" />
|
||||||
@@ -148,16 +151,18 @@ export default function DropFeed() {
|
|||||||
{d.username}
|
{d.username}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
d.username
|
<>
|
||||||
|
{d.respin ? (
|
||||||
|
<span className="drop-feed-respin-label">Respin</span>
|
||||||
|
) : null}
|
||||||
|
{d.respin ? ' ' : null}
|
||||||
|
{d.username}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="muted drop-feed-sub">
|
<div className="muted drop-feed-sub">
|
||||||
{d.source === 'jackpot'
|
{d.caseName ? `${d.caseName} · ` : ''}
|
||||||
? 'MultiPlayers Case · '
|
|
||||||
: d.caseName
|
|
||||||
? `${d.caseName} · `
|
|
||||||
: ''}
|
|
||||||
{formatCredits(d.item?.marketValue || 0)} cr
|
{formatCredits(d.item?.marketValue || 0)} cr
|
||||||
{d.openCount > 1
|
{d.openCount > 1
|
||||||
? d.source === 'jackpot'
|
? d.source === 'jackpot'
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { formatCredits, formatCreditsExact } from '../api';
|
import { formatCredits, formatCreditsExact, toCentsBigInt } from '../api';
|
||||||
import { rarityColor } from '../rarities';
|
import { rarityColor } from '../rarities';
|
||||||
|
|
||||||
/** Instance sell/bet price, else catalog FT reference */
|
/** Instance sell/bet price, else catalog FT reference (BigInt cents). */
|
||||||
export function itemValueCents(item) {
|
export function itemValueCents(item) {
|
||||||
if (!item) return 0;
|
if (!item) return 0n;
|
||||||
if (item.valueCents != null && Number.isFinite(Number(item.valueCents))) {
|
if (item.valueCents != null && item.valueCents !== '') {
|
||||||
return Number(item.valueCents);
|
return toCentsBigInt(item.valueCents);
|
||||||
}
|
}
|
||||||
return Number(item.marketValue) || 0;
|
return toCentsBigInt(item.marketValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFloat(f) {
|
export function formatFloat(f) {
|
||||||
@@ -41,6 +41,7 @@ export default function ItemTile({
|
|||||||
const titleParts = [];
|
const titleParts = [];
|
||||||
if (staked) titleParts.push('Vault');
|
if (staked) titleParts.push('Vault');
|
||||||
if (favorite) titleParts.push('Favorite');
|
if (favorite) titleParts.push('Favorite');
|
||||||
|
if (item.rarity) titleParts.push(item.rarity);
|
||||||
if (floatTitle) titleParts.push(floatTitle);
|
if (floatTitle) titleParts.push(floatTitle);
|
||||||
else titleParts.push(item.name);
|
else titleParts.push(item.name);
|
||||||
if (showValue) titleParts.push(`${formatCreditsExact(value)} cr`);
|
if (showValue) titleParts.push(`${formatCreditsExact(value)} cr`);
|
||||||
@@ -76,16 +77,13 @@ export default function ItemTile({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="item-name">{item.name}</div>
|
<div className="item-name">{item.name}</div>
|
||||||
<div className="item-meta" style={{ color }}>
|
|
||||||
{item.rarity}
|
|
||||||
</div>
|
|
||||||
{showWear && wear ? (
|
{showWear && wear ? (
|
||||||
<div className="item-wear" title={floatTitle}>
|
<div className="item-wear" title={floatTitle}>
|
||||||
{wear}
|
{wear}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{showValue && (
|
{showValue && (
|
||||||
<div className="item-meta" title={`${formatCreditsExact(value)} cr`}>
|
<div className="item-meta item-price" title={`${formatCreditsExact(value)} cr`}>
|
||||||
{formatCredits(value)} cr
|
{formatCredits(value)} cr
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { api, formatCredits, formatCreditsExact } from '../api';
|
|||||||
import DropFeed from './DropFeed';
|
import DropFeed from './DropFeed';
|
||||||
import SessionLimitOverlay from './SessionLimitOverlay';
|
import SessionLimitOverlay from './SessionLimitOverlay';
|
||||||
import { onInventoryChanged } from '../inventoryEvents';
|
import { onInventoryChanged } from '../inventoryEvents';
|
||||||
import { getSocket, onSessionLimitChange } from '../socket';
|
import { getSocket, onSessionLimitChange, getSessionLimitPayload } from '../socket';
|
||||||
|
|
||||||
const FEED_KEY = 'caseorion-feed-drop';
|
const FEED_KEY = 'caseorion-feed-drop';
|
||||||
|
|
||||||
@@ -17,27 +17,20 @@ function ScrollToTop() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function countWaitingBattles(jackpotRound, duelOpen) {
|
function countWaitingBattles(jackpotBattles) {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
|
|
||||||
// MultiPlayers: only if someone has already bet and round still waits for more players
|
for (const battle of jackpotBattles || []) {
|
||||||
const jpPlayers = jackpotRound?.players?.length ?? 0;
|
if (battle.status === 'spinning') continue;
|
||||||
const jpDeposits = jackpotRound?.deposits?.length ?? 0;
|
const jpPlayers = battle?.players?.length ?? 0;
|
||||||
if (
|
const jpDeposits = battle?.deposits?.length ?? 0;
|
||||||
jackpotRound?.waitingForPlayers &&
|
if (
|
||||||
jackpotRound.status !== 'spinning' &&
|
battle?.waitingForPlayers &&
|
||||||
(jpPlayers > 0 || jpDeposits > 0)
|
battle.status === 'open' &&
|
||||||
) {
|
(jpPlayers > 0 || jpDeposits > 0)
|
||||||
n += 1;
|
) {
|
||||||
}
|
n += 1;
|
||||||
|
}
|
||||||
// 1vX: each open room that already has at least one player with a bet
|
|
||||||
for (const room of duelOpen || []) {
|
|
||||||
if (room.status !== 'open') continue;
|
|
||||||
const hasBet =
|
|
||||||
(room.playerCount ?? room.players?.length ?? 0) > 0 ||
|
|
||||||
(room.deposits?.length ?? 0) > 0;
|
|
||||||
if (hasBet) n += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return n;
|
return n;
|
||||||
@@ -45,6 +38,7 @@ function countWaitingBattles(jackpotRound, duelOpen) {
|
|||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { pathname } = useLocation();
|
||||||
const [feedOpen, setFeedOpen] = useState(() => {
|
const [feedOpen, setFeedOpen] = useState(() => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(FEED_KEY) === '1';
|
return localStorage.getItem(FEED_KEY) === '1';
|
||||||
@@ -54,7 +48,12 @@ export default function Layout() {
|
|||||||
});
|
});
|
||||||
const [waitingBattles, setWaitingBattles] = useState(0);
|
const [waitingBattles, setWaitingBattles] = useState(0);
|
||||||
const [prestigeVisible, setPrestigeVisible] = useState(false);
|
const [prestigeVisible, setPrestigeVisible] = useState(false);
|
||||||
const [sessionLimit, setSessionLimit] = useState(null);
|
const [sessionLimit, setSessionLimit] = useState(() => getSessionLimitPayload());
|
||||||
|
const [navHidden, setNavHidden] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNavHidden(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -64,6 +63,47 @@ export default function Layout() {
|
|||||||
}
|
}
|
||||||
}, [feedOpen]);
|
}, [feedOpen]);
|
||||||
|
|
||||||
|
// Mobile: hide topnav while scrolling down, reveal on scroll up / near top
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 700px)');
|
||||||
|
let lastY = window.scrollY || 0;
|
||||||
|
let hidden = false;
|
||||||
|
|
||||||
|
const apply = (next) => {
|
||||||
|
if (hidden === next) return;
|
||||||
|
hidden = next;
|
||||||
|
setNavHidden(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!mq.matches) {
|
||||||
|
apply(false);
|
||||||
|
lastY = window.scrollY || 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const y = window.scrollY || 0;
|
||||||
|
const delta = y - lastY;
|
||||||
|
lastY = y;
|
||||||
|
if (y < 48) {
|
||||||
|
apply(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (delta > 6) apply(true);
|
||||||
|
else if (delta < -6) apply(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMq = () => {
|
||||||
|
if (!mq.matches) apply(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
mq.addEventListener?.('change', onMq);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', onScroll);
|
||||||
|
mq.removeEventListener?.('change', onMq);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || user.role === 'admin') {
|
if (!user || user.role === 'admin') {
|
||||||
setPrestigeVisible(false);
|
setPrestigeVisible(false);
|
||||||
@@ -97,24 +137,22 @@ export default function Layout() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let jackpotRound = null;
|
let jackpotBattles = [];
|
||||||
let duelOpen = [];
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setWaitingBattles((prev) => {
|
setWaitingBattles((prev) => {
|
||||||
const next = countWaitingBattles(jackpotRound, duelOpen);
|
const next = countWaitingBattles(jackpotBattles);
|
||||||
return prev === next ? prev : next;
|
return prev === next ? prev : next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
|
const jp = await api.jackpot();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
jackpotRound = jp.round;
|
jackpotBattles = jp.battles || [];
|
||||||
duelOpen = duels.open || [];
|
|
||||||
sync();
|
sync();
|
||||||
} catch {
|
} catch {
|
||||||
/* nav badge is optional */
|
/* nav badge is optional */
|
||||||
@@ -125,35 +163,22 @@ export default function Layout() {
|
|||||||
const offLimitChange = onSessionLimitChange((payload) => {
|
const offLimitChange = onSessionLimitChange((payload) => {
|
||||||
setSessionLimit(payload);
|
setSessionLimit(payload);
|
||||||
});
|
});
|
||||||
const onJackpot = (state) => {
|
const onJackpots = (payload) => {
|
||||||
jackpotRound = state;
|
jackpotBattles = payload.battles || [];
|
||||||
sync();
|
|
||||||
};
|
|
||||||
const onDuels = (list) => {
|
|
||||||
duelOpen = list.open || [];
|
|
||||||
sync();
|
sync();
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.emit('jackpot:subscribe');
|
socket.emit('jackpots:subscribe');
|
||||||
socket.emit('duels:subscribe');
|
socket.on('jackpots:list', onJackpots);
|
||||||
socket.on('jackpot:state', onJackpot);
|
|
||||||
socket.on('jackpot:spin', onJackpot);
|
|
||||||
socket.on('jackpot:result', onJackpot);
|
|
||||||
socket.on('duels:list', onDuels);
|
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
socket.emit('jackpot:subscribe');
|
socket.emit('jackpots:subscribe');
|
||||||
socket.emit('duels:subscribe');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
offLimitChange();
|
offLimitChange();
|
||||||
socket.emit('jackpot:unsubscribe');
|
socket.emit('jackpots:unsubscribe');
|
||||||
socket.emit('duels:unsubscribe');
|
socket.off('jackpots:list', onJackpots);
|
||||||
socket.off('jackpot:state', onJackpot);
|
|
||||||
socket.off('jackpot:spin', onJackpot);
|
|
||||||
socket.off('jackpot:result', onJackpot);
|
|
||||||
socket.off('duels:list', onDuels);
|
|
||||||
};
|
};
|
||||||
}, [user?.id, user?.role]);
|
}, [user?.id, user?.role]);
|
||||||
|
|
||||||
@@ -164,8 +189,8 @@ export default function Layout() {
|
|||||||
className={`app-shell${feedOpen ? ' feed-open' : ''}${sessionLimit ? ' session-locked' : ''}`}
|
className={`app-shell${feedOpen ? ' feed-open' : ''}${sessionLimit ? ' session-locked' : ''}`}
|
||||||
>
|
>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
{sessionLimit && <SessionLimitOverlay max={sessionLimit.max || 10} />}
|
{sessionLimit && <SessionLimitOverlay max={sessionLimit.max || 2} />}
|
||||||
<header className="topnav">
|
<header className={`topnav${navHidden ? ' topnav--hidden' : ''}`}>
|
||||||
<div className="topnav-left">
|
<div className="topnav-left">
|
||||||
<NavLink to="/dashboard" className="brand">
|
<NavLink to="/dashboard" className="brand">
|
||||||
CaseOrion
|
CaseOrion
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ export default function ProfilePrestigeStats({ prestige }) {
|
|||||||
if (!prestige || !prestige.count) return null;
|
if (!prestige || !prestige.count) return null;
|
||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
typeof prestige.prBalance === 'number' && prestige.prBalance > 0
|
(() => {
|
||||||
? { label: 'Pr', value: <strong>{prestige.prBalance}</strong> }
|
try {
|
||||||
|
return BigInt(String(prestige.prBalance ?? 0)) > 0n;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
? { label: 'Pr', value: <strong>{String(prestige.prBalance)}</strong> }
|
||||||
: null,
|
: null,
|
||||||
{ label: 'Fortune', value: <strong>+{prestige.chanceBonus}%</strong> },
|
{ label: 'Fortune', value: <strong>+{prestige.chanceBonus}%</strong> },
|
||||||
{ label: 'Yield', value: <strong>+{prestige.gainBonus}%</strong> },
|
{ label: 'Yield', value: <strong>+{prestige.gainBonus}%</strong> },
|
||||||
@@ -15,9 +21,9 @@ export default function ProfilePrestigeStats({ prestige }) {
|
|||||||
label: 'Bulk',
|
label: 'Bulk',
|
||||||
value: <strong>+{prestige.openBonus} opens</strong>,
|
value: <strong>+{prestige.openBonus} opens</strong>,
|
||||||
},
|
},
|
||||||
(prestige.caseDiscount || 0) > 0 && {
|
(prestige.prBonus || 0) > 0 && {
|
||||||
label: 'Discount',
|
label: 'PR Farmer',
|
||||||
value: <strong>−{prestige.caseDiscount}%</strong>,
|
value: <strong>+{prestige.prBonus}%</strong>,
|
||||||
},
|
},
|
||||||
(prestige.qualityBonus || 0) > 0 && {
|
(prestige.qualityBonus || 0) > 0 && {
|
||||||
label: 'Quality',
|
label: 'Quality',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const FAKE_ADS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SessionLimitOverlay({ max = 10 }) {
|
export default function SessionLimitOverlay({ max = 2 }) {
|
||||||
const [ad] = useState(
|
const [ad] = useState(
|
||||||
() => FAKE_ADS[Math.floor(Math.random() * FAKE_ADS.length)]
|
() => FAKE_ADS[Math.floor(Math.random() * FAKE_ADS.length)]
|
||||||
);
|
);
|
||||||
|
|||||||
+493
-16
@@ -89,6 +89,20 @@ h3 {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
transition: transform 280ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.topnav.topnav--hidden {
|
||||||
|
transform: translateY(-110%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.topnav {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -321,11 +335,23 @@ h3 {
|
|||||||
|
|
||||||
.drop-feed {
|
.drop-feed {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 220px;
|
max-height: none;
|
||||||
position: static;
|
position: static;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px solid var(--stroke);
|
border-bottom: 1px solid var(--stroke);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drop-feed-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One drop row visible; scroll for the rest */
|
||||||
|
.drop-feed-list {
|
||||||
|
flex: none;
|
||||||
|
max-height: calc(1rem + 2 * 0.45rem + 5.75rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.balance-pill {
|
.balance-pill {
|
||||||
@@ -384,9 +410,13 @@ a.balance-pill-link.active {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn.danger {
|
.btn.danger {
|
||||||
background: transparent;
|
background: var(--danger);
|
||||||
border-color: rgba(235, 75, 75, 0.5);
|
border-color: transparent;
|
||||||
color: var(--danger);
|
color: #1a0606;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.danger:hover:not(:disabled) {
|
||||||
|
background: #f06565;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.ghost {
|
.btn.ghost {
|
||||||
@@ -668,7 +698,7 @@ a.balance-pill-link.active {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.item-wear {
|
.item-wear {
|
||||||
color: var(--gold);
|
color: var(--accent);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
@@ -693,6 +723,39 @@ a.balance-pill-link.active {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.case-hero-wrap {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
transition: grid-template-rows 420ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-hero-wrap > .case-hero {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition:
|
||||||
|
opacity 320ms ease,
|
||||||
|
transform 420ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-hero-wrap--hidden {
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-hero-wrap--hidden > .case-hero {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-1.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.case-hero-wrap,
|
||||||
|
.case-hero-wrap > .case-hero {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.case-hero {
|
.case-hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -734,7 +797,8 @@ a.balance-pill-link.active {
|
|||||||
transition: width 300ms ease;
|
transition: width 300ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.case-key-badge {
|
.case-price,
|
||||||
|
.item-price {
|
||||||
color: var(--gold);
|
color: var(--gold);
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
@@ -768,6 +832,11 @@ a.balance-pill-link.active {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.multi-reel-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.multi-reels.many {
|
.multi-reels.many {
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
@@ -1703,6 +1772,15 @@ a.balance-pill-link.active {
|
|||||||
border-top: 1px solid var(--stroke);
|
border-top: 1px solid var(--stroke);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-adjust-panel .admin-adjust-row:first-child {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-adjust-panel .admin-adjust-row + .admin-adjust-row {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
padding-top: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-adjust-row label {
|
.admin-adjust-row label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1719,6 +1797,35 @@ a.balance-pill-link.active {
|
|||||||
min-width: 8rem;
|
min-width: 8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-adjust-input-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-adjust-preview {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 4.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-adjust-preview.pos {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-adjust-preview.neg {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-adjust-result {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
align-self: end;
|
||||||
|
padding-bottom: 0.55rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-card-list {
|
.admin-card-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1843,18 +1950,24 @@ a.balance-pill-link.active {
|
|||||||
|
|
||||||
.item-vault-badge {
|
.item-vault-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.35rem;
|
top: 0.3rem;
|
||||||
left: 0.35rem;
|
left: 0.3rem;
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
width: 1.15rem;
|
min-width: 1.35rem;
|
||||||
height: 1.15rem;
|
height: 1.35rem;
|
||||||
|
padding: 0 0.2rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 0.2rem;
|
border-radius: 0.25rem;
|
||||||
background: rgba(80, 160, 220, 0.35);
|
background: #0a121c;
|
||||||
color: #9fd4ff;
|
color: #e8f6ff;
|
||||||
font-size: 0.7rem;
|
border: 1.5px solid #7ec8ff;
|
||||||
font-weight: 700;
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.85),
|
||||||
|
0 2px 6px rgba(0, 0, 0, 0.75);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -1869,6 +1982,24 @@ a.balance-pill-link.active {
|
|||||||
|
|
||||||
.vault-panel .section-head {
|
.vault-panel .section-head {
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-timer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 7.5rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-timer strong {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vault-stats {
|
.vault-stats {
|
||||||
@@ -1881,8 +2012,60 @@ a.balance-pill-link.active {
|
|||||||
|
|
||||||
.vault-grid {
|
.vault-grid {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(96px, 112px));
|
||||||
|
gap: 0.55rem;
|
||||||
|
justify-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vault-grid .item-tile {
|
||||||
|
padding: 0.45rem;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-grid .item-visual {
|
||||||
|
min-height: 56px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-grid .item-visual.has-img {
|
||||||
|
min-height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-grid .item-visual img {
|
||||||
|
max-height: 52px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-grid .item-name {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-grid .item-meta,
|
||||||
|
.vault-grid .item-wear {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-grid .item-vault-badge {
|
||||||
|
min-width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-grid .item-favorite-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.1rem 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.item-tile.disabled {
|
.item-tile.disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -1909,6 +2092,10 @@ a.balance-pill-link.active {
|
|||||||
min-height: 220px;
|
min-height: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-card-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.mode-card-top {
|
.mode-card-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -2148,6 +2335,32 @@ a.mode-card:hover {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-actions .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ico {
|
||||||
|
color: var(--gold);
|
||||||
|
font-size: 0.95em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ico-vault {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 1.1em;
|
||||||
|
height: 1.1em;
|
||||||
|
padding: 0 0.15em;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
background: #0a121c;
|
||||||
|
color: #e8f6ff;
|
||||||
|
border: 1px solid #7ec8ff;
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
.room-list {
|
.room-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2658,6 +2871,238 @@ a.achievement-badge:hover .achievement-badge-name {
|
|||||||
padding: 0.35rem 0.7rem;
|
padding: 0.35rem 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(220px, 0.7fr);
|
||||||
|
gap: 1.5rem 2rem;
|
||||||
|
align-items: end;
|
||||||
|
padding: 0.25rem 0 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-kicker {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-hero h1 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: clamp(1.75rem, 3vw, 2.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-lede {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 38rem;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-lede strong {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-wallet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(61, 214, 198, 0.08), transparent 55%),
|
||||||
|
var(--bg-panel);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-wallet-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-wallet-osu {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-mock-note {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-section-head h2 {
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-section-head .muted {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-osu-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-osu-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 1rem 0.9rem 0.9rem;
|
||||||
|
background: linear-gradient(180deg, var(--bg-elevated), var(--bg-panel));
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-osu-card.popular {
|
||||||
|
border-color: rgba(61, 214, 198, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-osu-card.highlight {
|
||||||
|
border-color: rgba(228, 174, 57, 0.55);
|
||||||
|
background:
|
||||||
|
linear-gradient(160deg, rgba(228, 174, 57, 0.1), transparent 45%),
|
||||||
|
linear-gradient(180deg, var(--bg-elevated), var(--bg-panel));
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.55rem;
|
||||||
|
right: 0.55rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--bg-deep);
|
||||||
|
background: var(--accent);
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-osu-card.highlight .shop-badge {
|
||||||
|
background: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-badge.subtle {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
position: static;
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-osu-card h3,
|
||||||
|
.shop-credit-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-osu-amount {
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.65rem;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-osu-amount span {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-osu-blurb,
|
||||||
|
.shop-osu-rate {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-buy-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-credit-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-credit-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-credit-card .pack-credits {
|
||||||
|
margin: 0.1rem 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-credit-cost {
|
||||||
|
margin: 0.15rem 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-credit-scale {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-credit-card .btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-ad-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border: 1px dashed var(--stroke);
|
||||||
|
background: rgba(61, 214, 198, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-ad-panel h2 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-ad-panel .muted {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.shop-hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.inv-toolbar {
|
.inv-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -2667,6 +3112,17 @@ a.achievement-badge:hover .achievement-badge-name {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inv-section-head {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inv-section-head h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.inv-toolbar-left {
|
.inv-toolbar-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -2786,6 +3242,13 @@ a.achievement-badge:hover .achievement-badge-name {
|
|||||||
margin: 0.35rem 0;
|
margin: 0.35rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pack-credits {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--gold);
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.ad-panel {
|
.ad-panel {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -3289,6 +3752,20 @@ a.achievement-badge:hover .achievement-badge-name {
|
|||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prestige-skill-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prestige-skill-actions .btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.prestige-history {
|
.prestige-history {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import AdminItems from '../admin/AdminItems';
|
|||||||
import AdminCategories from '../admin/AdminCategories';
|
import AdminCategories from '../admin/AdminCategories';
|
||||||
import AdminDrops from '../admin/AdminDrops';
|
import AdminDrops from '../admin/AdminDrops';
|
||||||
import AdminPlayers from '../admin/AdminPlayers';
|
import AdminPlayers from '../admin/AdminPlayers';
|
||||||
|
import AdminPrestigeServer from '../admin/AdminPrestigeServer';
|
||||||
|
import AdminShop from '../admin/AdminShop';
|
||||||
|
|
||||||
function AdminLogin() {
|
function AdminLogin() {
|
||||||
const { adminLogin } = useAuth();
|
const { adminLogin } = useAuth();
|
||||||
@@ -74,6 +76,8 @@ export default function Admin() {
|
|||||||
<Route path="items" element={<AdminItems />} />
|
<Route path="items" element={<AdminItems />} />
|
||||||
<Route path="categories" element={<AdminCategories />} />
|
<Route path="categories" element={<AdminCategories />} />
|
||||||
<Route path="drops" element={<AdminDrops />} />
|
<Route path="drops" element={<AdminDrops />} />
|
||||||
|
<Route path="prestige-server" element={<AdminPrestigeServer />} />
|
||||||
|
<Route path="shop" element={<AdminShop />} />
|
||||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
+47
-204
@@ -1,43 +1,14 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, Navigate } from 'react-router-dom';
|
import { Link, Navigate } from 'react-router-dom';
|
||||||
import { api, formatCredits } from '../api';
|
import { api } from '../api';
|
||||||
import { useAuth } from '../AuthContext';
|
import { useAuth } from '../AuthContext';
|
||||||
import CaseReel from '../components/CaseReel';
|
import BattleLiveCard from '../components/BattleLiveCard';
|
||||||
import { useCountdown } from '../hooks/useCountdown';
|
|
||||||
import { rarityColor } from '../rarities';
|
|
||||||
import { getSocket } from '../socket';
|
import { getSocket } from '../socket';
|
||||||
|
|
||||||
const TOP_ITEMS = 5;
|
|
||||||
|
|
||||||
function waitingDuelRooms(open) {
|
|
||||||
return (open || []).filter(
|
|
||||||
(r) =>
|
|
||||||
r.status === 'open' &&
|
|
||||||
((r.playerCount ?? r.players?.length ?? 0) > 0 || (r.deposits?.length ?? 0) > 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function depositsToReelItems(deposits) {
|
|
||||||
return (deposits || [])
|
|
||||||
.filter((d) => d.item)
|
|
||||||
.map((d) => ({
|
|
||||||
...d.item,
|
|
||||||
id: d.id,
|
|
||||||
ownerUsername: d.username,
|
|
||||||
ownerAvatarUrl: d.avatarUrl || '',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BattleHub() {
|
export default function BattleHub() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
const [round, setRound] = useState(null);
|
const [battles, setBattles] = useState([]);
|
||||||
const [duelOpen, setDuelOpen] = useState([]);
|
const [spinById, setSpinById] = useState({});
|
||||||
const [spinning, setSpinning] = useState(false);
|
|
||||||
const [spinActive, setSpinActive] = useState(false);
|
|
||||||
const [spinDeposits, setSpinDeposits] = useState([]);
|
|
||||||
const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
|
|
||||||
const [lastWinner, setLastWinner] = useState(null);
|
|
||||||
const [revealWinner, setRevealWinner] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || user.role === 'admin') return undefined;
|
if (!user || user.role === 'admin') return undefined;
|
||||||
@@ -45,103 +16,53 @@ export default function BattleHub() {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
|
const data = await api.jackpot();
|
||||||
if (cancelled) return;
|
if (!cancelled) setBattles(data.battles || []);
|
||||||
setRound(jp.round);
|
|
||||||
setDuelOpen(duels.open || []);
|
|
||||||
if (jp.round?.status === 'spinning') {
|
|
||||||
setSpinDeposits(jp.round.deposits || []);
|
|
||||||
setSpinActive(true);
|
|
||||||
setSpinning(true);
|
|
||||||
setReelWinnerItemId(jp.round.reelWinnerItemId || null);
|
|
||||||
setLastWinner(jp.round.winnerUsername || null);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* hub still usable without live preview */
|
/* hub still usable */
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
const onState = (state) => setRound(state);
|
const onList = (payload) => {
|
||||||
const onSpin = (state) => {
|
setBattles(payload.battles || []);
|
||||||
setRound(state);
|
};
|
||||||
setSpinDeposits(state.deposits || []);
|
const onSpin = (state) => {
|
||||||
setSpinActive(true);
|
if (!state?.id) return;
|
||||||
setSpinning(true);
|
setSpinById((prev) => ({ ...prev, [state.id]: state }));
|
||||||
setRevealWinner(false);
|
setBattles((prev) => {
|
||||||
setReelWinnerItemId(state.reelWinnerItemId);
|
const idx = prev.findIndex((b) => b.id === state.id);
|
||||||
setLastWinner(state.winnerUsername);
|
if (idx < 0) return [...prev, state];
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = state;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onResult = (state) => {
|
||||||
|
const id = state?.previousRoundId || state?.id;
|
||||||
|
if (!id) return;
|
||||||
|
setSpinById((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const onResult = (state) => setRound(state);
|
|
||||||
const onDuels = (list) => setDuelOpen(list.open || []);
|
|
||||||
|
|
||||||
socket.emit('jackpot:subscribe');
|
socket.emit('jackpots:subscribe');
|
||||||
socket.emit('duels:subscribe');
|
socket.on('jackpots:list', onList);
|
||||||
socket.on('jackpot:state', onState);
|
|
||||||
socket.on('jackpot:spin', onSpin);
|
socket.on('jackpot:spin', onSpin);
|
||||||
socket.on('jackpot:result', onResult);
|
socket.on('jackpot:result', onResult);
|
||||||
socket.on('duels:list', onDuels);
|
socket.on('connect', () => socket.emit('jackpots:subscribe'));
|
||||||
socket.on('connect', () => {
|
|
||||||
socket.emit('jackpot:subscribe');
|
|
||||||
socket.emit('duels:subscribe');
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
socket.emit('jackpot:unsubscribe');
|
socket.emit('jackpots:unsubscribe');
|
||||||
socket.emit('duels:unsubscribe');
|
socket.off('jackpots:list', onList);
|
||||||
socket.off('jackpot:state', onState);
|
|
||||||
socket.off('jackpot:spin', onSpin);
|
socket.off('jackpot:spin', onSpin);
|
||||||
socket.off('jackpot:result', onResult);
|
socket.off('jackpot:result', onResult);
|
||||||
socket.off('duels:list', onDuels);
|
|
||||||
};
|
};
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!revealWinner) return undefined;
|
|
||||||
const t = setTimeout(() => {
|
|
||||||
setSpinActive(false);
|
|
||||||
setRevealWinner(false);
|
|
||||||
}, 4000);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [revealWinner]);
|
|
||||||
|
|
||||||
const topItems = useMemo(() => {
|
|
||||||
const deposits = [...(round?.deposits || [])].filter((d) => d.item);
|
|
||||||
deposits.sort((a, b) => (b.valueCents || 0) - (a.valueCents || 0));
|
|
||||||
return deposits.slice(0, TOP_ITEMS);
|
|
||||||
}, [round]);
|
|
||||||
|
|
||||||
const potItems = useMemo(
|
|
||||||
() => depositsToReelItems(spinActive && spinDeposits.length ? spinDeposits : round?.deposits),
|
|
||||||
[round, spinDeposits, spinActive]
|
|
||||||
);
|
|
||||||
|
|
||||||
const showReel = spinActive && potItems.length > 0;
|
|
||||||
const waitingRooms = useMemo(() => waitingDuelRooms(duelOpen), [duelOpen]);
|
|
||||||
const waitingRoomCount = waitingRooms.length;
|
|
||||||
|
|
||||||
const liveTimerEndsAt =
|
|
||||||
round?.status === 'open' && round?.endsAt && !round?.waitingForPlayers && !spinning
|
|
||||||
? round.endsAt
|
|
||||||
: null;
|
|
||||||
const { label: liveTimerLabel } = useCountdown(liveTimerEndsAt);
|
|
||||||
|
|
||||||
const playerCount = round?.players?.length ?? 0;
|
|
||||||
const potValue = round?.totalValue ?? 0;
|
|
||||||
const itemCount = round?.deposits?.length ?? 0;
|
|
||||||
const statusLabel = !round
|
|
||||||
? '…'
|
|
||||||
: round.status === 'spinning' || spinning
|
|
||||||
? 'Spinning'
|
|
||||||
: round.waitingForPlayers
|
|
||||||
? 'Waiting for players'
|
|
||||||
: round.status === 'open'
|
|
||||||
? liveTimerEndsAt
|
|
||||||
? `Live ${liveTimerLabel}`
|
|
||||||
: 'Live'
|
|
||||||
: round.status;
|
|
||||||
|
|
||||||
if (loading) return <p className="muted">Loading…</p>;
|
if (loading) return <p className="muted">Loading…</p>;
|
||||||
if (!user) return <Navigate to="/login" replace />;
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||||
@@ -151,106 +72,28 @@ export default function BattleHub() {
|
|||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>Battle</h1>
|
<h1>Battle</h1>
|
||||||
<p className="muted">Multiplayer item gambling modes.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mode-grid">
|
<div className="mode-grid">
|
||||||
<Link to="/battle/multi" className="mode-card mode-card-live">
|
<Link to="/battle/create" className="mode-card mode-card-wide">
|
||||||
<div className="mode-card-top">
|
<div className="mode-card-top">
|
||||||
<h2>MultiPlayers Case</h2>
|
<h2>Create a Battle</h2>
|
||||||
<span className={`mode-status${round?.status === 'spinning' || spinning ? ' spinning' : ''}`}>
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="muted mode-blurb">
|
<p className="muted mode-blurb">
|
||||||
Join the live pot. Bet up to {round?.maxItemsPerPlayer || 3} items — chance scales with
|
Bet items to open a new pot. Others can join — chance scales with value. Timer starts at
|
||||||
value.
|
2 players.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mode-pot-stats">
|
|
||||||
<div>
|
|
||||||
<span className="muted">Pot</span>
|
|
||||||
<strong>{formatCredits(potValue)} cr</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="muted">Players</span>
|
|
||||||
<strong>{playerCount}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="muted">Items</span>
|
|
||||||
<strong>{itemCount}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showReel ? (
|
|
||||||
<div className="mode-card-reel">
|
|
||||||
<CaseReel
|
|
||||||
items={potItems}
|
|
||||||
winnerId={reelWinnerItemId || potItems[0]?.id}
|
|
||||||
spinning={spinning || round?.status === 'spinning'}
|
|
||||||
showOwners
|
|
||||||
compact
|
|
||||||
onDone={() => {
|
|
||||||
setSpinning(false);
|
|
||||||
setRevealWinner(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{revealWinner && lastWinner && (
|
|
||||||
<p className="mode-card-winner">
|
|
||||||
Winner: <strong>{lastWinner}</strong>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : topItems.length > 0 ? (
|
|
||||||
<div className="mode-pot-items" aria-label="Top items in pot">
|
|
||||||
{topItems.map((d) => (
|
|
||||||
<div
|
|
||||||
key={d.id}
|
|
||||||
className="mode-pot-thumb"
|
|
||||||
style={{ borderColor: rarityColor(d.item.rarity) }}
|
|
||||||
title={`${d.item.name} · ${formatCredits(d.valueCents)} cr`}
|
|
||||||
>
|
|
||||||
{d.item.imageUrl ? (
|
|
||||||
<img src={d.item.imageUrl} alt="" loading="lazy" />
|
|
||||||
) : (
|
|
||||||
<span>{d.item.name.slice(0, 2).toUpperCase()}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{itemCount > topItems.length && (
|
|
||||||
<span className="mode-pot-more muted">+{itemCount - topItems.length}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="muted mode-pot-empty">No items in the pot yet — be the first.</p>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link to="/battle/1vx" className="mode-card">
|
{battles.map((b) => (
|
||||||
<div className="mode-card-top">
|
<BattleLiveCard key={b.id} battle={b} spinEvent={spinById[b.id]} />
|
||||||
<h2>1vX Case</h2>
|
))}
|
||||||
{waitingRoomCount > 0 && (
|
|
||||||
<span className="mode-status">
|
|
||||||
{waitingRoomCount} waiting
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="muted mode-blurb">
|
|
||||||
Create or join custom rooms. Set player/item limits and min value. Spectate live duels.
|
|
||||||
</p>
|
|
||||||
<div className="mode-pot-stats">
|
|
||||||
<div>
|
|
||||||
<span className="muted">Open rooms</span>
|
|
||||||
<strong>{waitingRoomCount}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="mode-card stub">
|
{battles.length === 0 && (
|
||||||
<h2>Coinflip</h2>
|
<p className="muted mode-pot-empty" style={{ gridColumn: '1 / -1' }}>
|
||||||
<p className="muted">Classic 1v1 item flip. Coming soon.</p>
|
No open battles — create one to get started.
|
||||||
<span className="badge">Soon</span>
|
</p>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, Navigate } from 'react-router-dom';
|
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { api, formatCredits, formatChance } from '../api';
|
import { api, formatCredits, formatChance, compareCents } from '../api';
|
||||||
import { useAuth } from '../AuthContext';
|
import { useAuth } from '../AuthContext';
|
||||||
import ItemTile, { itemValueCents } from '../components/ItemTile';
|
import ItemTile, { itemValueCents } from '../components/ItemTile';
|
||||||
import CaseReel from '../components/CaseReel';
|
import CaseReel from '../components/CaseReel';
|
||||||
@@ -8,7 +8,13 @@ import { useCountdown } from '../hooks/useCountdown';
|
|||||||
import { onInventoryChanged } from '../inventoryEvents';
|
import { onInventoryChanged } from '../inventoryEvents';
|
||||||
import { getSocket } from '../socket';
|
import { getSocket } from '../socket';
|
||||||
|
|
||||||
export default function MultiPlayersPage() {
|
/**
|
||||||
|
* Create mode (`/battle/create`) or join/spectate (`/battle/:id`).
|
||||||
|
*/
|
||||||
|
export default function BattleRoomPage({ create = false }) {
|
||||||
|
const { id: paramId } = useParams();
|
||||||
|
const battleId = create ? null : Number(paramId);
|
||||||
|
const navigate = useNavigate();
|
||||||
const { user, loading, refresh } = useAuth();
|
const { user, loading, refresh } = useAuth();
|
||||||
const [round, setRound] = useState(null);
|
const [round, setRound] = useState(null);
|
||||||
const [inventory, setInventory] = useState([]);
|
const [inventory, setInventory] = useState([]);
|
||||||
@@ -37,8 +43,8 @@ export default function MultiPlayersPage() {
|
|||||||
() =>
|
() =>
|
||||||
[...inventory].sort(
|
[...inventory].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
itemValueCents(b.item) - itemValueCents(a.item) ||
|
compareCents(itemValueCents(b.item), itemValueCents(a.item)) ||
|
||||||
(b.valueCents ?? 0) - (a.valueCents ?? 0)
|
compareCents(b.valueCents, a.valueCents)
|
||||||
),
|
),
|
||||||
[inventory]
|
[inventory]
|
||||||
);
|
);
|
||||||
@@ -46,23 +52,56 @@ export default function MultiPlayersPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || user.role === 'admin') return undefined;
|
if (!user || user.role === 'admin') return undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [jp] = await Promise.all([api.jackpot(), loadInventory()]);
|
await loadInventory();
|
||||||
if (!cancelled) setRound(jp.round);
|
if (create) {
|
||||||
|
if (!cancelled) setRound(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!battleId) {
|
||||||
|
if (!cancelled) setError('Invalid battle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await api.jackpotRound(battleId);
|
||||||
|
if (cancelled) return;
|
||||||
|
setRound(data.round);
|
||||||
|
if (data.round?.status === 'spinning') {
|
||||||
|
setSpinDeposits(data.round.deposits || []);
|
||||||
|
setSpinActive(true);
|
||||||
|
setSpinning(true);
|
||||||
|
setLastWinner(data.round.winnerUsername || null);
|
||||||
|
}
|
||||||
|
if (data.round?.status === 'resolved' || data.round?.status === 'cancelled') {
|
||||||
|
setLastWinner(data.round.winnerUsername || null);
|
||||||
|
if (data.round.status === 'resolved') setRevealWinner(true);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setError(err.message);
|
if (!cancelled) setError(err.message);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
if (create || !battleId) {
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
const onState = (state) => {
|
const onState = (state) => {
|
||||||
|
if (state?.id && state.id !== battleId) return;
|
||||||
setRound(state);
|
setRound(state);
|
||||||
|
if (state?.status === 'cancelled') {
|
||||||
|
navigate('/battle', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (state?.status === 'open') {
|
if (state?.status === 'open') {
|
||||||
loadInventory().catch(() => {});
|
loadInventory().catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onSpin = (state) => {
|
const onSpin = (state) => {
|
||||||
|
if (state?.id && state.id !== battleId) return;
|
||||||
setRound(state);
|
setRound(state);
|
||||||
setSpinDeposits(state.deposits || []);
|
setSpinDeposits(state.deposits || []);
|
||||||
setSpinActive(true);
|
setSpinActive(true);
|
||||||
@@ -72,25 +111,30 @@ export default function MultiPlayersPage() {
|
|||||||
setLastWinner(state.winnerUsername);
|
setLastWinner(state.winnerUsername);
|
||||||
};
|
};
|
||||||
const onResult = (state) => {
|
const onResult = (state) => {
|
||||||
|
if (state?.previousRoundId && state.previousRoundId !== battleId) return;
|
||||||
|
if (state?.id && state.id !== battleId && !state.previousRoundId) return;
|
||||||
setRound(state);
|
setRound(state);
|
||||||
|
if (state.winnerUsername) setLastWinner(state.winnerUsername);
|
||||||
|
setRevealWinner(true);
|
||||||
|
setSpinning(false);
|
||||||
loadInventory().catch(() => {});
|
loadInventory().catch(() => {});
|
||||||
refresh().catch(() => {});
|
refresh().catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.emit('jackpot:subscribe');
|
socket.emit('jackpot:subscribe', battleId);
|
||||||
socket.on('jackpot:state', onState);
|
socket.on('jackpot:state', onState);
|
||||||
socket.on('jackpot:spin', onSpin);
|
socket.on('jackpot:spin', onSpin);
|
||||||
socket.on('jackpot:result', onResult);
|
socket.on('jackpot:result', onResult);
|
||||||
socket.on('connect', () => socket.emit('jackpot:subscribe'));
|
socket.on('connect', () => socket.emit('jackpot:subscribe', battleId));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
socket.emit('jackpot:unsubscribe');
|
socket.emit('jackpot:unsubscribe', battleId);
|
||||||
socket.off('jackpot:state', onState);
|
socket.off('jackpot:state', onState);
|
||||||
socket.off('jackpot:spin', onSpin);
|
socket.off('jackpot:spin', onSpin);
|
||||||
socket.off('jackpot:result', onResult);
|
socket.off('jackpot:result', onResult);
|
||||||
};
|
};
|
||||||
}, [user, loadInventory, refresh]);
|
}, [user, create, battleId, loadInventory, refresh, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || user.role === 'admin') return undefined;
|
if (!user || user.role === 'admin') return undefined;
|
||||||
@@ -106,6 +150,8 @@ export default function MultiPlayersPage() {
|
|||||||
[round, user]
|
[round, user]
|
||||||
);
|
);
|
||||||
const mySlotsLeft = Math.max(0, maxItems - myDeposits.length);
|
const mySlotsLeft = Math.max(0, maxItems - myDeposits.length);
|
||||||
|
const canBet =
|
||||||
|
create || round?.status === 'open' || (!round && create);
|
||||||
|
|
||||||
const potItems = useMemo(() => {
|
const potItems = useMemo(() => {
|
||||||
const source = spinActive && spinDeposits.length ? spinDeposits : round?.deposits || [];
|
const source = spinActive && spinDeposits.length ? spinDeposits : round?.deposits || [];
|
||||||
@@ -140,7 +186,14 @@ export default function MultiPlayersPage() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = await api.jackpotBet(selected);
|
if (create && !round) {
|
||||||
|
const data = await api.jackpotCreateBet(selected);
|
||||||
|
setSelected([]);
|
||||||
|
await loadInventory();
|
||||||
|
navigate(`/battle/${data.round.id}`, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await api.jackpotBet(round.id, selected);
|
||||||
setRound(data.round);
|
setRound(data.round);
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
await loadInventory();
|
await loadInventory();
|
||||||
@@ -152,10 +205,15 @@ export default function MultiPlayersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const withdraw = async (inventoryItemIds) => {
|
const withdraw = async (inventoryItemIds) => {
|
||||||
|
if (!round?.id) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = await api.jackpotWithdraw(inventoryItemIds);
|
const data = await api.jackpotWithdraw(round.id, inventoryItemIds);
|
||||||
|
if (data.round?.status === 'cancelled') {
|
||||||
|
navigate('/battle', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setRound(data.round);
|
setRound(data.round);
|
||||||
await loadInventory();
|
await loadInventory();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -169,15 +227,22 @@ export default function MultiPlayersPage() {
|
|||||||
if (!user) return <Navigate to="/login" replace />;
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||||
|
|
||||||
|
const title = create && !round ? 'Create a Battle' : round?.name || `Battle ${battleId || ''}`;
|
||||||
|
const isOpen = create || round?.status === 'open';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bet-page">
|
<div className="bet-page">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>MultiPlayers Case</h1>
|
<h1>{title}</h1>
|
||||||
<p className="muted">Live shared pot — win chance = your value / total pot.</p>
|
<p className="muted">
|
||||||
|
{create && !round
|
||||||
|
? 'Bet items to open a new battle. Chance = your value / total pot.'
|
||||||
|
: 'Win chance = your value / total pot.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/battle" className="btn secondary">
|
<Link to="/battle" className="btn secondary">
|
||||||
Modes
|
Back
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -185,25 +250,27 @@ export default function MultiPlayersPage() {
|
|||||||
|
|
||||||
<div className="jackpot-status">
|
<div className="jackpot-status">
|
||||||
<div className="timer-block">
|
<div className="timer-block">
|
||||||
<span className="muted">Round</span>
|
<span className="muted">Battle</span>
|
||||||
<strong>#{round?.id || '—'}</strong>
|
<strong>#{round?.id || '—'}</strong>
|
||||||
<span className={`status-pill ${round?.status || ''}`}>{round?.status || '…'}</span>
|
<span className={`status-pill ${round?.status || ''}`}>
|
||||||
|
{round?.status || (create ? 'new' : '…')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="timer-block">
|
<div className="timer-block">
|
||||||
<span className="muted">Timer</span>
|
<span className="muted">Timer</span>
|
||||||
<strong className="timer-digits">
|
<strong className="timer-digits">
|
||||||
{round?.status === 'spinning' || spinning
|
{round?.status === 'spinning' || spinning
|
||||||
? 'SPIN'
|
? 'SPIN'
|
||||||
: round?.waitingForPlayers
|
: round?.waitingForPlayers || (create && !round)
|
||||||
? 'WAIT'
|
? 'WAIT'
|
||||||
: round?.status === 'open' && round?.endsAt
|
: round?.status === 'open' && round?.endsAt
|
||||||
? timerLabel
|
? timerLabel
|
||||||
: '—'}
|
: '—'}
|
||||||
</strong>
|
</strong>
|
||||||
{round?.waitingForPlayers && round?.status === 'open' && (
|
{((round?.waitingForPlayers && round?.status === 'open') || (create && !round)) && (
|
||||||
<span className="muted">
|
<span className="muted">
|
||||||
Need {round.minPlayers || 2} players ({round.players?.length || 0}/
|
Need {round?.minPlayers || 2} players ({round?.players?.length || 0}/
|
||||||
{round.minPlayers || 2})
|
{round?.minPlayers || 2})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{round?.status === 'open' &&
|
{round?.status === 'open' &&
|
||||||
@@ -289,7 +356,9 @@ export default function MultiPlayersPage() {
|
|||||||
{myDeposits.length > 0 && round?.status === 'open' && (
|
{myDeposits.length > 0 && round?.status === 'open' && (
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<h2>Your bets ({myDeposits.length}/{maxItems})</h2>
|
<h2>
|
||||||
|
Your bets ({myDeposits.length}/{maxItems})
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn danger"
|
className="btn danger"
|
||||||
@@ -302,11 +371,11 @@ export default function MultiPlayersPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{round?.status === 'open' && (
|
{isOpen && canBet && (
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Bet from inventory</h2>
|
<h2>{create && !round ? 'Bet to create' : 'Bet from inventory'}</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Select up to {mySlotsLeft} more item{mySlotsLeft === 1 ? '' : 's'}.
|
Select up to {mySlotsLeft} more item{mySlotsLeft === 1 ? '' : 's'}.
|
||||||
</p>
|
</p>
|
||||||
@@ -317,7 +386,7 @@ export default function MultiPlayersPage() {
|
|||||||
disabled={busy || !selected.length || mySlotsLeft === 0}
|
disabled={busy || !selected.length || mySlotsLeft === 0}
|
||||||
onClick={placeBet}
|
onClick={placeBet}
|
||||||
>
|
>
|
||||||
Place bet
|
{create && !round ? 'Create battle' : 'Place bet'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{inventoryByValue.length === 0 ? (
|
{inventoryByValue.length === 0 ? (
|
||||||
@@ -331,8 +400,12 @@ export default function MultiPlayersPage() {
|
|||||||
key={inv.id}
|
key={inv.id}
|
||||||
item={inv.item}
|
item={inv.item}
|
||||||
favorite={inv.favorite}
|
favorite={inv.favorite}
|
||||||
|
staked={inv.staked}
|
||||||
selected={selected.includes(inv.id)}
|
selected={selected.includes(inv.id)}
|
||||||
disabled={mySlotsLeft === 0 && !selected.includes(inv.id)}
|
disabled={
|
||||||
|
inv.staked ||
|
||||||
|
(mySlotsLeft === 0 && !selected.includes(inv.id))
|
||||||
|
}
|
||||||
onClick={() => toggleSelect(inv.id)}
|
onClick={() => toggleSelect(inv.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
+415
-167
@@ -1,14 +1,16 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
import { Navigate, useParams } from 'react-router-dom';
|
||||||
import { api, formatCredits } from '../api';
|
import { api, formatCredits, compareCents, toCentsBigInt } from '../api';
|
||||||
import { useAuth } from '../AuthContext';
|
import { useAuth } from '../AuthContext';
|
||||||
import CaseReel from '../components/CaseReel';
|
import CaseReel from '../components/CaseReel';
|
||||||
import ItemTile from '../components/ItemTile';
|
import ItemTile, { itemValueCents } from '../components/ItemTile';
|
||||||
import { notifyInventoryChanged } from '../inventoryEvents';
|
import { notifyInventoryChanged } from '../inventoryEvents';
|
||||||
import { rarityColor } from '../rarities';
|
import { rarityColor } from '../rarities';
|
||||||
import { loadCaseOpenQty, resolveOpenQty, saveCaseOpenQty } from '../userPrefs';
|
import { loadCaseOpenQty, resolveOpenQty, saveCaseOpenQty } from '../userPrefs';
|
||||||
|
|
||||||
const DEFAULT_MAX_OPENS = 10;
|
const DEFAULT_MAX_OPENS = 10;
|
||||||
|
/** Cap simultaneous reel animations to avoid lag on large multi-opens. */
|
||||||
|
const MAX_VISIBLE_REELS = 5;
|
||||||
|
|
||||||
/** 1–5, then steps of 5 (10, 15…), plus max if not already listed. */
|
/** 1–5, then steps of 5 (10, 15…), plus max if not already listed. */
|
||||||
function buildOpenQuantityOptions(maxOpens) {
|
function buildOpenQuantityOptions(maxOpens) {
|
||||||
@@ -20,26 +22,69 @@ function buildOpenQuantityOptions(maxOpens) {
|
|||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dropValue(item) {
|
||||||
|
return itemValueCents(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best drops first (by instance value), capped for reel display. */
|
||||||
|
function pickShowcaseDrops(items, limit = MAX_VISIBLE_REELS) {
|
||||||
|
return [...items]
|
||||||
|
.sort((a, b) => compareCents(dropValue(b), dropValue(a)))
|
||||||
|
.slice(0, Math.max(1, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best drop by valueCents (BigInt-safe). */
|
||||||
|
function pickBestDrop(items) {
|
||||||
|
if (!items?.length) return null;
|
||||||
|
let best = items[0];
|
||||||
|
let bestVal = dropValue(best);
|
||||||
|
for (let i = 1; i < items.length; i += 1) {
|
||||||
|
const it = items[i];
|
||||||
|
const v = dropValue(it);
|
||||||
|
if (v > bestVal) {
|
||||||
|
best = it;
|
||||||
|
bestVal = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
export default function CasePage() {
|
export default function CasePage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { user, loading, updateBalance } = useAuth();
|
const { user, loading, updateBalance } = useAuth();
|
||||||
const [crate, setCrate] = useState(null);
|
const [crate, setCrate] = useState(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [keyMessage, setKeyMessage] = useState('');
|
const [keyMessage, setKeyMessage] = useState('');
|
||||||
const [catalogMessage, setCatalogMessage] = useState('');
|
|
||||||
const [openBonusMessage, setOpenBonusMessage] = useState('');
|
const [openBonusMessage, setOpenBonusMessage] = useState('');
|
||||||
const [animReduction, setAnimReduction] = useState(0);
|
const [animReduction, setAnimReduction] = useState(0);
|
||||||
const [quantity, setQuantity] = useState(1);
|
const [quantity, setQuantity] = useState(1);
|
||||||
const [opening, setOpening] = useState(false);
|
const [opening, setOpening] = useState(false);
|
||||||
const [spins, setSpins] = useState([]);
|
const [spins, setSpins] = useState([]);
|
||||||
|
const [allDrops, setAllDrops] = useState([]);
|
||||||
const [caseName, setCaseName] = useState('');
|
const [caseName, setCaseName] = useState('');
|
||||||
|
/** True while paused on discarded winners before the respin reels restart. */
|
||||||
|
const [respinRestarting, setRespinRestarting] = useState(false);
|
||||||
const announcedRef = useRef(false);
|
const announcedRef = useRef(false);
|
||||||
|
const preAnnouncedRef = useRef(false);
|
||||||
const finishingRef = useRef(false);
|
const finishingRef = useRef(false);
|
||||||
const pendingAnnounceRef = useRef({ ids: [], caseName: '' });
|
const pendingAnnounceRef = useRef({ ids: [], caseName: '', respin: false });
|
||||||
const pendingAutoSellRef = useRef([]);
|
const pendingAutoSellRef = useRef([]);
|
||||||
|
/** Auto-sell ids from the discarded first roll — applied when that reel finishes. */
|
||||||
|
const pendingPreRespinAutoSellRef = useRef([]);
|
||||||
|
/** When set, first reel pass is a discarded roll; then we restart with these final items. */
|
||||||
|
const pendingRespinFinalRef = useRef(null);
|
||||||
|
/** Snapshot of first-roll drops for feed (best item announced when first reels finish). */
|
||||||
|
const pendingPreRespinAnnounceRef = useRef(null);
|
||||||
|
/**
|
||||||
|
* Respin animation phase (kept out of setState updaters — must be Strict-Mode safe):
|
||||||
|
* null | 'pre' | 'restarting' | 'final'
|
||||||
|
*/
|
||||||
|
const respinPhaseRef = useRef(null);
|
||||||
const quantityRef = useRef(1);
|
const quantityRef = useRef(1);
|
||||||
const caseIdRef = useRef(null);
|
const caseIdRef = useRef(null);
|
||||||
const prefReadyRef = useRef(false);
|
const prefReadyRef = useRef(false);
|
||||||
|
/** Tracks case maxOpens so we can bump qty when a key unlocks. */
|
||||||
|
const prevMaxOpensRef = useRef(null);
|
||||||
|
|
||||||
const keyProgress = crate?.keyProgress ?? null;
|
const keyProgress = crate?.keyProgress ?? null;
|
||||||
const maxOpens = keyProgress?.maxOpens ?? DEFAULT_MAX_OPENS;
|
const maxOpens = keyProgress?.maxOpens ?? DEFAULT_MAX_OPENS;
|
||||||
@@ -61,10 +106,15 @@ export default function CasePage() {
|
|||||||
const announcePendingDrop = async (useKeepalive = false) => {
|
const announcePendingDrop = async (useKeepalive = false) => {
|
||||||
const ids = pendingAnnounceRef.current.ids.filter(Boolean);
|
const ids = pendingAnnounceRef.current.ids.filter(Boolean);
|
||||||
const announceCaseName = pendingAnnounceRef.current.caseName || caseName || crate?.name || '';
|
const announceCaseName = pendingAnnounceRef.current.caseName || caseName || crate?.name || '';
|
||||||
|
const respin = Boolean(pendingAnnounceRef.current.respin);
|
||||||
if (!ids.length || announcedRef.current) return;
|
if (!ids.length || announcedRef.current) return;
|
||||||
|
|
||||||
announcedRef.current = true;
|
announcedRef.current = true;
|
||||||
const body = JSON.stringify({ inventoryItemIds: ids, caseName: announceCaseName });
|
const body = JSON.stringify({
|
||||||
|
inventoryItemIds: ids,
|
||||||
|
caseName: announceCaseName,
|
||||||
|
respin,
|
||||||
|
});
|
||||||
|
|
||||||
if (useKeepalive) {
|
if (useKeepalive) {
|
||||||
fetch('/api/feed/announce', {
|
fetch('/api/feed/announce', {
|
||||||
@@ -80,15 +130,51 @@ export default function CasePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.feedAnnounce(ids, announceCaseName);
|
await api.feedAnnounce(ids, announceCaseName, { respin });
|
||||||
} catch {
|
} catch {
|
||||||
announcedRef.current = false;
|
announcedRef.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const announcePreRespinDrop = async () => {
|
||||||
|
const pending = pendingPreRespinAnnounceRef.current;
|
||||||
|
if (!pending?.items?.length || preAnnouncedRef.current) return;
|
||||||
|
preAnnouncedRef.current = true;
|
||||||
|
pendingPreRespinAnnounceRef.current = null;
|
||||||
|
const best = pickBestDrop(pending.items);
|
||||||
|
if (!best) {
|
||||||
|
preAnnouncedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const count = pending.items.filter((it) => it.id === best.id).length;
|
||||||
|
try {
|
||||||
|
await api.feedAnnounce([], pending.caseName || caseName || crate?.name || '', {
|
||||||
|
snapshotBest: {
|
||||||
|
id: best.id,
|
||||||
|
name: best.name,
|
||||||
|
imageUrl: best.imageUrl,
|
||||||
|
rarity: best.rarity,
|
||||||
|
valueCents: best.valueCents ?? best.marketValue,
|
||||||
|
wear: best.wear,
|
||||||
|
floatValue: best.floatValue,
|
||||||
|
paintSeed: best.paintSeed,
|
||||||
|
},
|
||||||
|
openCount: pending.items.length,
|
||||||
|
count,
|
||||||
|
respin: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
preAnnouncedRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const applyPendingAutoSellKeepalive = () => {
|
const applyPendingAutoSellKeepalive = () => {
|
||||||
const ids = pendingAutoSellRef.current.filter(Boolean);
|
const ids = [
|
||||||
|
...pendingPreRespinAutoSellRef.current,
|
||||||
|
...pendingAutoSellRef.current,
|
||||||
|
].filter(Boolean);
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
|
pendingPreRespinAutoSellRef.current = [];
|
||||||
pendingAutoSellRef.current = [];
|
pendingAutoSellRef.current = [];
|
||||||
fetch('/api/inventory/auto-sell/apply', {
|
fetch('/api/inventory/auto-sell/apply', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -99,42 +185,79 @@ export default function CasePage() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
const finalizeOpens = async (completedSpins, announceCaseName) => {
|
const markSpinsAutoSold = (soldIds, soldDetails = []) => {
|
||||||
|
const soldSet = new Set(soldIds);
|
||||||
|
const payoutById = new Map(
|
||||||
|
(soldDetails || []).map((s) => [s.id, s.payoutCents ?? s.valueCents])
|
||||||
|
);
|
||||||
|
setSpins((curr) =>
|
||||||
|
curr.map((s) =>
|
||||||
|
soldSet.has(s.item.inventoryId)
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
item: {
|
||||||
|
...s.item,
|
||||||
|
autoSold: true,
|
||||||
|
soldForCents:
|
||||||
|
payoutById.get(s.item.inventoryId) ?? s.item.valueCents,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setAllDrops((curr) =>
|
||||||
|
curr.map((item) =>
|
||||||
|
soldSet.has(item.inventoryId)
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
autoSold: true,
|
||||||
|
soldForCents: payoutById.get(item.inventoryId) ?? item.valueCents,
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Credit auto-sell for the discarded first roll once its reels finish. */
|
||||||
|
const applyPreRespinAutoSell = async () => {
|
||||||
|
const toSell = [...pendingPreRespinAutoSellRef.current];
|
||||||
|
pendingPreRespinAutoSellRef.current = [];
|
||||||
|
if (!toSell.length) return;
|
||||||
|
try {
|
||||||
|
const data = await api.applyPendingAutoSell(toSell);
|
||||||
|
updateBalance(data.user.balance);
|
||||||
|
notifyInventoryChanged();
|
||||||
|
if (data.soldIds?.length) markSpinsAutoSold(data.soldIds, data.sold);
|
||||||
|
} catch (err) {
|
||||||
|
// Keep ids so unload keepalive can still flush if the request failed mid-respin.
|
||||||
|
pendingPreRespinAutoSellRef.current = toSell;
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalizeOpens = async (announceCaseName) => {
|
||||||
if (finishingRef.current) return;
|
if (finishingRef.current) return;
|
||||||
finishingRef.current = true;
|
finishingRef.current = true;
|
||||||
|
|
||||||
const ids = completedSpins.map((s) => s.item?.inventoryId).filter(Boolean);
|
// Keep full-batch ids already stored in pending refs (not only showcase reels)
|
||||||
pendingAnnounceRef.current = { ids, caseName: announceCaseName };
|
if (announceCaseName) {
|
||||||
|
pendingAnnounceRef.current = {
|
||||||
|
...pendingAnnounceRef.current,
|
||||||
|
caseName: announceCaseName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await announcePendingDrop(false);
|
await announcePendingDrop(false);
|
||||||
|
|
||||||
const toSell = completedSpins
|
const toSell = [...pendingAutoSellRef.current];
|
||||||
.filter((s) => s.item?.willAutoSell)
|
|
||||||
.map((s) => s.item.inventoryId)
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
pendingAutoSellRef.current = [];
|
pendingAutoSellRef.current = [];
|
||||||
|
|
||||||
if (toSell.length) {
|
if (toSell.length) {
|
||||||
const data = await api.applyPendingAutoSell(toSell);
|
const data = await api.applyPendingAutoSell(toSell);
|
||||||
updateBalance(data.user.balance);
|
updateBalance(data.user.balance);
|
||||||
notifyInventoryChanged();
|
notifyInventoryChanged();
|
||||||
const soldSet = new Set(data.soldIds);
|
if (data.soldIds?.length) markSpinsAutoSold(data.soldIds, data.sold);
|
||||||
setSpins((curr) =>
|
|
||||||
curr.map((s) =>
|
|
||||||
soldSet.has(s.item.inventoryId)
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
item: {
|
|
||||||
...s.item,
|
|
||||||
autoSold: true,
|
|
||||||
soldForCents: s.item.valueCents,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: s
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -147,6 +270,7 @@ export default function CasePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
prefReadyRef.current = false;
|
prefReadyRef.current = false;
|
||||||
|
prevMaxOpensRef.current = null;
|
||||||
caseIdRef.current = null;
|
caseIdRef.current = null;
|
||||||
setCrate(null);
|
setCrate(null);
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -167,9 +291,16 @@ export default function CasePage() {
|
|||||||
|
|
||||||
const maxAffordable = useMemo(() => {
|
const maxAffordable = useMemo(() => {
|
||||||
if (!crate || !user) return 1;
|
if (!crate || !user) return 1;
|
||||||
if (crate.price <= 0) return maxOpens;
|
try {
|
||||||
const affordable = Math.floor(user.balance / crate.price);
|
const price = BigInt(String(crate.price ?? 0).split('.')[0] || '0');
|
||||||
return Math.min(maxOpens, Math.max(affordable, 0));
|
if (price <= 0n) return maxOpens;
|
||||||
|
const bal = BigInt(String(user.balance ?? 0).split('.')[0] || '0');
|
||||||
|
const affordable = bal / price;
|
||||||
|
const n = affordable > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(affordable);
|
||||||
|
return Math.min(maxOpens, Math.max(n, 0));
|
||||||
|
} catch {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}, [crate, user, maxOpens]);
|
}, [crate, user, maxOpens]);
|
||||||
|
|
||||||
const openOptions = useMemo(() => buildOpenQuantityOptions(maxOpens), [maxOpens]);
|
const openOptions = useMemo(() => buildOpenQuantityOptions(maxOpens), [maxOpens]);
|
||||||
@@ -182,18 +313,28 @@ export default function CasePage() {
|
|||||||
|
|
||||||
if (!prefReadyRef.current) {
|
if (!prefReadyRef.current) {
|
||||||
prefReadyRef.current = true;
|
prefReadyRef.current = true;
|
||||||
|
prevMaxOpensRef.current = maxOpens;
|
||||||
setQuantity(resolved);
|
setQuantity(resolved);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prevMax = prevMaxOpensRef.current;
|
||||||
|
prevMaxOpensRef.current = maxOpens;
|
||||||
|
// New key unlocked → jump select to highest affordable tier (new max if credits allow).
|
||||||
|
const unlockedHigher = prevMax != null && maxOpens > prevMax;
|
||||||
|
|
||||||
setQuantity((q) => {
|
setQuantity((q) => {
|
||||||
const allowed = openOptions.filter((n) => n <= Math.max(1, maxAffordable));
|
const allowed = openOptions.filter((n) => n <= Math.max(1, maxAffordable));
|
||||||
if (!allowed.length) return 1;
|
if (!allowed.length) return 1;
|
||||||
|
// Jump to newly unlocked max only when the balance can cover it.
|
||||||
|
if (unlockedHigher && maxOpens <= Math.max(1, maxAffordable)) {
|
||||||
|
return maxOpens;
|
||||||
|
}
|
||||||
if (allowed.includes(q)) return q;
|
if (allowed.includes(q)) return q;
|
||||||
const below = allowed.filter((n) => n <= q);
|
const below = allowed.filter((n) => n <= q);
|
||||||
return below.length ? below[below.length - 1] : allowed[0];
|
return below.length ? below[below.length - 1] : allowed[0];
|
||||||
});
|
});
|
||||||
}, [crate, maxAffordable, openOptions, user?.id]);
|
}, [crate, maxAffordable, openOptions, user?.id, maxOpens]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!prefReadyRef.current || !user?.id || crate?.id == null) return;
|
if (!prefReadyRef.current || !user?.id || crate?.id == null) return;
|
||||||
@@ -202,6 +343,37 @@ export default function CasePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
// Flush first-spin feed if page closes mid-respin
|
||||||
|
const pre = pendingPreRespinAnnounceRef.current;
|
||||||
|
if (pre?.items?.length && !preAnnouncedRef.current) {
|
||||||
|
preAnnouncedRef.current = true;
|
||||||
|
const best = pickBestDrop(pre.items);
|
||||||
|
if (best) {
|
||||||
|
const count = pre.items.filter((it) => it.id === best.id).length;
|
||||||
|
fetch('/api/feed/announce', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
keepalive: true,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
snapshotBest: {
|
||||||
|
id: best.id,
|
||||||
|
name: best.name,
|
||||||
|
imageUrl: best.imageUrl,
|
||||||
|
rarity: best.rarity,
|
||||||
|
valueCents: best.valueCents ?? best.marketValue,
|
||||||
|
wear: best.wear,
|
||||||
|
floatValue: best.floatValue,
|
||||||
|
paintSeed: best.paintSeed,
|
||||||
|
},
|
||||||
|
openCount: pre.items.length,
|
||||||
|
count,
|
||||||
|
caseName: pre.caseName || '',
|
||||||
|
respin: false,
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
announcePendingDrop(true);
|
announcePendingDrop(true);
|
||||||
applyPendingAutoSellKeepalive();
|
applyPendingAutoSellKeepalive();
|
||||||
};
|
};
|
||||||
@@ -213,7 +385,7 @@ export default function CasePage() {
|
|||||||
const ca = Number(a.chance ?? a.dropRate ?? 0);
|
const ca = Number(a.chance ?? a.dropRate ?? 0);
|
||||||
const cb = Number(b.chance ?? b.dropRate ?? 0);
|
const cb = Number(b.chance ?? b.dropRate ?? 0);
|
||||||
if (ca !== cb) return ca - cb;
|
if (ca !== cb) return ca - cb;
|
||||||
return (b.marketValue || 0) - (a.marketValue || 0);
|
return compareCents(b.marketValue, a.marketValue);
|
||||||
});
|
});
|
||||||
}, [crate]);
|
}, [crate]);
|
||||||
|
|
||||||
@@ -225,31 +397,57 @@ export default function CasePage() {
|
|||||||
);
|
);
|
||||||
}, [keyProgress]);
|
}, [keyProgress]);
|
||||||
|
|
||||||
if (loading) return <p className="muted">Loading…</p>;
|
const totalCost = useMemo(() => {
|
||||||
if (!user) return <Navigate to="/login" replace />;
|
if (!crate) return '0';
|
||||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
return (toCentsBigInt(crate.price) * BigInt(quantity)).toString();
|
||||||
|
}, [crate, quantity]);
|
||||||
|
|
||||||
const totalCost = crate ? crate.price * quantity : 0;
|
const canOpen = useMemo(() => {
|
||||||
const canOpen =
|
if (!user || !crate || opening || !crate.items?.length) return false;
|
||||||
crate &&
|
if (quantity < 1 || quantity > maxOpens) return false;
|
||||||
!opening &&
|
return toCentsBigInt(user.balance) >= toCentsBigInt(totalCost);
|
||||||
crate.items.length > 0 &&
|
}, [user, crate, opening, quantity, maxOpens, totalCost]);
|
||||||
quantity >= 1 &&
|
|
||||||
quantity <= maxOpens &&
|
|
||||||
user.balance >= totalCost;
|
|
||||||
|
|
||||||
const allDone = spins.length > 0 && spins.every((s) => s.done);
|
const allDone =
|
||||||
|
spins.length > 0 &&
|
||||||
|
spins.every((s) => s.done) &&
|
||||||
|
!respinRestarting &&
|
||||||
|
respinPhaseRef.current !== 'pre' &&
|
||||||
|
respinPhaseRef.current !== 'restarting';
|
||||||
|
|
||||||
|
const heroHidden = opening || (spins.length > 0 && !allDone);
|
||||||
|
|
||||||
|
const beginReelSpins = (items) => {
|
||||||
|
setAllDrops(items);
|
||||||
|
const showcase = pickShowcaseDrops(items, MAX_VISIBLE_REELS);
|
||||||
|
const next = showcase.map((item, idx) => ({
|
||||||
|
key: `${item.inventoryId || item.id || 'x'}-${idx}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
item,
|
||||||
|
spinning: false,
|
||||||
|
done: false,
|
||||||
|
}));
|
||||||
|
setSpins(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setSpins((prev) => prev.map((s) => ({ ...s, spinning: true })));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const openCase = async () => {
|
const openCase = async () => {
|
||||||
if (!canOpen) return;
|
if (!canOpen || !crate) return;
|
||||||
setError('');
|
setError('');
|
||||||
setKeyMessage('');
|
setKeyMessage('');
|
||||||
setCatalogMessage('');
|
|
||||||
setOpenBonusMessage('');
|
setOpenBonusMessage('');
|
||||||
setSpins([]);
|
setSpins([]);
|
||||||
|
setAllDrops([]);
|
||||||
setOpening(true);
|
setOpening(true);
|
||||||
|
setRespinRestarting(false);
|
||||||
announcedRef.current = false;
|
announcedRef.current = false;
|
||||||
|
preAnnouncedRef.current = false;
|
||||||
finishingRef.current = false;
|
finishingRef.current = false;
|
||||||
|
pendingRespinFinalRef.current = null;
|
||||||
|
pendingPreRespinAnnounceRef.current = null;
|
||||||
|
pendingPreRespinAutoSellRef.current = [];
|
||||||
|
respinPhaseRef.current = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.openCase(crate.id, quantity);
|
const result = await api.openCase(crate.id, quantity);
|
||||||
@@ -259,10 +457,9 @@ export default function CasePage() {
|
|||||||
if (result.animReduction != null) {
|
if (result.animReduction != null) {
|
||||||
setAnimReduction(result.animReduction);
|
setAnimReduction(result.animReduction);
|
||||||
}
|
}
|
||||||
const bonusParts = [];
|
if (result.refunded) {
|
||||||
if (result.refunded) bonusParts.push('Refund! Case cost returned');
|
setOpenBonusMessage('Refund! Case cost returned');
|
||||||
if (result.respin) bonusParts.push('Respin! All drops re-rolled');
|
}
|
||||||
if (bonusParts.length) setOpenBonusMessage(bonusParts.join(' · '));
|
|
||||||
if (result.keyProgress) {
|
if (result.keyProgress) {
|
||||||
setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
|
setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
|
||||||
}
|
}
|
||||||
@@ -272,19 +469,6 @@ export default function CasePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (result.catalog) {
|
if (result.catalog) {
|
||||||
const parts = [];
|
|
||||||
if (result.catalog.newlyFilled?.length) {
|
|
||||||
const n = result.catalog.newlyFilled.length;
|
|
||||||
parts.push(
|
|
||||||
`${n} catalog slot${n === 1 ? '' : 's'} filled`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (result.catalog.chapterJustCompleted) {
|
|
||||||
parts.push('chapter complete · +1% luck');
|
|
||||||
} else if (result.catalog.luckPercent > 0 && result.catalog.newlyFilled?.length) {
|
|
||||||
parts.push(`luck +${result.catalog.luckPercent}%`);
|
|
||||||
}
|
|
||||||
if (parts.length) setCatalogMessage(parts.join(' · '));
|
|
||||||
if (result.catalog.luckPercent != null) {
|
if (result.catalog.luckPercent != null) {
|
||||||
setCrate((prev) =>
|
setCrate((prev) =>
|
||||||
prev ? { ...prev, catalogLuckPercent: result.catalog.luckPercent } : prev
|
prev ? { ...prev, catalogLuckPercent: result.catalog.luckPercent } : prev
|
||||||
@@ -308,24 +492,34 @@ export default function CasePage() {
|
|||||||
setCaseName(resolvedCaseName);
|
setCaseName(resolvedCaseName);
|
||||||
const items = result.items?.length ? result.items : result.item ? [result.item] : [];
|
const items = result.items?.length ? result.items : result.item ? [result.item] : [];
|
||||||
const inventoryIds = items.map((item) => item?.inventoryId).filter(Boolean);
|
const inventoryIds = items.map((item) => item?.inventoryId).filter(Boolean);
|
||||||
|
const pre = result.respin && result.preRespinItems?.length ? result.preRespinItems : null;
|
||||||
|
|
||||||
pendingAnnounceRef.current = {
|
pendingAnnounceRef.current = {
|
||||||
ids: inventoryIds,
|
ids: inventoryIds,
|
||||||
caseName: resolvedCaseName || '',
|
caseName: resolvedCaseName || '',
|
||||||
|
respin: Boolean(pre),
|
||||||
};
|
};
|
||||||
pendingAutoSellRef.current = items
|
pendingAutoSellRef.current = items
|
||||||
.filter((item) => item?.willAutoSell)
|
.filter((item) => item?.willAutoSell)
|
||||||
.map((item) => item.inventoryId)
|
.map((item) => item.inventoryId)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const next = items.map((item, idx) => ({
|
|
||||||
key: `${item.inventoryId || item.id}-${idx}-${Date.now()}`,
|
if (pre) {
|
||||||
item,
|
pendingPreRespinAutoSellRef.current = pre
|
||||||
spinning: false,
|
.filter((item) => item?.willAutoSell && item?.inventoryId)
|
||||||
done: false,
|
.map((item) => item.inventoryId);
|
||||||
}));
|
pendingPreRespinAnnounceRef.current = {
|
||||||
setSpins(next);
|
items: pre,
|
||||||
requestAnimationFrame(() => {
|
caseName: resolvedCaseName || '',
|
||||||
setSpins((prev) => prev.map((s) => ({ ...s, spinning: true })));
|
};
|
||||||
});
|
pendingRespinFinalRef.current = items;
|
||||||
|
respinPhaseRef.current = 'pre';
|
||||||
|
beginReelSpins(pre);
|
||||||
|
} else {
|
||||||
|
pendingPreRespinAutoSellRef.current = [];
|
||||||
|
respinPhaseRef.current = null;
|
||||||
|
beginReelSpins(items);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
setOpening(false);
|
setOpening(false);
|
||||||
@@ -336,109 +530,149 @@ export default function CasePage() {
|
|||||||
setSpins((prev) => {
|
setSpins((prev) => {
|
||||||
const alreadyDone = prev.find((s) => s.key === key)?.done;
|
const alreadyDone = prev.find((s) => s.key === key)?.done;
|
||||||
if (alreadyDone) return prev;
|
if (alreadyDone) return prev;
|
||||||
|
return prev.map((s) =>
|
||||||
const next = prev.map((s) =>
|
|
||||||
s.key === key ? { ...s, spinning: false, done: true } : s
|
s.key === key ? { ...s, spinning: false, done: true } : s
|
||||||
);
|
);
|
||||||
|
|
||||||
const wasComplete = prev.length > 0 && prev.every((s) => s.done);
|
|
||||||
const nowComplete = next.length > 0 && next.every((s) => s.done);
|
|
||||||
|
|
||||||
if (nowComplete && !wasComplete) {
|
|
||||||
setOpening(false);
|
|
||||||
void finalizeOpens(next, caseName || crate?.name || '');
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 1) When first spin reels finish → feed line #1, start respin pause
|
||||||
|
// 2) When final reels finish → feed line #2 (respin) via finalizeOpens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!spins.length || spins.some((s) => !s.done)) return;
|
||||||
|
|
||||||
|
const phase = respinPhaseRef.current;
|
||||||
|
|
||||||
|
if (phase === 'pre') {
|
||||||
|
if (!pendingRespinFinalRef.current) return;
|
||||||
|
// Block re-entry while we credit first-roll auto-sell, then pause before restart.
|
||||||
|
respinPhaseRef.current = 'restarting';
|
||||||
|
void (async () => {
|
||||||
|
await announcePreRespinDrop();
|
||||||
|
await applyPreRespinAutoSell();
|
||||||
|
setRespinRestarting(true);
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === 'restarting') return;
|
||||||
|
|
||||||
|
if ((phase === 'final' || phase === null) && opening && !finishingRef.current) {
|
||||||
|
respinPhaseRef.current = null;
|
||||||
|
setOpening(false);
|
||||||
|
void finalizeOpens(caseName || crate?.name || '');
|
||||||
|
}
|
||||||
|
}, [spins, opening, caseName, crate?.name]);
|
||||||
|
|
||||||
|
// After pause, restart reels with real loot (respin feed waits until those finish)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!respinRestarting) return;
|
||||||
|
const finalItems = pendingRespinFinalRef.current;
|
||||||
|
if (!finalItems) return;
|
||||||
|
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
pendingRespinFinalRef.current = null;
|
||||||
|
respinPhaseRef.current = 'final';
|
||||||
|
setOpenBonusMessage((msg) =>
|
||||||
|
msg?.includes('Respin')
|
||||||
|
? msg
|
||||||
|
: msg
|
||||||
|
? `${msg} · Respin! Re-rolling…`
|
||||||
|
: 'Respin! Re-rolling all drops…'
|
||||||
|
);
|
||||||
|
beginReelSpins(finalItems);
|
||||||
|
setRespinRestarting(false);
|
||||||
|
}, 550);
|
||||||
|
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [respinRestarting]);
|
||||||
|
|
||||||
|
if (loading) return <p className="muted">Loading…</p>;
|
||||||
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
|
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="case-page">
|
<div className="case-page">
|
||||||
<Link to="/cases" className="muted">
|
|
||||||
← Back to cases
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{!crate && !error && <p className="muted">Loading case…</p>}
|
{!crate && !error && <p className="muted">Loading case…</p>}
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
{keyMessage && <div className="ok-msg">{keyMessage}</div>}
|
{keyMessage && <div className="ok-msg">{keyMessage}</div>}
|
||||||
{catalogMessage && <div className="ok-msg">{catalogMessage}</div>}
|
|
||||||
{openBonusMessage && <div className="ok-msg">{openBonusMessage}</div>}
|
{openBonusMessage && <div className="ok-msg">{openBonusMessage}</div>}
|
||||||
|
|
||||||
{crate && (
|
{crate && (
|
||||||
<>
|
<>
|
||||||
<div className="case-hero">
|
<div
|
||||||
<h1>{crate.name}</h1>
|
className={`case-hero-wrap${heroHidden ? ' case-hero-wrap--hidden' : ''}`}
|
||||||
<p className="muted">
|
aria-hidden={heroHidden}
|
||||||
Cost {formatCredits(crate.price)} cr each
|
>
|
||||||
{crate.basePrice != null && Number(crate.basePrice) !== Number(crate.price)
|
<div className="case-hero">
|
||||||
? ` (was ${formatCredits(crate.basePrice)})`
|
<h1>{crate.name}</h1>
|
||||||
: ''}{' '}
|
|
||||||
· Balance {formatCredits(user.balance)} cr
|
|
||||||
{crate.catalogLuckPercent > 0
|
|
||||||
? ` · Catalog luck +${crate.catalogLuckPercent}%`
|
|
||||||
: ''}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{keyProgress && (
|
{keyProgress && (
|
||||||
<div className="case-key-progress">
|
<div className="case-key-progress">
|
||||||
<div className="case-key-head">
|
<div className="case-key-head">
|
||||||
<span>
|
<span>
|
||||||
Keys <strong>{keyProgress.keys}</strong> · Max{' '}
|
Keys <strong>{keyProgress.keys}</strong> · Max{' '}
|
||||||
<strong>x{keyProgress.maxOpens}</strong>
|
<strong>x{keyProgress.maxOpens}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span className="muted">
|
<span className="muted">
|
||||||
Next key: {keyProgress.opensSinceLastKey} / {keyProgress.opensRequired} opens
|
Next key: {keyProgress.opensSinceLastKey} / {keyProgress.opensRequired} opens
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="case-key-bar" role="progressbar" aria-valuenow={progressPct}>
|
<div className="case-key-bar" role="progressbar" aria-valuenow={progressPct}>
|
||||||
<div className="case-key-bar-fill" style={{ width: `${progressPct}%` }} />
|
<div className="case-key-bar-fill" style={{ width: `${progressPct}%` }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="open-qty">
|
||||||
|
<label className="muted">
|
||||||
|
Open
|
||||||
|
<select
|
||||||
|
value={quantity}
|
||||||
|
disabled={opening}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = Number(e.target.value);
|
||||||
|
setQuantity(next);
|
||||||
|
persistQty(crate.id, next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{openOptions.map((n) => (
|
||||||
|
<option key={n} value={n} disabled={n > maxAffordable && maxAffordable > 0}>
|
||||||
|
{n}×
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span className="muted">
|
||||||
|
Total {formatCredits(totalCost)} cr
|
||||||
|
{maxAffordable < quantity ? ' · insufficient balance' : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="open-qty">
|
<button
|
||||||
<label className="muted">
|
type="button"
|
||||||
Open
|
className={`case-icon ${canOpen ? '' : 'disabled'}${crate.imageUrl ? ' has-img' : ''}`}
|
||||||
<select
|
onClick={openCase}
|
||||||
value={quantity}
|
disabled={!canOpen}
|
||||||
disabled={opening}
|
title="Open case"
|
||||||
onChange={(e) => {
|
>
|
||||||
const next = Number(e.target.value);
|
{crate.imageUrl ? <img src={crate.imageUrl} alt="" /> : '▣'}
|
||||||
setQuantity(next);
|
</button>
|
||||||
persistQty(crate.id, next);
|
<button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
|
||||||
}}
|
{opening
|
||||||
>
|
? `Opening ${quantity}×…`
|
||||||
{openOptions.map((n) => (
|
: `Open ${quantity}× for ${formatCredits(totalCost)} cr`}
|
||||||
<option key={n} value={n} disabled={n > maxAffordable && maxAffordable > 0}>
|
</button>
|
||||||
{n}×
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<span className="muted">
|
|
||||||
Total {formatCredits(totalCost)} cr
|
|
||||||
{maxAffordable < quantity ? ' · insufficient balance' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`case-icon ${canOpen ? '' : 'disabled'}${crate.imageUrl ? ' has-img' : ''}`}
|
|
||||||
onClick={openCase}
|
|
||||||
disabled={!canOpen}
|
|
||||||
title="Open case"
|
|
||||||
>
|
|
||||||
{crate.imageUrl ? <img src={crate.imageUrl} alt="" /> : '▣'}
|
|
||||||
</button>
|
|
||||||
<button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
|
|
||||||
{opening
|
|
||||||
? `Opening ${quantity}×…`
|
|
||||||
: `Open ${quantity}× for ${formatCredits(totalCost)} cr`}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{spins.length > 0 && (
|
{spins.length > 0 && (
|
||||||
<div className={`multi-reels${spins.length > 1 ? ' many' : ''}`}>
|
<div className={`multi-reels${spins.length > 1 ? ' many' : ''}`}>
|
||||||
|
{allDrops.length > spins.length && (
|
||||||
|
<p className="muted multi-reel-hint">
|
||||||
|
Showing top {spins.length} of {allDrops.length} drops
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{spins.map((spin, idx) => (
|
{spins.map((spin, idx) => (
|
||||||
<div key={spin.key} className="multi-reel-slot">
|
<div key={spin.key} className="multi-reel-slot">
|
||||||
{spins.length > 1 && (
|
{spins.length > 1 && (
|
||||||
@@ -464,14 +698,18 @@ export default function CasePage() {
|
|||||||
{spin.item.rarity}
|
{spin.item.rarity}
|
||||||
</div>
|
</div>
|
||||||
<div className="muted">
|
<div className="muted">
|
||||||
|
{[
|
||||||
|
spin.item.wear || null,
|
||||||
|
`${formatCredits(spin.item.valueCents ?? spin.item.marketValue)} cr`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
{spin.item.autoSold ? (
|
{spin.item.autoSold ? (
|
||||||
<>Auto-sold for {formatCredits(spin.item.soldForCents)} cr</>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
{formatCredits(spin.item.valueCents ?? spin.item.marketValue)} cr
|
{' '}
|
||||||
{spin.item.wear ? ` · ${spin.item.wear}` : ''}
|
· Auto-sold for {formatCredits(spin.item.soldForCents)} cr
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -480,13 +718,23 @@ export default function CasePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{allDone && spins.length > 1 && (
|
{allDone && allDrops.length > 1 && (
|
||||||
<div className="won-banner section">
|
<div className="won-banner section">
|
||||||
<div className="muted">You unboxed {spins.length} items</div>
|
<div className="muted">
|
||||||
|
You unboxed {allDrops.length} items
|
||||||
|
{allDrops.length > spins.length
|
||||||
|
? ` · reels showed the top ${spins.length} by value`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
<div className="grid" style={{ marginTop: '0.75rem' }}>
|
<div className="grid" style={{ marginTop: '0.75rem' }}>
|
||||||
{spins.map((s) => (
|
{[...allDrops]
|
||||||
<ItemTile key={s.key} item={s.item} />
|
.sort((a, b) => compareCents(dropValue(b), dropValue(a)))
|
||||||
))}
|
.map((item) => (
|
||||||
|
<ItemTile
|
||||||
|
key={item.inventoryId || `${item.id}-${item.floatValue}`}
|
||||||
|
item={item}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,12 +30,6 @@ export default function CasesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<div className="section-head">
|
|
||||||
<div>
|
|
||||||
<h1>Cases</h1>
|
|
||||||
<p className="muted">Pick a crate to open.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
<div className="grid case-grid">
|
<div className="grid case-grid">
|
||||||
{cases.map((c) => (
|
{cases.map((c) => (
|
||||||
@@ -44,7 +38,7 @@ export default function CasesPage() {
|
|||||||
{c.imageUrl ? <img src={c.imageUrl} alt="" loading="lazy" /> : '▣'}
|
{c.imageUrl ? <img src={c.imageUrl} alt="" loading="lazy" /> : '▣'}
|
||||||
</div>
|
</div>
|
||||||
<div className="case-name">{c.name}</div>
|
<div className="case-name">{c.name}</div>
|
||||||
<div className="case-meta">{formatCredits(c.price)} cr</div>
|
<div className="case-meta case-price">{formatCredits(c.price)} cr</div>
|
||||||
<div className="case-meta">{c.items.length} possible drops</div>
|
<div className="case-meta">{c.items.length} possible drops</div>
|
||||||
{c.keyProgress && (
|
{c.keyProgress && (
|
||||||
<div className="case-meta case-key-badge">
|
<div className="case-meta case-key-badge">
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { Navigate, Link } from 'react-router-dom';
|
|
||||||
import { useAuth } from '../AuthContext';
|
|
||||||
|
|
||||||
export default function CoinflipStub() {
|
|
||||||
const { user, loading } = useAuth();
|
|
||||||
|
|
||||||
if (loading) return <p className="muted">Loading…</p>;
|
|
||||||
if (!user) return <Navigate to="/login" replace />;
|
|
||||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="section">
|
|
||||||
<div className="section-head">
|
|
||||||
<div>
|
|
||||||
<h1>Coinflip</h1>
|
|
||||||
<p className="muted">1v1 item flip — coming soon.</p>
|
|
||||||
</div>
|
|
||||||
<Link to="/battle" className="btn secondary">
|
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="empty stub-panel">
|
|
||||||
<p>This mode is not playable yet. Use MultiPlayers Case or 1vX Case in the meantime.</p>
|
|
||||||
<div className="row-actions" style={{ marginTop: '1rem' }}>
|
|
||||||
<Link to="/battle/multi" className="btn">
|
|
||||||
MultiPlayers
|
|
||||||
</Link>
|
|
||||||
<Link to="/battle/1vx" className="btn secondary">
|
|
||||||
1vX Case
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+253
-211
@@ -1,13 +1,22 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, Navigate } from 'react-router-dom';
|
import { Link, Navigate } from 'react-router-dom';
|
||||||
import { api, formatCredits } from '../api';
|
import { api, formatCredits, creditsToCentsString, centsToCreditsString, compareCents, toCentsBigInt } from '../api';
|
||||||
import { useAuth } from '../AuthContext';
|
import { useAuth } from '../AuthContext';
|
||||||
import ItemTile from '../components/ItemTile';
|
import ItemTile from '../components/ItemTile';
|
||||||
import { onInventoryChanged } from '../inventoryEvents';
|
import { onInventoryChanged } from '../inventoryEvents';
|
||||||
|
import { getSocket, onSocketSessionOk } from '../socket';
|
||||||
import { loadInventorySort, saveInventorySort } from '../userPrefs';
|
import { loadInventorySort, saveInventorySort } from '../userPrefs';
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
function formatVaultCountdown(ms) {
|
||||||
|
if (ms == null || !Number.isFinite(ms)) return '—';
|
||||||
|
const totalSec = Math.max(0, Math.ceil(ms / 1000));
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { user, loading, patchUser } = useAuth();
|
const { user, loading, patchUser } = useAuth();
|
||||||
const [inventory, setInventory] = useState([]);
|
const [inventory, setInventory] = useState([]);
|
||||||
@@ -21,6 +30,7 @@ export default function Dashboard() {
|
|||||||
const [maxValue, setMaxValue] = useState('10');
|
const [maxValue, setMaxValue] = useState('10');
|
||||||
const [autoSellEnabled, setAutoSellEnabled] = useState(false);
|
const [autoSellEnabled, setAutoSellEnabled] = useState(false);
|
||||||
const [settingsBusy, setSettingsBusy] = useState(false);
|
const [settingsBusy, setSettingsBusy] = useState(false);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
@@ -32,7 +42,7 @@ export default function Dashboard() {
|
|||||||
if (!user) return;
|
if (!user) return;
|
||||||
setAutoSellEnabled(Boolean(user.autoSellEnabled));
|
setAutoSellEnabled(Boolean(user.autoSellEnabled));
|
||||||
if (user.autoSellThresholdCents != null) {
|
if (user.autoSellThresholdCents != null) {
|
||||||
setMaxValue((user.autoSellThresholdCents / 100).toFixed(2).replace(/\.?0+$/, '') || '0');
|
setMaxValue(centsToCreditsString(user.autoSellThresholdCents));
|
||||||
}
|
}
|
||||||
}, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]);
|
}, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]);
|
||||||
|
|
||||||
@@ -64,13 +74,64 @@ export default function Dashboard() {
|
|||||||
});
|
});
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || user.role === 'admin') return undefined;
|
||||||
|
const socket = getSocket();
|
||||||
|
const onYield = (payload) => {
|
||||||
|
if (payload?.user) patchUser(payload.user);
|
||||||
|
else if (payload?.balance != null) patchUser({ balance: payload.balance });
|
||||||
|
if (payload?.vault) setVault(payload.vault);
|
||||||
|
const credited = toCentsBigInt(payload?.credited);
|
||||||
|
if (credited > 0n) {
|
||||||
|
setVaultMessage(`Vault yield +${formatCredits(payload.credited)} cr`);
|
||||||
|
}
|
||||||
|
load().catch(() => {});
|
||||||
|
};
|
||||||
|
const onSession = () => {
|
||||||
|
load().catch(() => {});
|
||||||
|
};
|
||||||
|
socket.on('vault:yield', onYield);
|
||||||
|
const offSession = onSocketSessionOk(onSession);
|
||||||
|
return () => {
|
||||||
|
socket.off('vault:yield', onYield);
|
||||||
|
offSession();
|
||||||
|
};
|
||||||
|
}, [user, patchUser]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(vault?.used > 0)) return undefined;
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [vault?.used]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!(vault?.used > 0) || !vault?.nextYieldAt) return undefined;
|
||||||
|
const dueAt = new Date(vault.nextYieldAt).getTime();
|
||||||
|
const delay = Math.max(500, dueAt - Date.now() + 800);
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
load().catch(() => {});
|
||||||
|
}, delay);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [vault?.nextYieldAt, vault?.used]);
|
||||||
|
|
||||||
|
const vaultCountdownMs = useMemo(() => {
|
||||||
|
if (!(vault?.used > 0) || !vault?.nextYieldAt) return null;
|
||||||
|
return new Date(vault.nextYieldAt).getTime() - now;
|
||||||
|
}, [vault?.used, vault?.nextYieldAt, now]);
|
||||||
|
|
||||||
|
const vaultTickLabel = useMemo(() => {
|
||||||
|
const ms = vault?.tickMs ?? (vault?.mode === 'offline' ? 600_000 : 300_000);
|
||||||
|
return `${Math.round(ms / 60_000)} min`;
|
||||||
|
}, [vault?.tickMs, vault?.mode]);
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const list = [...inventory];
|
const list = inventory.filter((inv) => !inv.staked);
|
||||||
if (sort === 'value') {
|
if (sort === 'value') {
|
||||||
list.sort(
|
list.sort((a, b) =>
|
||||||
(a, b) =>
|
compareCents(
|
||||||
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
|
b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue,
|
||||||
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
|
a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
|
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
|
||||||
@@ -93,28 +154,23 @@ export default function Dashboard() {
|
|||||||
if (page > totalPages) setPage(totalPages);
|
if (page > totalPages) setPage(totalPages);
|
||||||
}, [page, totalPages]);
|
}, [page, totalPages]);
|
||||||
|
|
||||||
const selectedValue = useMemo(
|
const selectedValue = useMemo(() => {
|
||||||
() =>
|
let sum = 0n;
|
||||||
inventory
|
for (const inv of inventory) {
|
||||||
.filter((inv) => selected.includes(inv.id))
|
if (!selected.includes(inv.id)) continue;
|
||||||
.reduce((sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0), 0),
|
sum += toCentsBigInt(inv.valueCents ?? inv.item?.valueCents);
|
||||||
[inventory, selected]
|
}
|
||||||
);
|
return sum.toString();
|
||||||
|
}, [inventory, selected]);
|
||||||
|
|
||||||
const thresholdCents = useMemo(() => {
|
const thresholdCents = useMemo(() => creditsToCentsString(maxValue), [maxValue]);
|
||||||
const credits = Number(maxValue);
|
|
||||||
if (!Number.isFinite(credits) || credits < 0) return null;
|
|
||||||
return Math.round(credits * 100);
|
|
||||||
}, [maxValue]);
|
|
||||||
|
|
||||||
const belowThreshold = useMemo(() => {
|
const belowThreshold = useMemo(() => {
|
||||||
if (thresholdCents == null) return [];
|
if (thresholdCents == null) return [];
|
||||||
return inventory.filter(
|
return inventory.filter((inv) => {
|
||||||
(inv) =>
|
if (inv.favorite || inv.staked) return false;
|
||||||
!inv.favorite &&
|
return compareCents(inv.valueCents ?? inv.item?.valueCents, thresholdCents) < 0;
|
||||||
!inv.staked &&
|
});
|
||||||
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
|
||||||
);
|
|
||||||
}, [inventory, thresholdCents]);
|
}, [inventory, thresholdCents]);
|
||||||
|
|
||||||
const selectedItems = useMemo(
|
const selectedItems = useMemo(
|
||||||
@@ -134,14 +190,13 @@ export default function Dashboard() {
|
|||||||
const canStake = selectedUnstaked.length > 0 && selectedUnstaked.length <= vaultFree;
|
const canStake = selectedUnstaked.length > 0 && selectedUnstaked.length <= vaultFree;
|
||||||
const canUnstake = selectedStaked.length > 0;
|
const canUnstake = selectedStaked.length > 0;
|
||||||
|
|
||||||
const belowValue = useMemo(
|
const belowValue = useMemo(() => {
|
||||||
() =>
|
let sum = 0n;
|
||||||
belowThreshold.reduce(
|
for (const inv of belowThreshold) {
|
||||||
(sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0),
|
sum += toCentsBigInt(inv.valueCents ?? inv.item?.valueCents);
|
||||||
0
|
}
|
||||||
),
|
return sum.toString();
|
||||||
[belowThreshold]
|
}, [belowThreshold]);
|
||||||
);
|
|
||||||
|
|
||||||
const toggle = (id) => {
|
const toggle = (id) => {
|
||||||
setSelected((prev) =>
|
setSelected((prev) =>
|
||||||
@@ -217,27 +272,6 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const claimVault = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
setError('');
|
|
||||||
setVaultMessage('');
|
|
||||||
try {
|
|
||||||
const data = await api.vaultClaim();
|
|
||||||
if (data.user) patchUser({ balance: data.user.balance });
|
|
||||||
if (data.vault) setVault(data.vault);
|
|
||||||
setVaultMessage(
|
|
||||||
data.credited > 0
|
|
||||||
? `Vault yield +${formatCredits(data.credited)} cr`
|
|
||||||
: 'No vault yield ready yet'
|
|
||||||
);
|
|
||||||
await load();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setFavorite = async (favorite) => {
|
const setFavorite = async (favorite) => {
|
||||||
if (!selected.length) return;
|
if (!selected.length) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -257,7 +291,8 @@ export default function Dashboard() {
|
|||||||
if (!ids.length || thresholdCents == null) return;
|
if (!ids.length || thresholdCents == null) return;
|
||||||
const favSkipped = inventory.filter(
|
const favSkipped = inventory.filter(
|
||||||
(inv) =>
|
(inv) =>
|
||||||
inv.favorite && (inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
inv.favorite &&
|
||||||
|
compareCents(inv.valueCents ?? inv.item?.valueCents, thresholdCents) < 0
|
||||||
).length;
|
).length;
|
||||||
const ok = window.confirm(
|
const ok = window.confirm(
|
||||||
`Sell ${ids.length} item${ids.length === 1 ? '' : 's'} below ${formatCredits(thresholdCents)} cr for ${formatCredits(belowValue)} cr?` +
|
`Sell ${ids.length} item${ids.length === 1 ? '' : 's'} below ${formatCredits(thresholdCents)} cr for ${formatCredits(belowValue)} cr?` +
|
||||||
@@ -282,19 +317,18 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const onAutoSellToggle = async (enabled) => {
|
const onAutoSellToggle = async (enabled) => {
|
||||||
setAutoSellEnabled(enabled);
|
setAutoSellEnabled(enabled);
|
||||||
const credits = Number(maxValue);
|
if (creditsToCentsString(maxValue) == null) return;
|
||||||
if (!Number.isFinite(credits) || credits < 0) return;
|
await saveAutoSell(enabled, maxValue);
|
||||||
await saveAutoSell(enabled, credits);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onThresholdBlur = async () => {
|
const onThresholdBlur = async () => {
|
||||||
const credits = Number(maxValue);
|
const cents = creditsToCentsString(maxValue);
|
||||||
if (!Number.isFinite(credits) || credits < 0) return;
|
if (cents == null) return;
|
||||||
const savedCents = user?.autoSellThresholdCents ?? 1000;
|
const savedCents = String(user?.autoSellThresholdCents ?? 1000);
|
||||||
if (Math.round(credits * 100) === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
|
if (cents === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await saveAutoSell(autoSellEnabled, credits);
|
await saveAutoSell(autoSellEnabled, maxValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <p className="muted">Loading…</p>;
|
if (loading) return <p className="muted">Loading…</p>;
|
||||||
@@ -303,15 +337,56 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<section className="section">
|
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
|
||||||
<div className="section-head">
|
|
||||||
<div>
|
|
||||||
<h1>Inventory</h1>
|
|
||||||
<p className="muted">Select items to sell at market value.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inv-toolbar">
|
{stakedItems.length > 0 && (
|
||||||
|
<section className="section">
|
||||||
|
<div className="vault-panel admin-block">
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>Vault</h2>
|
||||||
|
</div>
|
||||||
|
<div className="vault-timer" title={`Shared ${vaultTickLabel} tick (${vault?.mode || 'online'})`}>
|
||||||
|
<span className="muted">Next payment</span>
|
||||||
|
<strong className={vaultCountdownMs != null && vaultCountdownMs <= 0 ? 'gold' : ''}>
|
||||||
|
{vaultCountdownMs != null && vaultCountdownMs <= 0
|
||||||
|
? 'paying…'
|
||||||
|
: formatVaultCountdown(vaultCountdownMs)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="vault-stats">
|
||||||
|
<span>
|
||||||
|
Slots <strong>{vault?.used ?? 0}/{vault?.slots ?? 3}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Online / tick{' '}
|
||||||
|
<strong className="gold">{formatCredits(vault?.onlinePerTick ?? 0)} cr</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Offline / tick{' '}
|
||||||
|
<strong>{formatCredits(vault?.offlinePerTick ?? 0)} cr</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid vault-grid">
|
||||||
|
{stakedItems.map((inv) => (
|
||||||
|
<ItemTile
|
||||||
|
key={`vault-${inv.id}`}
|
||||||
|
item={inv.item}
|
||||||
|
favorite={inv.favorite}
|
||||||
|
staked
|
||||||
|
selected={selected.includes(inv.id)}
|
||||||
|
onClick={() => toggle(inv.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="section">
|
||||||
|
<div className="section-head inv-section-head">
|
||||||
|
<h1>Inventory</h1>
|
||||||
<div className="inv-toolbar-left">
|
<div className="inv-toolbar-left">
|
||||||
<div className="sort-toggle" role="group" aria-label="Sort inventory">
|
<div className="sort-toggle" role="group" aria-label="Sort inventory">
|
||||||
<button
|
<button
|
||||||
@@ -335,87 +410,103 @@ export default function Dashboard() {
|
|||||||
Most recent
|
Most recent
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{inventory.length > 0 && (
|
{sorted.length > 0 && (
|
||||||
<span className="muted inv-count">
|
<span className="muted inv-count">
|
||||||
{inventory.length} item{inventory.length === 1 ? '' : 's'}
|
{sorted.length} item{sorted.length === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="row-actions">
|
|
||||||
{selected.length > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="muted">
|
|
||||||
{selected.length} selected · {formatCredits(selectedValue)} cr
|
|
||||||
{selectedHasFavorite ? ' · includes favorites' : ''}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn ghost"
|
|
||||||
onClick={() => setSelected([])}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
{!selectedAllFavorite && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary"
|
|
||||||
onClick={() => setFavorite(true)}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Favorite
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{selectedItems.some((inv) => inv.favorite) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn ghost"
|
|
||||||
onClick={() => setFavorite(false)}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Unfavorite
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
onClick={sell}
|
|
||||||
disabled={busy || !selectedCanSell}
|
|
||||||
title={
|
|
||||||
selectedHasFavorite || selectedStaked.length
|
|
||||||
? 'Favorites and vault items cannot be sold'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Sell
|
|
||||||
</button>
|
|
||||||
{canStake && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary"
|
|
||||||
onClick={stakeSelected}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Stake ({selectedUnstaked.length})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canUnstake && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary"
|
|
||||||
onClick={unstakeSelected}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Unstake ({selectedStaked.length})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<div className="inv-toolbar">
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn danger"
|
||||||
|
onClick={sell}
|
||||||
|
disabled={busy || !selectedCanSell}
|
||||||
|
title={
|
||||||
|
selectedHasFavorite || selectedStaked.length
|
||||||
|
? 'Favorites and vault items cannot be sold'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Sell
|
||||||
|
</button>
|
||||||
|
<span className="muted">
|
||||||
|
{selected.length} selected ·{' '}
|
||||||
|
<span className="item-price">
|
||||||
|
{formatCredits(selectedValue)} cr
|
||||||
|
</span>
|
||||||
|
{selectedHasFavorite ? ' · includes favorites' : ''}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost"
|
||||||
|
onClick={() => setSelected([])}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
{!selectedAllFavorite && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => setFavorite(true)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<span className="btn-ico" aria-hidden="true">
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
Favorite
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selectedItems.some((inv) => inv.favorite) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost"
|
||||||
|
onClick={() => setFavorite(false)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<span className="btn-ico" aria-hidden="true">
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
Unfavorite
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canStake && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={stakeSelected}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<span className="btn-ico btn-ico-vault" aria-hidden="true">
|
||||||
|
V
|
||||||
|
</span>
|
||||||
|
Stake ({selectedUnstaked.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canUnstake && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={unstakeSelected}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<span className="btn-ico btn-ico-vault" aria-hidden="true">
|
||||||
|
V
|
||||||
|
</span>
|
||||||
|
Unstake ({selectedStaked.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="inv-bulk-sell">
|
<div className="inv-bulk-sell">
|
||||||
{inventory.length > 0 && (
|
{sorted.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -440,11 +531,13 @@ export default function Dashboard() {
|
|||||||
</label>
|
</label>
|
||||||
<span className="muted inv-bulk-preview">
|
<span className="muted inv-bulk-preview">
|
||||||
{belowThreshold.length} item{belowThreshold.length === 1 ? '' : 's'} ·{' '}
|
{belowThreshold.length} item{belowThreshold.length === 1 ? '' : 's'} ·{' '}
|
||||||
{formatCredits(belowValue)} cr
|
<span className="item-price">
|
||||||
|
{formatCredits(belowValue)} cr
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{inventory.length === 0 && (
|
{sorted.length === 0 && (
|
||||||
<label className="inv-bulk-label">
|
<label className="inv-bulk-label">
|
||||||
Threshold
|
Threshold
|
||||||
<input
|
<input
|
||||||
@@ -471,76 +564,26 @@ export default function Dashboard() {
|
|||||||
<span>Auto-sell</span>
|
<span>Auto-sell</span>
|
||||||
</label>
|
</label>
|
||||||
<span className="inv-auto-sell-at muted">
|
<span className="inv-auto-sell-at muted">
|
||||||
at{' '}
|
sell below {formatCredits(creditsToCentsString(maxValue) ?? 0)} cr
|
||||||
{formatCredits(
|
|
||||||
Number.isFinite(Number(maxValue)) && Number(maxValue) >= 0
|
|
||||||
? Math.round(Number(maxValue) * 100)
|
|
||||||
: 0
|
|
||||||
)}{' '}
|
|
||||||
cr
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
|
|
||||||
|
|
||||||
<div className="vault-panel admin-block">
|
{sorted.length === 0 ? (
|
||||||
<div className="section-head">
|
|
||||||
<div>
|
|
||||||
<h2>Vault</h2>
|
|
||||||
<p className="muted">
|
|
||||||
Stake up to {vault?.slots ?? 3} items. Online: 10% / 10 min · Offline: 1% / 10 min
|
|
||||||
(scales with wear & rarity).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary"
|
|
||||||
onClick={claimVault}
|
|
||||||
disabled={busy || !(vault?.used > 0)}
|
|
||||||
>
|
|
||||||
Claim yield
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="vault-stats">
|
|
||||||
<span>
|
|
||||||
Slots <strong>{vault?.used ?? 0}/{vault?.slots ?? 3}</strong>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Online / tick{' '}
|
|
||||||
<strong className="gold">{formatCredits(vault?.onlinePerTick ?? 0)} cr</strong>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Offline / tick{' '}
|
|
||||||
<strong>{formatCredits(vault?.offlinePerTick ?? 0)} cr</strong>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{stakedItems.length > 0 ? (
|
|
||||||
<div className="grid vault-grid">
|
|
||||||
{stakedItems.map((inv) => (
|
|
||||||
<ItemTile
|
|
||||||
key={`vault-${inv.id}`}
|
|
||||||
item={inv.item}
|
|
||||||
favorite={inv.favorite}
|
|
||||||
staked
|
|
||||||
selected={selected.includes(inv.id)}
|
|
||||||
onClick={() => toggle(inv.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="muted">No items staked — select items below and Stake.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{inventory.length === 0 ? (
|
|
||||||
<div className="empty">
|
<div className="empty">
|
||||||
Empty for now —{' '}
|
{stakedItems.length > 0 ? (
|
||||||
<Link to="/cases" style={{ color: 'var(--accent)' }}>
|
'All items are in the vault.'
|
||||||
open a case
|
) : (
|
||||||
</Link>
|
<>
|
||||||
.
|
Empty for now —{' '}
|
||||||
|
<Link to="/cases" style={{ color: 'var(--accent)' }}>
|
||||||
|
open a case
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -550,7 +593,6 @@ export default function Dashboard() {
|
|||||||
key={inv.id}
|
key={inv.id}
|
||||||
item={inv.item}
|
item={inv.item}
|
||||||
favorite={inv.favorite}
|
favorite={inv.favorite}
|
||||||
staked={inv.staked}
|
|
||||||
selected={selected.includes(inv.id)}
|
selected={selected.includes(inv.id)}
|
||||||
onClick={() => toggle(inv.id)}
|
onClick={() => toggle(inv.id)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 { useAuth } from '../AuthContext';
|
||||||
import ItemTile from '../components/ItemTile';
|
import ItemTile from '../components/ItemTile';
|
||||||
import ProfileCaseStats from '../components/ProfileCaseStats';
|
import ProfileCaseStats from '../components/ProfileCaseStats';
|
||||||
@@ -38,10 +38,11 @@ export default function PlayerProfilePage() {
|
|||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const list = [...inventory];
|
const list = [...inventory];
|
||||||
if (sort === 'value') {
|
if (sort === 'value') {
|
||||||
list.sort(
|
list.sort((a, b) =>
|
||||||
(a, b) =>
|
compareCents(
|
||||||
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
|
b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue,
|
||||||
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
|
a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
|
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
|
||||||
@@ -161,10 +162,7 @@ export default function PlayerProfilePage() {
|
|||||||
Cases opened <strong>{profile.casesOpened}</strong>
|
Cases opened <strong>{profile.casesOpened}</strong>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Jackpot wins <strong>{profile.jackpotWins}</strong>
|
Battle wins <strong>{profile.jackpotWins}</strong>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Duel wins <strong>{profile.duelWins}</strong>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ProfileAchievementBadges achievements={profile.achievements} />
|
<ProfileAchievementBadges achievements={profile.achievements} />
|
||||||
@@ -223,7 +221,12 @@ export default function PlayerProfilePage() {
|
|||||||
<>
|
<>
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
{pageItems.map((inv) => (
|
{pageItems.map((inv) => (
|
||||||
<ItemTile key={inv.id} item={inv.item} favorite={inv.favorite} />
|
<ItemTile
|
||||||
|
key={inv.id}
|
||||||
|
item={inv.item}
|
||||||
|
favorite={inv.favorite}
|
||||||
|
staked={inv.staked}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { api, formatCredits } from '../api';
|
import { api, formatCredits, toCentsBigInt } from '../api';
|
||||||
import { useAuth } from '../AuthContext';
|
import { useAuth } from '../AuthContext';
|
||||||
|
|
||||||
function formatSkillValue(skill, value) {
|
function formatSkillValue(skill, value) {
|
||||||
@@ -67,7 +67,7 @@ export default function PrestigePage() {
|
|||||||
patchUser(result.user);
|
patchUser(result.user);
|
||||||
await refresh();
|
await refresh();
|
||||||
setMessage(
|
setMessage(
|
||||||
`Prestige #${result.prestige.count} · +${result.prGranted} Pr. Balance, inventory, keys & catalog reset.`
|
`Prestige #${result.prestige.count} · +${result.prGranted} Pr. Balance, inventory & keys reset.`
|
||||||
);
|
);
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -77,14 +77,14 @@ export default function PrestigePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUpgrade = async (skillId) => {
|
const onUpgrade = async (skillId, count = 1) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
try {
|
try {
|
||||||
const result = await api.prestigeUpgrade(skillId);
|
const result = await api.prestigeUpgrade(skillId, count);
|
||||||
patchUser(result.user);
|
patchUser(result.user);
|
||||||
setMessage(`Upgraded ${skillId}.`);
|
setMessage(count > 1 ? `Upgraded ${skillId} ×${count}.` : `Upgraded ${skillId}.`);
|
||||||
setData((prev) =>
|
setData((prev) =>
|
||||||
prev
|
prev
|
||||||
? {
|
? {
|
||||||
@@ -101,7 +101,10 @@ export default function PrestigePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const shortfall = Math.max(0, cost - (Number(user.balance) || 0));
|
const shortfall =
|
||||||
|
toCentsBigInt(cost) > toCentsBigInt(user.balance)
|
||||||
|
? toCentsBigInt(cost) - toCentsBigInt(user.balance)
|
||||||
|
: 0n;
|
||||||
const statusLabel = !data?.canAfford
|
const statusLabel = !data?.canAfford
|
||||||
? `Need ${formatCredits(shortfall)} cr more`
|
? `Need ${formatCredits(shortfall)} cr more`
|
||||||
: data?.lockedItems > 0
|
: data?.lockedItems > 0
|
||||||
@@ -178,8 +181,8 @@ export default function PrestigePage() {
|
|||||||
<strong>+{p.openBonus || 0}</strong>
|
<strong>+{p.openBonus || 0}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="muted">Discount</span>
|
<span className="muted">PR Farmer</span>
|
||||||
<strong>−{p.caseDiscount || 0}%</strong>
|
<strong>+{p.prBonus || 0}%</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="muted">Quality</span>
|
<span className="muted">Quality</span>
|
||||||
@@ -251,6 +254,7 @@ export default function PrestigePage() {
|
|||||||
<li>All Pr already earned</li>
|
<li>All Pr already earned</li>
|
||||||
<li>Skill-tree bonuses</li>
|
<li>Skill-tree bonuses</li>
|
||||||
<li>Account & prestige count</li>
|
<li>Account & prestige count</li>
|
||||||
|
<li>Catalog progress</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -258,7 +262,7 @@ export default function PrestigePage() {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>Balance</li>
|
<li>Balance</li>
|
||||||
<li>Inventory & vault stakes</li>
|
<li>Inventory & vault stakes</li>
|
||||||
<li>Case keys & catalog progress</li>
|
<li>Case keys</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,21 +312,43 @@ export default function PrestigePage() {
|
|||||||
<p className="muted">{skill.description}</p>
|
<p className="muted">{skill.description}</p>
|
||||||
<div className="prestige-skill-meta">
|
<div className="prestige-skill-meta">
|
||||||
<span>
|
<span>
|
||||||
Lv {skill.level} · now{' '}
|
Lv {skill.level}
|
||||||
|
{skill.maxLevel != null ? `/${skill.maxLevel}` : ''}
|
||||||
|
{' · now '}
|
||||||
<strong>{formatSkillValue(skill, skill.current)}</strong>
|
<strong>{formatSkillValue(skill, skill.current)}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span className="prestige-delta">
|
{!skill.atMax && (
|
||||||
→ {formatSkillValue(skill, skill.nextValue)}
|
<span className="prestige-delta">
|
||||||
</span>
|
→ {formatSkillValue(skill, skill.nextValue)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{skill.atMax && <span className="prestige-delta">MAX</span>}
|
||||||
|
</div>
|
||||||
|
<div className="prestige-skill-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
disabled={busy || skill.atMax || !skill.canAfford}
|
||||||
|
onClick={() => onUpgrade(skill.id, 1)}
|
||||||
|
>
|
||||||
|
{skill.atMax ? 'Maxed' : `Buy · ${skill.cost} Pr`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
disabled={busy || skill.atMax || !skill.canAfford10}
|
||||||
|
onClick={() => onUpgrade(skill.id, skill.buyCount10 || 10)}
|
||||||
|
title={
|
||||||
|
skill.atMax
|
||||||
|
? 'Max level reached'
|
||||||
|
: `${skill.buyCount10 || 10} levels → ${formatSkillValue(skill, skill.nextValue10)}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{skill.atMax
|
||||||
|
? '—'
|
||||||
|
: `×${skill.buyCount10 || 10} · ${skill.cost10} Pr`}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn secondary"
|
|
||||||
disabled={busy || !skill.canAfford}
|
|
||||||
onClick={() => onUpgrade(skill.id)}
|
|
||||||
>
|
|
||||||
Buy · {skill.cost} Pr
|
|
||||||
</button>
|
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ProfileAchievementBadges from '../components/ProfileAchievementBadges';
|
|||||||
import ProfilePrestigeStats from '../components/ProfilePrestigeStats';
|
import ProfilePrestigeStats from '../components/ProfilePrestigeStats';
|
||||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { onSocketSessionOk } from '../socket';
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, loading, refresh, logout } = useAuth();
|
const { user, loading, refresh, logout } = useAuth();
|
||||||
@@ -21,7 +22,7 @@ export default function ProfilePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || user.role === 'admin') return;
|
if (!user || user.role === 'admin') return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
const loadProfile = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.profile();
|
const data = await api.profile();
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -31,9 +32,14 @@ export default function ProfilePage() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setError(err.message);
|
if (!cancelled) setError(err.message);
|
||||||
}
|
}
|
||||||
})();
|
};
|
||||||
|
loadProfile();
|
||||||
|
const offSession = onSocketSessionOk(() => {
|
||||||
|
loadProfile().catch(() => {});
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
offSession();
|
||||||
};
|
};
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
@@ -130,7 +136,13 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
<label className="btn secondary file-btn">
|
<label className="btn secondary file-btn">
|
||||||
Change photo
|
Change photo
|
||||||
<input type="file" accept="image/*" hidden onChange={onAvatar} disabled={busy} />
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif,.png,.jpg,.jpeg,.webp,.gif"
|
||||||
|
hidden
|
||||||
|
onChange={onAvatar}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="muted" style={{ marginTop: '0.75rem' }}>
|
<div className="muted" style={{ marginTop: '0.75rem' }}>
|
||||||
Balance {formatCredits(user.balance)} cr
|
Balance {formatCredits(user.balance)} cr
|
||||||
@@ -178,10 +190,7 @@ export default function ProfilePage() {
|
|||||||
Cases opened <strong>{profile.casesOpened}</strong>
|
Cases opened <strong>{profile.casesOpened}</strong>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Jackpot wins <strong>{profile.jackpotWins}</strong>
|
Battle wins <strong>{profile.jackpotWins}</strong>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Duel wins <strong>{profile.duelWins}</strong>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ProfileAchievementBadges
|
<ProfileAchievementBadges
|
||||||
|
|||||||
+160
-54
@@ -23,20 +23,33 @@ function useAdCountdown(nextAdAt) {
|
|||||||
return remaining;
|
return remaining;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCountdown(sec) {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ShopPage() {
|
export default function ShopPage() {
|
||||||
const { user, loading, patchUser } = useAuth();
|
const { user, loading, patchUser } = useAuth();
|
||||||
const [packages, setPackages] = useState([]);
|
const [packages, setPackages] = useState([]);
|
||||||
|
const [osuTopups, setOsuTopups] = useState([]);
|
||||||
|
const [osuTopupsEnabled, setOsuTopupsEnabled] = useState(true);
|
||||||
|
const [payments, setPayments] = useState(null);
|
||||||
const [ad, setAd] = useState(null);
|
const [ad, setAd] = useState(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [watching, setWatching] = useState(false);
|
const [watching, setWatching] = useState(false);
|
||||||
|
const [confirmingId, setConfirmingId] = useState(null);
|
||||||
|
|
||||||
const remaining = useAdCountdown(ad?.ready ? null : ad?.nextAdAt);
|
const remaining = useAdCountdown(ad?.ready ? null : ad?.nextAdAt);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const data = await api.shop();
|
const data = await api.shop();
|
||||||
setPackages(data.packages || []);
|
setPackages(data.packages || []);
|
||||||
|
setOsuTopups(data.osuTopups || []);
|
||||||
|
setOsuTopupsEnabled(data.osuTopupsEnabled !== false && (data.osuTopups || []).length > 0);
|
||||||
|
setPayments(data.payments || null);
|
||||||
setAd(data.ad);
|
setAd(data.ad);
|
||||||
patchUser({
|
patchUser({
|
||||||
balance: data.user.balance,
|
balance: data.user.balance,
|
||||||
@@ -60,7 +73,7 @@ export default function ShopPage() {
|
|||||||
};
|
};
|
||||||
}, [user?.id, user?.role, load]);
|
}, [user?.id, user?.role, load]);
|
||||||
|
|
||||||
const buy = async (packageId) => {
|
const buyCredits = async (packageId) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
@@ -70,7 +83,7 @@ export default function ShopPage() {
|
|||||||
balance: data.user.balance,
|
balance: data.user.balance,
|
||||||
osuBalance: data.user.osuBalance,
|
osuBalance: data.user.osuBalance,
|
||||||
});
|
});
|
||||||
setMessage(`Bought ${data.package.name} — +${formatCredits(data.package.credits)} cr`);
|
setMessage(`Exchanged — +${formatCredits(data.package.credits)} cr`);
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -79,12 +92,41 @@ export default function ShopPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buyOsu = async (packId) => {
|
||||||
|
setBusy(true);
|
||||||
|
setConfirmingId(packId);
|
||||||
|
setError('');
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
const checkout = await api.shopOsuCheckout(packId);
|
||||||
|
if (checkout.mode === 'mock' && checkout.confirmable) {
|
||||||
|
const data = await api.shopOsuConfirm(checkout.order.id);
|
||||||
|
patchUser({
|
||||||
|
balance: data.user.balance,
|
||||||
|
osuBalance: data.user.osuBalance,
|
||||||
|
});
|
||||||
|
setMessage(
|
||||||
|
data.alreadyPaid
|
||||||
|
? 'Order already completed'
|
||||||
|
: `+${data.osuGranted} osu added to your wallet`
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} else {
|
||||||
|
setError('Payment provider is not ready for live checkout yet');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setConfirmingId(null);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const watchAd = async () => {
|
const watchAd = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
setWatching(true);
|
setWatching(true);
|
||||||
// Simulated short ad
|
|
||||||
await new Promise((r) => setTimeout(r, 1800));
|
await new Promise((r) => setTimeout(r, 1800));
|
||||||
try {
|
try {
|
||||||
const data = await api.shopAd();
|
const data = await api.shopAd();
|
||||||
@@ -115,30 +157,122 @@ export default function ShopPage() {
|
|||||||
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
if (user.role === 'admin') return <Navigate to="/admin" replace />;
|
||||||
|
|
||||||
const adReady = ad?.ready || (ad?.nextAdAt && remaining === 0);
|
const adReady = ad?.ready || (ad?.nextAdAt && remaining === 0);
|
||||||
|
const osuBal = user.osuBalance ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="shop-page">
|
||||||
<section className="section">
|
<header className="shop-hero">
|
||||||
<div className="section-head">
|
<div className="shop-hero-copy">
|
||||||
<div>
|
<p className="shop-kicker">CaseOrion Shop</p>
|
||||||
<h1>Shop</h1>
|
<h1>Fuel your runs</h1>
|
||||||
<p className="muted">Spend osu for credit packs, or watch an ad every minute.</p>
|
<p className="shop-lede">
|
||||||
</div>
|
<strong>osu</strong> is the premium wallet — bought with real money, spent on credit
|
||||||
<div className="row-actions">
|
packs. Credits are for opening cases, vaulting, and battling.
|
||||||
<span className="osu-pill">{user.osuBalance ?? 0} osu</span>
|
|
||||||
<span className="balance-pill">{formatCredits(user.balance)} cr</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
|
||||||
{message && <div className="ok-msg">{message}</div>}
|
|
||||||
|
|
||||||
<div className="admin-block ad-panel">
|
|
||||||
<h2>Watch an ad</h2>
|
|
||||||
<p className="muted">
|
|
||||||
Earn between {formatCredits(ad?.rewardMin || 10000)} and{' '}
|
|
||||||
{formatCredits(ad?.rewardMax || 20000)} cr. Cooldown: 1 minute.
|
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shop-wallet">
|
||||||
|
<div className="shop-wallet-row">
|
||||||
|
<span className="muted">osu</span>
|
||||||
|
<strong className="osu-pill shop-wallet-osu">{osuBal}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="shop-wallet-row">
|
||||||
|
<span className="muted">Credits</span>
|
||||||
|
<strong className="balance-pill">{formatCredits(user.balance)} cr</strong>
|
||||||
|
</div>
|
||||||
|
{payments?.mock && (
|
||||||
|
<p className="shop-mock-note muted">
|
||||||
|
Dev payments: mock checkout credits osu instantly (Stripe-ready backend).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
{message && <div className="ok-msg">{message}</div>}
|
||||||
|
|
||||||
|
{osuTopupsEnabled && osuTopups.length > 0 && (
|
||||||
|
<section className="shop-section">
|
||||||
|
<div className="shop-section-head">
|
||||||
|
<h2>Get osu</h2>
|
||||||
|
<p className="muted">Premium top-ups · better rate on larger packs</p>
|
||||||
|
</div>
|
||||||
|
<div className="shop-osu-grid">
|
||||||
|
{osuTopups.map((pack) => (
|
||||||
|
<article
|
||||||
|
key={pack.id}
|
||||||
|
className={`shop-osu-card${pack.badge ? ' has-badge' : ''}${
|
||||||
|
pack.badge === 'Best value' ? ' highlight' : ''
|
||||||
|
}${pack.badge === 'Popular' ? ' popular' : ''}`}
|
||||||
|
>
|
||||||
|
{pack.badge ? <span className="shop-badge">{pack.badge}</span> : null}
|
||||||
|
<h3>{pack.name}</h3>
|
||||||
|
<p className="shop-osu-amount">
|
||||||
|
{pack.osu} <span>osu</span>
|
||||||
|
</p>
|
||||||
|
<p className="muted shop-osu-blurb">{pack.blurb}</p>
|
||||||
|
<p className="shop-osu-rate muted">{pack.perOsuLabel}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn shop-buy-btn"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => buyOsu(pack.id)}
|
||||||
|
>
|
||||||
|
{confirmingId === pack.id
|
||||||
|
? 'Processing…'
|
||||||
|
: `Buy · ${pack.displayPrice || `€${(pack.priceCents / 100).toFixed(2)}`}`}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="shop-section">
|
||||||
|
<div className="shop-section-head">
|
||||||
|
<h2>Exchange for credits</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Spend osu · payout scales with your credit balance (min floor early game)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shop-credit-grid">
|
||||||
|
{packages.map((p) => {
|
||||||
|
const canAfford = osuBal >= p.osuCost;
|
||||||
|
return (
|
||||||
|
<article key={p.id} className="shop-credit-card">
|
||||||
|
{p.tag ? <span className="shop-badge subtle">{p.tag}</span> : null}
|
||||||
|
<h3>{p.name}</h3>
|
||||||
|
<p className="pack-credits">+{formatCredits(p.credits)} cr</p>
|
||||||
|
<p className="muted shop-credit-scale">
|
||||||
|
{p.balancePct != null ? `${p.balancePct}% of balance` : null}
|
||||||
|
{p.scaled === false ? ' · floor' : ''}
|
||||||
|
</p>
|
||||||
|
<p className="shop-credit-cost">
|
||||||
|
<span className="osu-pill">{p.osuCost} osu</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
disabled={busy || !canAfford}
|
||||||
|
onClick={() => buyCredits(p.id)}
|
||||||
|
title={canAfford ? undefined : 'Not enough osu'}
|
||||||
|
>
|
||||||
|
{canAfford ? 'Exchange' : 'Need osu'}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="shop-section shop-free">
|
||||||
|
<div className="shop-ad-panel">
|
||||||
|
<div>
|
||||||
|
<h2>Free credits</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Watch a short ad every minute for {formatCredits(ad?.rewardMin || 10000)}–
|
||||||
|
{formatCredits(ad?.rewardMax || 20000)} cr.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{watching ? (
|
{watching ? (
|
||||||
<div className="ad-sim">
|
<div className="ad-sim">
|
||||||
<div className="ad-bar" />
|
<div className="ad-bar" />
|
||||||
@@ -147,43 +281,15 @@ export default function ShopPage() {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn secondary"
|
||||||
disabled={busy || !adReady}
|
disabled={busy || !adReady}
|
||||||
onClick={watchAd}
|
onClick={watchAd}
|
||||||
>
|
>
|
||||||
{adReady
|
{adReady ? 'Watch ad' : `Ready in ${formatCountdown(remaining)}`}
|
||||||
? 'Watch ad'
|
|
||||||
: `Available in ${String(Math.floor(remaining / 60)).padStart(2, '0')}:${String(
|
|
||||||
remaining % 60
|
|
||||||
).padStart(2, '0')}`}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="section">
|
|
||||||
<h2>Credit packs</h2>
|
|
||||||
<p className="muted" style={{ marginBottom: '1rem' }}>
|
|
||||||
Buy credits with osu (demo currency).
|
|
||||||
</p>
|
|
||||||
<div className="mode-grid">
|
|
||||||
{packages.map((p) => (
|
|
||||||
<div key={p.id} className="mode-card shop-pack">
|
|
||||||
<h2>{p.name}</h2>
|
|
||||||
<p className="pack-credits">{p.blurb}</p>
|
|
||||||
<p className="muted">{p.osuCost} osu</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
disabled={busy || (user.osuBalance ?? 0) < p.osuCost}
|
|
||||||
onClick={() => buy(p.id)}
|
|
||||||
>
|
|
||||||
Buy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-3
@@ -4,10 +4,14 @@ let socket = null;
|
|||||||
let limited = false;
|
let limited = false;
|
||||||
let limitPayload = null;
|
let limitPayload = null;
|
||||||
const limitListeners = new Set();
|
const limitListeners = new Set();
|
||||||
|
const sessionOkListeners = new Set();
|
||||||
|
let unloadBound = false;
|
||||||
|
let connectTimer = null;
|
||||||
|
let reconnectSeq = 0;
|
||||||
|
|
||||||
function notifyLimit(payload) {
|
function notifyLimit(payload) {
|
||||||
limited = true;
|
limited = true;
|
||||||
limitPayload = payload || { max: 10 };
|
limitPayload = payload || { max: 2 };
|
||||||
for (const fn of limitListeners) {
|
for (const fn of limitListeners) {
|
||||||
try {
|
try {
|
||||||
fn(limitPayload);
|
fn(limitPayload);
|
||||||
@@ -29,32 +33,125 @@ function notifyOk() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notifySessionOk(payload) {
|
||||||
|
for (const fn of sessionOkListeners) {
|
||||||
|
try {
|
||||||
|
fn(payload);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearConnectTimer() {
|
||||||
|
if (connectTimer != null) {
|
||||||
|
clearTimeout(connectTimer);
|
||||||
|
connectTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConnected() {
|
||||||
|
if (!socket || limited) return;
|
||||||
|
socket.io.reconnection(true);
|
||||||
|
if (!socket.connected) {
|
||||||
|
clearConnectTimer();
|
||||||
|
connectTimer = setTimeout(() => {
|
||||||
|
connectTimer = null;
|
||||||
|
if (!socket || limited || socket.connected) return;
|
||||||
|
socket.connect();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindUnloadOnce() {
|
||||||
|
if (unloadBound || typeof window === 'undefined') return;
|
||||||
|
unloadBound = true;
|
||||||
|
const release = () => {
|
||||||
|
clearConnectTimer();
|
||||||
|
if (socket?.connected) {
|
||||||
|
// Manual disconnect frees the presence slot; pageshow/visibility will reconnect.
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// pagehide covers tab close, bfcache, and mobile backgrounding better than unload
|
||||||
|
window.addEventListener('pagehide', release);
|
||||||
|
window.addEventListener('pageshow', () => {
|
||||||
|
ensureConnected();
|
||||||
|
});
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') ensureConnected();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getSocket() {
|
export function getSocket() {
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
|
// Connect only after auth is known (see refreshSocketSession) to avoid
|
||||||
|
// anonymous+auth double handshakes that stack slots on rapid refresh.
|
||||||
socket = io({
|
socket = io({
|
||||||
path: '/socket.io',
|
path: '/socket.io',
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
autoConnect: true,
|
autoConnect: false,
|
||||||
reconnection: true,
|
reconnection: true,
|
||||||
reconnectionDelay: 500,
|
reconnectionDelay: 500,
|
||||||
reconnectionAttempts: Infinity,
|
reconnectionAttempts: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bindUnloadOnce();
|
||||||
|
|
||||||
socket.on('session:limit', (payload) => {
|
socket.on('session:limit', (payload) => {
|
||||||
socket.io.reconnection(false);
|
socket.io.reconnection(false);
|
||||||
notifyLimit(payload);
|
notifyLimit(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('session:ok', () => {
|
socket.on('session:replaced', () => {
|
||||||
|
// Another tab/refresh took our slot — stay quiet, allow a later reconnect
|
||||||
|
// without showing the hard limit overlay.
|
||||||
|
socket.io.reconnection(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('session:ok', (payload) => {
|
||||||
if (!socket.io.reconnection()) {
|
if (!socket.io.reconnection()) {
|
||||||
socket.io.reconnection(true);
|
socket.io.reconnection(true);
|
||||||
}
|
}
|
||||||
notifyOk();
|
notifyOk();
|
||||||
|
notifySessionOk(payload);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force a new Socket.IO handshake so express-session reloads the auth cookie.
|
||||||
|
* Needed after login/register/logout — otherwise an anonymous socket stays "offline".
|
||||||
|
* Serialized so spam refresh cannot open many overlapping sockets from this tab.
|
||||||
|
*/
|
||||||
|
export function refreshSocketSession() {
|
||||||
|
const s = getSocket();
|
||||||
|
if (limited) return s;
|
||||||
|
|
||||||
|
const seq = ++reconnectSeq;
|
||||||
|
clearConnectTimer();
|
||||||
|
s.io.reconnection(true);
|
||||||
|
|
||||||
|
const connectNow = () => {
|
||||||
|
if (seq !== reconnectSeq || limited) return;
|
||||||
|
if (!s.connected) s.connect();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (s.connected) {
|
||||||
|
s.once('disconnect', () => {
|
||||||
|
if (seq !== reconnectSeq) return;
|
||||||
|
clearConnectTimer();
|
||||||
|
connectTimer = setTimeout(connectNow, 40);
|
||||||
|
});
|
||||||
|
s.disconnect();
|
||||||
|
} else {
|
||||||
|
clearConnectTimer();
|
||||||
|
connectTimer = setTimeout(connectNow, 0);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
export function isSessionLimited() {
|
export function isSessionLimited() {
|
||||||
return limited;
|
return limited;
|
||||||
}
|
}
|
||||||
@@ -70,10 +167,17 @@ export function onSessionLimitChange(fn) {
|
|||||||
return () => limitListeners.delete(fn);
|
return () => limitListeners.delete(fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fired when the server confirms an authenticated presence session. */
|
||||||
|
export function onSocketSessionOk(fn) {
|
||||||
|
sessionOkListeners.add(fn);
|
||||||
|
return () => sessionOkListeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
/** Allow reconnect after the user closes other tabs and reloads. */
|
/** Allow reconnect after the user closes other tabs and reloads. */
|
||||||
export function clearSessionLimit() {
|
export function clearSessionLimit() {
|
||||||
notifyOk();
|
notifyOk();
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.io.reconnection(true);
|
socket.io.reconnection(true);
|
||||||
|
if (!socket.connected) socket.connect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
port: 5173,
|
port: 5888,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3001',
|
||||||
|
|||||||
@@ -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:
|
# Usage:
|
||||||
# ./scripts/local-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS
|
# ./scripts/local-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS
|
||||||
# ./scripts/local-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
|
# ./scripts/local-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
|
||||||
|
# ./scripts/local-db-import.sh ./backups/….tar.gz <username> # player-only merge
|
||||||
#
|
#
|
||||||
# Typical flow (prod → laptop):
|
# Typical flow (prod → laptop):
|
||||||
# # on prod:
|
# # on prod:
|
||||||
# ./scripts/docker-db-export.sh
|
# ./scripts/docker-db-export.sh
|
||||||
# # copy the .tar.gz here, then:
|
# # copy the .tar.gz here, then:
|
||||||
# ./scripts/local-db-import.sh ./backups/casegambling-….tar.gz
|
# ./scripts/local-db-import.sh ./backups/casegambling-….tar.gz
|
||||||
|
# # or only one player into the existing local DB:
|
||||||
|
# ./scripts/local-db-import.sh ./backups/casegambling-….tar.gz SomePlayer
|
||||||
#
|
#
|
||||||
# Stop `npm run dev` before importing so SQLite is not locked.
|
# Stop `npm run dev` before importing so SQLite is not locked.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
MERGE_PLAYER_SCRIPT="$ROOT/scripts/merge-player-db.sh"
|
||||||
|
|
||||||
if [[ -z "${1:-}" ]]; then
|
if [[ -z "${1:-}" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
echo "Usage: $0 <backup-dir-or-tar.gz>" >&2
|
echo "Usage: $0 <backup-dir-or-tar.gz> [username]" >&2
|
||||||
echo "Example: $0 ./backups/casegambling-20260116-203000.tar.gz" >&2
|
echo "Example: $0 ./backups/casegambling-20260116-203000.tar.gz" >&2
|
||||||
|
echo " $0 ./backups/casegambling-20260116-203000.tar.gz SomePlayer" >&2
|
||||||
echo "Create a backup from Docker: ./scripts/docker-db-export.sh" >&2
|
echo "Create a backup from Docker: ./scripts/docker-db-export.sh" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
BACKUP_ARG="$1"
|
||||||
|
USERNAME="${2:-}"
|
||||||
|
|
||||||
# Resolve backup path from the caller's cwd
|
# Resolve backup path from the caller's cwd
|
||||||
if [[ "$1" = /* ]]; then
|
if [[ "$BACKUP_ARG" = /* ]]; then
|
||||||
SRC="$1"
|
SRC="$BACKUP_ARG"
|
||||||
else
|
else
|
||||||
SRC="$(pwd)/$1"
|
SRC="$(pwd)/$BACKUP_ARG"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
LOCAL_DB="${LOCAL_DB:-$ROOT/server/prisma/dev.db}"
|
LOCAL_DB="${LOCAL_DB:-$ROOT/server/prisma/dev.db}"
|
||||||
@@ -49,6 +57,25 @@ is_archive() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
copy_player_avatar() {
|
||||||
|
local avatar_url="$1"
|
||||||
|
local uploads_src="$2"
|
||||||
|
local uploads_dst="$3"
|
||||||
|
[[ -z "$avatar_url" || ! -d "$uploads_src" ]] && return 0
|
||||||
|
local rel="${avatar_url#/}"
|
||||||
|
rel="${rel#uploads/}"
|
||||||
|
rel="${rel#./}"
|
||||||
|
case "$rel" in
|
||||||
|
''|http:*|https:*|*..*) return 0 ;;
|
||||||
|
esac
|
||||||
|
local src_file="$uploads_src/$rel"
|
||||||
|
if [[ -f "$src_file" ]]; then
|
||||||
|
mkdir -p "$uploads_dst/$(dirname "$rel")"
|
||||||
|
cp -f "$src_file" "$uploads_dst/$rel"
|
||||||
|
echo "==> Copied avatar upload: $rel"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
if [[ -f "$SRC" ]] && is_archive "$SRC"; then
|
if [[ -f "$SRC" ]] && is_archive "$SRC"; then
|
||||||
echo "==> Extracting $(basename "$SRC")"
|
echo "==> Extracting $(basename "$SRC")"
|
||||||
TMP="$(mktemp -d)"
|
TMP="$(mktemp -d)"
|
||||||
@@ -84,6 +111,35 @@ if [[ -z "$DB" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Player-only merge into existing local DB ─────────────────────────────────
|
||||||
|
if [[ -n "$USERNAME" ]]; then
|
||||||
|
if [[ ! -f "$LOCAL_DB" ]]; then
|
||||||
|
echo "Error: local DB not found: $LOCAL_DB" >&2
|
||||||
|
echo "Tip: run a full import first (without username), or start the app once." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -x "$MERGE_PLAYER_SCRIPT" ]]; then
|
||||||
|
echo "Error: merge helper missing or not executable: $MERGE_PLAYER_SCRIPT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Drop WAL/SHM so we merge against the real file contents
|
||||||
|
rm -f "${LOCAL_DB}-wal" "${LOCAL_DB}-shm"
|
||||||
|
|
||||||
|
"$MERGE_PLAYER_SCRIPT" "$DB" "$LOCAL_DB" "$USERNAME"
|
||||||
|
AVATAR_URL="$(sqlite3 "$DB" "SELECT avatarUrl FROM User WHERE username = '${USERNAME//\'/\'\'}' LIMIT 1;" 2>/dev/null || true)"
|
||||||
|
if [[ -d "$SRC/uploads" ]]; then
|
||||||
|
copy_player_avatar "${AVATAR_URL:-}" "$SRC/uploads" "$LOCAL_UPLOADS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Done (player-only)."
|
||||||
|
echo " db: $LOCAL_DB"
|
||||||
|
echo " Tip: stop npm run dev before import if the DB was locked; then restart:"
|
||||||
|
echo " npm run dev"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Full import (replace local DB + uploads) ─────────────────────────────────
|
||||||
mkdir -p "$(dirname "$LOCAL_DB")"
|
mkdir -p "$(dirname "$LOCAL_DB")"
|
||||||
|
|
||||||
echo "==> Importing database → $LOCAL_DB"
|
echo "==> Importing database → $LOCAL_DB"
|
||||||
|
|||||||
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.
|
# Run this on the machine that runs docker compose.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./scripts/docker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS
|
# ./scripts/xdocker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS
|
||||||
# ./scripts/docker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
|
# ./scripts/xdocker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
|
||||||
|
# ./scripts/xdocker-db-import.sh ./backups/….tar.gz <username> # player-only merge
|
||||||
#
|
#
|
||||||
# Expects a backup from docker-db-export.sh or local-db-export.sh
|
# Expects a backup from docker-db-export.sh or local-db-export.sh
|
||||||
# (prod.db or dev.db + uploads/).
|
# (prod.db or dev.db + uploads/).
|
||||||
@@ -24,18 +25,24 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "${1:-}" ]]; then
|
MERGE_PLAYER_SCRIPT="$ROOT/scripts/merge-player-db.sh"
|
||||||
echo "Usage: $0 <backup-dir-or-tar.gz>" >&2
|
|
||||||
|
if [[ -z "${1:-}" || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
echo "Usage: $0 <backup-dir-or-tar.gz> [username]" >&2
|
||||||
echo "Example: $0 ./backups/casegambling-20260116-203000.tar.gz" >&2
|
echo "Example: $0 ./backups/casegambling-20260116-203000.tar.gz" >&2
|
||||||
|
echo " $0 ./backups/casegambling-20260116-203000.tar.gz SomePlayer" >&2
|
||||||
echo "Create a backup first: ./scripts/docker-db-export.sh (or local-db-export.sh)" >&2
|
echo "Create a backup first: ./scripts/docker-db-export.sh (or local-db-export.sh)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
BACKUP_ARG="$1"
|
||||||
|
USERNAME="${2:-}"
|
||||||
|
|
||||||
# Resolve backup path from the caller's cwd (before any cd)
|
# Resolve backup path from the caller's cwd (before any cd)
|
||||||
if [[ "$1" = /* ]]; then
|
if [[ "$BACKUP_ARG" = /* ]]; then
|
||||||
SRC="$1"
|
SRC="$BACKUP_ARG"
|
||||||
else
|
else
|
||||||
SRC="$(pwd)/$1"
|
SRC="$(pwd)/$BACKUP_ARG"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
@@ -52,10 +59,14 @@ fi
|
|||||||
SERVICE="${SERVICE:-casegambling}"
|
SERVICE="${SERVICE:-casegambling}"
|
||||||
|
|
||||||
TMP=""
|
TMP=""
|
||||||
|
WORK=""
|
||||||
cleanup() {
|
cleanup() {
|
||||||
if [[ -n "$TMP" && -d "$TMP" ]]; then
|
if [[ -n "$TMP" && -d "$TMP" ]]; then
|
||||||
rm -rf "$TMP"
|
rm -rf "$TMP"
|
||||||
fi
|
fi
|
||||||
|
if [[ -n "$WORK" && -d "$WORK" ]]; then
|
||||||
|
rm -rf "$WORK"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
@@ -66,6 +77,25 @@ is_archive() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
copy_player_avatar_docker() {
|
||||||
|
local avatar_url="$1"
|
||||||
|
local uploads_src="$2"
|
||||||
|
[[ -z "$avatar_url" || ! -d "$uploads_src" ]] && return 0
|
||||||
|
local rel="${avatar_url#/}"
|
||||||
|
rel="${rel#uploads/}"
|
||||||
|
rel="${rel#./}"
|
||||||
|
case "$rel" in
|
||||||
|
''|http:*|https:*|*..*) return 0 ;;
|
||||||
|
esac
|
||||||
|
local src_file="$uploads_src/$rel"
|
||||||
|
if [[ -f "$src_file" ]]; then
|
||||||
|
"${COMPOSE[@]}" run --rm --no-deps --entrypoint sh "$SERVICE" -c \
|
||||||
|
"mkdir -p /app/uploads/$(dirname "$rel")" >/dev/null
|
||||||
|
"${COMPOSE[@]}" cp "$src_file" "$SERVICE:/app/uploads/$rel"
|
||||||
|
echo "==> Copied avatar upload: $rel"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
echo "Error: docker not found" >&2
|
echo "Error: docker not found" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -109,6 +139,58 @@ fi
|
|||||||
echo "==> Ensuring compose service exists"
|
echo "==> Ensuring compose service exists"
|
||||||
"${COMPOSE[@]}" up -d --no-start "$SERVICE" >/dev/null
|
"${COMPOSE[@]}" up -d --no-start "$SERVICE" >/dev/null
|
||||||
|
|
||||||
|
# ── Player-only merge into existing Docker DB ────────────────────────────────
|
||||||
|
if [[ -n "$USERNAME" ]]; then
|
||||||
|
if [[ ! -x "$MERGE_PLAYER_SCRIPT" ]]; then
|
||||||
|
echo "Error: merge helper missing or not executable: $MERGE_PLAYER_SCRIPT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! command -v sqlite3 >/dev/null 2>&1; then
|
||||||
|
echo "Error: sqlite3 is required on the host for player-only import" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Stopping $SERVICE (safe SQLite merge)"
|
||||||
|
"${COMPOSE[@]}" stop "$SERVICE" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
TARGET_DB="$WORK/prod.db"
|
||||||
|
|
||||||
|
echo "==> Fetching current /data/prod.db"
|
||||||
|
if ! "${COMPOSE[@]}" cp "$SERVICE:/data/prod.db" "$TARGET_DB" 2>/dev/null; then
|
||||||
|
echo "Error: could not copy /data/prod.db from $SERVICE" >&2
|
||||||
|
echo "Tip: run a full import once first so the volume has a database." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
"${COMPOSE[@]}" run --rm --no-deps --entrypoint sh "$SERVICE" -c \
|
||||||
|
'rm -f /data/prod.db-wal /data/prod.db-shm' >/dev/null
|
||||||
|
|
||||||
|
"$MERGE_PLAYER_SCRIPT" "$DB" "$TARGET_DB" "$USERNAME"
|
||||||
|
|
||||||
|
echo "==> Writing merged database → /data/prod.db"
|
||||||
|
"${COMPOSE[@]}" cp "$TARGET_DB" "$SERVICE:/data/prod.db"
|
||||||
|
|
||||||
|
AVATAR_URL="$(sqlite3 "$DB" "SELECT avatarUrl FROM User WHERE username = '${USERNAME//\'/\'\'}' LIMIT 1;" 2>/dev/null || true)"
|
||||||
|
if [[ -d "$SRC/uploads" ]]; then
|
||||||
|
copy_player_avatar_docker "${AVATAR_URL:-}" "$SRC/uploads"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Starting $SERVICE"
|
||||||
|
"${COMPOSE[@]}" start "$SERVICE" 2>/dev/null || "${COMPOSE[@]}" up -d "$SERVICE"
|
||||||
|
|
||||||
|
HOST_PORT_VAL="${HOST_PORT:-}"
|
||||||
|
if [[ -z "$HOST_PORT_VAL" && -f "$ENV_FILE" ]]; then
|
||||||
|
HOST_PORT_VAL="$(grep -E '^HOST_PORT=' "$ENV_FILE" 2>/dev/null | cut -d= -f2- | tr -d '"' || true)"
|
||||||
|
fi
|
||||||
|
HOST_PORT_VAL="${HOST_PORT_VAL:-3009}"
|
||||||
|
|
||||||
|
echo "==> Done (player-only)."
|
||||||
|
echo " App: http://localhost:${HOST_PORT_VAL}"
|
||||||
|
echo " Logs: ${COMPOSE[*]} logs -f --tail=50"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Full import (replace Docker DB + uploads) ────────────────────────────────
|
||||||
echo "==> Stopping $SERVICE (safe SQLite replace)"
|
echo "==> Stopping $SERVICE (safe SQLite replace)"
|
||||||
"${COMPOSE[@]}" stop "$SERVICE" >/dev/null 2>&1 || true
|
"${COMPOSE[@]}" stop "$SERVICE" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
|||||||
Generated
+508
@@ -15,10 +15,448 @@
|
|||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"prisma": "^6.11.1",
|
"prisma": "^6.11.1",
|
||||||
|
"sharp": "^0.34.4",
|
||||||
"socket.io": "^4.8.3"
|
"socket.io": "^4.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {}
|
"devDependencies": {}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||||
|
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.34.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz",
|
||||||
|
"integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.34.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz",
|
||||||
|
"integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.34.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz",
|
||||||
|
"integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.34.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz",
|
||||||
|
"integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.34.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz",
|
||||||
|
"integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.34.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz",
|
||||||
|
"integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.34.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz",
|
||||||
|
"integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.34.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz",
|
||||||
|
"integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.34.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz",
|
||||||
|
"integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.34.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz",
|
||||||
|
"integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.34.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz",
|
||||||
|
"integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.34.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz",
|
||||||
|
"integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.34.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz",
|
||||||
|
"integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||||
@@ -457,6 +895,15 @@
|
|||||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -1420,6 +1867,18 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||||
@@ -1471,6 +1930,48 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.34.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz",
|
||||||
|
"integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.0.0",
|
||||||
|
"detect-libc": "^2.1.0",
|
||||||
|
"semver": "^7.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.34.4",
|
||||||
|
"@img/sharp-darwin-x64": "0.34.4",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.2.3",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.2.3",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.2.3",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.2.3",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.3",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.2.3",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.2.3",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.3",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.3",
|
||||||
|
"@img/sharp-linux-arm": "0.34.4",
|
||||||
|
"@img/sharp-linux-arm64": "0.34.4",
|
||||||
|
"@img/sharp-linux-ppc64": "0.34.4",
|
||||||
|
"@img/sharp-linux-s390x": "0.34.4",
|
||||||
|
"@img/sharp-linux-x64": "0.34.4",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.34.4",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.34.4",
|
||||||
|
"@img/sharp-wasm32": "0.34.4",
|
||||||
|
"@img/sharp-win32-arm64": "0.34.4",
|
||||||
|
"@img/sharp-win32-ia32": "0.34.4",
|
||||||
|
"@img/sharp-win32-x64": "0.34.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
@@ -1671,6 +2172,13 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||||
|
|||||||
+2
-2
@@ -24,7 +24,7 @@
|
|||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
"prisma": "^6.11.1",
|
"prisma": "^6.11.1",
|
||||||
|
"sharp": "^0.34.4",
|
||||||
"socket.io": "^4.8.3"
|
"socket.io": "^4.8.3"
|
||||||
},
|
}
|
||||||
"devDependencies": {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
+58
-52
@@ -11,29 +11,21 @@ model User {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String @unique
|
username String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
balance BigInt @default(0)
|
balance String @default("0")
|
||||||
osuBalance Int @default(0)
|
osuBalance Int @default(0)
|
||||||
prBalance Int @default(0)
|
prBalance String @default("0")
|
||||||
lastAdAt DateTime?
|
lastAdAt DateTime?
|
||||||
role String @default("user")
|
role String @default("user")
|
||||||
avatarUrl String @default("")
|
avatarUrl String @default("")
|
||||||
bio String @default("")
|
bio String @default("")
|
||||||
autoSellEnabled Boolean @default(false)
|
autoSellEnabled Boolean @default(false)
|
||||||
autoSellThresholdCents BigInt @default(1000)
|
autoSellThresholdCents String @default("1000")
|
||||||
prestigeCount Int @default(0)
|
prestigeCount Int @default(0)
|
||||||
prestigeChanceBonus Int @default(0)
|
prestigeChanceBonus Int @default(0)
|
||||||
prestigeGainBonus Int @default(0)
|
prestigeGainBonus Int @default(0)
|
||||||
prestigeKeyOpenReduction Int @default(0)
|
|
||||||
prestigeStartBonus Int @default(0)
|
|
||||||
prestigeAdBonus Int @default(0)
|
|
||||||
prestigeAdCooldownReduction Int @default(0)
|
|
||||||
prestigeWearBonus Int @default(0)
|
prestigeWearBonus Int @default(0)
|
||||||
prestigePrBonus Int @default(0)
|
prestigePrBonus Int @default(0)
|
||||||
prestigeShopDiscount Int @default(0)
|
|
||||||
prestigeStartOsu Int @default(0)
|
|
||||||
prestigeDropValueBonus Int @default(0)
|
|
||||||
prestigeOpenBonus Int @default(0)
|
prestigeOpenBonus Int @default(0)
|
||||||
prestigeCaseDiscount Int @default(0)
|
|
||||||
prestigeQualityBonus Int @default(0)
|
prestigeQualityBonus Int @default(0)
|
||||||
prestigeAnimReduction Int @default(0)
|
prestigeAnimReduction Int @default(0)
|
||||||
prestigeRefundChance Int @default(0)
|
prestigeRefundChance Int @default(0)
|
||||||
@@ -41,27 +33,46 @@ model User {
|
|||||||
prestigeVaultSlots Int @default(0)
|
prestigeVaultSlots Int @default(0)
|
||||||
prestigeOfflineYield Int @default(0)
|
prestigeOfflineYield Int @default(0)
|
||||||
prestigeOnlineYield Int @default(0)
|
prestigeOnlineYield Int @default(0)
|
||||||
|
vaultYieldAt DateTime?
|
||||||
lastConnection DateTime?
|
lastConnection DateTime?
|
||||||
playTimeSeconds BigInt @default(0)
|
playTimeSeconds BigInt @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
inventory InventoryItem[]
|
inventory InventoryItem[]
|
||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
jackpotWins JackpotRound[] @relation("JackpotWinner")
|
jackpotWins JackpotRound[] @relation("JackpotWinner")
|
||||||
|
jackpotCreated JackpotRound[] @relation("JackpotCreator")
|
||||||
jackpotDeposits JackpotDeposit[]
|
jackpotDeposits JackpotDeposit[]
|
||||||
duelRoomsCreated DuelRoom[] @relation("DuelCreator")
|
|
||||||
duelWins DuelRoom[] @relation("DuelWinner")
|
|
||||||
duelDeposits DuelDeposit[]
|
|
||||||
caseKeyProgress CaseKeyProgress[]
|
caseKeyProgress CaseKeyProgress[]
|
||||||
catalogEntries CatalogEntry[]
|
catalogEntries CatalogEntry[]
|
||||||
prestigeLogs PrestigeLog[]
|
prestigeLogs PrestigeLog[]
|
||||||
achievements UserAchievement[]
|
achievements UserAchievement[]
|
||||||
|
paymentOrders PaymentOrder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model PaymentOrder {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId Int
|
||||||
|
packId String
|
||||||
|
osuAmount Int
|
||||||
|
priceCents Int
|
||||||
|
currency String @default("eur")
|
||||||
|
status String @default("pending")
|
||||||
|
provider String @default("mock")
|
||||||
|
providerRef String @default("")
|
||||||
|
meta String @default("{}")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
paidAt DateTime?
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, status])
|
||||||
|
@@index([providerRef])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Case {
|
model Case {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
imageUrl String @default("")
|
imageUrl String @default("")
|
||||||
price BigInt
|
price String @default("0")
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
items CaseItem[]
|
items CaseItem[]
|
||||||
@@ -81,7 +92,7 @@ model Item {
|
|||||||
imageUrl String @default("")
|
imageUrl String @default("")
|
||||||
rarity String
|
rarity String
|
||||||
category String @default("Misc")
|
category String @default("Misc")
|
||||||
marketValue BigInt
|
marketValue String @default("0")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
cases CaseItem[]
|
cases CaseItem[]
|
||||||
inventory InventoryItem[]
|
inventory InventoryItem[]
|
||||||
@@ -110,20 +121,19 @@ model InventoryItem {
|
|||||||
floatValue Float @default(0.265)
|
floatValue Float @default(0.265)
|
||||||
wear String @default("Field-Tested")
|
wear String @default("Field-Tested")
|
||||||
paintSeed Int @default(0)
|
paintSeed Int @default(0)
|
||||||
valueCents BigInt @default(0)
|
valueCents String @default("0")
|
||||||
favorite Boolean @default(false)
|
favorite Boolean @default(false)
|
||||||
obtainedAt DateTime @default(now())
|
obtainedAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||||
jackpotDeposits JackpotDeposit[]
|
jackpotDeposits JackpotDeposit[]
|
||||||
duelDeposits DuelDeposit[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Transaction {
|
model Transaction {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
type String
|
type String
|
||||||
amount BigInt
|
amount String
|
||||||
meta String @default("{}")
|
meta String @default("{}")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -133,11 +143,15 @@ model JackpotRound {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
status String @default("open")
|
status String @default("open")
|
||||||
endsAt DateTime
|
endsAt DateTime
|
||||||
|
creatorId Int?
|
||||||
winnerUserId Int?
|
winnerUserId Int?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
resolvedAt DateTime?
|
resolvedAt DateTime?
|
||||||
|
creator User? @relation("JackpotCreator", fields: [creatorId], references: [id])
|
||||||
winner User? @relation("JackpotWinner", fields: [winnerUserId], references: [id])
|
winner User? @relation("JackpotWinner", fields: [winnerUserId], references: [id])
|
||||||
deposits JackpotDeposit[]
|
deposits JackpotDeposit[]
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
model JackpotDeposit {
|
model JackpotDeposit {
|
||||||
@@ -145,7 +159,7 @@ model JackpotDeposit {
|
|||||||
roundId Int
|
roundId Int
|
||||||
userId Int
|
userId Int
|
||||||
inventoryItemId Int
|
inventoryItemId Int
|
||||||
valueCents BigInt
|
valueCents String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
round JackpotRound @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
round JackpotRound @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -154,37 +168,6 @@ model JackpotDeposit {
|
|||||||
@@unique([roundId, inventoryItemId])
|
@@unique([roundId, inventoryItemId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model DuelRoom {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
creatorId Int
|
|
||||||
status String @default("open")
|
|
||||||
maxPlayers Int
|
|
||||||
maxItemsPerPlayer Int
|
|
||||||
minItemValue BigInt @default(0)
|
|
||||||
minPlayersToStart Int @default(2)
|
|
||||||
winnerUserId Int?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
startedAt DateTime?
|
|
||||||
resolvedAt DateTime?
|
|
||||||
creator User @relation("DuelCreator", fields: [creatorId], references: [id], onDelete: Cascade)
|
|
||||||
winner User? @relation("DuelWinner", fields: [winnerUserId], references: [id])
|
|
||||||
deposits DuelDeposit[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model DuelDeposit {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
roomId Int
|
|
||||||
userId Int
|
|
||||||
inventoryItemId Int
|
|
||||||
valueCents BigInt
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
room DuelRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
inventoryItem InventoryItem @relation(fields: [inventoryItemId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([roomId, inventoryItemId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model CaseKeyProgress {
|
model CaseKeyProgress {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
@@ -215,7 +198,7 @@ model PrestigeLog {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
choice String
|
choice String
|
||||||
prGranted Int @default(0)
|
prGranted String @default("0")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
@@ -241,3 +224,26 @@ model UserAchievement {
|
|||||||
|
|
||||||
@@unique([userId, achievementId])
|
@@unique([userId, achievementId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Key/value app settings (e.g. shop.osuTopupsEnabled). */
|
||||||
|
model AppSetting {
|
||||||
|
key String @id
|
||||||
|
value String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Configurable real-money → osu packs for the shop. */
|
||||||
|
model ShopOsuPack {
|
||||||
|
id String @id
|
||||||
|
name String
|
||||||
|
osu Int
|
||||||
|
priceCents Int
|
||||||
|
currency String @default("eur")
|
||||||
|
badge String @default("")
|
||||||
|
blurb String @default("")
|
||||||
|
perOsuLabel String @default("")
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
+23
-16
@@ -1,6 +1,7 @@
|
|||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import { countCompletedChapters } from './catalog.js';
|
import { countCompletedChapters } from './catalog.js';
|
||||||
import { livePlayTimeSeconds } from './presence.js';
|
import { livePlayTimeSeconds } from './presence.js';
|
||||||
|
import { isPrestigeSkillTreeMaxed } from './prestige.js';
|
||||||
|
|
||||||
/** First batch of achievements — upserted on seed / boot. */
|
/** First batch of achievements — upserted on seed / boot. */
|
||||||
export const ACHIEVEMENT_DEFS = [
|
export const ACHIEVEMENT_DEFS = [
|
||||||
@@ -34,44 +35,44 @@ export const ACHIEVEMENT_DEFS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'jackpot_win',
|
key: 'jackpot_win',
|
||||||
name: 'Pot Lucky',
|
name: 'Battle Winner',
|
||||||
description: 'Win a MultiPlayers Case round.',
|
description: 'Win a Battle pot.',
|
||||||
icon: '♠',
|
icon: '♠',
|
||||||
sortOrder: 5,
|
sortOrder: 5,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'duel_win',
|
|
||||||
name: 'Duel Victor',
|
|
||||||
description: 'Win a 1vX Case battle.',
|
|
||||||
icon: '⚔',
|
|
||||||
sortOrder: 6,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'catalog_chapter',
|
key: 'catalog_chapter',
|
||||||
name: 'Chapter Complete',
|
name: 'Chapter Complete',
|
||||||
description: 'Finish a full catalog chapter.',
|
description: 'Finish a full catalog chapter.',
|
||||||
icon: '◆',
|
icon: '◆',
|
||||||
sortOrder: 7,
|
sortOrder: 6,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'first_prestige',
|
key: 'first_prestige',
|
||||||
name: 'Prestige Bound',
|
name: 'Prestige Bound',
|
||||||
description: 'Prestige for the first time.',
|
description: 'Prestige for the first time.',
|
||||||
icon: '✦',
|
icon: '✦',
|
||||||
sortOrder: 8,
|
sortOrder: 7,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'shop_customer',
|
key: 'shop_customer',
|
||||||
name: 'Shop Regular',
|
name: 'Shop Regular',
|
||||||
description: 'Buy a shop pack or claim an ad reward.',
|
description: 'Buy a shop pack or claim an ad reward.',
|
||||||
icon: '◈',
|
icon: '◈',
|
||||||
sortOrder: 9,
|
sortOrder: 8,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'playtime_hour',
|
key: 'playtime_hour',
|
||||||
name: 'Hourglass',
|
name: 'Hourglass',
|
||||||
description: 'Spend 1 hour playing on CaseOrion.',
|
description: 'Spend 1 hour playing on CaseOrion.',
|
||||||
icon: '⌛',
|
icon: '⌛',
|
||||||
|
sortOrder: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'prestige_skills_max',
|
||||||
|
name: 'Skill Absolute',
|
||||||
|
description: 'Reach max level on every Prestige skill tree branch.',
|
||||||
|
icon: '♛',
|
||||||
sortOrder: 10,
|
sortOrder: 10,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -89,6 +90,11 @@ export async function ensureAchievements() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Removed game modes (e.g. 1vX duels) — drop obsolete achievement defs
|
||||||
|
await prisma.userAchievement.deleteMany({
|
||||||
|
where: { achievement: { key: 'duel_win' } },
|
||||||
|
});
|
||||||
|
await prisma.achievement.deleteMany({ where: { key: 'duel_win' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeAchievement(row, unlockedAt = null) {
|
function serializeAchievement(row, unlockedAt = null) {
|
||||||
@@ -160,16 +166,15 @@ export async function checkAchievements(userId) {
|
|||||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user || user.role === 'admin') return [];
|
if (!user || user.role === 'admin') return [];
|
||||||
|
|
||||||
const [casesOpened, jackpotWins, duelWins, chapters, shopOrAd, owned] =
|
const [casesOpened, jackpotWins, chapters, shopOrAd, owned] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.transaction.count({ where: { userId, type: 'open_case' } }),
|
prisma.transaction.count({ where: { userId, type: 'open_case' } }),
|
||||||
prisma.transaction.count({ where: { userId, type: 'jackpot_win' } }),
|
prisma.transaction.count({ where: { userId, type: 'jackpot_win' } }),
|
||||||
prisma.transaction.count({ where: { userId, type: 'duel_win' } }),
|
|
||||||
countCompletedChapters(prisma, userId),
|
countCompletedChapters(prisma, userId),
|
||||||
prisma.transaction.count({
|
prisma.transaction.count({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
type: { in: ['shop_purchase', 'ad_reward'] },
|
type: { in: ['shop_purchase', 'ad_reward', 'osu_purchase'] },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.userAchievement.findMany({
|
prisma.userAchievement.findMany({
|
||||||
@@ -186,7 +191,6 @@ export async function checkAchievements(userId) {
|
|||||||
if (casesOpened >= 25 && !have.has('case_25')) candidates.push('case_25');
|
if (casesOpened >= 25 && !have.has('case_25')) candidates.push('case_25');
|
||||||
if (casesOpened >= 100 && !have.has('case_100')) candidates.push('case_100');
|
if (casesOpened >= 100 && !have.has('case_100')) candidates.push('case_100');
|
||||||
if (jackpotWins >= 1 && !have.has('jackpot_win')) candidates.push('jackpot_win');
|
if (jackpotWins >= 1 && !have.has('jackpot_win')) candidates.push('jackpot_win');
|
||||||
if (duelWins >= 1 && !have.has('duel_win')) candidates.push('duel_win');
|
|
||||||
if (chapters >= 1 && !have.has('catalog_chapter')) candidates.push('catalog_chapter');
|
if (chapters >= 1 && !have.has('catalog_chapter')) candidates.push('catalog_chapter');
|
||||||
if ((user.prestigeCount ?? 0) >= 1 && !have.has('first_prestige')) {
|
if ((user.prestigeCount ?? 0) >= 1 && !have.has('first_prestige')) {
|
||||||
candidates.push('first_prestige');
|
candidates.push('first_prestige');
|
||||||
@@ -195,6 +199,9 @@ export async function checkAchievements(userId) {
|
|||||||
if (livePlayTimeSeconds(user) >= 3600 && !have.has('playtime_hour')) {
|
if (livePlayTimeSeconds(user) >= 3600 && !have.has('playtime_hour')) {
|
||||||
candidates.push('playtime_hour');
|
candidates.push('playtime_hour');
|
||||||
}
|
}
|
||||||
|
if (isPrestigeSkillTreeMaxed(user) && !have.has('prestige_skills_max')) {
|
||||||
|
candidates.push('prestige_skills_max');
|
||||||
|
}
|
||||||
|
|
||||||
return unlockMany(userId, candidates);
|
return unlockMany(userId, candidates);
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-28
@@ -4,48 +4,62 @@
|
|||||||
* Prestige gain bonus multiplies credited amount (not the sell threshold check).
|
* Prestige gain bonus multiplies credited amount (not the sell threshold check).
|
||||||
*/
|
*/
|
||||||
import { applyGainMultiplier } from './prestige.js';
|
import { applyGainMultiplier } from './prestige.js';
|
||||||
|
import { toCentsBigInt, centsJson } from './cents.js';
|
||||||
|
|
||||||
export function shouldAutoSell(user, valueCents) {
|
export function shouldAutoSell(user, valueCents) {
|
||||||
if (!user?.autoSellEnabled) return false;
|
if (!user?.autoSellEnabled) return false;
|
||||||
const threshold = user.autoSellThresholdCents ?? 0;
|
const threshold = toCentsBigInt(user.autoSellThresholdCents ?? 0);
|
||||||
return valueCents < threshold;
|
return toCentsBigInt(valueCents) < threshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyAutoSell(tx, user, inventoryItems) {
|
/** Compute auto-sell payout without mutating inventory (for deferred claims). */
|
||||||
|
export function computeAutoSell(user, inventoryItems) {
|
||||||
if (!user?.autoSellEnabled) {
|
if (!user?.autoSellEnabled) {
|
||||||
return { sold: [], balanceDelta: 0 };
|
return { sold: [], balanceDelta: 0n };
|
||||||
}
|
}
|
||||||
|
|
||||||
const threshold = user.autoSellThresholdCents ?? 0;
|
const threshold = toCentsBigInt(user.autoSellThresholdCents ?? 0);
|
||||||
const gainBonus = user.prestigeGainBonus ?? 0;
|
const gainBonus = user.prestigeGainBonus ?? 0;
|
||||||
const sold = [];
|
const sold = [];
|
||||||
let balanceDelta = 0;
|
let balanceDelta = 0n;
|
||||||
|
|
||||||
for (const inv of inventoryItems) {
|
for (const inv of inventoryItems) {
|
||||||
if (!inv || inv.locked || inv.staked || inv.favorite || inv.valueCents >= threshold) continue;
|
if (!inv || inv.locked || inv.staked || inv.favorite) continue;
|
||||||
|
const value = toCentsBigInt(inv.valueCents);
|
||||||
const payout = applyGainMultiplier(inv.valueCents, gainBonus);
|
if (value >= threshold) continue;
|
||||||
|
|
||||||
await tx.inventoryItem.delete({ where: { id: inv.id } });
|
|
||||||
await tx.transaction.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
type: 'sell_item',
|
|
||||||
amount: payout,
|
|
||||||
meta: JSON.stringify({
|
|
||||||
inventoryItemId: inv.id,
|
|
||||||
itemId: inv.itemId,
|
|
||||||
autoSell: true,
|
|
||||||
valueCents: inv.valueCents,
|
|
||||||
prestigeGainBonus: gainBonus,
|
|
||||||
payout,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
sold.push(inv);
|
sold.push(inv);
|
||||||
balanceDelta += payout;
|
balanceDelta += applyGainMultiplier(value, gainBonus);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sold, balanceDelta };
|
return { sold, balanceDelta };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function applyAutoSell(tx, user, inventoryItems) {
|
||||||
|
const { sold, balanceDelta } = computeAutoSell(user, inventoryItems);
|
||||||
|
const gainBonus = user.prestigeGainBonus ?? 0;
|
||||||
|
|
||||||
|
if (!sold.length) {
|
||||||
|
return { sold: [], balanceDelta: 0n };
|
||||||
|
}
|
||||||
|
|
||||||
|
const soldIds = sold.map((inv) => inv.id);
|
||||||
|
await tx.inventoryItem.deleteMany({
|
||||||
|
where: { id: { in: soldIds }, userId: user.id },
|
||||||
|
});
|
||||||
|
await tx.transaction.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
type: 'sell_item',
|
||||||
|
amount: centsJson(balanceDelta),
|
||||||
|
meta: JSON.stringify({
|
||||||
|
count: sold.length,
|
||||||
|
inventoryItemIds: soldIds,
|
||||||
|
autoSell: true,
|
||||||
|
prestigeGainBonus: gainBonus,
|
||||||
|
payout: centsJson(balanceDelta),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sold, balanceDelta };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import { applyCatalogLuck, normalizeRates } from './weighted.js';
|
import { applyCatalogLuck, normalizeRates } from './weighted.js';
|
||||||
|
import { toCentsBigInt } from './cents.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count how many active cases the user has fully collected.
|
* Count how many active cases the user has fully collected.
|
||||||
@@ -103,7 +104,13 @@ export async function getCatalogState(userId) {
|
|||||||
orderBy: { dropRate: 'asc' },
|
orderBy: { dropRate: 'asc' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { price: 'asc' },
|
});
|
||||||
|
cases.sort((a, b) => {
|
||||||
|
const pa = toCentsBigInt(a.price);
|
||||||
|
const pb = toCentsBigInt(b.price);
|
||||||
|
if (pa < pb) return -1;
|
||||||
|
if (pa > pb) return 1;
|
||||||
|
return a.id - b.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
const entries = await prisma.catalogEntry.findMany({
|
const entries = await prisma.catalogEntry.findMany({
|
||||||
|
|||||||
@@ -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';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
/** Prisma BigInt → Number so the rest of the app can keep using number arithmetic / JSON. */
|
/**
|
||||||
function reviveMoney(value) {
|
* Plain Prisma client — no BigInt→Number revive.
|
||||||
if (typeof value === 'bigint') return Number(value);
|
*
|
||||||
if (value == null || typeof value !== 'object') return value;
|
* Credits / Pr / prices are stored as decimal **strings** and parsed with
|
||||||
if (value instanceof Date) return value;
|
* `toCentsBigInt` / `centsJson` so values can grow past Number.MAX_SAFE_INTEGER.
|
||||||
if (Array.isArray(value)) return value.map(reviveMoney);
|
* Any remaining Prisma BigInt fields (e.g. playTimeSeconds) stay as bigint until
|
||||||
for (const key of Object.keys(value)) {
|
* an API boundary stringifies them (`res.json` replacer in index.js) or an
|
||||||
value[key] = reviveMoney(value[key]);
|
* explicit Number()/toString() conversion.
|
||||||
}
|
*/
|
||||||
return value;
|
export const prisma = new PrismaClient();
|
||||||
}
|
|
||||||
|
|
||||||
const base = new PrismaClient();
|
|
||||||
|
|
||||||
export const prisma = base.$extends({
|
|
||||||
query: {
|
|
||||||
$allOperations({ args, query }) {
|
|
||||||
return query(args).then(reviveMoney);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
+11
-1
@@ -13,9 +13,11 @@ function fingerprint(entry) {
|
|||||||
return [
|
return [
|
||||||
entry.username || '',
|
entry.username || '',
|
||||||
entry.item?.id || '',
|
entry.item?.id || '',
|
||||||
|
String(entry.item?.valueCents ?? entry.item?.marketValue ?? ''),
|
||||||
entry.count || 1,
|
entry.count || 1,
|
||||||
entry.openCount || 1,
|
entry.openCount || 1,
|
||||||
ids.join(','),
|
entry.respin ? 'respin' : 'open',
|
||||||
|
ids.join(',') || `snap:${entry.item?.name || ''}`,
|
||||||
].join('|');
|
].join('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,3 +49,11 @@ export function pushDrop(entry) {
|
|||||||
const { _fp, ...publicEvent } = event;
|
const { _fp, ...publicEvent } = event;
|
||||||
return publicEvent;
|
return publicEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearDropFeed() {
|
||||||
|
feed.length = 0;
|
||||||
|
const io = getIO();
|
||||||
|
if (io) {
|
||||||
|
io.to('feed').emit('feed:history', []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+61
-50
@@ -13,7 +13,6 @@ import inventoryRoutes from './routes/inventory.js';
|
|||||||
import leaderboardRoutes from './routes/leaderboard.js';
|
import leaderboardRoutes from './routes/leaderboard.js';
|
||||||
import adminRoutes from './routes/admin.js';
|
import adminRoutes from './routes/admin.js';
|
||||||
import jackpotRoutes from './routes/jackpot.js';
|
import jackpotRoutes from './routes/jackpot.js';
|
||||||
import duelsRoutes from './routes/duels.js';
|
|
||||||
import profileRoutes from './routes/profile.js';
|
import profileRoutes from './routes/profile.js';
|
||||||
import shopRoutes from './routes/shop.js';
|
import shopRoutes from './routes/shop.js';
|
||||||
import feedRoutes from './routes/feed.js';
|
import feedRoutes from './routes/feed.js';
|
||||||
@@ -23,12 +22,11 @@ import achievementsRoutes from './routes/achievements.js';
|
|||||||
import vaultRoutes from './routes/vault.js';
|
import vaultRoutes from './routes/vault.js';
|
||||||
import { setIO } from './realtime.js';
|
import { setIO } from './realtime.js';
|
||||||
import { getDropFeed } from './feed.js';
|
import { getDropFeed } from './feed.js';
|
||||||
import { ensureJackpotRound } from './services/jackpot.js';
|
import { ensureJackpotRound, listJackpotBattles, getJackpotRound } from './services/jackpot.js';
|
||||||
import { recoverDuelSpins, getDuelRoom, listDuelRooms, startEmptyDuelRoomPurge } from './services/duel.js';
|
|
||||||
import { getJackpotState } from './services/jackpot.js';
|
|
||||||
import { ensureAdminFromEnv } from './ensureAdmin.js';
|
import { ensureAdminFromEnv } from './ensureAdmin.js';
|
||||||
import { ensureAchievements, checkAchievements } from './achievements.js';
|
import { ensureAchievements, checkAchievements } from './achievements.js';
|
||||||
import { ensureCategories } from './categories.js';
|
import { ensureCategories } from './categories.js';
|
||||||
|
import { ensureShopOsuPacks } from './shopOsu.js';
|
||||||
import { trackConnect, trackDisconnect, settleOnlineVaultForConnected } from './presence.js';
|
import { trackConnect, trackDisconnect, settleOnlineVaultForConnected } from './presence.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -40,7 +38,7 @@ if (!fs.existsSync(uploadsDir)) {
|
|||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const PORT = Number(process.env.PORT || 3001);
|
const PORT = Number(process.env.PORT || 3001);
|
||||||
const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173';
|
const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5888';
|
||||||
const TRUST_PROXY = process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1';
|
const TRUST_PROXY = process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1';
|
||||||
|
|
||||||
/** true | false | 'auto' (secure cookie only on HTTPS) */
|
/** true | false | 'auto' (secure cookie only on HTTPS) */
|
||||||
@@ -96,6 +94,19 @@ const sessionMiddleware = session({
|
|||||||
app.use(cors(corsOptions));
|
app.use(cors(corsOptions));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(sessionMiddleware);
|
app.use(sessionMiddleware);
|
||||||
|
// Serialize BigInt fields (balance / valueCents) as strings for JSON responses
|
||||||
|
app.use((_req, res, next) => {
|
||||||
|
const originalJson = res.json.bind(res);
|
||||||
|
res.json = (body) =>
|
||||||
|
originalJson(
|
||||||
|
JSON.parse(
|
||||||
|
JSON.stringify(body, (_key, value) =>
|
||||||
|
typeof value === 'bigint' ? value.toString() : value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
next();
|
||||||
|
});
|
||||||
app.use('/uploads', express.static(uploadsDir));
|
app.use('/uploads', express.static(uploadsDir));
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
@@ -108,7 +119,6 @@ app.use('/api/inventory', inventoryRoutes);
|
|||||||
app.use('/api/leaderboard', leaderboardRoutes);
|
app.use('/api/leaderboard', leaderboardRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
app.use('/api/jackpot', jackpotRoutes);
|
app.use('/api/jackpot', jackpotRoutes);
|
||||||
app.use('/api/duels', duelsRoutes);
|
|
||||||
app.use('/api/profile', profileRoutes);
|
app.use('/api/profile', profileRoutes);
|
||||||
app.use('/api/shop', shopRoutes);
|
app.use('/api/shop', shopRoutes);
|
||||||
app.use('/api/feed', feedRoutes);
|
app.use('/api/feed', feedRoutes);
|
||||||
@@ -137,7 +147,7 @@ io.on('connection', async (socket) => {
|
|||||||
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
const result = await trackConnect(userId, socket.id);
|
const result = await trackConnect(userId, socket.id, io);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
socket.emit('session:limit', {
|
socket.emit('session:limit', {
|
||||||
max: result.max,
|
max: result.max,
|
||||||
@@ -147,6 +157,7 @@ io.on('connection', async (socket) => {
|
|||||||
socket.disconnect(true);
|
socket.disconnect(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Soft-replace of older sockets no longer emits session:limit (refresh-safe).
|
||||||
socket.join(`user:${userId}`);
|
socket.join(`user:${userId}`);
|
||||||
socket.emit('session:ok', {
|
socket.emit('session:ok', {
|
||||||
count: result.count,
|
count: result.count,
|
||||||
@@ -158,52 +169,52 @@ io.on('connection', async (socket) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on('jackpot:subscribe', async () => {
|
socket.on('jackpots:subscribe', async () => {
|
||||||
|
socket.join('jackpots');
|
||||||
|
try {
|
||||||
|
const battles = await listJackpotBattles();
|
||||||
|
socket.emit('jackpots:list', { battles });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('jackpots:unsubscribe', () => {
|
||||||
|
socket.leave('jackpots');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('jackpot:subscribe', async (roundId) => {
|
||||||
|
const id = Number(roundId);
|
||||||
|
if (id) {
|
||||||
|
socket.join(`jackpot:${id}`);
|
||||||
|
try {
|
||||||
|
const state = await getJackpotRound(id);
|
||||||
|
if (state) socket.emit('jackpot:state', state);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Legacy: join global room and send list head
|
||||||
socket.join('jackpot');
|
socket.join('jackpot');
|
||||||
try {
|
try {
|
||||||
const state = await getJackpotState();
|
const battles = await listJackpotBattles();
|
||||||
socket.emit('jackpot:state', state);
|
socket.emit('jackpots:list', { battles });
|
||||||
|
if (battles[0]) socket.emit('jackpot:state', battles[0]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('jackpot:unsubscribe', () => {
|
socket.on('jackpot:unsubscribe', (roundId) => {
|
||||||
|
const id = Number(roundId);
|
||||||
|
if (id) {
|
||||||
|
socket.leave(`jackpot:${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
socket.leave('jackpot');
|
socket.leave('jackpot');
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('duels:subscribe', async () => {
|
|
||||||
socket.join('duels');
|
|
||||||
try {
|
|
||||||
const list = await listDuelRooms();
|
|
||||||
socket.emit('duels:list', list);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('duels:unsubscribe', () => {
|
|
||||||
socket.leave('duels');
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('duel:subscribe', async (roomId) => {
|
|
||||||
const id = Number(roomId);
|
|
||||||
if (!id) return;
|
|
||||||
socket.join(`duel:${id}`);
|
|
||||||
try {
|
|
||||||
const room = await getDuelRoom(id);
|
|
||||||
if (room) socket.emit('duel:state', room);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('duel:unsubscribe', (roomId) => {
|
|
||||||
const id = Number(roomId);
|
|
||||||
if (!id) return;
|
|
||||||
socket.leave(`duel:${id}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('feed:subscribe', () => {
|
socket.on('feed:subscribe', () => {
|
||||||
socket.join('feed');
|
socket.join('feed');
|
||||||
socket.emit('feed:history', getDropFeed());
|
socket.emit('feed:history', getDropFeed());
|
||||||
@@ -214,9 +225,10 @@ io.on('connection', async (socket) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
if (userId) {
|
const uid = socket.data.userId || userId;
|
||||||
trackDisconnect(userId, socket.id)
|
if (uid) {
|
||||||
.then(() => checkAchievements(userId))
|
trackDisconnect(uid, socket.id)
|
||||||
|
.then(() => checkAchievements(uid))
|
||||||
.catch((err) => console.error(err));
|
.catch((err) => console.error(err));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -244,6 +256,7 @@ async function bootstrap() {
|
|||||||
await ensureAdminFromEnv();
|
await ensureAdminFromEnv();
|
||||||
await ensureAchievements();
|
await ensureAchievements();
|
||||||
await ensureCategories();
|
await ensureCategories();
|
||||||
|
await ensureShopOsuPacks();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err.message || err);
|
console.error(err.message || err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -253,14 +266,12 @@ async function bootstrap() {
|
|||||||
console.log(`API listening on http://localhost:${PORT}`);
|
console.log(`API listening on http://localhost:${PORT}`);
|
||||||
try {
|
try {
|
||||||
await ensureJackpotRound();
|
await ensureJackpotRound();
|
||||||
await recoverDuelSpins();
|
// Online vault dividends: check every 30s for completed ticks (5 min online)
|
||||||
startEmptyDuelRoomPurge();
|
|
||||||
// Online vault dividends: check every 60s for completed 10-min ticks
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
settleOnlineVaultForConnected().catch((err) =>
|
settleOnlineVaultForConnected().catch((err) =>
|
||||||
console.error('vault online interval', err)
|
console.error('vault online interval', err)
|
||||||
);
|
);
|
||||||
}, 60_000);
|
}, 30_000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to bootstrap game services', err);
|
console.error('Failed to bootstrap game services', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,21 +19,24 @@ export function publicUser(user) {
|
|||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
balance: user.balance,
|
balance: user.balance == null ? '0' : user.balance.toString(),
|
||||||
osuBalance: user.osuBalance ?? 0,
|
osuBalance: user.osuBalance ?? 0,
|
||||||
prBalance: user.prBalance ?? 0,
|
prBalance: user.prBalance == null ? '0' : String(user.prBalance),
|
||||||
lastAdAt: user.lastAdAt || null,
|
lastAdAt: user.lastAdAt || null,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
avatarUrl: user.avatarUrl || '',
|
avatarUrl: user.avatarUrl || '',
|
||||||
bio: user.bio || '',
|
bio: user.bio || '',
|
||||||
autoSellEnabled: Boolean(user.autoSellEnabled),
|
autoSellEnabled: Boolean(user.autoSellEnabled),
|
||||||
autoSellThresholdCents: user.autoSellThresholdCents ?? 1000,
|
autoSellThresholdCents:
|
||||||
|
user.autoSellThresholdCents == null
|
||||||
|
? '1000'
|
||||||
|
: user.autoSellThresholdCents.toString(),
|
||||||
prestigeCount: user.prestigeCount ?? 0,
|
prestigeCount: user.prestigeCount ?? 0,
|
||||||
prestigeChanceBonus: user.prestigeChanceBonus ?? 0,
|
prestigeChanceBonus: user.prestigeChanceBonus ?? 0,
|
||||||
prestigeGainBonus: user.prestigeGainBonus ?? 0,
|
prestigeGainBonus: user.prestigeGainBonus ?? 0,
|
||||||
prestigeWearBonus: user.prestigeWearBonus ?? 0,
|
prestigeWearBonus: user.prestigeWearBonus ?? 0,
|
||||||
prestigeOpenBonus: user.prestigeOpenBonus ?? 0,
|
prestigeOpenBonus: user.prestigeOpenBonus ?? 0,
|
||||||
prestigeCaseDiscount: user.prestigeCaseDiscount ?? 0,
|
prestigePrBonus: user.prestigePrBonus ?? 0,
|
||||||
prestigeQualityBonus: user.prestigeQualityBonus ?? 0,
|
prestigeQualityBonus: user.prestigeQualityBonus ?? 0,
|
||||||
prestigeAnimReduction: user.prestigeAnimReduction ?? 0,
|
prestigeAnimReduction: user.prestigeAnimReduction ?? 0,
|
||||||
prestigeRefundChance: user.prestigeRefundChance ?? 0,
|
prestigeRefundChance: user.prestigeRefundChance ?? 0,
|
||||||
|
|||||||
@@ -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(
|
export const MAX_SOCKETS_PER_USER = Math.max(
|
||||||
1,
|
1,
|
||||||
Number(process.env.MAX_SOCKETS_PER_USER || 10)
|
Number(process.env.MAX_SOCKETS_PER_USER || 2)
|
||||||
);
|
);
|
||||||
|
|
||||||
/** @type {Map<number, { sockets: Set<string>, sessionStartedAt: number, lastVaultCredit?: number }>} */
|
/** @type {Map<number, { sockets: Set<string>, sessionStartedAt: number, lastVaultCredit?: number }>} */
|
||||||
@@ -35,11 +35,13 @@ export function totalSocketCount() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Track an authenticated socket. Updates lastConnection on first socket for this user.
|
* Track an authenticated socket. Updates lastConnection on first socket for this user.
|
||||||
* Rejects when the account already has MAX_SOCKETS_PER_USER live windows.
|
* Caps concurrent sockets at MAX_SOCKETS_PER_USER. When full, the oldest socket is
|
||||||
|
* replaced (covers refresh spam / ghost connections) instead of blocking the new tab.
|
||||||
* Slot claim is synchronous so concurrent connects cannot exceed the max.
|
* Slot claim is synchronous so concurrent connects cannot exceed the max.
|
||||||
* @returns {Promise<{ ok: true, count: number, max: number, vaultCredited?: number } | { ok: false, count: number, max: number }>}
|
* @param {import('socket.io').Server | null} [io]
|
||||||
|
* @returns {Promise<{ ok: true, count: number, max: number, vaultCredited?: string, replaced?: string[] } | { ok: false, count: number, max: number }>}
|
||||||
*/
|
*/
|
||||||
export async function trackConnect(userId, socketId) {
|
export async function trackConnect(userId, socketId, io = null) {
|
||||||
const id = Number(userId);
|
const id = Number(userId);
|
||||||
const max = MAX_SOCKETS_PER_USER;
|
const max = MAX_SOCKETS_PER_USER;
|
||||||
if (!id || !socketId) {
|
if (!id || !socketId) {
|
||||||
@@ -52,18 +54,42 @@ export async function trackConnect(userId, socketId) {
|
|||||||
online.set(id, entry);
|
online.set(id, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop ids that are no longer connected (HMR / crashed tabs leave ghosts otherwise)
|
||||||
|
pruneDeadSockets(entry, io);
|
||||||
|
|
||||||
|
if (entry.sockets.size === 0) {
|
||||||
|
entry.sessionStartedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.sockets.has(socketId)) {
|
if (entry.sockets.has(socketId)) {
|
||||||
return { ok: true, count: entry.sockets.size, max };
|
return { ok: true, count: entry.sockets.size, max };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.sockets.size >= max) {
|
// At capacity: free the oldest slot(s) so a refresh never soft-locks the only open tab
|
||||||
return { ok: false, count: entry.sockets.size, max };
|
const replaced = [];
|
||||||
|
while (entry.sockets.size >= max) {
|
||||||
|
const oldest = entry.sockets.values().next().value;
|
||||||
|
if (!oldest) break;
|
||||||
|
entry.sockets.delete(oldest);
|
||||||
|
replaced.push(oldest);
|
||||||
|
const oldSock = io?.sockets?.sockets?.get(oldest);
|
||||||
|
if (oldSock) {
|
||||||
|
try {
|
||||||
|
oldSock.emit('session:replaced', {
|
||||||
|
reason: 'slot_taken',
|
||||||
|
max,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
oldSock.disconnect(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claim slot before any await (avoids race past the limit)
|
// Claim slot before any await (avoids race past the limit)
|
||||||
entry.sockets.add(socketId);
|
entry.sockets.add(socketId);
|
||||||
const isFirst = entry.sockets.size === 1;
|
const isFirst = entry.sockets.size === 1 && replaced.length === 0;
|
||||||
let vaultCredited = 0;
|
let vaultCredited = '0';
|
||||||
|
|
||||||
if (isFirst) {
|
if (isFirst) {
|
||||||
try {
|
try {
|
||||||
@@ -77,13 +103,23 @@ export async function trackConnect(userId, socketId) {
|
|||||||
// Offline yield accrued while disconnected
|
// Offline yield accrued while disconnected
|
||||||
try {
|
try {
|
||||||
const settled = await settleVaultYield(id, 'offline');
|
const settled = await settleVaultYield(id, 'offline');
|
||||||
vaultCredited = settled.credited;
|
vaultCredited = settled.credited || '0';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('presence vault offline settle', err);
|
console.error('presence vault offline settle', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, count: entry.sockets.size, max, vaultCredited };
|
return { ok: true, count: entry.sockets.size, max, vaultCredited, replaced };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneDeadSockets(entry, io) {
|
||||||
|
if (!entry?.sockets?.size) return;
|
||||||
|
if (!io?.sockets?.sockets) return;
|
||||||
|
for (const sid of [...entry.sockets]) {
|
||||||
|
if (!io.sockets.sockets.has(sid)) {
|
||||||
|
entry.sockets.delete(sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,7 +159,17 @@ export async function trackDisconnect(userId, socketId) {
|
|||||||
|
|
||||||
/** Live playtime including the current open session (if any). */
|
/** Live playtime including the current open session (if any). */
|
||||||
export function livePlayTimeSeconds(user) {
|
export function livePlayTimeSeconds(user) {
|
||||||
const stored = Number(user?.playTimeSeconds ?? 0);
|
let stored = 0;
|
||||||
|
try {
|
||||||
|
// playTimeSeconds is Prisma BigInt — avoid Number() on the raw value path
|
||||||
|
// for consistency; seconds stay well within safe integer range for decades.
|
||||||
|
stored = Number(typeof user?.playTimeSeconds === 'bigint'
|
||||||
|
? user.playTimeSeconds
|
||||||
|
: BigInt(user?.playTimeSeconds ?? 0));
|
||||||
|
} catch {
|
||||||
|
stored = 0;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(stored) || stored < 0) stored = 0;
|
||||||
const entry = online.get(Number(user?.id));
|
const entry = online.get(Number(user?.id));
|
||||||
if (!entry) return stored;
|
if (!entry) return stored;
|
||||||
const live = Math.max(0, Math.floor((Date.now() - entry.sessionStartedAt) / 1000));
|
const live = Math.max(0, Math.floor((Date.now() - entry.sessionStartedAt) / 1000));
|
||||||
@@ -143,14 +189,14 @@ export function serializePresence(user) {
|
|||||||
/** Settle online vault yield for all currently connected users. */
|
/** Settle online vault yield for all currently connected users. */
|
||||||
export async function settleOnlineVaultForConnected() {
|
export async function settleOnlineVaultForConnected() {
|
||||||
const ids = onlineUserIds();
|
const ids = onlineUserIds();
|
||||||
let total = 0;
|
let total = 0n;
|
||||||
for (const uid of ids) {
|
for (const uid of ids) {
|
||||||
try {
|
try {
|
||||||
const settled = await settleVaultYield(uid, 'online');
|
const settled = await settleVaultYield(uid, 'online');
|
||||||
total += settled.credited;
|
total += BigInt(settled.credited || '0');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('vault online tick', uid, err);
|
console.error('vault online tick', uid, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return total;
|
return total.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-57
@@ -1,17 +1,19 @@
|
|||||||
|
import { applyPercentFloor, toCentsBigInt } from './cents.js';
|
||||||
|
|
||||||
/** 1_000_000_000_000.00 cr in cents */
|
/** 1_000_000_000_000.00 cr in cents */
|
||||||
export const PRESTIGE_COST_CENTS = 100_000_000_000_000n;
|
export const PRESTIGE_COST_CENTS = 100_000_000_000_000n;
|
||||||
|
|
||||||
/** Page unlock: 10_000_000_000.00 cr total wealth (balance + unlocked inventory). */
|
/** Page unlock: 10_000_000_000.00 cr total wealth (balance + unlocked inventory). */
|
||||||
export const PRESTIGE_VISIBLE_AT_CENTS = 1_000_000_000_000;
|
export const PRESTIGE_VISIBLE_AT_CENTS = 1_000_000_000_000n;
|
||||||
|
|
||||||
/** Base Pr granted on every prestige. */
|
/** Base Pr granted on every prestige. */
|
||||||
export const PR_BASE_REWARD = 1000;
|
export const PR_BASE_REWARD = 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For every 100_000_000_000.00 cr above the prestige cost, +1000 Pr on average.
|
* For every 250_000_000_000.00 cr above the prestige cost, +1000 Pr on average.
|
||||||
* (Stored in cents.)
|
* (Stored in cents.)
|
||||||
*/
|
*/
|
||||||
export const PR_EXCESS_CHUNK_CENTS = 10_000_000_000_000n;
|
export const PR_EXCESS_CHUNK_CENTS = 25_000_000_000_000n;
|
||||||
export const PR_PER_EXCESS_CHUNK = 1000;
|
export const PR_PER_EXCESS_CHUNK = 1000;
|
||||||
|
|
||||||
const BASE_AD_COOLDOWN_MS = 60_000;
|
const BASE_AD_COOLDOWN_MS = 60_000;
|
||||||
@@ -19,12 +21,14 @@ const BASE_AD_COOLDOWN_MS = 60_000;
|
|||||||
/**
|
/**
|
||||||
* Skill tree upgrades bought with Pr (stackable branches).
|
* Skill tree upgrades bought with Pr (stackable branches).
|
||||||
* level = how many times this branch has been purchased.
|
* level = how many times this branch has been purchased.
|
||||||
|
* maxLevel = hard cap on purchases.
|
||||||
*/
|
*/
|
||||||
export const PRESTIGE_SKILLS = {
|
export const PRESTIGE_SKILLS = {
|
||||||
chance: {
|
chance: {
|
||||||
id: 'chance',
|
id: 'chance',
|
||||||
field: 'prestigeChanceBonus',
|
field: 'prestigeChanceBonus',
|
||||||
delta: 1,
|
delta: 1,
|
||||||
|
maxLevel: 100,
|
||||||
title: 'Fortune',
|
title: 'Fortune',
|
||||||
description: '+1% drop luck on case opens (permanent)',
|
description: '+1% drop luck on case opens (permanent)',
|
||||||
unit: '%',
|
unit: '%',
|
||||||
@@ -34,6 +38,7 @@ export const PRESTIGE_SKILLS = {
|
|||||||
id: 'gain',
|
id: 'gain',
|
||||||
field: 'prestigeGainBonus',
|
field: 'prestigeGainBonus',
|
||||||
delta: 2,
|
delta: 2,
|
||||||
|
maxLevel: 20,
|
||||||
title: 'Yield',
|
title: 'Yield',
|
||||||
description: '+2% credits on sell / auto-sell (permanent)',
|
description: '+2% credits on sell / auto-sell (permanent)',
|
||||||
unit: '%',
|
unit: '%',
|
||||||
@@ -43,6 +48,7 @@ export const PRESTIGE_SKILLS = {
|
|||||||
id: 'polish',
|
id: 'polish',
|
||||||
field: 'prestigeWearBonus',
|
field: 'prestigeWearBonus',
|
||||||
delta: 1,
|
delta: 1,
|
||||||
|
maxLevel: 100,
|
||||||
title: 'Polish',
|
title: 'Polish',
|
||||||
description: 'Better float / wear rolls on case opens',
|
description: 'Better float / wear rolls on case opens',
|
||||||
unit: '',
|
unit: '',
|
||||||
@@ -52,25 +58,27 @@ export const PRESTIGE_SKILLS = {
|
|||||||
id: 'bulk',
|
id: 'bulk',
|
||||||
field: 'prestigeOpenBonus',
|
field: 'prestigeOpenBonus',
|
||||||
delta: 1,
|
delta: 1,
|
||||||
|
maxLevel: 20,
|
||||||
title: 'Bulk',
|
title: 'Bulk',
|
||||||
description: '+1 max simultaneous case opens (permanent)',
|
description: '+1 max simultaneous case opens (permanent)',
|
||||||
unit: '',
|
unit: '',
|
||||||
branch: 'drops',
|
branch: 'drops',
|
||||||
},
|
},
|
||||||
discount: {
|
prFarmer: {
|
||||||
id: 'discount',
|
id: 'prFarmer',
|
||||||
field: 'prestigeCaseDiscount',
|
field: 'prestigePrBonus',
|
||||||
delta: 2,
|
delta: 2,
|
||||||
title: 'Discount',
|
maxLevel: 50,
|
||||||
description: '−2% case open price (permanent)',
|
title: 'PR Farmer',
|
||||||
|
description: '+2% Pr obtained when you prestige (permanent)',
|
||||||
unit: '%',
|
unit: '%',
|
||||||
prefix: '−',
|
|
||||||
branch: 'economy',
|
branch: 'economy',
|
||||||
},
|
},
|
||||||
quality: {
|
quality: {
|
||||||
id: 'quality',
|
id: 'quality',
|
||||||
field: 'prestigeQualityBonus',
|
field: 'prestigeQualityBonus',
|
||||||
delta: 1,
|
delta: 1,
|
||||||
|
maxLevel: 100,
|
||||||
title: 'Quality',
|
title: 'Quality',
|
||||||
description: 'Better float / wear rolls on case opens (items below Field-Tested get upgraded)',
|
description: 'Better float / wear rolls on case opens (items below Field-Tested get upgraded)',
|
||||||
unit: '',
|
unit: '',
|
||||||
@@ -80,6 +88,7 @@ export const PRESTIGE_SKILLS = {
|
|||||||
id: 'noTime',
|
id: 'noTime',
|
||||||
field: 'prestigeAnimReduction',
|
field: 'prestigeAnimReduction',
|
||||||
delta: 10,
|
delta: 10,
|
||||||
|
maxLevel: 100,
|
||||||
title: 'noTime',
|
title: 'noTime',
|
||||||
description: 'Reduced case opening animation time',
|
description: 'Reduced case opening animation time',
|
||||||
unit: '%',
|
unit: '%',
|
||||||
@@ -90,6 +99,7 @@ export const PRESTIGE_SKILLS = {
|
|||||||
id: 'refund',
|
id: 'refund',
|
||||||
field: 'prestigeRefundChance',
|
field: 'prestigeRefundChance',
|
||||||
delta: 2,
|
delta: 2,
|
||||||
|
maxLevel: 20,
|
||||||
title: 'Refund',
|
title: 'Refund',
|
||||||
description: 'Chance to get a free case open refund',
|
description: 'Chance to get a free case open refund',
|
||||||
unit: '%',
|
unit: '%',
|
||||||
@@ -99,8 +109,9 @@ export const PRESTIGE_SKILLS = {
|
|||||||
id: 'respin',
|
id: 'respin',
|
||||||
field: 'prestigeRespinChance',
|
field: 'prestigeRespinChance',
|
||||||
delta: 1,
|
delta: 1,
|
||||||
|
maxLevel: 20,
|
||||||
title: 'Respin',
|
title: 'Respin',
|
||||||
description: 'Chance to get a free case respin (respins all cases if multi-opening)',
|
description: 'Chance to free-respin the open (shows first result, then reels restart)',
|
||||||
unit: '%',
|
unit: '%',
|
||||||
branch: 'drops',
|
branch: 'drops',
|
||||||
},
|
},
|
||||||
@@ -108,6 +119,7 @@ export const PRESTIGE_SKILLS = {
|
|||||||
id: 'vaultCap',
|
id: 'vaultCap',
|
||||||
field: 'prestigeVaultSlots',
|
field: 'prestigeVaultSlots',
|
||||||
delta: 1,
|
delta: 1,
|
||||||
|
maxLevel: 10,
|
||||||
title: 'Vault Capacity',
|
title: 'Vault Capacity',
|
||||||
description: '+1 max staking slot in the vault',
|
description: '+1 max staking slot in the vault',
|
||||||
unit: '',
|
unit: '',
|
||||||
@@ -118,6 +130,7 @@ export const PRESTIGE_SKILLS = {
|
|||||||
id: 'offlineYield',
|
id: 'offlineYield',
|
||||||
field: 'prestigeOfflineYield',
|
field: 'prestigeOfflineYield',
|
||||||
delta: 10,
|
delta: 10,
|
||||||
|
maxLevel: 100,
|
||||||
title: 'Offline Yield',
|
title: 'Offline Yield',
|
||||||
description: 'Increased dividends for staked items while offline',
|
description: 'Increased dividends for staked items while offline',
|
||||||
unit: '%',
|
unit: '%',
|
||||||
@@ -127,6 +140,7 @@ export const PRESTIGE_SKILLS = {
|
|||||||
id: 'onlineYield',
|
id: 'onlineYield',
|
||||||
field: 'prestigeOnlineYield',
|
field: 'prestigeOnlineYield',
|
||||||
delta: 10,
|
delta: 10,
|
||||||
|
maxLevel: 100,
|
||||||
title: 'Online Yield',
|
title: 'Online Yield',
|
||||||
description: 'Increased dividends for staked items while online',
|
description: 'Increased dividends for staked items while online',
|
||||||
unit: '%',
|
unit: '%',
|
||||||
@@ -142,6 +156,37 @@ export function skillUpgradeCost(skillId, level = 0) {
|
|||||||
return Math.round((1000 + lv * 500) * mult);
|
return Math.round((1000 + lv * 500) * mult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Total Pr cost to buy `count` levels starting at `level`. */
|
||||||
|
export function skillUpgradeCostBulk(skillId, level = 0, count = 1) {
|
||||||
|
const n = Math.max(1, Math.min(100, Math.floor(Number(count) || 1)));
|
||||||
|
const start = Math.max(0, Number(level) || 0);
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
total += skillUpgradeCost(skillId, start + i);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skillMaxLevel(skillId) {
|
||||||
|
const skill = PRESTIGE_SKILLS[skillId];
|
||||||
|
const max = Number(skill?.maxLevel);
|
||||||
|
return Number.isFinite(max) && max > 0 ? Math.floor(max) : Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many levels can still be bought before the cap. */
|
||||||
|
export function skillLevelsRemaining(user, skillId) {
|
||||||
|
const max = skillMaxLevel(skillId);
|
||||||
|
if (!Number.isFinite(max)) return Infinity;
|
||||||
|
return Math.max(0, max - skillLevelFromUser(user, skillId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when every prestige skill is at its max level. */
|
||||||
|
export function isPrestigeSkillTreeMaxed(user) {
|
||||||
|
return Object.keys(PRESTIGE_SKILLS).every(
|
||||||
|
(id) => skillLevelFromUser(user, id) >= skillMaxLevel(id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function skillLevelFromUser(user, skillId) {
|
export function skillLevelFromUser(user, skillId) {
|
||||||
const skill = PRESTIGE_SKILLS[skillId];
|
const skill = PRESTIGE_SKILLS[skillId];
|
||||||
if (!skill) return 0;
|
if (!skill) return 0;
|
||||||
@@ -150,35 +195,64 @@ export function skillLevelFromUser(user, skillId) {
|
|||||||
return Math.floor(value / delta);
|
return Math.floor(value / delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Parse Pr amounts (stored as decimal strings; may exceed i64). */
|
||||||
* Expected Pr (no RNG) for UI preview.
|
export function toPrBigInt(value) {
|
||||||
*/
|
return toCentsBigInt(value);
|
||||||
export function estimatePrReward(balanceCents) {
|
}
|
||||||
const balance = BigInt(balanceCents);
|
|
||||||
const cost = PRESTIGE_COST_CENTS;
|
export function prJson(value) {
|
||||||
if (balance < cost) return 0;
|
return toPrBigInt(value).toString();
|
||||||
const excess = balance - cost;
|
}
|
||||||
const chunk = PR_EXCESS_CHUNK_CENTS;
|
|
||||||
const exact = Number(excess) / Number(chunk);
|
/** Apply PR Farmer % to a base Pr reward (floor). Returns BigInt. */
|
||||||
return Math.round(PR_BASE_REWARD + exact * PR_PER_EXCESS_CHUNK);
|
export function applyPrBonus(basePr, prBonusPct = 0) {
|
||||||
|
const base = toPrBigInt(basePr);
|
||||||
|
const pct = Math.max(0, Math.floor(Number(prBonusPct) || 0));
|
||||||
|
if (base <= 0n) return 0n;
|
||||||
|
if (pct <= 0) return base;
|
||||||
|
return (base * BigInt(100 + pct)) / 100n;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pr reward: 1000 base + 1000 per full 100B cr excess, plus a fractional
|
* Expected Pr (no RNG) for UI preview. Returns string.
|
||||||
* roll so partial chunks average to the remaining Pr.
|
|
||||||
*/
|
*/
|
||||||
export function computePrReward(balanceCents) {
|
export function estimatePrReward(balanceCents, prBonusPct = 0) {
|
||||||
const balance = BigInt(balanceCents);
|
const balance = toCentsBigInt(balanceCents);
|
||||||
|
const cost = PRESTIGE_COST_CENTS;
|
||||||
|
if (balance < cost) return '0';
|
||||||
|
const excess = balance - cost;
|
||||||
|
const chunk = PR_EXCESS_CHUNK_CENTS;
|
||||||
|
const full = excess / chunk;
|
||||||
|
const rem = excess % chunk;
|
||||||
|
// Round partial chunk: rem/chunk >= 0.5 → +1 chunk of Pr
|
||||||
|
const half = chunk / 2n;
|
||||||
|
const roundedChunks = full + (rem >= half ? 1n : 0n);
|
||||||
|
const base = BigInt(PR_BASE_REWARD) + roundedChunks * BigInt(PR_PER_EXCESS_CHUNK);
|
||||||
|
return prJson(applyPrBonus(base, prBonusPct));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pr reward: 1000 base + 1000 per full 250B cr excess, plus a fractional
|
||||||
|
* roll so partial chunks average to the remaining Pr.
|
||||||
|
* PR Farmer adds +N% to the final grant.
|
||||||
|
* Returns string (may exceed Number.MAX_SAFE_INTEGER).
|
||||||
|
*/
|
||||||
|
export function computePrReward(balanceCents, prBonusPct = 0) {
|
||||||
|
const balance = toCentsBigInt(balanceCents);
|
||||||
const cost = PRESTIGE_COST_CENTS;
|
const cost = PRESTIGE_COST_CENTS;
|
||||||
const excess = balance > cost ? balance - cost : 0n;
|
const excess = balance > cost ? balance - cost : 0n;
|
||||||
const chunk = PR_EXCESS_CHUNK_CENTS;
|
const chunk = PR_EXCESS_CHUNK_CENTS;
|
||||||
const full = Number(excess / chunk);
|
const full = excess / chunk;
|
||||||
const rem = excess % chunk;
|
const rem = excess % chunk;
|
||||||
const frac = Number(rem) / Number(chunk);
|
// Math.random() < rem/chunk without Number overflow
|
||||||
const bonus =
|
let extra = 0n;
|
||||||
full * PR_PER_EXCESS_CHUNK +
|
if (rem > 0n) {
|
||||||
(Math.random() < frac ? PR_PER_EXCESS_CHUNK : 0);
|
const scale = 1_000_000_000n;
|
||||||
return PR_BASE_REWARD + bonus;
|
const roll = BigInt(Math.floor(Math.random() * 1e9));
|
||||||
|
if (roll * chunk < rem * scale) extra = BigInt(PR_PER_EXCESS_CHUNK);
|
||||||
|
}
|
||||||
|
const base = BigInt(PR_BASE_REWARD) + full * BigInt(PR_PER_EXCESS_CHUNK) + extra;
|
||||||
|
return prJson(applyPrBonus(base, prBonusPct));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adCooldownMs(_user) {
|
export function adCooldownMs(_user) {
|
||||||
@@ -192,7 +266,7 @@ export function serializePrestige(user) {
|
|||||||
gainBonus: user?.prestigeGainBonus ?? 0,
|
gainBonus: user?.prestigeGainBonus ?? 0,
|
||||||
wearBonus: user?.prestigeWearBonus ?? 0,
|
wearBonus: user?.prestigeWearBonus ?? 0,
|
||||||
openBonus: user?.prestigeOpenBonus ?? 0,
|
openBonus: user?.prestigeOpenBonus ?? 0,
|
||||||
caseDiscount: user?.prestigeCaseDiscount ?? 0,
|
prBonus: user?.prestigePrBonus ?? 0,
|
||||||
qualityBonus: user?.prestigeQualityBonus ?? 0,
|
qualityBonus: user?.prestigeQualityBonus ?? 0,
|
||||||
animReduction: user?.prestigeAnimReduction ?? 0,
|
animReduction: user?.prestigeAnimReduction ?? 0,
|
||||||
refundChance: user?.prestigeRefundChance ?? 0,
|
refundChance: user?.prestigeRefundChance ?? 0,
|
||||||
@@ -200,7 +274,7 @@ export function serializePrestige(user) {
|
|||||||
vaultSlots: user?.prestigeVaultSlots ?? 0,
|
vaultSlots: user?.prestigeVaultSlots ?? 0,
|
||||||
offlineYield: user?.prestigeOfflineYield ?? 0,
|
offlineYield: user?.prestigeOfflineYield ?? 0,
|
||||||
onlineYield: user?.prestigeOnlineYield ?? 0,
|
onlineYield: user?.prestigeOnlineYield ?? 0,
|
||||||
prBalance: user?.prBalance ?? 0,
|
prBalance: prJson(user?.prBalance ?? 0),
|
||||||
costCents: Number(PRESTIGE_COST_CENTS),
|
costCents: Number(PRESTIGE_COST_CENTS),
|
||||||
visibleAtCents: PRESTIGE_VISIBLE_AT_CENTS,
|
visibleAtCents: PRESTIGE_VISIBLE_AT_CENTS,
|
||||||
prBaseReward: PR_BASE_REWARD,
|
prBaseReward: PR_BASE_REWARD,
|
||||||
@@ -210,10 +284,17 @@ export function serializePrestige(user) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function serializeSkillTree(user) {
|
export function serializeSkillTree(user) {
|
||||||
|
const pr = toPrBigInt(user?.prBalance ?? 0);
|
||||||
return Object.values(PRESTIGE_SKILLS).map((skill) => {
|
return Object.values(PRESTIGE_SKILLS).map((skill) => {
|
||||||
const level = skillLevelFromUser(user, skill.id);
|
const level = skillLevelFromUser(user, skill.id);
|
||||||
|
const maxLevel = skillMaxLevel(skill.id);
|
||||||
|
const room = Math.max(0, maxLevel - level);
|
||||||
|
const atMax = room <= 0;
|
||||||
const current = Number(user?.[skill.field] ?? 0);
|
const current = Number(user?.[skill.field] ?? 0);
|
||||||
const cost = skillUpgradeCost(skill.id, level);
|
const buyCount = atMax ? 0 : 1;
|
||||||
|
const buyCount10 = atMax ? 0 : Math.min(10, room);
|
||||||
|
const cost = buyCount ? skillUpgradeCost(skill.id, level) : 0;
|
||||||
|
const cost10 = buyCount10 ? skillUpgradeCostBulk(skill.id, level, buyCount10) : 0;
|
||||||
return {
|
return {
|
||||||
id: skill.id,
|
id: skill.id,
|
||||||
title: skill.title,
|
title: skill.title,
|
||||||
@@ -223,28 +304,24 @@ export function serializeSkillTree(user) {
|
|||||||
prefix: skill.prefix || (skill.unit === '%' || skill.unit === 'cr-cents' ? '+' : ''),
|
prefix: skill.prefix || (skill.unit === '%' || skill.unit === 'cr-cents' ? '+' : ''),
|
||||||
branch: skill.branch || 'misc',
|
branch: skill.branch || 'misc',
|
||||||
level,
|
level,
|
||||||
|
maxLevel: Number.isFinite(maxLevel) ? maxLevel : null,
|
||||||
|
atMax,
|
||||||
|
room,
|
||||||
|
buyCount10,
|
||||||
current,
|
current,
|
||||||
nextValue: current + skill.delta,
|
nextValue: atMax ? current : current + skill.delta,
|
||||||
|
nextValue10: atMax ? current : current + skill.delta * buyCount10,
|
||||||
cost,
|
cost,
|
||||||
canAfford: (user?.prBalance ?? 0) >= cost,
|
cost10,
|
||||||
|
canAfford: !atMax && pr >= BigInt(cost),
|
||||||
|
canAfford10: buyCount10 >= 2 && pr >= BigInt(cost10),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply case price discount (%). Min 1 cent. */
|
/** Apply lifetime gain % to a credit amount (cents). Returns BigInt. */
|
||||||
export function applyCaseDiscount(priceCents, discountPct = 0) {
|
|
||||||
const base = Math.max(0, Math.floor(Number(priceCents) || 0));
|
|
||||||
const pct = Math.max(0, Number(discountPct) || 0);
|
|
||||||
if (pct <= 0) return Math.max(1, base);
|
|
||||||
return Math.max(1, Math.floor(base * (1 - pct / 100)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply lifetime gain % to a credit amount (cents). */
|
|
||||||
export function applyGainMultiplier(cents, gainBonusPct = 0) {
|
export function applyGainMultiplier(cents, gainBonusPct = 0) {
|
||||||
const base = Number(cents) || 0;
|
return applyPercentFloor(cents, gainBonusPct);
|
||||||
const pct = Number(gainBonusPct) || 0;
|
|
||||||
if (pct <= 0) return Math.floor(base);
|
|
||||||
return Math.floor(base * (1 + pct / 100));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function totalLuckPercent(catalogLuck = 0, prestigeChanceBonus = 0) {
|
export function totalLuckPercent(catalogLuck = 0, prestigeChanceBonus = 0) {
|
||||||
@@ -255,17 +332,17 @@ export function isValidSkill(skillId) {
|
|||||||
return Boolean(PRESTIGE_SKILLS[skillId]);
|
return Boolean(PRESTIGE_SKILLS[skillId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Visible if already prestiged, or total wealth reaches the unlock threshold. */
|
|
||||||
export function isPrestigePageVisible(user, totalCents) {
|
|
||||||
if ((user?.prestigeCount ?? 0) > 0) return true;
|
|
||||||
if ((user?.prBalance ?? 0) > 0) return true;
|
|
||||||
return (Number(totalCents) || 0) >= PRESTIGE_VISIBLE_AT_CENTS;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUnlockedInventoryValueCents(db, userId) {
|
export async function getUnlockedInventoryValueCents(db, userId) {
|
||||||
const rows = await db.inventoryItem.findMany({
|
const rows = await db.inventoryItem.findMany({
|
||||||
where: { userId, locked: false },
|
where: { userId, locked: false },
|
||||||
select: { valueCents: true },
|
select: { valueCents: true },
|
||||||
});
|
});
|
||||||
return rows.reduce((sum, r) => sum + (Number(r.valueCents) || 0), 0);
|
return rows.reduce((sum, r) => sum + toCentsBigInt(r.valueCents), 0n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible if already prestiged, or total wealth reaches the unlock threshold. */
|
||||||
|
export function isPrestigePageVisible(user, totalCents) {
|
||||||
|
if ((user?.prestigeCount ?? 0) > 0) return true;
|
||||||
|
if (toPrBigInt(user?.prBalance) > 0n) return true;
|
||||||
|
return toCentsBigInt(totalCents) >= PRESTIGE_VISIBLE_AT_CENTS;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-8
@@ -8,17 +8,21 @@ export function getIO() {
|
|||||||
return io;
|
return io;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emitJackpot(event, payload) {
|
export function emitJackpot(roundId, event, payload) {
|
||||||
if (!io) return;
|
if (!io) return;
|
||||||
|
const id = Number(roundId);
|
||||||
|
if (id) {
|
||||||
|
io.to(`jackpot:${id}`).emit(event, payload);
|
||||||
|
}
|
||||||
|
// Legacy global room
|
||||||
io.to('jackpot').emit(event, payload);
|
io.to('jackpot').emit(event, payload);
|
||||||
|
// Hub list subscribers need spin/result for live cards
|
||||||
|
if (event === 'jackpot:spin' || event === 'jackpot:result') {
|
||||||
|
io.to('jackpots').emit(event, payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emitDuel(roomId, event, payload) {
|
export function emitJackpotsList(payload) {
|
||||||
if (!io) return;
|
if (!io) return;
|
||||||
io.to(`duel:${roomId}`).emit(event, payload);
|
io.to('jackpots').emit('jackpots:list', payload);
|
||||||
}
|
|
||||||
|
|
||||||
export function emitDuelsList(payload) {
|
|
||||||
if (!io) return;
|
|
||||||
io.to('duels').emit('duels:list', payload);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+174
-26
@@ -3,7 +3,7 @@ import bcrypt from 'bcrypt';
|
|||||||
import { prisma } from '../db.js';
|
import { prisma } from '../db.js';
|
||||||
import { requireAdmin, publicUser } from '../middleware.js';
|
import { requireAdmin, publicUser } from '../middleware.js';
|
||||||
import { isValidRarity, RARITIES } from '../rarities.js';
|
import { isValidRarity, RARITIES } from '../rarities.js';
|
||||||
import { createUploader } from '../upload.js';
|
import { createUploader, processUploadedImage } from '../upload.js';
|
||||||
import { onlineUserCount, totalSocketCount } from '../presence.js';
|
import { onlineUserCount, totalSocketCount } from '../presence.js';
|
||||||
import { ACHIEVEMENT_DEFS } from '../achievements.js';
|
import { ACHIEVEMENT_DEFS } from '../achievements.js';
|
||||||
import {
|
import {
|
||||||
@@ -12,10 +12,24 @@ import {
|
|||||||
ensureCategory,
|
ensureCategory,
|
||||||
listCategoriesWithCounts,
|
listCategoriesWithCounts,
|
||||||
} from '../categories.js';
|
} from '../categories.js';
|
||||||
|
import { toCentsBigInt, centsJson } from '../cents.js';
|
||||||
|
import { toPrBigInt, prJson } from '../prestige.js';
|
||||||
|
import {
|
||||||
|
listShopOsuPacksAdmin,
|
||||||
|
updateShopOsuPack,
|
||||||
|
setOsuTopupsSectionEnabled,
|
||||||
|
} from '../shopOsu.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const upload = createUploader('asset');
|
const upload = createUploader('asset');
|
||||||
|
|
||||||
|
/** Parse admin money input (cents) → non-negative decimal string for Prisma. */
|
||||||
|
function parseCentsField(raw) {
|
||||||
|
const bi = toCentsBigInt(raw);
|
||||||
|
if (bi < 0n) return null;
|
||||||
|
return centsJson(bi);
|
||||||
|
}
|
||||||
|
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const username = String(req.body.username || '').trim();
|
const username = String(req.body.username || '').trim();
|
||||||
@@ -45,6 +59,39 @@ router.get('/rarities', requireAdmin, (_req, res) => {
|
|||||||
res.json({ rarities: RARITIES });
|
res.json({ rarities: RARITIES });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/shop/osu-packs', requireAdmin, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await listShopOsuPacksAdmin();
|
||||||
|
res.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to load osu packs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/shop/osu-section', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const enabled = Boolean(req.body.enabled);
|
||||||
|
const sectionEnabled = await setOsuTopupsSectionEnabled(enabled);
|
||||||
|
const data = await listShopOsuPacksAdmin();
|
||||||
|
res.json({ ...data, sectionEnabled });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to update shop section' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/shop/osu-packs/:id', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pack = await updateShopOsuPack(req.params.id, req.body || {});
|
||||||
|
res.json({ pack });
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Failed to update pack' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/categories', requireAdmin, async (_req, res) => {
|
router.get('/categories', requireAdmin, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const categories = await listCategoriesWithCounts();
|
const categories = await listCategoriesWithCounts();
|
||||||
@@ -218,16 +265,21 @@ router.get('/users', requireAdmin, async (_req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
users: users.map((u) => {
|
users: users.map((u) => {
|
||||||
const inventoryValue = u.inventory.reduce((s, inv) => s + inv.valueCents, 0);
|
const inventoryValue = u.inventory.reduce(
|
||||||
|
(s, inv) => s + toCentsBigInt(inv.valueCents),
|
||||||
|
0n
|
||||||
|
);
|
||||||
|
const balance = toCentsBigInt(u.balance);
|
||||||
return {
|
return {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
username: u.username,
|
username: u.username,
|
||||||
avatarUrl: u.avatarUrl || '',
|
avatarUrl: u.avatarUrl || '',
|
||||||
balance: u.balance,
|
balance: centsJson(balance),
|
||||||
osuBalance: u.osuBalance ?? 0,
|
osuBalance: u.osuBalance ?? 0,
|
||||||
inventoryValue,
|
prBalance: prJson(u.prBalance ?? 0),
|
||||||
|
inventoryValue: centsJson(inventoryValue),
|
||||||
inventoryCount: u.inventory.length,
|
inventoryCount: u.inventory.length,
|
||||||
totalWealth: u.balance + inventoryValue,
|
totalWealth: centsJson(balance + inventoryValue),
|
||||||
casesOpened: opensByUser.get(u.id) || 0,
|
casesOpened: opensByUser.get(u.id) || 0,
|
||||||
achievements: u._count.achievements,
|
achievements: u._count.achievements,
|
||||||
prestigeCount: u.prestigeCount ?? 0,
|
prestigeCount: u.prestigeCount ?? 0,
|
||||||
@@ -253,22 +305,19 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
|
|||||||
|
|
||||||
const data = {};
|
const data = {};
|
||||||
if (req.body.balance !== undefined) {
|
if (req.body.balance !== undefined) {
|
||||||
const balance = Number(req.body.balance);
|
const balance = String(req.body.balance).trim();
|
||||||
if (!Number.isInteger(balance) || balance < 0) {
|
if (!/^\d+$/.test(balance)) {
|
||||||
return res.status(400).json({ error: 'balance must be a non-negative integer (cents)' });
|
return res.status(400).json({ error: 'balance must be a non-negative integer (cents)' });
|
||||||
}
|
}
|
||||||
data.balance = balance;
|
data.balance = balance;
|
||||||
}
|
}
|
||||||
if (req.body.balanceDelta !== undefined) {
|
if (req.body.balanceDelta !== undefined) {
|
||||||
const delta = Number(req.body.balanceDelta);
|
const deltaRaw = String(req.body.balanceDelta).trim();
|
||||||
if (!Number.isInteger(delta)) {
|
if (!/^-?\d+$/.test(deltaRaw)) {
|
||||||
return res.status(400).json({ error: 'balanceDelta must be an integer (cents)' });
|
return res.status(400).json({ error: 'balanceDelta must be an integer (cents)' });
|
||||||
}
|
}
|
||||||
const next = Number(user.balance) + delta;
|
const next = toCentsBigInt(user.balance) + BigInt(deltaRaw);
|
||||||
if (next < 0) {
|
data.balance = (next < 0n ? 0n : next).toString();
|
||||||
return res.status(400).json({ error: 'Resulting balance cannot be negative' });
|
|
||||||
}
|
|
||||||
data.balance = next;
|
|
||||||
}
|
}
|
||||||
if (req.body.osuBalance !== undefined) {
|
if (req.body.osuBalance !== undefined) {
|
||||||
const osu = Number(req.body.osuBalance);
|
const osu = Number(req.body.osuBalance);
|
||||||
@@ -277,6 +326,21 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
data.osuBalance = osu;
|
data.osuBalance = osu;
|
||||||
}
|
}
|
||||||
|
if (req.body.osuBalanceDelta !== undefined) {
|
||||||
|
const delta = Number(req.body.osuBalanceDelta);
|
||||||
|
if (!Number.isInteger(delta) || delta === 0) {
|
||||||
|
return res.status(400).json({ error: 'osuBalanceDelta must be a non-zero integer' });
|
||||||
|
}
|
||||||
|
data.osuBalance = Math.max(0, (user.osuBalance ?? 0) + delta);
|
||||||
|
}
|
||||||
|
if (req.body.prBalanceDelta !== undefined) {
|
||||||
|
const deltaRaw = String(req.body.prBalanceDelta).trim();
|
||||||
|
if (!/^-?\d+$/.test(deltaRaw) || deltaRaw === '0' || deltaRaw === '-0') {
|
||||||
|
return res.status(400).json({ error: 'prBalanceDelta must be a non-zero integer' });
|
||||||
|
}
|
||||||
|
const next = toPrBigInt(user.prBalance) + BigInt(deltaRaw);
|
||||||
|
data.prBalance = prJson(next < 0n ? 0n : next);
|
||||||
|
}
|
||||||
|
|
||||||
if (!Object.keys(data).length) {
|
if (!Object.keys(data).length) {
|
||||||
return res.status(400).json({ error: 'No changes provided' });
|
return res.status(400).json({ error: 'No changes provided' });
|
||||||
@@ -284,12 +348,15 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
|
|||||||
|
|
||||||
const updated = await prisma.$transaction(async (tx) => {
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
const next = await tx.user.update({ where: { id }, data });
|
const next = await tx.user.update({ where: { id }, data });
|
||||||
if (data.balance !== undefined && Number(data.balance) !== Number(user.balance)) {
|
if (
|
||||||
|
data.balance !== undefined &&
|
||||||
|
toCentsBigInt(data.balance) !== toCentsBigInt(user.balance)
|
||||||
|
) {
|
||||||
await tx.transaction.create({
|
await tx.transaction.create({
|
||||||
data: {
|
data: {
|
||||||
userId: id,
|
userId: id,
|
||||||
type: 'admin_adjust',
|
type: 'admin_adjust',
|
||||||
amount: Number(data.balance) - Number(user.balance),
|
amount: centsJson(toCentsBigInt(data.balance) - toCentsBigInt(user.balance)),
|
||||||
meta: JSON.stringify({ reason: 'admin_panel', by: req.session.userId }),
|
meta: JSON.stringify({ reason: 'admin_panel', by: req.session.userId }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -305,14 +372,20 @@ router.patch('/users/:id', requireAdmin, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/upload', requireAdmin, (req, res) => {
|
router.post('/upload', requireAdmin, (req, res) => {
|
||||||
upload.single('image')(req, res, (err) => {
|
upload.single('image')(req, res, async (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return res.status(400).json({ error: err.message || 'Upload failed' });
|
return res.status(400).json({ error: err.message || 'Upload failed' });
|
||||||
}
|
}
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
}
|
}
|
||||||
res.status(201).json({ url: `/uploads/${req.file.filename}` });
|
try {
|
||||||
|
await processUploadedImage(req.file);
|
||||||
|
res.status(201).json({ url: `/uploads/${req.file.filename}` });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(400).json({ error: 'Image processing failed' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -356,11 +429,11 @@ router.post('/cases', requireAdmin, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const name = String(req.body.name || '').trim();
|
const name = String(req.body.name || '').trim();
|
||||||
const imageUrl = String(req.body.imageUrl || '');
|
const imageUrl = String(req.body.imageUrl || '');
|
||||||
const price = Number(req.body.price);
|
const price = parseCentsField(req.body.price);
|
||||||
const active = req.body.active !== false;
|
const active = req.body.active !== false;
|
||||||
|
|
||||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||||
if (!Number.isInteger(price) || price < 0) {
|
if (price == null) {
|
||||||
return res.status(400).json({ error: 'Price must be a non-negative integer (cents)' });
|
return res.status(400).json({ error: 'Price must be a non-negative integer (cents)' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,8 +454,8 @@ router.put('/cases/:id', requireAdmin, async (req, res) => {
|
|||||||
if (req.body.name !== undefined) data.name = String(req.body.name).trim();
|
if (req.body.name !== undefined) data.name = String(req.body.name).trim();
|
||||||
if (req.body.imageUrl !== undefined) data.imageUrl = String(req.body.imageUrl);
|
if (req.body.imageUrl !== undefined) data.imageUrl = String(req.body.imageUrl);
|
||||||
if (req.body.price !== undefined) {
|
if (req.body.price !== undefined) {
|
||||||
const price = Number(req.body.price);
|
const price = parseCentsField(req.body.price);
|
||||||
if (!Number.isInteger(price) || price < 0) {
|
if (price == null) {
|
||||||
return res.status(400).json({ error: 'Invalid price' });
|
return res.status(400).json({ error: 'Invalid price' });
|
||||||
}
|
}
|
||||||
data.price = price;
|
data.price = price;
|
||||||
@@ -425,13 +498,13 @@ router.post('/items', requireAdmin, async (req, res) => {
|
|||||||
const imageUrl = String(req.body.imageUrl || '');
|
const imageUrl = String(req.body.imageUrl || '');
|
||||||
const rarity = String(req.body.rarity || '');
|
const rarity = String(req.body.rarity || '');
|
||||||
const category = await ensureCategory(req.body.category);
|
const category = await ensureCategory(req.body.category);
|
||||||
const marketValue = Number(req.body.marketValue);
|
const marketValue = parseCentsField(req.body.marketValue);
|
||||||
|
|
||||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||||
if (!isValidRarity(rarity)) {
|
if (!isValidRarity(rarity)) {
|
||||||
return res.status(400).json({ error: `Invalid rarity. Use: ${RARITIES.join(', ')}` });
|
return res.status(400).json({ error: `Invalid rarity. Use: ${RARITIES.join(', ')}` });
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(marketValue) || marketValue < 0) {
|
if (marketValue == null) {
|
||||||
return res.status(400).json({ error: 'marketValue must be a non-negative integer (cents)' });
|
return res.status(400).json({ error: 'marketValue must be a non-negative integer (cents)' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,8 +534,8 @@ router.put('/items/:id', requireAdmin, async (req, res) => {
|
|||||||
data.category = await ensureCategory(req.body.category);
|
data.category = await ensureCategory(req.body.category);
|
||||||
}
|
}
|
||||||
if (req.body.marketValue !== undefined) {
|
if (req.body.marketValue !== undefined) {
|
||||||
const marketValue = Number(req.body.marketValue);
|
const marketValue = parseCentsField(req.body.marketValue);
|
||||||
if (!Number.isInteger(marketValue) || marketValue < 0) {
|
if (marketValue == null) {
|
||||||
return res.status(400).json({ error: 'Invalid marketValue' });
|
return res.status(400).json({ error: 'Invalid marketValue' });
|
||||||
}
|
}
|
||||||
data.marketValue = marketValue;
|
data.marketValue = marketValue;
|
||||||
@@ -545,4 +618,79 @@ router.delete('/case-items/:id', requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview counts before a server prestige wipe.
|
||||||
|
*/
|
||||||
|
router.get('/prestige-server', requireAdmin, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const [players, inventory, prestigeLogs, opensKept] = await Promise.all([
|
||||||
|
prisma.user.count({ where: { role: { not: 'admin' } } }),
|
||||||
|
prisma.inventoryItem.count({
|
||||||
|
where: { user: { role: { not: 'admin' } } },
|
||||||
|
}),
|
||||||
|
prisma.prestigeLog.count({
|
||||||
|
where: { user: { role: { not: 'admin' } } },
|
||||||
|
}),
|
||||||
|
prisma.transaction.count({
|
||||||
|
where: {
|
||||||
|
user: { role: { not: 'admin' } },
|
||||||
|
type: { in: ['open_case', 'jackpot_win'] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
res.json({
|
||||||
|
preview: {
|
||||||
|
players,
|
||||||
|
inventoryItems: inventory,
|
||||||
|
prestigeLogs,
|
||||||
|
statsTransactionsKept: opensKept,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to load prestige-server preview' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full player wipe (server prestige). Requires admin password confirmation.
|
||||||
|
* Achievement fields must be configured before activation.
|
||||||
|
*/
|
||||||
|
router.post('/prestige-server', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const password = String(req.body.password || '');
|
||||||
|
if (!password) {
|
||||||
|
return res.status(400).json({ error: 'Admin password required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
||||||
|
if (!admin || admin.role !== 'admin') {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await bcrypt.compare(password, admin.passwordHash);
|
||||||
|
if (!ok) {
|
||||||
|
return res.status(401).json({ error: 'Invalid admin password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ach = req.body.achievement || {};
|
||||||
|
const { runServerPrestige } = await import('../serverPrestige.js');
|
||||||
|
const result = await runServerPrestige({
|
||||||
|
achievementKey: ach.key,
|
||||||
|
achievementName: ach.name,
|
||||||
|
achievementDescription: ach.description,
|
||||||
|
achievementIcon: ach.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
const status = err.status || 500;
|
||||||
|
res.status(status).json({ error: err.message || 'Server prestige failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ router.post('/register', async (req, res) => {
|
|||||||
data: {
|
data: {
|
||||||
username,
|
username,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
balance: STARTING_BALANCE,
|
balance: String(STARTING_BALANCE),
|
||||||
osuBalance: STARTING_OSU,
|
osuBalance: STARTING_OSU,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
},
|
},
|
||||||
@@ -43,7 +43,7 @@ router.post('/register', async (req, res) => {
|
|||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: 'seed_credit',
|
type: 'seed_credit',
|
||||||
amount: STARTING_BALANCE,
|
amount: String(STARTING_BALANCE),
|
||||||
meta: JSON.stringify({ reason: 'starting_balance' }),
|
meta: JSON.stringify({ reason: 'starting_balance' }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -52,7 +52,7 @@ router.post('/register', async (req, res) => {
|
|||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: 'seed_osu',
|
type: 'seed_osu',
|
||||||
amount: STARTING_OSU,
|
amount: String(STARTING_OSU),
|
||||||
meta: JSON.stringify({ reason: 'starting_osu' }),
|
meta: JSON.stringify({ reason: 'starting_osu' }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -97,20 +97,20 @@ router.post('/login', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Offline vault yield if user is not already socket-connected
|
// Offline vault yield if user is not already socket-connected
|
||||||
let vaultCredited = 0;
|
let vaultCredited = '0';
|
||||||
try {
|
try {
|
||||||
const { isConnected } = await import('../presence.js');
|
const { isConnected } = await import('../presence.js');
|
||||||
const { settleVaultYield } = await import('../vault.js');
|
const { settleVaultYield } = await import('../vault.js');
|
||||||
if (!isConnected(user.id)) {
|
if (!isConnected(user.id)) {
|
||||||
const settled = await settleVaultYield(user.id, 'offline');
|
const settled = await settleVaultYield(user.id, 'offline');
|
||||||
vaultCredited = settled.credited;
|
vaultCredited = settled.credited || '0';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('login vault settle', err);
|
console.error('login vault settle', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshed =
|
const refreshed =
|
||||||
vaultCredited > 0
|
vaultCredited !== '0' && vaultCredited !== 0
|
||||||
? await prisma.user.findUnique({ where: { id: user.id } })
|
? await prisma.user.findUnique({ where: { id: user.id } })
|
||||||
: withConn;
|
: withConn;
|
||||||
|
|
||||||
|
|||||||
+58
-46
@@ -15,7 +15,8 @@ import {
|
|||||||
fillCatalogSlots,
|
fillCatalogSlots,
|
||||||
formatCaseItemsWithLuck,
|
formatCaseItemsWithLuck,
|
||||||
} from '../catalog.js';
|
} from '../catalog.js';
|
||||||
import { totalLuckPercent, applyCaseDiscount } from '../prestige.js';
|
import { totalLuckPercent } from '../prestige.js';
|
||||||
|
import { toCentsBigInt, adjustUserBalance, spendUserBalance, centsJson } from '../cents.js';
|
||||||
import { checkAchievements } from '../achievements.js';
|
import { checkAchievements } from '../achievements.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -47,7 +48,6 @@ async function loadUserLuckAndKeys(userId) {
|
|||||||
return {
|
return {
|
||||||
luckPercent: 0,
|
luckPercent: 0,
|
||||||
openBonus: 0,
|
openBonus: 0,
|
||||||
caseDiscount: 0,
|
|
||||||
user: null,
|
user: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,6 @@ async function loadUserLuckAndKeys(userId) {
|
|||||||
prestigeChanceBonus: true,
|
prestigeChanceBonus: true,
|
||||||
prestigeWearBonus: true,
|
prestigeWearBonus: true,
|
||||||
prestigeOpenBonus: true,
|
prestigeOpenBonus: true,
|
||||||
prestigeCaseDiscount: true,
|
|
||||||
prestigeQualityBonus: true,
|
prestigeQualityBonus: true,
|
||||||
prestigeAnimReduction: true,
|
prestigeAnimReduction: true,
|
||||||
prestigeRefundChance: true,
|
prestigeRefundChance: true,
|
||||||
@@ -70,7 +69,6 @@ async function loadUserLuckAndKeys(userId) {
|
|||||||
return {
|
return {
|
||||||
luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus),
|
luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus),
|
||||||
openBonus: user?.prestigeOpenBonus ?? 0,
|
openBonus: user?.prestigeOpenBonus ?? 0,
|
||||||
caseDiscount: user?.prestigeCaseDiscount ?? 0,
|
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -102,17 +100,20 @@ router.get('/', async (req, res) => {
|
|||||||
include: {
|
include: {
|
||||||
items: { include: { item: true } },
|
items: { include: { item: true } },
|
||||||
},
|
},
|
||||||
orderBy: { price: 'asc' },
|
});
|
||||||
|
cases.sort((a, b) => {
|
||||||
|
const pa = toCentsBigInt(a.price);
|
||||||
|
const pb = toCentsBigInt(b.price);
|
||||||
|
if (pa < pb) return -1;
|
||||||
|
if (pa > pb) return 1;
|
||||||
|
return a.id - b.id;
|
||||||
});
|
});
|
||||||
const progressMap = await loadKeyProgressMap(userId);
|
const progressMap = await loadKeyProgressMap(userId);
|
||||||
const { luckPercent, openBonus, caseDiscount } = await loadUserLuckAndKeys(userId);
|
const { luckPercent, openBonus } = await loadUserLuckAndKeys(userId);
|
||||||
res.json({
|
res.json({
|
||||||
catalogLuckPercent: luckPercent,
|
catalogLuckPercent: luckPercent,
|
||||||
cases: cases.map((c) => {
|
cases: cases.map((c) => {
|
||||||
const unitPrice = userId
|
const formatted = formatCase(c, luckPercent, userId ? c.price : Number(c.price));
|
||||||
? applyCaseDiscount(c.price, caseDiscount)
|
|
||||||
: Number(c.price);
|
|
||||||
const formatted = formatCase(c, luckPercent, unitPrice);
|
|
||||||
if (!userId) return formatted;
|
if (!userId) return formatted;
|
||||||
const progress = progressMap.get(c.id);
|
const progress = progressMap.get(c.id);
|
||||||
return {
|
return {
|
||||||
@@ -140,10 +141,8 @@ router.get('/:id', async (req, res) => {
|
|||||||
if (!c || !c.active) {
|
if (!c || !c.active) {
|
||||||
return res.status(404).json({ error: 'Case not found' });
|
return res.status(404).json({ error: 'Case not found' });
|
||||||
}
|
}
|
||||||
const { luckPercent, openBonus, caseDiscount } = await loadUserLuckAndKeys(userId);
|
const { luckPercent, openBonus } = await loadUserLuckAndKeys(userId);
|
||||||
const unitPrice = userId
|
const unitPrice = userId ? c.price : Number(c.price);
|
||||||
? applyCaseDiscount(c.price, caseDiscount)
|
|
||||||
: Number(c.price);
|
|
||||||
const formatted = formatCase(c, luckPercent, unitPrice);
|
const formatted = formatCase(c, luckPercent, unitPrice);
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const progress = await prisma.caseKeyProgress.findUnique({
|
const progress = await prisma.caseKeyProgress.findUnique({
|
||||||
@@ -201,13 +200,10 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const unitPrice = applyCaseDiscount(c.price, user.prestigeCaseDiscount ?? 0);
|
const unitPrice = toCentsBigInt(c.price);
|
||||||
const totalCost = unitPrice * count;
|
const totalCost = unitPrice * BigInt(count);
|
||||||
if (user.balance < totalCost) {
|
// Charge up front (atomic WHERE balance >= cost). Refunded opens credit back later.
|
||||||
const err = new Error('Insufficient balance');
|
await spendUserBalance(tx, userId, totalCost);
|
||||||
err.status = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const catalogLuck = await countCompletedChapters(tx, userId);
|
const catalogLuck = await countCompletedChapters(tx, userId);
|
||||||
const luckPercent = totalLuckPercent(catalogLuck, user.prestigeChanceBonus);
|
const luckPercent = totalLuckPercent(catalogLuck, user.prestigeChanceBonus);
|
||||||
@@ -231,7 +227,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
floatValue: rolled.floatValue,
|
floatValue: rolled.floatValue,
|
||||||
wear: rolled.wear,
|
wear: rolled.wear,
|
||||||
paintSeed: rolled.paintSeed,
|
paintSeed: rolled.paintSeed,
|
||||||
valueCents: rolled.valueCents,
|
valueCents: centsJson(rolled.valueCents),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,7 +237,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
type: 'open_case',
|
type: 'open_case',
|
||||||
amount: -unitPrice,
|
amount: centsJson(-unitPrice),
|
||||||
meta: JSON.stringify({
|
meta: JSON.stringify({
|
||||||
caseId: c.id,
|
caseId: c.id,
|
||||||
caseName: c.name,
|
caseName: c.name,
|
||||||
@@ -251,12 +247,12 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
floatValue: rolled.floatValue,
|
floatValue: rolled.floatValue,
|
||||||
wear: rolled.wear,
|
wear: rolled.wear,
|
||||||
paintSeed: rolled.paintSeed,
|
paintSeed: rolled.paintSeed,
|
||||||
valueCents: rolled.valueCents,
|
valueCents: centsJson(rolled.valueCents),
|
||||||
willAutoSell,
|
willAutoSell,
|
||||||
openIndex: i,
|
openIndex: i,
|
||||||
openCount: batchCount,
|
openCount: batchCount,
|
||||||
unitPrice,
|
unitPrice: centsJson(unitPrice),
|
||||||
basePrice: Number(c.price),
|
basePrice: centsJson(c.price),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -268,11 +264,11 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
name: item.name,
|
name: item.name,
|
||||||
imageUrl: item.imageUrl,
|
imageUrl: item.imageUrl,
|
||||||
rarity: item.rarity,
|
rarity: item.rarity,
|
||||||
marketValue: item.marketValue,
|
marketValue: toCentsBigInt(item.marketValue).toString(),
|
||||||
floatValue: rolled.floatValue,
|
floatValue: rolled.floatValue,
|
||||||
wear: rolled.wear,
|
wear: rolled.wear,
|
||||||
paintSeed: rolled.paintSeed,
|
paintSeed: rolled.paintSeed,
|
||||||
valueCents: rolled.valueCents,
|
valueCents: toCentsBigInt(rolled.valueCents).toString(),
|
||||||
willAutoSell,
|
willAutoSell,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -281,12 +277,32 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
let { drops, createdTxIds } = await createDrops(count);
|
let { drops, createdTxIds } = await createDrops(count);
|
||||||
|
|
||||||
// Respin: re-roll all drops for free (replace first batch)
|
// Respin: re-roll all drops for free (replace first batch).
|
||||||
|
// preRespinItems animate the discarded roll. Auto-sell-eligible items stay in
|
||||||
|
// inventory (with inventoryId) so the client can credit them when the first
|
||||||
|
// reels finish; the rest are discarded immediately.
|
||||||
|
let preRespinItems = null;
|
||||||
const respinChance = Math.max(0, Number(user.prestigeRespinChance) || 0);
|
const respinChance = Math.max(0, Number(user.prestigeRespinChance) || 0);
|
||||||
const didRespin = respinChance > 0 && Math.random() * 100 < respinChance;
|
const didRespin = respinChance > 0 && Math.random() * 100 < respinChance;
|
||||||
if (didRespin) {
|
if (didRespin) {
|
||||||
const oldIds = drops.map((d) => d.inventoryId);
|
const discardIds = drops
|
||||||
await tx.inventoryItem.deleteMany({ where: { id: { in: oldIds } } });
|
.filter((d) => !d.willAutoSell)
|
||||||
|
.map((d) => d.inventoryId)
|
||||||
|
.filter(Boolean);
|
||||||
|
const pendingAutoSellIds = drops
|
||||||
|
.filter((d) => d.willAutoSell)
|
||||||
|
.map((d) => d.inventoryId)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
preRespinItems = drops.map((d) => ({
|
||||||
|
...d,
|
||||||
|
inventoryId: d.willAutoSell ? d.inventoryId : null,
|
||||||
|
preRespin: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (discardIds.length) {
|
||||||
|
await tx.inventoryItem.deleteMany({ where: { id: { in: discardIds } } });
|
||||||
|
}
|
||||||
await tx.transaction.deleteMany({ where: { id: { in: createdTxIds } } });
|
await tx.transaction.deleteMany({ where: { id: { in: createdTxIds } } });
|
||||||
const again = await createDrops(count);
|
const again = await createDrops(count);
|
||||||
drops = again.drops;
|
drops = again.drops;
|
||||||
@@ -295,11 +311,12 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
type: 'case_respin',
|
type: 'case_respin',
|
||||||
amount: 0,
|
amount: '0',
|
||||||
meta: JSON.stringify({
|
meta: JSON.stringify({
|
||||||
caseId: c.id,
|
caseId: c.id,
|
||||||
openCount: count,
|
openCount: count,
|
||||||
inventoryItemIds: drops.map((d) => d.inventoryId),
|
inventoryItemIds: drops.map((d) => d.inventoryId),
|
||||||
|
preRespinPendingAutoSellIds: pendingAutoSellIds,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -326,41 +343,35 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let balanceDelta = -totalCost;
|
let updatedUser = await tx.user.findUnique({ where: { id: userId } });
|
||||||
if (didRefund) {
|
if (didRefund) {
|
||||||
balanceDelta += totalCost;
|
updatedUser = await adjustUserBalance(tx, userId, updatedUser.balance, totalCost);
|
||||||
await tx.transaction.create({
|
await tx.transaction.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
type: 'case_refund',
|
type: 'case_refund',
|
||||||
amount: totalCost,
|
amount: centsJson(totalCost),
|
||||||
meta: JSON.stringify({
|
meta: JSON.stringify({
|
||||||
caseId: c.id,
|
caseId: c.id,
|
||||||
caseName: c.name,
|
caseName: c.name,
|
||||||
openCount: count,
|
openCount: count,
|
||||||
unitPrice,
|
unitPrice: centsJson(unitPrice),
|
||||||
totalCost,
|
totalCost: centsJson(totalCost),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = await tx.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: {
|
|
||||||
balance: { increment: balanceDelta },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: publicUser(updatedUser),
|
user: publicUser(updatedUser),
|
||||||
caseName: c.name,
|
caseName: c.name,
|
||||||
items: drops,
|
items: drops,
|
||||||
|
preRespinItems,
|
||||||
keyProgress: serializeKeyProgress(updatedProgress, 0, openBonus),
|
keyProgress: serializeKeyProgress(updatedProgress, 0, openBonus),
|
||||||
keysGained: applied.keysGained,
|
keysGained: applied.keysGained,
|
||||||
catalog,
|
catalog,
|
||||||
unitPrice,
|
unitPrice: centsJson(unitPrice),
|
||||||
totalCost,
|
totalCost: centsJson(totalCost),
|
||||||
refunded: didRefund,
|
refunded: didRefund,
|
||||||
respin: didRespin,
|
respin: didRespin,
|
||||||
animReduction: user.prestigeAnimReduction ?? 0,
|
animReduction: user.prestigeAnimReduction ?? 0,
|
||||||
@@ -374,6 +385,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
caseName: result.caseName,
|
caseName: result.caseName,
|
||||||
count: result.items.length,
|
count: result.items.length,
|
||||||
items: result.items,
|
items: result.items,
|
||||||
|
preRespinItems: result.preRespinItems,
|
||||||
keyProgress: result.keyProgress,
|
keyProgress: result.keyProgress,
|
||||||
keysGained: result.keysGained,
|
keysGained: result.keysGained,
|
||||||
catalog: result.catalog,
|
catalog: result.catalog,
|
||||||
|
|||||||
@@ -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 { prisma } from '../db.js';
|
||||||
import { requireAuth } from '../middleware.js';
|
import { requireAuth } from '../middleware.js';
|
||||||
import { getDropFeed, pushDrop } from '../feed.js';
|
import { getDropFeed, pushDrop } from '../feed.js';
|
||||||
|
import { toCentsBigInt } from '../cents.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -13,9 +14,89 @@ router.get('/', (_req, res) => {
|
|||||||
* Announce case drop(s) to the live feed (after client spin finishes).
|
* Announce case drop(s) to the live feed (after client spin finishes).
|
||||||
* For multi-opens: send all inventoryItemIds — only the best drop is shown,
|
* For multi-opens: send all inventoryItemIds — only the best drop is shown,
|
||||||
* with count of how many times that item appeared (e.g. x3).
|
* with count of how many times that item appeared (e.g. x3).
|
||||||
|
*
|
||||||
|
* Respin flow:
|
||||||
|
* - First spin (discarded): body.snapshotItems (no inventory left)
|
||||||
|
* - Final spin: body.inventoryItemIds + body.respin=true
|
||||||
*/
|
*/
|
||||||
router.post('/announce', requireAuth, async (req, res) => {
|
router.post('/announce', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const caseName = String(req.body.caseName || '').slice(0, 80);
|
||||||
|
const respin = Boolean(req.body.respin);
|
||||||
|
const snapshotItems = Array.isArray(req.body.snapshotItems)
|
||||||
|
? req.body.snapshotItems
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Discarded first roll (respin preview) — announce from client snapshot.
|
||||||
|
// Prefer compact payload: snapshotBest + openCount (+ optional count).
|
||||||
|
if (snapshotItems?.length || req.body.snapshotBest) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.session.userId },
|
||||||
|
select: { username: true, avatarUrl: true },
|
||||||
|
});
|
||||||
|
if (!user) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
|
||||||
|
const normalizeOne = (raw) => ({
|
||||||
|
id: Number(raw?.id) || 0,
|
||||||
|
name: String(raw?.name || '').slice(0, 120),
|
||||||
|
imageUrl: String(raw?.imageUrl || '').slice(0, 500),
|
||||||
|
rarity: String(raw?.rarity || '').slice(0, 40),
|
||||||
|
valueCents: String(raw?.valueCents ?? raw?.marketValue ?? '0'),
|
||||||
|
wear: raw?.wear ? String(raw.wear).slice(0, 40) : '',
|
||||||
|
floatValue: Number(raw?.floatValue) || 0,
|
||||||
|
paintSeed: Number(raw?.paintSeed) || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let best;
|
||||||
|
let openCount;
|
||||||
|
let count;
|
||||||
|
|
||||||
|
if (req.body.snapshotBest) {
|
||||||
|
best = normalizeOne(req.body.snapshotBest);
|
||||||
|
if (!best.id || !best.name) {
|
||||||
|
return res.status(400).json({ error: 'Invalid snapshot best item' });
|
||||||
|
}
|
||||||
|
openCount = Math.max(1, Math.floor(Number(req.body.openCount) || 1));
|
||||||
|
count = Math.max(1, Math.min(openCount, Math.floor(Number(req.body.count) || 1)));
|
||||||
|
} else {
|
||||||
|
const normalized = snapshotItems.map(normalizeOne).filter((it) => it.id && it.name);
|
||||||
|
if (!normalized.length) {
|
||||||
|
return res.status(400).json({ error: 'Invalid snapshot items' });
|
||||||
|
}
|
||||||
|
best = normalized[0];
|
||||||
|
for (const it of normalized) {
|
||||||
|
if (toCentsBigInt(it.valueCents) > toCentsBigInt(best.valueCents)) best = it;
|
||||||
|
}
|
||||||
|
openCount = normalized.length;
|
||||||
|
count = normalized.filter((it) => it.id === best.id).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drop = pushDrop({
|
||||||
|
source: 'case',
|
||||||
|
caseName,
|
||||||
|
respin: false,
|
||||||
|
count,
|
||||||
|
openCount,
|
||||||
|
inventoryIds: [],
|
||||||
|
username: user.username,
|
||||||
|
avatarUrl: user.avatarUrl || '',
|
||||||
|
item: {
|
||||||
|
inventoryId: null,
|
||||||
|
id: best.id,
|
||||||
|
name: best.name,
|
||||||
|
imageUrl: best.imageUrl,
|
||||||
|
rarity: best.rarity,
|
||||||
|
marketValue: best.valueCents,
|
||||||
|
valueCents: best.valueCents,
|
||||||
|
wear: best.wear,
|
||||||
|
floatValue: best.floatValue,
|
||||||
|
paintSeed: best.paintSeed,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ drop });
|
||||||
|
}
|
||||||
|
|
||||||
let ids = [];
|
let ids = [];
|
||||||
if (Array.isArray(req.body.inventoryItemIds)) {
|
if (Array.isArray(req.body.inventoryItemIds)) {
|
||||||
ids = req.body.inventoryItemIds.map(Number).filter(Boolean);
|
ids = req.body.inventoryItemIds.map(Number).filter(Boolean);
|
||||||
@@ -51,15 +132,15 @@ router.post('/announce', requireAuth, async (req, res) => {
|
|||||||
// Best drop = highest instance value (tie-break: first in batch)
|
// Best drop = highest instance value (tie-break: first in batch)
|
||||||
let best = invItems[0];
|
let best = invItems[0];
|
||||||
for (const inv of invItems) {
|
for (const inv of invItems) {
|
||||||
if (inv.valueCents > best.valueCents) best = inv;
|
if (toCentsBigInt(inv.valueCents) > toCentsBigInt(best.valueCents)) best = inv;
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = invItems.filter((inv) => inv.itemId === best.itemId).length;
|
const count = invItems.filter((inv) => inv.itemId === best.itemId).length;
|
||||||
const caseName = String(req.body.caseName || '').slice(0, 80);
|
|
||||||
|
|
||||||
const drop = pushDrop({
|
const drop = pushDrop({
|
||||||
source: 'case',
|
source: 'case',
|
||||||
caseName,
|
caseName,
|
||||||
|
respin,
|
||||||
count,
|
count,
|
||||||
openCount: invItems.length,
|
openCount: invItems.length,
|
||||||
inventoryIds: invItems.map((i) => i.id),
|
inventoryIds: invItems.map((i) => i.id),
|
||||||
|
|||||||
+109
-92
@@ -3,10 +3,9 @@ import { prisma } from '../db.js';
|
|||||||
import { requireAuth, publicUser } from '../middleware.js';
|
import { requireAuth, publicUser } from '../middleware.js';
|
||||||
import { applyAutoSell } from '../autoSell.js';
|
import { applyAutoSell } from '../autoSell.js';
|
||||||
import { applyGainMultiplier } from '../prestige.js';
|
import { applyGainMultiplier } from '../prestige.js';
|
||||||
|
import { toCentsBigInt, adjustUserBalance, centsJson, creditsToCents } from '../cents.js';
|
||||||
import {
|
import {
|
||||||
maxVaultSlots,
|
summarizeVaultState,
|
||||||
summarizeVaultRates,
|
|
||||||
estimatePendingYield,
|
|
||||||
} from '../vault.js';
|
} from '../vault.js';
|
||||||
import { isConnected } from '../presence.js';
|
import { isConnected } from '../presence.js';
|
||||||
|
|
||||||
@@ -54,16 +53,10 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
const staked = items.filter((i) => i.staked);
|
const staked = items.filter((i) => i.staked);
|
||||||
const mode = isConnected(userId) ? 'online' : 'offline';
|
const mode = isConnected(userId) ? 'online' : 'offline';
|
||||||
const rates = summarizeVaultRates(staked, user);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
inventory: items.map(serializeInv),
|
inventory: items.map(serializeInv),
|
||||||
vault: {
|
vault: summarizeVaultState(user, staked, mode),
|
||||||
used: staked.length,
|
|
||||||
slots: maxVaultSlots(user),
|
|
||||||
...rates,
|
|
||||||
pendingYield: estimatePendingYield(staked, user, mode),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -78,11 +71,11 @@ router.patch('/auto-sell', requireAuth, async (req, res) => {
|
|||||||
data.autoSellEnabled = Boolean(req.body.enabled);
|
data.autoSellEnabled = Boolean(req.body.enabled);
|
||||||
}
|
}
|
||||||
if (req.body.thresholdCredits != null) {
|
if (req.body.thresholdCredits != null) {
|
||||||
const credits = Number(req.body.thresholdCredits);
|
const cents = creditsToCents(req.body.thresholdCredits);
|
||||||
if (!Number.isFinite(credits) || credits < 0) {
|
if (cents == null) {
|
||||||
return res.status(400).json({ error: 'Invalid threshold' });
|
return res.status(400).json({ error: 'Invalid threshold' });
|
||||||
}
|
}
|
||||||
data.autoSellThresholdCents = Math.round(credits * 100);
|
data.autoSellThresholdCents = centsJson(cents);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Object.keys(data).length) {
|
if (!Object.keys(data).length) {
|
||||||
@@ -108,44 +101,56 @@ router.post('/auto-sell/apply', requireAuth, async (req, res) => {
|
|||||||
return res.json({ user: publicUser(await prisma.user.findUnique({ where: { id: req.session.userId } })), soldIds: [], total: 0 });
|
return res.json({ user: publicUser(await prisma.user.findUnique({ where: { id: req.session.userId } })), soldIds: [], total: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(
|
||||||
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
async (tx) => {
|
||||||
if (!user) {
|
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
||||||
const err = new Error('User not found');
|
if (!user) {
|
||||||
err.status = 401;
|
const err = new Error('User not found');
|
||||||
throw err;
|
err.status = 401;
|
||||||
}
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const invItems = await tx.inventoryItem.findMany({
|
const invItems = await tx.inventoryItem.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: ids },
|
id: { in: ids },
|
||||||
userId: req.session.userId,
|
userId: req.session.userId,
|
||||||
locked: false,
|
locked: false,
|
||||||
staked: false,
|
staked: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { sold, balanceDelta } = await applyAutoSell(tx, user, invItems);
|
const { sold, balanceDelta } = await applyAutoSell(tx, user, invItems);
|
||||||
if (!balanceDelta) {
|
if (balanceDelta === 0n) {
|
||||||
return { user, soldIds: [], total: 0 };
|
return { user, soldIds: [], total: 0n, sold: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = await tx.user.update({
|
const updatedUser = await adjustUserBalance(
|
||||||
where: { id: req.session.userId },
|
tx,
|
||||||
data: { balance: { increment: balanceDelta } },
|
req.session.userId,
|
||||||
});
|
user.balance,
|
||||||
|
balanceDelta
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
const gainBonus = user.prestigeGainBonus ?? 0;
|
||||||
user: updatedUser,
|
return {
|
||||||
soldIds: sold.map((inv) => inv.id),
|
user: updatedUser,
|
||||||
total: balanceDelta,
|
soldIds: sold.map((inv) => inv.id),
|
||||||
};
|
total: balanceDelta,
|
||||||
});
|
sold: sold.map((inv) => ({
|
||||||
|
id: inv.id,
|
||||||
|
valueCents: centsJson(inv.valueCents),
|
||||||
|
payoutCents: centsJson(applyGainMultiplier(inv.valueCents, gainBonus)),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ timeout: 60_000, maxWait: 15_000 }
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
user: publicUser(result.user),
|
user: publicUser(result.user),
|
||||||
soldIds: result.soldIds,
|
soldIds: result.soldIds,
|
||||||
total: result.total,
|
total: centsJson(result.total),
|
||||||
|
sold: result.sold || [],
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = err.status || 500;
|
const status = err.status || 500;
|
||||||
@@ -161,74 +166,86 @@ router.post('/sell', requireAuth, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Select at least one item to sell' });
|
return res.status(400).json({ error: 'Select at least one item to sell' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(
|
||||||
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
async (tx) => {
|
||||||
if (!user) {
|
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
||||||
const err = new Error('User not found');
|
if (!user) {
|
||||||
err.status = 401;
|
const err = new Error('User not found');
|
||||||
throw err;
|
err.status = 401;
|
||||||
}
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
const invItems = await tx.inventoryItem.findMany({
|
const invItems = await tx.inventoryItem.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: { in: ids },
|
id: { in: ids },
|
||||||
userId: req.session.userId,
|
userId: req.session.userId,
|
||||||
locked: false,
|
locked: false,
|
||||||
staked: false,
|
staked: false,
|
||||||
},
|
},
|
||||||
include: { item: true },
|
select: {
|
||||||
});
|
id: true,
|
||||||
|
itemId: true,
|
||||||
|
valueCents: true,
|
||||||
|
favorite: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (invItems.length !== ids.length) {
|
if (invItems.length !== ids.length) {
|
||||||
const err = new Error('Invalid, locked, or staked inventory items');
|
const err = new Error('Invalid, locked, or staked inventory items');
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invItems.some((inv) => inv.favorite)) {
|
if (invItems.some((inv) => inv.favorite)) {
|
||||||
const err = new Error('Favorite items cannot be sold');
|
const err = new Error('Favorite items cannot be sold');
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gainBonus = user.prestigeGainBonus ?? 0;
|
const gainBonus = user.prestigeGainBonus ?? 0;
|
||||||
let total = 0;
|
let total = 0n;
|
||||||
|
for (const inv of invItems) {
|
||||||
|
total += applyGainMultiplier(inv.valueCents, gainBonus);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.inventoryItem.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: { in: ids },
|
||||||
|
userId: req.session.userId,
|
||||||
|
locked: false,
|
||||||
|
staked: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
for (const inv of invItems) {
|
|
||||||
const payout = applyGainMultiplier(inv.valueCents, gainBonus);
|
|
||||||
total += payout;
|
|
||||||
await tx.inventoryItem.delete({ where: { id: inv.id } });
|
|
||||||
await tx.transaction.create({
|
await tx.transaction.create({
|
||||||
data: {
|
data: {
|
||||||
userId: req.session.userId,
|
userId: req.session.userId,
|
||||||
type: 'sell_item',
|
type: 'sell_item',
|
||||||
amount: payout,
|
amount: centsJson(total),
|
||||||
meta: JSON.stringify({
|
meta: JSON.stringify({
|
||||||
inventoryItemId: inv.id,
|
count: invItems.length,
|
||||||
itemId: inv.itemId,
|
inventoryItemIds: ids,
|
||||||
itemName: inv.item.name,
|
|
||||||
valueCents: inv.valueCents,
|
|
||||||
floatValue: inv.floatValue,
|
|
||||||
wear: inv.wear,
|
|
||||||
paintSeed: inv.paintSeed,
|
|
||||||
prestigeGainBonus: gainBonus,
|
prestigeGainBonus: gainBonus,
|
||||||
payout,
|
payout: centsJson(total),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUser = await tx.user.update({
|
const updatedUser = await adjustUserBalance(
|
||||||
where: { id: req.session.userId },
|
tx,
|
||||||
data: { balance: { increment: total } },
|
req.session.userId,
|
||||||
});
|
user.balance,
|
||||||
|
total
|
||||||
|
);
|
||||||
|
|
||||||
return { user: updatedUser, total, soldCount: invItems.length };
|
return { user: updatedUser, total, soldCount: invItems.length };
|
||||||
});
|
},
|
||||||
|
{ timeout: 60_000, maxWait: 15_000 }
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
user: publicUser(result.user),
|
user: publicUser(result.user),
|
||||||
total: result.total,
|
total: centsJson(result.total),
|
||||||
soldCount: result.soldCount,
|
soldCount: result.soldCount,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../middleware.js';
|
import { requireAuth } from '../middleware.js';
|
||||||
import {
|
import {
|
||||||
getJackpotState,
|
listJackpotBattles,
|
||||||
|
getJackpotRound,
|
||||||
|
createJackpotBattle,
|
||||||
placeJackpotBet,
|
placeJackpotBet,
|
||||||
withdrawJackpotBet,
|
withdrawJackpotBet,
|
||||||
} from '../services/jackpot.js';
|
} from '../services/jackpot.js';
|
||||||
@@ -16,25 +18,58 @@ function handleError(res, err) {
|
|||||||
|
|
||||||
router.get('/', async (_req, res) => {
|
router.get('/', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const state = await getJackpotState();
|
const battles = await listJackpotBattles();
|
||||||
res.json({ round: state });
|
res.json({ battles });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, err);
|
handleError(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const round = await getJackpotRound(req.params.id);
|
||||||
|
if (!round) {
|
||||||
|
return res.status(404).json({ error: 'Battle not found' });
|
||||||
|
}
|
||||||
|
res.json({ round });
|
||||||
|
} catch (err) {
|
||||||
|
handleError(res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Create a battle by placing the first bet (no round id). */
|
||||||
router.post('/bet', requireAuth, async (req, res) => {
|
router.post('/bet', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const state = await placeJackpotBet(req.session.userId, req.body.inventoryItemIds);
|
const state = await createJackpotBattle(
|
||||||
|
req.session.userId,
|
||||||
|
req.body.inventoryItemIds
|
||||||
|
);
|
||||||
res.json({ round: state });
|
res.json({ round: state });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, err);
|
handleError(res, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/withdraw', requireAuth, async (req, res) => {
|
router.post('/:id/bet', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const state = await withdrawJackpotBet(req.session.userId, req.body.inventoryItemIds);
|
const state = await placeJackpotBet(
|
||||||
|
req.params.id,
|
||||||
|
req.session.userId,
|
||||||
|
req.body.inventoryItemIds
|
||||||
|
);
|
||||||
|
res.json({ round: state });
|
||||||
|
} catch (err) {
|
||||||
|
handleError(res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/withdraw', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const state = await withdrawJackpotBet(
|
||||||
|
req.params.id,
|
||||||
|
req.session.userId,
|
||||||
|
req.body.inventoryItemIds
|
||||||
|
);
|
||||||
res.json({ round: state });
|
res.json({ round: state });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(res, err);
|
handleError(res, err);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { prisma } from '../db.js';
|
import { prisma } from '../db.js';
|
||||||
|
import { toCentsBigInt, centsJson } from '../cents.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -17,22 +18,28 @@ router.get('/', async (_req, res) => {
|
|||||||
const ranked = users
|
const ranked = users
|
||||||
.map((u) => {
|
.map((u) => {
|
||||||
const inventoryValue = u.inventory.reduce(
|
const inventoryValue = u.inventory.reduce(
|
||||||
(sum, inv) => sum + inv.valueCents,
|
(sum, inv) => sum + toCentsBigInt(inv.valueCents),
|
||||||
0
|
0n
|
||||||
);
|
);
|
||||||
|
const balance = toCentsBigInt(u.balance);
|
||||||
|
const totalWealth = balance + inventoryValue;
|
||||||
return {
|
return {
|
||||||
id: u.id,
|
id: u.id,
|
||||||
username: u.username,
|
username: u.username,
|
||||||
avatarUrl: u.avatarUrl || '',
|
avatarUrl: u.avatarUrl || '',
|
||||||
balance: u.balance,
|
balance: centsJson(balance),
|
||||||
inventoryValue,
|
inventoryValue: centsJson(inventoryValue),
|
||||||
totalWealth: u.balance + inventoryValue,
|
totalWealth,
|
||||||
itemCount: u.inventory.length,
|
itemCount: u.inventory.length,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.totalWealth - a.totalWealth)
|
.sort((a, b) => (a.totalWealth < b.totalWealth ? 1 : a.totalWealth > b.totalWealth ? -1 : 0))
|
||||||
.slice(0, 50)
|
.slice(0, 50)
|
||||||
.map((entry, index) => ({ rank: index + 1, ...entry }));
|
.map((entry, index) => ({
|
||||||
|
rank: index + 1,
|
||||||
|
...entry,
|
||||||
|
totalWealth: centsJson(entry.totalWealth),
|
||||||
|
}));
|
||||||
|
|
||||||
res.json({ leaderboard: ranked });
|
res.json({ leaderboard: ranked });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ import {
|
|||||||
serializePrestige,
|
serializePrestige,
|
||||||
serializeSkillTree,
|
serializeSkillTree,
|
||||||
skillLevelFromUser,
|
skillLevelFromUser,
|
||||||
skillUpgradeCost,
|
skillLevelsRemaining,
|
||||||
|
skillUpgradeCostBulk,
|
||||||
|
toPrBigInt,
|
||||||
|
prJson,
|
||||||
} from '../prestige.js';
|
} from '../prestige.js';
|
||||||
import { checkAchievements } from '../achievements.js';
|
import { checkAchievements } from '../achievements.js';
|
||||||
|
import { centsJson, toCentsBigInt, addCents, spendUserBalance } from '../cents.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const COST = PRESTIGE_COST_CENTS;
|
const COST = PRESTIGE_COST_CENTS;
|
||||||
@@ -36,18 +40,20 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const balance = BigInt(user.balance);
|
const balance = toCentsBigInt(user.balance);
|
||||||
const totalCents = Number(user.balance) + inventoryValue;
|
const totalCents = balance + inventoryValue;
|
||||||
const pageVisible = isPrestigePageVisible(user, totalCents);
|
const pageVisible = isPrestigePageVisible(user, totalCents);
|
||||||
const canAfford = balance >= COST;
|
const canAfford = balance >= COST;
|
||||||
const canPrestige = pageVisible && canAfford && lockedCount === 0;
|
const canPrestige = pageVisible && canAfford && lockedCount === 0;
|
||||||
const previewPr = canAfford ? estimatePrReward(user.balance) : 0;
|
const previewPr = canAfford
|
||||||
|
? estimatePrReward(user.balance, user.prestigePrBonus ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
prestige: serializePrestige(user),
|
prestige: serializePrestige(user),
|
||||||
skills: serializeSkillTree(user),
|
skills: serializeSkillTree(user),
|
||||||
pageVisible,
|
pageVisible,
|
||||||
totalCents,
|
totalCents: totalCents.toString(),
|
||||||
canAfford,
|
canAfford,
|
||||||
lockedItems: lockedCount,
|
lockedItems: lockedCount,
|
||||||
canPrestige,
|
canPrestige,
|
||||||
@@ -55,7 +61,7 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
history: logs.map((l) => ({
|
history: logs.map((l) => ({
|
||||||
id: l.id,
|
id: l.id,
|
||||||
choice: l.choice,
|
choice: l.choice,
|
||||||
prGranted: l.prGranted ?? 0,
|
prGranted: prJson(l.prGranted ?? 0),
|
||||||
createdAt: l.createdAt,
|
createdAt: l.createdAt,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
@@ -85,11 +91,6 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
err.status = 403;
|
err.status = 403;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (BigInt(user.balance) < COST) {
|
|
||||||
const err = new Error('Insufficient balance for prestige');
|
|
||||||
err.status = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lockedCount = await tx.inventoryItem.count({
|
const lockedCount = await tx.inventoryItem.count({
|
||||||
where: { userId: user.id, locked: true },
|
where: { userId: user.id, locked: true },
|
||||||
@@ -102,22 +103,28 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prGranted = computePrReward(user.balance);
|
// Lock funds atomically (balance >= COST). Remainder wiped with the reset below.
|
||||||
|
await spendUserBalance(tx, user.id, COST, {
|
||||||
|
message: 'Insufficient balance for prestige',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fresh = await tx.user.findUnique({ where: { id: user.id } });
|
||||||
|
const previousBalance = toCentsBigInt(user.balance);
|
||||||
|
const prGranted = computePrReward(previousBalance, user.prestigePrBonus ?? 0);
|
||||||
const prestigeCount = (user.prestigeCount ?? 0) + 1;
|
const prestigeCount = (user.prestigeCount ?? 0) + 1;
|
||||||
|
|
||||||
await tx.inventoryItem.deleteMany({ where: { userId: user.id } });
|
await tx.inventoryItem.deleteMany({ where: { userId: user.id } });
|
||||||
await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } });
|
await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } });
|
||||||
await tx.catalogEntry.deleteMany({ where: { userId: user.id } });
|
|
||||||
|
|
||||||
await tx.transaction.create({
|
await tx.transaction.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: 'prestige',
|
type: 'prestige',
|
||||||
amount: -Number(COST),
|
amount: centsJson(-COST),
|
||||||
meta: JSON.stringify({
|
meta: JSON.stringify({
|
||||||
prestigeCount,
|
prestigeCount,
|
||||||
prGranted,
|
prGranted,
|
||||||
previousBalance: Number(user.balance),
|
previousBalance: centsJson(previousBalance),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -126,20 +133,22 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
choice: 'reset',
|
choice: 'reset',
|
||||||
prGranted,
|
prGranted: prJson(prGranted),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wipe leftover credits after the COST spend (prestige resets economy).
|
||||||
const updated = await tx.user.update({
|
const updated = await tx.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
prestigeCount,
|
prestigeCount,
|
||||||
balance: 0,
|
balance: '0',
|
||||||
prBalance: { increment: prGranted },
|
prBalance: addCents(fresh.prBalance, prGranted),
|
||||||
|
vaultYieldAt: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { user: updated, prGranted };
|
return { user: updated, prGranted: prJson(prGranted) };
|
||||||
});
|
});
|
||||||
|
|
||||||
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
|
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
|
||||||
@@ -164,6 +173,10 @@ router.post('/upgrade', requireAuth, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Unknown skill' });
|
return res.status(400).json({ error: 'Unknown skill' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let count = Number(req.body?.count ?? 1);
|
||||||
|
if (!Number.isFinite(count)) count = 1;
|
||||||
|
count = Math.max(1, Math.min(10, Math.floor(count)));
|
||||||
|
|
||||||
const skill = PRESTIGE_SKILLS[skillId];
|
const skill = PRESTIGE_SKILLS[skillId];
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
@@ -180,8 +193,16 @@ router.post('/upgrade', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const level = skillLevelFromUser(user, skillId);
|
const level = skillLevelFromUser(user, skillId);
|
||||||
const cost = skillUpgradeCost(skillId, level);
|
const room = skillLevelsRemaining(user, skillId);
|
||||||
if ((user.prBalance ?? 0) < cost) {
|
if (room <= 0) {
|
||||||
|
const err = new Error('Skill is already at max level');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
count = Math.min(count, room);
|
||||||
|
|
||||||
|
const cost = skillUpgradeCostBulk(skillId, level, count);
|
||||||
|
if (toPrBigInt(user.prBalance) < BigInt(cost)) {
|
||||||
const err = new Error('Not enough Pr');
|
const err = new Error('Not enough Pr');
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
throw err;
|
||||||
@@ -190,8 +211,8 @@ router.post('/upgrade', requireAuth, async (req, res) => {
|
|||||||
const updated = await tx.user.update({
|
const updated = await tx.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
prBalance: { decrement: cost },
|
prBalance: addCents(user.prBalance, -cost),
|
||||||
[skill.field]: { increment: skill.delta },
|
[skill.field]: { increment: skill.delta * count },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,11 +220,12 @@ router.post('/upgrade', requireAuth, async (req, res) => {
|
|||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: 'prestige_upgrade',
|
type: 'prestige_upgrade',
|
||||||
amount: 0,
|
amount: '0',
|
||||||
meta: JSON.stringify({
|
meta: JSON.stringify({
|
||||||
skillId,
|
skillId,
|
||||||
cost,
|
cost,
|
||||||
delta: skill.delta,
|
count,
|
||||||
|
delta: skill.delta * count,
|
||||||
field: skill.field,
|
field: skill.field,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -212,6 +234,8 @@ router.post('/upgrade', requireAuth, async (req, res) => {
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
checkAchievements(result.id).catch((err) => console.error('achievements', err));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
user: publicUser(result),
|
user: publicUser(result),
|
||||||
prestige: serializePrestige(result),
|
prestige: serializePrestige(result),
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import multer from 'multer';
|
|
||||||
import path from 'path';
|
|
||||||
import fs from 'fs';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { prisma } from '../db.js';
|
import { prisma } from '../db.js';
|
||||||
import { requireAuth, publicUser } from '../middleware.js';
|
import { requireAuth, publicUser } from '../middleware.js';
|
||||||
import { getCompletedCatalogBadges } from '../catalog.js';
|
import { getCompletedCatalogBadges } from '../catalog.js';
|
||||||
@@ -13,32 +9,15 @@ import {
|
|||||||
checkAchievements,
|
checkAchievements,
|
||||||
getUnlockedAchievements,
|
getUnlockedAchievements,
|
||||||
} from '../achievements.js';
|
} from '../achievements.js';
|
||||||
|
import { toCentsBigInt, centsJson } from '../cents.js';
|
||||||
|
import { createUploader, processUploadedImage, safeExt } from '../upload.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const uploadsDir = path.join(__dirname, '../../uploads');
|
|
||||||
|
|
||||||
if (!fs.existsSync(uploadsDir)) {
|
const upload = createUploader('avatar', {
|
||||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
maxBytes: 2 * 1024 * 1024,
|
||||||
}
|
|
||||||
|
|
||||||
const storage = multer.diskStorage({
|
|
||||||
destination: (_req, _file, cb) => cb(null, uploadsDir),
|
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const ext = path.extname(file.originalname).toLowerCase() || '.png';
|
cb(null, `avatar-${req.session.userId}-${Date.now()}${safeExt(file.originalname, file.mimetype)}`);
|
||||||
const safe = ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext) ? ext : '.png';
|
|
||||||
cb(null, `avatar-${req.session.userId}-${Date.now()}${safe}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const upload = multer({
|
|
||||||
storage,
|
|
||||||
limits: { fileSize: 2 * 1024 * 1024 },
|
|
||||||
fileFilter: (_req, file, cb) => {
|
|
||||||
if (!file.mimetype.startsWith('image/')) {
|
|
||||||
return cb(new Error('Only images are allowed'));
|
|
||||||
}
|
|
||||||
cb(null, true);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,6 +49,7 @@ function serializeInv(inv) {
|
|||||||
paintSeed: inv.paintSeed,
|
paintSeed: inv.paintSeed,
|
||||||
valueCents: inv.valueCents,
|
valueCents: inv.valueCents,
|
||||||
favorite: Boolean(inv.favorite),
|
favorite: Boolean(inv.favorite),
|
||||||
|
staked: Boolean(inv.staked),
|
||||||
item: {
|
item: {
|
||||||
id: inv.item.id,
|
id: inv.item.id,
|
||||||
name: inv.item.name,
|
name: inv.item.name,
|
||||||
@@ -103,18 +83,18 @@ router.get('/player/:username', async (req, res) => {
|
|||||||
});
|
});
|
||||||
if (!user) return res.status(404).json({ error: 'Player not found' });
|
if (!user) return res.status(404).json({ error: 'Player not found' });
|
||||||
|
|
||||||
const inventoryValue = user.inventory.reduce((sum, inv) => sum + inv.valueCents, 0);
|
const inventoryValue = user.inventory.reduce(
|
||||||
|
(sum, inv) => sum + toCentsBigInt(inv.valueCents),
|
||||||
|
0n
|
||||||
|
);
|
||||||
|
|
||||||
await checkAchievements(user.id);
|
await checkAchievements(user.id);
|
||||||
|
|
||||||
const [jackpotWins, duelWins, casesOpened, caseOpens, catalog, achievements] =
|
const [jackpotWins, casesOpened, caseOpens, catalog, achievements] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.transaction.count({
|
prisma.transaction.count({
|
||||||
where: { userId: user.id, type: 'jackpot_win' },
|
where: { userId: user.id, type: 'jackpot_win' },
|
||||||
}),
|
}),
|
||||||
prisma.transaction.count({
|
|
||||||
where: { userId: user.id, type: 'duel_win' },
|
|
||||||
}),
|
|
||||||
prisma.transaction.count({
|
prisma.transaction.count({
|
||||||
where: { userId: user.id, type: 'open_case' },
|
where: { userId: user.id, type: 'open_case' },
|
||||||
}),
|
}),
|
||||||
@@ -127,10 +107,9 @@ router.get('/player/:username', async (req, res) => {
|
|||||||
user: publicUser(user),
|
user: publicUser(user),
|
||||||
profile: {
|
profile: {
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
inventoryValue,
|
inventoryValue: centsJson(inventoryValue),
|
||||||
inventoryCount: user.inventory.length,
|
inventoryCount: user.inventory.length,
|
||||||
jackpotWins,
|
jackpotWins,
|
||||||
duelWins,
|
|
||||||
casesOpened,
|
casesOpened,
|
||||||
caseOpens,
|
caseOpens,
|
||||||
catalogCompleted: catalog.completed,
|
catalogCompleted: catalog.completed,
|
||||||
@@ -158,20 +137,17 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
const inventoryValue = user.inventory.reduce(
|
const inventoryValue = user.inventory.reduce(
|
||||||
(sum, inv) => sum + (inv.locked ? 0 : inv.valueCents),
|
(sum, inv) => sum + (inv.locked ? 0n : toCentsBigInt(inv.valueCents)),
|
||||||
0
|
0n
|
||||||
);
|
);
|
||||||
|
|
||||||
await checkAchievements(user.id);
|
await checkAchievements(user.id);
|
||||||
|
|
||||||
const [jackpotWins, duelWins, casesOpened, caseOpens, catalog, achievements] =
|
const [jackpotWins, casesOpened, caseOpens, catalog, achievements] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.transaction.count({
|
prisma.transaction.count({
|
||||||
where: { userId: user.id, type: 'jackpot_win' },
|
where: { userId: user.id, type: 'jackpot_win' },
|
||||||
}),
|
}),
|
||||||
prisma.transaction.count({
|
|
||||||
where: { userId: user.id, type: 'duel_win' },
|
|
||||||
}),
|
|
||||||
prisma.transaction.count({
|
prisma.transaction.count({
|
||||||
where: { userId: user.id, type: 'open_case' },
|
where: { userId: user.id, type: 'open_case' },
|
||||||
}),
|
}),
|
||||||
@@ -184,10 +160,9 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
user: publicUser(user),
|
user: publicUser(user),
|
||||||
profile: {
|
profile: {
|
||||||
createdAt: user.createdAt,
|
createdAt: user.createdAt,
|
||||||
inventoryValue,
|
inventoryValue: centsJson(inventoryValue),
|
||||||
inventoryCount: user.inventory.filter((i) => !i.locked).length,
|
inventoryCount: user.inventory.filter((i) => !i.locked).length,
|
||||||
jackpotWins,
|
jackpotWins,
|
||||||
duelWins,
|
|
||||||
casesOpened,
|
casesOpened,
|
||||||
caseOpens,
|
caseOpens,
|
||||||
catalogCompleted: catalog.completed,
|
catalogCompleted: catalog.completed,
|
||||||
@@ -256,6 +231,7 @@ router.post('/avatar', requireAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await processUploadedImage(req.file);
|
||||||
const avatarUrl = `/uploads/${req.file.filename}`;
|
const avatarUrl = `/uploads/${req.file.filename}`;
|
||||||
const user = await prisma.user.update({
|
const user = await prisma.user.update({
|
||||||
where: { id: req.session.userId },
|
where: { id: req.session.userId },
|
||||||
|
|||||||
+79
-28
@@ -3,17 +3,18 @@ import { prisma } from '../db.js';
|
|||||||
import { requireAuth, publicUser } from '../middleware.js';
|
import { requireAuth, publicUser } from '../middleware.js';
|
||||||
import { checkAchievements } from '../achievements.js';
|
import { checkAchievements } from '../achievements.js';
|
||||||
import { adCooldownMs } from '../prestige.js';
|
import { adCooldownMs } from '../prestige.js';
|
||||||
|
import { centsJson, creditUserBalance } from '../cents.js';
|
||||||
|
import {
|
||||||
|
getCreditPack,
|
||||||
|
paymentsProvider,
|
||||||
|
scaleCreditPack,
|
||||||
|
scaleCreditPacks,
|
||||||
|
} from '../shopCatalog.js';
|
||||||
|
import { listEnabledShopOsuPacks, isOsuTopupsSectionEnabled } from '../shopOsu.js';
|
||||||
|
import { confirmMockOsuPayment, createOsuCheckout } from '../payments.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
/** Packages: spend osu → receive credits (cents). */
|
|
||||||
export const SHOP_PACKAGES = [
|
|
||||||
{ id: 'starter', name: 'Starter Pack', osuCost: 10, credits: 5000, blurb: '50.00 cr' },
|
|
||||||
{ id: 'boost', name: 'Boost Pack', osuCost: 25, credits: 15000, blurb: '150.00 cr' },
|
|
||||||
{ id: 'pro', name: 'Pro Pack', osuCost: 50, credits: 35000, blurb: '350.00 cr' },
|
|
||||||
{ id: 'whale', name: 'Whale Pack', osuCost: 100, credits: 80000, blurb: '800.00 cr' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const AD_REWARD_MIN = 10000; // 100.00 cr
|
const AD_REWARD_MIN = 10000; // 100.00 cr
|
||||||
const AD_REWARD_MAX = 20000; // 200.00 cr
|
const AD_REWARD_MAX = 20000; // 200.00 cr
|
||||||
|
|
||||||
@@ -38,13 +39,19 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
||||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
const packages = SHOP_PACKAGES.map((pack) => ({
|
const packages = scaleCreditPacks(user.balance);
|
||||||
...pack,
|
const sectionEnabled = await isOsuTopupsSectionEnabled();
|
||||||
baseOsuCost: pack.osuCost,
|
const osuTopups = sectionEnabled ? await listEnabledShopOsuPacks() : [];
|
||||||
}));
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
packages,
|
packages,
|
||||||
|
osuTopups,
|
||||||
|
osuTopupsEnabled: sectionEnabled && osuTopups.length > 0,
|
||||||
|
payments: {
|
||||||
|
provider: paymentsProvider(),
|
||||||
|
currency: 'eur',
|
||||||
|
mock: paymentsProvider() === 'mock',
|
||||||
|
},
|
||||||
user: publicUser(user),
|
user: publicUser(user),
|
||||||
ad: adPayload(user),
|
ad: adPayload(user),
|
||||||
});
|
});
|
||||||
@@ -54,11 +61,12 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Spend osu → credits (amount scaled to live balance at purchase time). */
|
||||||
router.post('/buy', requireAuth, async (req, res) => {
|
router.post('/buy', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const packageId = String(req.body.packageId || '');
|
const packageId = String(req.body.packageId || '');
|
||||||
const pack = SHOP_PACKAGES.find((p) => p.id === packageId);
|
const packDef = getCreditPack(packageId);
|
||||||
if (!pack) {
|
if (!packDef) {
|
||||||
return res.status(400).json({ error: 'Unknown package' });
|
return res.status(400).json({ error: 'Unknown package' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +77,7 @@ router.post('/buy', requireAuth, async (req, res) => {
|
|||||||
err.status = 404;
|
err.status = 404;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
const pack = scaleCreditPack(packDef, user.balance);
|
||||||
const osuCost = pack.osuCost;
|
const osuCost = pack.osuCost;
|
||||||
if (user.osuBalance < osuCost) {
|
if (user.osuBalance < osuCost) {
|
||||||
const err = new Error('Not enough osu');
|
const err = new Error('Not enough osu');
|
||||||
@@ -76,35 +85,36 @@ router.post('/buy', requireAuth, async (req, res) => {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: { osuBalance: { decrement: osuCost } },
|
||||||
osuBalance: { decrement: osuCost },
|
|
||||||
balance: { increment: pack.credits },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const updated = await creditUserBalance(tx, user.id, pack.credits);
|
||||||
|
|
||||||
await tx.transaction.create({
|
await tx.transaction.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: 'shop_purchase',
|
type: 'shop_purchase',
|
||||||
amount: pack.credits,
|
amount: centsJson(pack.credits),
|
||||||
meta: JSON.stringify({
|
meta: JSON.stringify({
|
||||||
packageId: pack.id,
|
packageId: pack.id,
|
||||||
osuCost,
|
osuCost,
|
||||||
credits: pack.credits,
|
credits: pack.credits,
|
||||||
|
balancePct: pack.balancePct,
|
||||||
|
balanceBefore: String(user.balance),
|
||||||
|
scaled: pack.scaled,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { user: updated, osuCost };
|
return { user: updated, osuCost, pack };
|
||||||
});
|
});
|
||||||
|
|
||||||
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
|
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
user: publicUser(result.user),
|
user: publicUser(result.user),
|
||||||
package: { ...pack, osuCost: result.osuCost },
|
package: result.pack,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = err.status || 500;
|
const status = err.status || 500;
|
||||||
@@ -113,6 +123,49 @@ router.post('/buy', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Start real-money → osu checkout (mock or future Stripe). */
|
||||||
|
router.post('/osu/checkout', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const packId = String(req.body.packId || '');
|
||||||
|
const checkout = await createOsuCheckout(req.session.userId, packId);
|
||||||
|
res.json(checkout);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Checkout failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Complete mock payment and credit osu. */
|
||||||
|
router.post('/osu/confirm', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const orderId = String(req.body.orderId || '');
|
||||||
|
const result = await confirmMockOsuPayment(req.session.userId, orderId);
|
||||||
|
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
|
||||||
|
res.json({
|
||||||
|
user: publicUser(result.user),
|
||||||
|
order: result.order,
|
||||||
|
alreadyPaid: result.alreadyPaid,
|
||||||
|
osuGranted: result.order.osuAmount,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Payment confirmation failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stripe webhook stub — verify signature & fulfill when PAYMENTS_PROVIDER=stripe.
|
||||||
|
* Keep raw body parsing in mind when wiring Stripe.
|
||||||
|
*/
|
||||||
|
router.post('/osu/webhook', async (req, res) => {
|
||||||
|
if (paymentsProvider() !== 'stripe') {
|
||||||
|
return res.status(404).json({ error: 'Stripe webhooks not enabled' });
|
||||||
|
}
|
||||||
|
res.status(501).json({ error: 'Stripe webhook not implemented yet' });
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/ad', requireAuth, async (req, res) => {
|
router.post('/ad', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
@@ -138,19 +191,17 @@ router.post('/ad', requireAuth, async (req, res) => {
|
|||||||
AD_REWARD_MIN + Math.floor(Math.random() * (AD_REWARD_MAX - AD_REWARD_MIN + 1));
|
AD_REWARD_MIN + Math.floor(Math.random() * (AD_REWARD_MAX - AD_REWARD_MIN + 1));
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const updated = await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: { lastAdAt: now },
|
||||||
balance: { increment: reward },
|
|
||||||
lastAdAt: now,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const updated = await creditUserBalance(tx, user.id, reward);
|
||||||
|
|
||||||
await tx.transaction.create({
|
await tx.transaction.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: 'ad_reward',
|
type: 'ad_reward',
|
||||||
amount: reward,
|
amount: centsJson(reward),
|
||||||
meta: JSON.stringify({ reward }),
|
meta: JSON.stringify({ reward }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+37
-46
@@ -4,8 +4,7 @@ import { requireAuth, publicUser } from '../middleware.js';
|
|||||||
import {
|
import {
|
||||||
maxVaultSlots,
|
maxVaultSlots,
|
||||||
settleVaultYield,
|
settleVaultYield,
|
||||||
summarizeVaultRates,
|
summarizeVaultState,
|
||||||
estimatePendingYield,
|
|
||||||
} from '../vault.js';
|
} from '../vault.js';
|
||||||
import { isConnected } from '../presence.js';
|
import { isConnected } from '../presence.js';
|
||||||
|
|
||||||
@@ -18,6 +17,8 @@ router.post('/stake', requireAuth, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Select at least one item to stake' });
|
return res.status(400).json({ error: 'Select at least one item to stake' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -61,16 +62,31 @@ router.post('/stake', requireAuth, async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { user, stakedIds: ids, maxSlots, used: used + ids.length };
|
// Shared timer: start only when vault was empty
|
||||||
|
let updatedUser = user;
|
||||||
|
if (used === 0 || !user.vaultYieldAt) {
|
||||||
|
updatedUser = await tx.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { vaultYieldAt: now },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const staked = await tx.inventoryItem.findMany({
|
||||||
|
where: { userId: user.id, staked: true },
|
||||||
|
include: { item: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: updatedUser,
|
||||||
|
stakedIds: ids,
|
||||||
|
vault: summarizeVaultState(updatedUser, staked, mode),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
user: publicUser(result.user),
|
user: publicUser(result.user),
|
||||||
stakedIds: result.stakedIds,
|
stakedIds: result.stakedIds,
|
||||||
vault: {
|
vault: result.vault,
|
||||||
used: result.used,
|
|
||||||
slots: result.maxSlots,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = err.status || 500;
|
const status = err.status || 500;
|
||||||
@@ -86,7 +102,6 @@ router.post('/unstake', requireAuth, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Select at least one item to unstake' });
|
return res.status(400).json({ error: 'Select at least one item to unstake' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settle yield before unstake (online if connected, else offline)
|
|
||||||
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
|
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
|
||||||
const settled = await settleVaultYield(req.session.userId, mode);
|
const settled = await settleVaultYield(req.session.userId, mode);
|
||||||
|
|
||||||
@@ -120,16 +135,25 @@ router.post('/unstake', requireAuth, async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const used = await tx.inventoryItem.count({
|
const remaining = await tx.inventoryItem.findMany({
|
||||||
where: { userId: user.id, staked: true },
|
where: { userId: user.id, staked: true },
|
||||||
|
include: { item: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshed = await tx.user.findUnique({ where: { id: user.id } });
|
let refreshed = user;
|
||||||
|
if (remaining.length === 0 && user.vaultYieldAt) {
|
||||||
|
refreshed = await tx.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { vaultYieldAt: null },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
refreshed = await tx.user.findUnique({ where: { id: user.id } });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: refreshed,
|
user: refreshed,
|
||||||
unstakedIds: ids,
|
unstakedIds: ids,
|
||||||
maxSlots: maxVaultSlots(refreshed),
|
vault: summarizeVaultState(refreshed, remaining, mode),
|
||||||
used,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,10 +161,7 @@ router.post('/unstake', requireAuth, async (req, res) => {
|
|||||||
user: publicUser(result.user),
|
user: publicUser(result.user),
|
||||||
unstakedIds: result.unstakedIds,
|
unstakedIds: result.unstakedIds,
|
||||||
yieldCredited: settled.credited,
|
yieldCredited: settled.credited,
|
||||||
vault: {
|
vault: result.vault,
|
||||||
used: result.used,
|
|
||||||
slots: result.maxSlots,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = err.status || 500;
|
const status = err.status || 500;
|
||||||
@@ -149,34 +170,4 @@ router.post('/unstake', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/claim', requireAuth, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
|
|
||||||
const settled = await settleVaultYield(req.session.userId, mode);
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
|
||||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
||||||
|
|
||||||
const staked = await prisma.inventoryItem.findMany({
|
|
||||||
where: { userId: user.id, staked: true },
|
|
||||||
include: { item: true },
|
|
||||||
});
|
|
||||||
const rates = summarizeVaultRates(staked, user);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
user: publicUser(user),
|
|
||||||
credited: settled.credited,
|
|
||||||
ticks: settled.ticks,
|
|
||||||
vault: {
|
|
||||||
used: staked.length,
|
|
||||||
slots: maxVaultSlots(user),
|
|
||||||
...rates,
|
|
||||||
pendingYield: estimatePendingYield(staked, user, mode),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: err.message || 'Failed to claim vault yield' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+328
-252
@@ -1,9 +1,10 @@
|
|||||||
import { prisma } from '../db.js';
|
import { prisma } from '../db.js';
|
||||||
import { pickWeighted } from '../weighted.js';
|
import { pickWeighted } from '../weighted.js';
|
||||||
import { applyAutoSell } from '../autoSell.js';
|
import { applyAutoSell } from '../autoSell.js';
|
||||||
import { emitJackpot } from '../realtime.js';
|
import { emitJackpot, emitJackpotsList } from '../realtime.js';
|
||||||
import { pushDrop } from '../feed.js';
|
import { pushDrop } from '../feed.js';
|
||||||
import { checkAchievements } from '../achievements.js';
|
import { checkAchievements } from '../achievements.js';
|
||||||
|
import { toCentsBigInt, centsJson, creditUserBalance } from '../cents.js';
|
||||||
|
|
||||||
const ROUND_SECONDS = Number(process.env.JACKPOT_ROUND_SECONDS || 30);
|
const ROUND_SECONDS = Number(process.env.JACKPOT_ROUND_SECONDS || 30);
|
||||||
const MAX_ITEMS = Number(process.env.JACKPOT_MAX_ITEMS || 3);
|
const MAX_ITEMS = Number(process.env.JACKPOT_MAX_ITEMS || 3);
|
||||||
@@ -12,8 +13,8 @@ const SPIN_MS = 5500;
|
|||||||
/** Sentinel endsAt while waiting for enough players (timer not armed). */
|
/** Sentinel endsAt while waiting for enough players (timer not armed). */
|
||||||
const WAITING_ENDS_AT = new Date('2099-01-01T00:00:00.000Z');
|
const WAITING_ENDS_AT = new Date('2099-01-01T00:00:00.000Z');
|
||||||
|
|
||||||
let resolveTimer = null;
|
const resolveTimers = new Map();
|
||||||
let spinFollowUp = null;
|
const spinFollowUps = new Map();
|
||||||
|
|
||||||
function isTimerWaiting(endsAt) {
|
function isTimerWaiting(endsAt) {
|
||||||
return new Date(endsAt).getFullYear() >= 2099;
|
return new Date(endsAt).getFullYear() >= 2099;
|
||||||
@@ -30,6 +31,14 @@ function depositInclude() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roundInclude() {
|
||||||
|
return {
|
||||||
|
deposits: { include: depositInclude() },
|
||||||
|
winner: { select: { id: true, username: true, avatarUrl: true } },
|
||||||
|
creator: { select: { id: true, username: true, avatarUrl: true } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function serializeJackpotRound(round) {
|
export function serializeJackpotRound(round) {
|
||||||
if (!round) return null;
|
if (!round) return null;
|
||||||
|
|
||||||
@@ -56,24 +65,27 @@ export function serializeJackpotRound(round) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const byUser = new Map();
|
const byUser = new Map();
|
||||||
let totalValue = 0;
|
let totalValue = 0n;
|
||||||
for (const d of deposits) {
|
for (const d of deposits) {
|
||||||
totalValue += d.valueCents;
|
const v = toCentsBigInt(d.valueCents);
|
||||||
|
totalValue += v;
|
||||||
const prev = byUser.get(d.userId) || {
|
const prev = byUser.get(d.userId) || {
|
||||||
userId: d.userId,
|
userId: d.userId,
|
||||||
username: d.username,
|
username: d.username,
|
||||||
avatarUrl: d.avatarUrl,
|
avatarUrl: d.avatarUrl,
|
||||||
valueCents: 0,
|
valueCents: 0n,
|
||||||
itemCount: 0,
|
itemCount: 0,
|
||||||
};
|
};
|
||||||
prev.valueCents += d.valueCents;
|
prev.valueCents += v;
|
||||||
prev.itemCount += 1;
|
prev.itemCount += 1;
|
||||||
byUser.set(d.userId, prev);
|
byUser.set(d.userId, prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
const players = [...byUser.values()].map((p) => ({
|
const players = [...byUser.values()].map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
chance: totalValue > 0 ? (p.valueCents / totalValue) * 100 : 0,
|
valueCents: centsJson(p.valueCents),
|
||||||
|
chance:
|
||||||
|
totalValue > 0n ? Number((p.valueCents * 10000n) / totalValue) / 100 : 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const waitingForPlayers =
|
const waitingForPlayers =
|
||||||
@@ -82,6 +94,9 @@ export function serializeJackpotRound(round) {
|
|||||||
return {
|
return {
|
||||||
id: round.id,
|
id: round.id,
|
||||||
status: round.status,
|
status: round.status,
|
||||||
|
creatorId: round.creatorId || null,
|
||||||
|
creatorUsername: round.creator?.username || null,
|
||||||
|
name: `Battle ${round.id}`,
|
||||||
endsAt: waitingForPlayers ? null : round.endsAt,
|
endsAt: waitingForPlayers ? null : round.endsAt,
|
||||||
waitingForPlayers,
|
waitingForPlayers,
|
||||||
roundSeconds: ROUND_SECONDS,
|
roundSeconds: ROUND_SECONDS,
|
||||||
@@ -90,133 +105,198 @@ export function serializeJackpotRound(round) {
|
|||||||
resolvedAt: round.resolvedAt || null,
|
resolvedAt: round.resolvedAt || null,
|
||||||
winnerUserId: round.winnerUserId || null,
|
winnerUserId: round.winnerUserId || null,
|
||||||
winnerUsername: round.winner?.username || null,
|
winnerUsername: round.winner?.username || null,
|
||||||
totalValue,
|
totalValue: centsJson(totalValue),
|
||||||
maxItemsPerPlayer: MAX_ITEMS,
|
maxItemsPerPlayer: MAX_ITEMS,
|
||||||
players,
|
players,
|
||||||
deposits,
|
deposits,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOpenOrLatest() {
|
async function loadRound(id) {
|
||||||
const open = await prisma.jackpotRound.findFirst({
|
return prisma.jackpotRound.findUnique({
|
||||||
where: { status: { in: ['open', 'spinning'] } },
|
where: { id: Number(id) },
|
||||||
include: {
|
include: roundInclude(),
|
||||||
deposits: { include: depositInclude() },
|
});
|
||||||
winner: { select: { id: true, username: true } },
|
}
|
||||||
},
|
|
||||||
orderBy: { id: 'desc' },
|
export async function getJackpotRound(id) {
|
||||||
});
|
return serializeJackpotRound(await loadRound(id));
|
||||||
if (open) return open;
|
}
|
||||||
|
|
||||||
return prisma.jackpotRound.findFirst({
|
export async function listJackpotBattles() {
|
||||||
include: {
|
const rounds = await prisma.jackpotRound.findMany({
|
||||||
deposits: { include: depositInclude() },
|
where: {
|
||||||
winner: { select: { id: true, username: true } },
|
OR: [
|
||||||
|
{ status: 'spinning' },
|
||||||
|
{ status: 'open', deposits: { some: {} } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
include: roundInclude(),
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
});
|
});
|
||||||
|
return rounds.map(serializeJackpotRound);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getJackpotState() {
|
export async function broadcastJackpotsList() {
|
||||||
const round = await loadOpenOrLatest();
|
const battles = await listJackpotBattles();
|
||||||
return serializeJackpotRound(round);
|
emitJackpotsList({ battles });
|
||||||
|
return battles;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function broadcastJackpot(extra = {}) {
|
function clearResolveTimer(roundId) {
|
||||||
const state = await getJackpotState();
|
const t = resolveTimers.get(roundId);
|
||||||
emitJackpot('jackpot:state', { ...state, ...extra });
|
if (t) {
|
||||||
return state;
|
clearTimeout(t);
|
||||||
}
|
resolveTimers.delete(roundId);
|
||||||
|
|
||||||
async function createOpenRound() {
|
|
||||||
return prisma.jackpotRound.create({
|
|
||||||
data: { status: 'open', endsAt: WAITING_ENDS_AT },
|
|
||||||
include: {
|
|
||||||
deposits: { include: depositInclude() },
|
|
||||||
winner: { select: { id: true, username: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearResolveTimer() {
|
|
||||||
if (resolveTimer) {
|
|
||||||
clearTimeout(resolveTimer);
|
|
||||||
resolveTimer = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearTimers() {
|
function clearSpinFollowUp(roundId) {
|
||||||
clearResolveTimer();
|
const t = spinFollowUps.get(roundId);
|
||||||
if (spinFollowUp) {
|
if (t) {
|
||||||
clearTimeout(spinFollowUp);
|
clearTimeout(t);
|
||||||
spinFollowUp = null;
|
spinFollowUps.delete(roundId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleResolve(endsAt) {
|
function clearTimers(roundId) {
|
||||||
clearResolveTimer();
|
clearResolveTimer(roundId);
|
||||||
|
clearSpinFollowUp(roundId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleResolve(roundId, endsAt) {
|
||||||
|
clearResolveTimer(roundId);
|
||||||
if (isTimerWaiting(endsAt)) return;
|
if (isTimerWaiting(endsAt)) return;
|
||||||
const delay = Math.max(0, new Date(endsAt).getTime() - Date.now());
|
const delay = Math.max(0, new Date(endsAt).getTime() - Date.now());
|
||||||
resolveTimer = setTimeout(() => {
|
resolveTimers.set(
|
||||||
resolveJackpotRound().catch((err) => console.error('jackpot resolve', err));
|
roundId,
|
||||||
}, delay);
|
setTimeout(() => {
|
||||||
|
resolveJackpotRound(roundId).catch((err) =>
|
||||||
|
console.error('jackpot resolve', err)
|
||||||
|
);
|
||||||
|
}, delay)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover timers for all active battles (boot). Does not create empty rounds.
|
||||||
|
* Cancels orphan open battles with zero deposits (legacy singleton leftovers).
|
||||||
|
*/
|
||||||
export async function ensureJackpotRound() {
|
export async function ensureJackpotRound() {
|
||||||
let round = await prisma.jackpotRound.findFirst({
|
await prisma.jackpotRound.updateMany({
|
||||||
where: { status: 'open' },
|
where: {
|
||||||
include: { deposits: true },
|
status: 'open',
|
||||||
orderBy: { id: 'desc' },
|
deposits: { none: {} },
|
||||||
|
},
|
||||||
|
data: { status: 'cancelled', resolvedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!round) {
|
const rounds = await prisma.jackpotRound.findMany({
|
||||||
const spinning = await prisma.jackpotRound.findFirst({
|
where: { status: { in: ['open', 'spinning'] } },
|
||||||
where: { status: 'spinning' },
|
include: { deposits: true },
|
||||||
orderBy: { id: 'desc' },
|
});
|
||||||
});
|
|
||||||
if (spinning) {
|
for (const round of rounds) {
|
||||||
spinFollowUp = setTimeout(() => {
|
if (round.status === 'spinning') {
|
||||||
finishJackpotSpin(spinning.id).catch((err) => console.error(err));
|
clearSpinFollowUp(round.id);
|
||||||
}, 1000);
|
spinFollowUps.set(
|
||||||
return getJackpotState();
|
round.id,
|
||||||
|
setTimeout(() => {
|
||||||
|
finishJackpotSpin(round.id).catch((err) => console.error(err));
|
||||||
|
}, 1000)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
round = await createOpenRound();
|
|
||||||
return getJackpotState();
|
|
||||||
}
|
|
||||||
|
|
||||||
const players = uniquePlayerCount(round.deposits);
|
const players = uniquePlayerCount(round.deposits);
|
||||||
|
if (players < MIN_PLAYERS) {
|
||||||
|
if (!isTimerWaiting(round.endsAt)) {
|
||||||
|
await prisma.jackpotRound.update({
|
||||||
|
where: { id: round.id },
|
||||||
|
data: { endsAt: WAITING_ENDS_AT },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
clearResolveTimer(round.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (players < MIN_PLAYERS) {
|
if (isTimerWaiting(round.endsAt)) {
|
||||||
if (!isTimerWaiting(round.endsAt)) {
|
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
|
||||||
await prisma.jackpotRound.update({
|
await prisma.jackpotRound.update({
|
||||||
where: { id: round.id },
|
where: { id: round.id },
|
||||||
data: { endsAt: WAITING_ENDS_AT },
|
data: { endsAt },
|
||||||
});
|
});
|
||||||
|
scheduleResolve(round.id, endsAt);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
clearResolveTimer();
|
|
||||||
return getJackpotState();
|
if (new Date(round.endsAt).getTime() <= Date.now()) {
|
||||||
|
await resolveJackpotRound(round.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleResolve(round.id, round.endsAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTimerWaiting(round.endsAt)) {
|
return listJackpotBattles();
|
||||||
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
|
|
||||||
await prisma.jackpotRound.update({
|
|
||||||
where: { id: round.id },
|
|
||||||
data: { endsAt },
|
|
||||||
});
|
|
||||||
scheduleResolve(endsAt);
|
|
||||||
return getJackpotState();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (new Date(round.endsAt).getTime() <= Date.now()) {
|
|
||||||
await resolveJackpotRound();
|
|
||||||
return getJackpotState();
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleResolve(round.endsAt);
|
|
||||||
return getJackpotState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function placeJackpotBet(userId, inventoryItemIds) {
|
async function applyDeposits(tx, roundId, userId, ids) {
|
||||||
|
const existingCount = await tx.jackpotDeposit.count({
|
||||||
|
where: { roundId, userId },
|
||||||
|
});
|
||||||
|
if (existingCount + ids.length > MAX_ITEMS) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(`You can bet at most ${MAX_ITEMS} items this round`),
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invItems = await tx.inventoryItem.findMany({
|
||||||
|
where: { id: { in: ids }, userId, locked: false, staked: false },
|
||||||
|
include: { item: true },
|
||||||
|
});
|
||||||
|
if (invItems.length !== ids.length) {
|
||||||
|
throw Object.assign(new Error('Invalid, locked, or staked inventory items'), {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const inv of invItems) {
|
||||||
|
await tx.inventoryItem.update({
|
||||||
|
where: { id: inv.id },
|
||||||
|
data: { locked: true },
|
||||||
|
});
|
||||||
|
await tx.jackpotDeposit.create({
|
||||||
|
data: {
|
||||||
|
roundId,
|
||||||
|
userId,
|
||||||
|
inventoryItemId: inv.id,
|
||||||
|
valueCents: inv.valueCents,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.transaction.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: 'jackpot_bet',
|
||||||
|
amount: '0',
|
||||||
|
meta: JSON.stringify({
|
||||||
|
roundId,
|
||||||
|
inventoryItemId: inv.id,
|
||||||
|
itemId: inv.itemId,
|
||||||
|
valueCents: centsJson(inv.valueCents),
|
||||||
|
wear: inv.wear,
|
||||||
|
floatValue: inv.floatValue,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new battle by placing the first bet.
|
||||||
|
*/
|
||||||
|
export async function createJackpotBattle(userId, inventoryItemIds) {
|
||||||
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
|
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
|
||||||
if (!ids.length) {
|
if (!ids.length) {
|
||||||
throw Object.assign(new Error('Select at least one item'), { status: 400 });
|
throw Object.assign(new Error('Select at least one item'), { status: 400 });
|
||||||
@@ -226,74 +306,58 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const round = await prisma.$transaction(async (tx) => {
|
const round = await prisma.$transaction(async (tx) => {
|
||||||
const open = await tx.jackpotRound.findFirst({
|
const created = await tx.jackpotRound.create({
|
||||||
where: { status: 'open' },
|
data: {
|
||||||
orderBy: { id: 'desc' },
|
status: 'open',
|
||||||
|
endsAt: WAITING_ENDS_AT,
|
||||||
|
creatorId: userId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!open) {
|
|
||||||
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
|
await applyDeposits(tx, created.id, userId, ids);
|
||||||
|
|
||||||
|
return tx.jackpotRound.findUnique({
|
||||||
|
where: { id: created.id },
|
||||||
|
include: roundInclude(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = serializeJackpotRound(round);
|
||||||
|
emitJackpot(round.id, 'jackpot:state', state);
|
||||||
|
await broadcastJackpotsList();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function placeJackpotBet(roundId, userId, inventoryItemIds) {
|
||||||
|
const id = Number(roundId);
|
||||||
|
if (!id) {
|
||||||
|
throw Object.assign(new Error('Invalid battle'), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
|
||||||
|
if (!ids.length) {
|
||||||
|
throw Object.assign(new Error('Select at least one item'), { status: 400 });
|
||||||
|
}
|
||||||
|
if (ids.length > MAX_ITEMS) {
|
||||||
|
throw Object.assign(new Error(`Max ${MAX_ITEMS} items per bet`), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const round = await prisma.$transaction(async (tx) => {
|
||||||
|
const open = await tx.jackpotRound.findUnique({ where: { id } });
|
||||||
|
if (!open || open.status !== 'open') {
|
||||||
|
throw Object.assign(new Error('Battle is not open'), { status: 409 });
|
||||||
}
|
}
|
||||||
if (!isTimerWaiting(open.endsAt) && new Date(open.endsAt).getTime() <= Date.now()) {
|
if (!isTimerWaiting(open.endsAt) && new Date(open.endsAt).getTime() <= Date.now()) {
|
||||||
throw Object.assign(new Error('Round is closing'), { status: 409 });
|
throw Object.assign(new Error('Round is closing'), { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingCount = await tx.jackpotDeposit.count({
|
await applyDeposits(tx, open.id, userId, ids);
|
||||||
where: { roundId: open.id, userId },
|
|
||||||
});
|
|
||||||
if (existingCount + ids.length > MAX_ITEMS) {
|
|
||||||
throw Object.assign(
|
|
||||||
new Error(`You can bet at most ${MAX_ITEMS} items this round`),
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const invItems = await tx.inventoryItem.findMany({
|
|
||||||
where: { id: { in: ids }, userId, locked: false, staked: false },
|
|
||||||
include: { item: true },
|
|
||||||
});
|
|
||||||
if (invItems.length !== ids.length) {
|
|
||||||
throw Object.assign(new Error('Invalid, locked, or staked inventory items'), { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const inv of invItems) {
|
|
||||||
await tx.inventoryItem.update({
|
|
||||||
where: { id: inv.id },
|
|
||||||
data: { locked: true },
|
|
||||||
});
|
|
||||||
await tx.jackpotDeposit.create({
|
|
||||||
data: {
|
|
||||||
roundId: open.id,
|
|
||||||
userId,
|
|
||||||
inventoryItemId: inv.id,
|
|
||||||
valueCents: inv.valueCents,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await tx.transaction.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
type: 'jackpot_bet',
|
|
||||||
amount: 0,
|
|
||||||
meta: JSON.stringify({
|
|
||||||
roundId: open.id,
|
|
||||||
inventoryItemId: inv.id,
|
|
||||||
itemId: inv.itemId,
|
|
||||||
valueCents: inv.valueCents,
|
|
||||||
wear: inv.wear,
|
|
||||||
floatValue: inv.floatValue,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let next = await tx.jackpotRound.findUnique({
|
let next = await tx.jackpotRound.findUnique({
|
||||||
where: { id: open.id },
|
where: { id: open.id },
|
||||||
include: {
|
include: roundInclude(),
|
||||||
deposits: { include: depositInclude() },
|
|
||||||
winner: { select: { id: true, username: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Arm countdown once we have enough distinct players
|
|
||||||
if (
|
if (
|
||||||
uniquePlayerCount(next.deposits) >= MIN_PLAYERS &&
|
uniquePlayerCount(next.deposits) >= MIN_PLAYERS &&
|
||||||
isTimerWaiting(next.endsAt)
|
isTimerWaiting(next.endsAt)
|
||||||
@@ -302,10 +366,7 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
|
|||||||
next = await tx.jackpotRound.update({
|
next = await tx.jackpotRound.update({
|
||||||
where: { id: next.id },
|
where: { id: next.id },
|
||||||
data: { endsAt },
|
data: { endsAt },
|
||||||
include: {
|
include: roundInclude(),
|
||||||
deposits: { include: depositInclude() },
|
|
||||||
winner: { select: { id: true, username: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,29 +377,35 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
|
|||||||
uniquePlayerCount(round.deposits) >= MIN_PLAYERS &&
|
uniquePlayerCount(round.deposits) >= MIN_PLAYERS &&
|
||||||
!isTimerWaiting(round.endsAt)
|
!isTimerWaiting(round.endsAt)
|
||||||
) {
|
) {
|
||||||
scheduleResolve(round.endsAt);
|
scheduleResolve(round.id, round.endsAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = serializeJackpotRound(round);
|
const state = serializeJackpotRound(round);
|
||||||
emitJackpot('jackpot:state', state);
|
emitJackpot(round.id, 'jackpot:state', state);
|
||||||
|
await broadcastJackpotsList();
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function withdrawJackpotBet(userId, inventoryItemIds) {
|
export async function withdrawJackpotBet(roundId, userId, inventoryItemIds) {
|
||||||
|
const id = Number(roundId);
|
||||||
|
if (!id) {
|
||||||
|
throw Object.assign(new Error('Invalid battle'), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
|
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
|
||||||
if (!ids.length) {
|
if (!ids.length) {
|
||||||
throw Object.assign(new Error('Select items to withdraw'), { status: 400 });
|
throw Object.assign(new Error('Select items to withdraw'), { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const round = await prisma.$transaction(async (tx) => {
|
const round = await prisma.$transaction(async (tx) => {
|
||||||
const open = await tx.jackpotRound.findFirst({
|
const open = await tx.jackpotRound.findUnique({ where: { id } });
|
||||||
where: { status: 'open' },
|
if (!open || open.status !== 'open') {
|
||||||
orderBy: { id: 'desc' },
|
throw Object.assign(new Error('Battle is not open'), { status: 409 });
|
||||||
});
|
|
||||||
if (!open) {
|
|
||||||
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
|
|
||||||
}
|
}
|
||||||
if (!isTimerWaiting(open.endsAt) && new Date(open.endsAt).getTime() - Date.now() < 3000) {
|
if (
|
||||||
|
!isTimerWaiting(open.endsAt) &&
|
||||||
|
new Date(open.endsAt).getTime() - Date.now() < 3000
|
||||||
|
) {
|
||||||
throw Object.assign(new Error('Too late to withdraw'), { status: 409 });
|
throw Object.assign(new Error('Too late to withdraw'), { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,33 +430,44 @@ export async function withdrawJackpotBet(userId, inventoryItemIds) {
|
|||||||
|
|
||||||
let next = await tx.jackpotRound.findUnique({
|
let next = await tx.jackpotRound.findUnique({
|
||||||
where: { id: open.id },
|
where: { id: open.id },
|
||||||
include: {
|
include: roundInclude(),
|
||||||
deposits: { include: depositInclude() },
|
|
||||||
winner: { select: { id: true, username: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Disarm timer if we drop below min players
|
|
||||||
if (uniquePlayerCount(next.deposits) < MIN_PLAYERS && !isTimerWaiting(next.endsAt)) {
|
if (uniquePlayerCount(next.deposits) < MIN_PLAYERS && !isTimerWaiting(next.endsAt)) {
|
||||||
next = await tx.jackpotRound.update({
|
next = await tx.jackpotRound.update({
|
||||||
where: { id: next.id },
|
where: { id: next.id },
|
||||||
data: { endsAt: WAITING_ENDS_AT },
|
data: { endsAt: WAITING_ENDS_AT },
|
||||||
include: {
|
include: roundInclude(),
|
||||||
deposits: { include: depositInclude() },
|
});
|
||||||
winner: { select: { id: true, username: true } },
|
}
|
||||||
},
|
|
||||||
|
// Creator left alone with no deposits → cancel empty battle
|
||||||
|
if (!next.deposits.length) {
|
||||||
|
next = await tx.jackpotRound.update({
|
||||||
|
where: { id: next.id },
|
||||||
|
data: { status: 'cancelled', resolvedAt: new Date() },
|
||||||
|
include: roundInclude(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (round.status === 'cancelled') {
|
||||||
|
clearTimers(round.id);
|
||||||
|
const state = serializeJackpotRound(round);
|
||||||
|
emitJackpot(round.id, 'jackpot:state', state);
|
||||||
|
await broadcastJackpotsList();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
if (isTimerWaiting(round.endsAt) || uniquePlayerCount(round.deposits) < MIN_PLAYERS) {
|
if (isTimerWaiting(round.endsAt) || uniquePlayerCount(round.deposits) < MIN_PLAYERS) {
|
||||||
clearResolveTimer();
|
clearResolveTimer(round.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = serializeJackpotRound(round);
|
const state = serializeJackpotRound(round);
|
||||||
emitJackpot('jackpot:state', state);
|
emitJackpot(round.id, 'jackpot:state', state);
|
||||||
|
await broadcastJackpotsList();
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,10 +478,7 @@ async function finishJackpotSpin(roundId) {
|
|||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
const round = await tx.jackpotRound.findUnique({
|
const round = await tx.jackpotRound.findUnique({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
include: {
|
include: roundInclude(),
|
||||||
deposits: { include: depositInclude() },
|
|
||||||
winner: { select: { id: true, username: true, avatarUrl: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!round || round.status !== 'spinning') return null;
|
if (!round || round.status !== 'spinning') return null;
|
||||||
|
|
||||||
@@ -418,7 +493,7 @@ async function finishJackpotSpin(roundId) {
|
|||||||
const acquiredIds = round.deposits
|
const acquiredIds = round.deposits
|
||||||
.filter((d) => d.userId !== winnerId)
|
.filter((d) => d.userId !== winnerId)
|
||||||
.map((d) => d.inventoryItemId);
|
.map((d) => d.inventoryItemId);
|
||||||
let autoSellTotal = 0;
|
let autoSellTotal = 0n;
|
||||||
if (acquiredIds.length) {
|
if (acquiredIds.length) {
|
||||||
const winner = await tx.user.findUnique({ where: { id: winnerId } });
|
const winner = await tx.user.findUnique({ where: { id: winnerId } });
|
||||||
const acquiredItems = await tx.inventoryItem.findMany({
|
const acquiredItems = await tx.inventoryItem.findMany({
|
||||||
@@ -428,39 +503,39 @@ async function finishJackpotSpin(roundId) {
|
|||||||
autoSellTotal = balanceDelta;
|
autoSellTotal = balanceDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalValue = round.deposits.reduce((s, d) => s + d.valueCents, 0);
|
const totalValue = round.deposits.reduce(
|
||||||
|
(s, d) => s + toCentsBigInt(d.valueCents),
|
||||||
|
0n
|
||||||
|
);
|
||||||
await tx.transaction.create({
|
await tx.transaction.create({
|
||||||
data: {
|
data: {
|
||||||
userId: winnerId,
|
userId: winnerId,
|
||||||
type: 'jackpot_win',
|
type: 'jackpot_win',
|
||||||
amount: totalValue,
|
amount: centsJson(totalValue),
|
||||||
meta: JSON.stringify({
|
meta: JSON.stringify({
|
||||||
roundId: round.id,
|
roundId: round.id,
|
||||||
depositIds: round.deposits.map((d) => d.id),
|
depositIds: round.deposits.map((d) => d.id),
|
||||||
autoSellTotal,
|
autoSellTotal: centsJson(autoSellTotal),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (autoSellTotal > 0) {
|
if (autoSellTotal > 0n) {
|
||||||
await tx.user.update({
|
await creditUserBalance(tx, winnerId, autoSellTotal);
|
||||||
where: { id: winnerId },
|
|
||||||
data: { balance: { increment: autoSellTotal } },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.jackpotRound.update({
|
const resolved = await tx.jackpotRound.update({
|
||||||
where: { id: round.id },
|
where: { id: round.id },
|
||||||
data: { status: 'resolved', resolvedAt: new Date() },
|
data: { status: 'resolved', resolvedAt: new Date() },
|
||||||
|
include: roundInclude(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Best item in the pot (highest instance value) for Feed Drop, with xN count
|
|
||||||
let best = null;
|
let best = null;
|
||||||
for (const d of round.deposits) {
|
for (const d of round.deposits) {
|
||||||
const inv = d.inventoryItem;
|
const inv = d.inventoryItem;
|
||||||
const item = inv?.item;
|
const item = inv?.item;
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
if (!best || d.valueCents > best.deposit.valueCents) {
|
if (!best || toCentsBigInt(d.valueCents) > toCentsBigInt(best.deposit.valueCents)) {
|
||||||
best = { deposit: d, item, inv };
|
best = { deposit: d, item, inv };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -476,7 +551,7 @@ async function finishJackpotSpin(roundId) {
|
|||||||
).length;
|
).length;
|
||||||
feedEntry = {
|
feedEntry = {
|
||||||
source: 'jackpot',
|
source: 'jackpot',
|
||||||
caseName: 'MultiPlayers Case',
|
caseName: `Battle ${round.id}`,
|
||||||
count,
|
count,
|
||||||
openCount: round.deposits.length,
|
openCount: round.deposits.length,
|
||||||
inventoryIds: round.deposits.map((d) => d.inventoryItemId).filter(Boolean),
|
inventoryIds: round.deposits.map((d) => d.inventoryItemId).filter(Boolean),
|
||||||
@@ -497,9 +572,11 @@ async function finishJackpotSpin(roundId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return createOpenRoundInTx(tx);
|
return resolved;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearTimers(roundId);
|
||||||
|
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
if (winnerId) {
|
if (winnerId) {
|
||||||
@@ -515,72 +592,56 @@ async function finishJackpotSpin(roundId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = serializeJackpotRound(result);
|
const state = serializeJackpotRound(result);
|
||||||
// New round waits for players — do not arm timer yet
|
emitJackpot(roundId, 'jackpot:result', {
|
||||||
clearResolveTimer();
|
|
||||||
emitJackpot('jackpot:result', {
|
|
||||||
previousRoundId: roundId,
|
previousRoundId: roundId,
|
||||||
...state,
|
...state,
|
||||||
});
|
});
|
||||||
emitJackpot('jackpot:state', state);
|
await broadcastJackpotsList();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createOpenRoundInTx(tx) {
|
export async function resolveJackpotRound(roundId) {
|
||||||
return tx.jackpotRound.create({
|
const id = Number(roundId);
|
||||||
data: { status: 'open', endsAt: WAITING_ENDS_AT },
|
clearTimers(id);
|
||||||
include: {
|
|
||||||
deposits: { include: depositInclude() },
|
|
||||||
winner: { select: { id: true, username: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveJackpotRound() {
|
|
||||||
clearTimers();
|
|
||||||
|
|
||||||
const spun = await prisma.$transaction(async (tx) => {
|
const spun = await prisma.$transaction(async (tx) => {
|
||||||
const open = await tx.jackpotRound.findFirst({
|
const open = await tx.jackpotRound.findUnique({
|
||||||
where: { status: 'open' },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
deposits: { include: depositInclude() },
|
deposits: { include: depositInclude() },
|
||||||
},
|
},
|
||||||
orderBy: { id: 'desc' },
|
|
||||||
});
|
});
|
||||||
if (!open) return null;
|
if (!open || open.status !== 'open') return null;
|
||||||
|
|
||||||
const players = uniquePlayerCount(open.deposits);
|
const players = uniquePlayerCount(open.deposits);
|
||||||
if (players < MIN_PLAYERS) {
|
if (players < MIN_PLAYERS || !open.deposits.length) {
|
||||||
const waiting = await tx.jackpotRound.update({
|
const waiting = await tx.jackpotRound.update({
|
||||||
where: { id: open.id },
|
where: { id: open.id },
|
||||||
data: { endsAt: WAITING_ENDS_AT },
|
data: { endsAt: WAITING_ENDS_AT },
|
||||||
include: {
|
include: roundInclude(),
|
||||||
deposits: { include: depositInclude() },
|
|
||||||
winner: { select: { id: true, username: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return { kind: 'waiting', updated: waiting };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open.deposits.length) {
|
|
||||||
const waiting = await tx.jackpotRound.update({
|
|
||||||
where: { id: open.id },
|
|
||||||
data: { endsAt: WAITING_ENDS_AT },
|
|
||||||
include: {
|
|
||||||
deposits: { include: depositInclude() },
|
|
||||||
winner: { select: { id: true, username: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return { kind: 'waiting', updated: waiting };
|
return { kind: 'waiting', updated: waiting };
|
||||||
}
|
}
|
||||||
|
|
||||||
const byUser = new Map();
|
const byUser = new Map();
|
||||||
for (const d of open.deposits) {
|
for (const d of open.deposits) {
|
||||||
byUser.set(d.userId, (byUser.get(d.userId) || 0) + d.valueCents);
|
byUser.set(
|
||||||
|
d.userId,
|
||||||
|
(byUser.get(d.userId) || 0n) + toCentsBigInt(d.valueCents)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const entries = [...byUser.entries()].map(([userId, dropRate]) => ({
|
const maxV = [...byUser.values()].reduce((a, b) => (a > b ? a : b), 0n);
|
||||||
userId,
|
const weightEntries =
|
||||||
dropRate,
|
maxV > BigInt(Number.MAX_SAFE_INTEGER)
|
||||||
}));
|
? [...byUser.entries()].map(([userId, v]) => ({
|
||||||
const winner = pickWeighted(entries);
|
userId,
|
||||||
|
dropRate: Number((v * 1_000_000n) / maxV) || 1,
|
||||||
|
}))
|
||||||
|
: [...byUser.entries()].map(([userId, v]) => ({
|
||||||
|
userId,
|
||||||
|
dropRate: Number(v),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const winner = pickWeighted(weightEntries);
|
||||||
|
|
||||||
const winnerDeposits = open.deposits.filter((d) => d.userId === winner.userId);
|
const winnerDeposits = open.deposits.filter((d) => d.userId === winner.userId);
|
||||||
const reelDepositId = winnerDeposits[0]?.id || null;
|
const reelDepositId = winnerDeposits[0]?.id || null;
|
||||||
@@ -588,10 +649,7 @@ export async function resolveJackpotRound() {
|
|||||||
const updated = await tx.jackpotRound.update({
|
const updated = await tx.jackpotRound.update({
|
||||||
where: { id: open.id },
|
where: { id: open.id },
|
||||||
data: { status: 'spinning', winnerUserId: winner.userId },
|
data: { status: 'spinning', winnerUserId: winner.userId },
|
||||||
include: {
|
include: roundInclude(),
|
||||||
deposits: { include: depositInclude() },
|
|
||||||
winner: { select: { id: true, username: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { kind: 'spin', updated, reelItemId: reelDepositId };
|
return { kind: 'spin', updated, reelItemId: reelDepositId };
|
||||||
@@ -599,23 +657,41 @@ export async function resolveJackpotRound() {
|
|||||||
|
|
||||||
if (!spun) return;
|
if (!spun) return;
|
||||||
|
|
||||||
if (spun.kind === 'waiting' || spun.kind === 'extend') {
|
if (spun.kind === 'waiting') {
|
||||||
const state = serializeJackpotRound(spun.updated);
|
const state = serializeJackpotRound(spun.updated);
|
||||||
clearResolveTimer();
|
clearResolveTimer(id);
|
||||||
emitJackpot('jackpot:state', state);
|
emitJackpot(id, 'jackpot:state', state);
|
||||||
|
await broadcastJackpotsList();
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { updated, reelItemId } = spun;
|
const { updated, reelItemId } = spun;
|
||||||
const state = serializeJackpotRound(updated);
|
const state = serializeJackpotRound(updated);
|
||||||
emitJackpot('jackpot:spin', {
|
emitJackpot(id, 'jackpot:spin', {
|
||||||
...state,
|
...state,
|
||||||
reelWinnerItemId: reelItemId,
|
reelWinnerItemId: reelItemId,
|
||||||
});
|
});
|
||||||
|
await broadcastJackpotsList();
|
||||||
|
|
||||||
spinFollowUp = setTimeout(() => {
|
spinFollowUps.set(
|
||||||
finishJackpotSpin(updated.id).catch((err) => console.error('jackpot finish', err));
|
updated.id,
|
||||||
}, SPIN_MS + 200);
|
setTimeout(() => {
|
||||||
|
finishJackpotSpin(updated.id).catch((err) =>
|
||||||
|
console.error('jackpot finish', err)
|
||||||
|
);
|
||||||
|
}, SPIN_MS + 200)
|
||||||
|
);
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated use listJackpotBattles / getJackpotRound */
|
||||||
|
export async function getJackpotState() {
|
||||||
|
const battles = await listJackpotBattles();
|
||||||
|
return battles[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function broadcastJackpot(extra = {}) {
|
||||||
|
const battles = await broadcastJackpotsList();
|
||||||
|
return battles[0] ? { ...battles[0], ...extra } : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 multer from 'multer';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import sharp from 'sharp';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
export const uploadsDir = path.join(__dirname, '../uploads');
|
export const uploadsDir = path.join(__dirname, '../uploads');
|
||||||
|
|
||||||
|
/** Max stored dimensions (aspect ratio preserved, never upscaled). */
|
||||||
|
export const MAX_IMAGE_WIDTH = 1920;
|
||||||
|
export const MAX_IMAGE_HEIGHT = 1080;
|
||||||
|
|
||||||
|
/** Raster only — no SVG (stored XSS if served as image/*). */
|
||||||
|
export const ALLOWED_IMAGE_MIMES = new Set([
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/webp',
|
||||||
|
'image/gif',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
|
||||||
|
|
||||||
|
/** sharp `metadata().format` values we accept */
|
||||||
|
const ALLOWED_SHARP_FORMATS = new Set(['png', 'jpeg', 'jpg', 'webp', 'gif']);
|
||||||
|
|
||||||
if (!fs.existsSync(uploadsDir)) {
|
if (!fs.existsSync(uploadsDir)) {
|
||||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUploader(prefix) {
|
export function safeExt(originalname, mimetype = '') {
|
||||||
|
const fromName = path.extname(originalname || '').toLowerCase();
|
||||||
|
if (ALLOWED_IMAGE_EXTS.has(fromName)) return fromName === '.jpeg' ? '.jpg' : fromName;
|
||||||
|
if (mimetype === 'image/png') return '.png';
|
||||||
|
if (mimetype === 'image/webp') return '.webp';
|
||||||
|
if (mimetype === 'image/gif') return '.gif';
|
||||||
|
if (mimetype === 'image/jpeg' || mimetype === 'image/jpg') return '.jpg';
|
||||||
|
return '.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedUpload(file) {
|
||||||
|
const mime = String(file?.mimetype || '').toLowerCase();
|
||||||
|
const ext = path.extname(file?.originalname || '').toLowerCase();
|
||||||
|
if (!ALLOWED_IMAGE_MIMES.has(mime)) return false;
|
||||||
|
// If the client sent an extension, it must match the allowlist (blocks .svg renamed tricks loosely)
|
||||||
|
if (ext && !ALLOWED_IMAGE_EXTS.has(ext)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downscale an uploaded image if larger than 1920×1080, keeping aspect ratio.
|
||||||
|
* Also rejects non-raster / spoofed files (e.g. SVG with a fake image/* type).
|
||||||
|
* Overwrites the file in place. No-ops when already within bounds.
|
||||||
|
*/
|
||||||
|
export async function processUploadedImage(file) {
|
||||||
|
if (!file?.path) return;
|
||||||
|
|
||||||
|
const filePath = file.path;
|
||||||
|
let meta;
|
||||||
|
try {
|
||||||
|
const animated = file.mimetype === 'image/gif';
|
||||||
|
meta = await sharp(filePath, { animated, failOn: 'none' }).metadata();
|
||||||
|
} catch {
|
||||||
|
await fs.promises.unlink(filePath).catch(() => {});
|
||||||
|
throw new Error('Invalid or unsupported image file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = String(meta.format || '').toLowerCase();
|
||||||
|
if (!ALLOWED_SHARP_FORMATS.has(format)) {
|
||||||
|
await fs.promises.unlink(filePath).catch(() => {});
|
||||||
|
throw new Error('Only PNG, JPEG, WebP, and GIF images are allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = meta.width || 0;
|
||||||
|
let height = meta.height || 0;
|
||||||
|
if (!width || !height) return;
|
||||||
|
|
||||||
|
// EXIF orientations 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({
|
const storage = multer.diskStorage({
|
||||||
destination: (_req, _file, cb) => cb(null, uploadsDir),
|
destination: (_req, _file, cb) => cb(null, uploadsDir),
|
||||||
filename: (_req, file, cb) => {
|
filename:
|
||||||
const ext = path.extname(file.originalname).toLowerCase() || '.png';
|
filename ||
|
||||||
const safe = ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext) ? ext : '.png';
|
((_req, file, cb) => {
|
||||||
cb(null, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${safe}`);
|
cb(
|
||||||
},
|
null,
|
||||||
|
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${safeExt(file.originalname, file.mimetype)}`
|
||||||
|
);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return multer({
|
return multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: 3 * 1024 * 1024 },
|
limits: { fileSize: maxBytes },
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
if (!file.mimetype.startsWith('image/')) {
|
if (!isAllowedUpload(file)) {
|
||||||
return cb(new Error('Only images are allowed'));
|
return cb(new Error('Only PNG, JPEG, WebP, and GIF images are allowed'));
|
||||||
}
|
}
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
},
|
},
|
||||||
|
|||||||
+171
-59
@@ -1,10 +1,18 @@
|
|||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
|
import { applyRateFloor, toCentsBigInt, minBigInt, centsJson, creditUserBalance } from './cents.js';
|
||||||
|
import { getIO } from './realtime.js';
|
||||||
|
import { publicUser } from './middleware.js';
|
||||||
|
|
||||||
export const BASE_VAULT_SLOTS = 3;
|
export const BASE_VAULT_SLOTS = 3;
|
||||||
export const VAULT_TICK_MS = 600_000; // 10 minutes
|
export const ONLINE_TICK_MS = 300_000; // 5 minutes
|
||||||
|
export const OFFLINE_TICK_MS = 600_000; // 10 minutes
|
||||||
|
/** @deprecated use ONLINE_TICK_MS / OFFLINE_TICK_MS */
|
||||||
|
export const VAULT_TICK_MS = OFFLINE_TICK_MS;
|
||||||
export const ONLINE_BASE_RATE = 0.1; // 10% of value per tick
|
export const ONLINE_BASE_RATE = 0.1; // 10% of value per tick
|
||||||
export const OFFLINE_BASE_RATE = 0.01; // 1% of value per tick
|
export const OFFLINE_BASE_RATE = 0.01; // 1% of value per tick
|
||||||
export const WEAR_FLOAT_K = 3;
|
export const WEAR_FLOAT_K = 3;
|
||||||
|
/** Soft cap: at most 500% of item value per tick */
|
||||||
|
export const MAX_TICK_VALUE_MULT = 5n;
|
||||||
|
|
||||||
/** Rarity → yield multiplier (seed rarities + fallbacks). */
|
/** Rarity → yield multiplier (seed rarities + fallbacks). */
|
||||||
export const RARITY_YIELD_MULT = {
|
export const RARITY_YIELD_MULT = {
|
||||||
@@ -17,6 +25,10 @@ export const RARITY_YIELD_MULT = {
|
|||||||
Extraordinary: 1.8,
|
Extraordinary: 1.8,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function vaultTickMs(mode) {
|
||||||
|
return mode === 'online' ? ONLINE_TICK_MS : OFFLINE_TICK_MS;
|
||||||
|
}
|
||||||
|
|
||||||
export function maxVaultSlots(user) {
|
export function maxVaultSlots(user) {
|
||||||
return BASE_VAULT_SLOTS + Math.max(0, Math.floor(Number(user?.prestigeVaultSlots) || 0));
|
return BASE_VAULT_SLOTS + Math.max(0, Math.floor(Number(user?.prestigeVaultSlots) || 0));
|
||||||
}
|
}
|
||||||
@@ -41,117 +53,217 @@ export function skillYieldMult(user, mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function tickPayoutCents(inv, user, mode) {
|
export function tickPayoutCents(inv, user, mode) {
|
||||||
const value = Math.max(0, Math.floor(Number(inv.valueCents) || 0));
|
const value = toCentsBigInt(inv.valueCents);
|
||||||
if (value <= 0) return 0;
|
if (value <= 0n) return 0n;
|
||||||
const baseRate = mode === 'online' ? ONLINE_BASE_RATE : OFFLINE_BASE_RATE;
|
const baseRate = mode === 'online' ? ONLINE_BASE_RATE : OFFLINE_BASE_RATE;
|
||||||
const wear = wearYieldMult(inv.floatValue);
|
const wear = wearYieldMult(inv.floatValue);
|
||||||
const rarity = rarityYieldMult(inv.item?.rarity ?? inv.rarity);
|
const rarity = rarityYieldMult(inv.item?.rarity ?? inv.rarity);
|
||||||
const skill = skillYieldMult(user, mode);
|
const skill = skillYieldMult(user, mode);
|
||||||
const payout = Math.floor(value * baseRate * wear * rarity * skill);
|
const combined = baseRate * wear * rarity * skill;
|
||||||
// Soft cap: at most 100% of item value per tick
|
const payout = applyRateFloor(value, combined);
|
||||||
return Math.min(value, Math.max(0, payout));
|
return minBigInt(value * MAX_TICK_VALUE_MULT, payout);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function estimateTickYield(inv, user, mode) {
|
export function estimateTickYield(inv, user, mode) {
|
||||||
return tickPayoutCents(inv, user, mode);
|
return tickPayoutCents(inv, user, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shared vault timer anchor (user-level). Falls back to oldest item stake/yield. */
|
||||||
|
export function resolveVaultAnchor(user, items, now = Date.now()) {
|
||||||
|
if (user?.vaultYieldAt) {
|
||||||
|
return new Date(user.vaultYieldAt).getTime();
|
||||||
|
}
|
||||||
|
let earliest = null;
|
||||||
|
for (const inv of items || []) {
|
||||||
|
const raw = inv.lastYieldAt || inv.stakedAt;
|
||||||
|
if (!raw) continue;
|
||||||
|
const ms = new Date(raw).getTime();
|
||||||
|
if (!Number.isFinite(ms)) continue;
|
||||||
|
if (earliest == null || ms < earliest) earliest = ms;
|
||||||
|
}
|
||||||
|
return earliest ?? now;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextYieldAtMs(user, items, mode, now = Date.now()) {
|
||||||
|
if (!items?.length) return null;
|
||||||
|
const tickMs = vaultTickMs(mode);
|
||||||
|
const anchor = resolveVaultAnchor(user, items, now);
|
||||||
|
const ticksDone = Math.floor(Math.max(0, now - anchor) / tickMs);
|
||||||
|
return anchor + (ticksDone + 1) * tickMs;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settle complete vault ticks for a user.
|
* Settle complete vault ticks for a user (shared timer across all staked items).
|
||||||
* @param {'online'|'offline'} mode
|
* @param {'online'|'offline'} mode
|
||||||
* @param {Date|number} [until] settle up to this time (default now)
|
* @param {Date|number} [until] settle up to this time (default now)
|
||||||
* @returns {{ credited: number, ticks: number, itemIds: number[] }}
|
* @returns {{ credited: string, ticks: number, itemIds: number[], balance?: string, vault?: object }}
|
||||||
*/
|
*/
|
||||||
export async function settleVaultYield(userId, mode, until = Date.now()) {
|
export async function settleVaultYield(userId, mode, until = Date.now()) {
|
||||||
const id = Number(userId);
|
const id = Number(userId);
|
||||||
if (!id) return { credited: 0, ticks: 0, itemIds: [] };
|
if (!id) return { credited: '0', ticks: 0, itemIds: [] };
|
||||||
|
|
||||||
const untilMs = until instanceof Date ? until.getTime() : Number(until) || Date.now();
|
const untilMs = until instanceof Date ? until.getTime() : Number(until) || Date.now();
|
||||||
|
const tickMs = vaultTickMs(mode);
|
||||||
|
|
||||||
return prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
const user = await tx.user.findUnique({ where: { id } });
|
const user = await tx.user.findUnique({ where: { id } });
|
||||||
if (!user) return { credited: 0, ticks: 0, itemIds: [] };
|
if (!user) return { credited: '0', ticks: 0, itemIds: [], user: null, items: [] };
|
||||||
|
|
||||||
const items = await tx.inventoryItem.findMany({
|
const items = await tx.inventoryItem.findMany({
|
||||||
where: { userId: id, staked: true },
|
where: { userId: id, staked: true },
|
||||||
include: { item: true },
|
include: { item: true },
|
||||||
});
|
});
|
||||||
if (!items.length) return { credited: 0, ticks: 0, itemIds: [] };
|
if (!items.length) {
|
||||||
|
if (user.vaultYieldAt) {
|
||||||
|
await tx.user.update({ where: { id }, data: { vaultYieldAt: null } });
|
||||||
|
}
|
||||||
|
return { credited: '0', ticks: 0, itemIds: [], user, items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
let totalCredit = 0;
|
const anchorMs = resolveVaultAnchor(user, items, untilMs);
|
||||||
let maxTicks = 0;
|
const elapsed = Math.max(0, untilMs - anchorMs);
|
||||||
|
const ticks = Math.floor(elapsed / tickMs);
|
||||||
|
|
||||||
|
if (ticks <= 0) {
|
||||||
|
// Ensure shared timer is persisted (migration from per-item anchors)
|
||||||
|
if (!user.vaultYieldAt) {
|
||||||
|
const updated = await tx.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { vaultYieldAt: new Date(anchorMs) },
|
||||||
|
});
|
||||||
|
return { credited: '0', ticks: 0, itemIds: [], user: updated, items };
|
||||||
|
}
|
||||||
|
return { credited: '0', ticks: 0, itemIds: [], user, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalCredit = 0n;
|
||||||
const itemIds = [];
|
const itemIds = [];
|
||||||
const now = new Date(untilMs);
|
|
||||||
|
|
||||||
for (const inv of items) {
|
for (const inv of items) {
|
||||||
const anchor = inv.lastYieldAt || inv.stakedAt || now;
|
|
||||||
const anchorMs = new Date(anchor).getTime();
|
|
||||||
const elapsed = Math.max(0, untilMs - anchorMs);
|
|
||||||
const ticks = Math.floor(elapsed / VAULT_TICK_MS);
|
|
||||||
if (ticks <= 0) continue;
|
|
||||||
|
|
||||||
const perTick = tickPayoutCents(inv, user, mode);
|
const perTick = tickPayoutCents(inv, user, mode);
|
||||||
const credit = perTick * ticks;
|
const credit = perTick * BigInt(ticks);
|
||||||
if (credit > 0) {
|
if (credit > 0n) {
|
||||||
totalCredit += credit;
|
totalCredit += credit;
|
||||||
itemIds.push(inv.id);
|
itemIds.push(inv.id);
|
||||||
}
|
}
|
||||||
maxTicks = Math.max(maxTicks, ticks);
|
|
||||||
|
|
||||||
const advanced = new Date(anchorMs + ticks * VAULT_TICK_MS);
|
|
||||||
await tx.inventoryItem.update({
|
await tx.inventoryItem.update({
|
||||||
where: { id: inv.id },
|
where: { id: inv.id },
|
||||||
data: { lastYieldAt: advanced },
|
data: { lastYieldAt: new Date(anchorMs + ticks * tickMs) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalCredit <= 0) {
|
const advanced = new Date(anchorMs + ticks * tickMs);
|
||||||
return { credited: 0, ticks: maxTicks, itemIds: [] };
|
let updatedUser;
|
||||||
|
if (totalCredit > 0n) {
|
||||||
|
await creditUserBalance(tx, id, totalCredit);
|
||||||
|
updatedUser = await tx.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { vaultYieldAt: advanced },
|
||||||
|
});
|
||||||
|
} else if (!user.vaultYieldAt || user.vaultYieldAt.getTime() !== advanced.getTime()) {
|
||||||
|
updatedUser = await tx.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { vaultYieldAt: advanced },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updatedUser = user;
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.user.update({
|
if (totalCredit > 0n) {
|
||||||
where: { id },
|
await tx.transaction.create({
|
||||||
data: { balance: { increment: totalCredit } },
|
data: {
|
||||||
});
|
userId: id,
|
||||||
|
type: 'vault_yield',
|
||||||
|
amount: centsJson(totalCredit),
|
||||||
|
meta: JSON.stringify({
|
||||||
|
mode,
|
||||||
|
ticks,
|
||||||
|
inventoryItemIds: itemIds,
|
||||||
|
tickMs,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await tx.transaction.create({
|
return {
|
||||||
data: {
|
credited: totalCredit.toString(),
|
||||||
userId: id,
|
ticks,
|
||||||
type: 'vault_yield',
|
itemIds,
|
||||||
amount: totalCredit,
|
user: updatedUser,
|
||||||
meta: JSON.stringify({
|
items,
|
||||||
mode,
|
};
|
||||||
ticks: maxTicks,
|
|
||||||
inventoryItemIds: itemIds,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { credited: totalCredit, ticks: maxTicks, itemIds };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const vault = result.user
|
||||||
|
? summarizeVaultState(result.user, result.items || [], mode, untilMs)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (result.credited !== '0' && result.user) {
|
||||||
|
emitVaultYield(id, {
|
||||||
|
credited: result.credited,
|
||||||
|
ticks: result.ticks,
|
||||||
|
balance: result.user.balance?.toString?.() ?? String(result.user.balance),
|
||||||
|
user: publicUser(result.user),
|
||||||
|
vault,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
credited: result.credited,
|
||||||
|
ticks: result.ticks,
|
||||||
|
itemIds: result.itemIds,
|
||||||
|
balance: result.user?.balance?.toString?.() ?? undefined,
|
||||||
|
vault,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitVaultYield(userId, payload) {
|
||||||
|
const io = getIO();
|
||||||
|
if (!io) return;
|
||||||
|
io.to(`user:${userId}`).emit('vault:yield', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Estimate pending yield for UI (incomplete ticks not included). */
|
/** Estimate pending yield for UI (incomplete ticks not included). */
|
||||||
export function estimatePendingYield(items, user, mode, now = Date.now()) {
|
export function estimatePendingYield(items, user, mode, now = Date.now()) {
|
||||||
let total = 0;
|
if (!items?.length) return '0';
|
||||||
|
const tickMs = vaultTickMs(mode);
|
||||||
|
const anchor = resolveVaultAnchor(user, items, now);
|
||||||
|
const ticks = Math.floor(Math.max(0, now - anchor) / tickMs);
|
||||||
|
if (ticks <= 0) return '0';
|
||||||
|
let total = 0n;
|
||||||
for (const inv of items) {
|
for (const inv of items) {
|
||||||
const anchor = inv.lastYieldAt || inv.stakedAt;
|
total += tickPayoutCents(inv, user, mode) * BigInt(ticks);
|
||||||
if (!anchor) continue;
|
|
||||||
const elapsed = Math.max(0, now - new Date(anchor).getTime());
|
|
||||||
const ticks = Math.floor(elapsed / VAULT_TICK_MS);
|
|
||||||
if (ticks <= 0) continue;
|
|
||||||
total += tickPayoutCents(inv, user, mode) * ticks;
|
|
||||||
}
|
}
|
||||||
return total;
|
return total.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-tick rate summary for UI (one tick online / offline). */
|
/** Per-tick rate summary for UI (one tick online / offline). */
|
||||||
export function summarizeVaultRates(items, user) {
|
export function summarizeVaultRates(items, user) {
|
||||||
let onlinePerTick = 0;
|
let onlinePerTick = 0n;
|
||||||
let offlinePerTick = 0;
|
let offlinePerTick = 0n;
|
||||||
for (const inv of items) {
|
for (const inv of items) {
|
||||||
onlinePerTick += tickPayoutCents(inv, user, 'online');
|
onlinePerTick += tickPayoutCents(inv, user, 'online');
|
||||||
offlinePerTick += tickPayoutCents(inv, user, 'offline');
|
offlinePerTick += tickPayoutCents(inv, user, 'offline');
|
||||||
}
|
}
|
||||||
return { onlinePerTick, offlinePerTick, tickMs: VAULT_TICK_MS };
|
return {
|
||||||
|
onlinePerTick: onlinePerTick.toString(),
|
||||||
|
offlinePerTick: offlinePerTick.toString(),
|
||||||
|
tickMsOnline: ONLINE_TICK_MS,
|
||||||
|
tickMsOffline: OFFLINE_TICK_MS,
|
||||||
|
tickMs: ONLINE_TICK_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeVaultState(user, items, mode, now = Date.now()) {
|
||||||
|
const rates = summarizeVaultRates(items, user);
|
||||||
|
const nextMs = nextYieldAtMs(user, items, mode, now);
|
||||||
|
return {
|
||||||
|
used: items.length,
|
||||||
|
slots: maxVaultSlots(user),
|
||||||
|
...rates,
|
||||||
|
mode,
|
||||||
|
tickMs: vaultTickMs(mode),
|
||||||
|
vaultYieldAt: user.vaultYieldAt ? new Date(user.vaultYieldAt).toISOString() : null,
|
||||||
|
nextYieldAt: nextMs != null ? new Date(nextMs).toISOString() : null,
|
||||||
|
pendingYield: estimatePendingYield(items, user, mode, now),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -5,6 +5,8 @@
|
|||||||
* Per-drop float f ∈ [0, 1) drives wear grade and price multiplier.
|
* Per-drop float f ∈ [0, 1) drives wear grade and price multiplier.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { toCentsBigInt } from './cents.js';
|
||||||
|
|
||||||
export const WEAR_BANDS = [
|
export const WEAR_BANDS = [
|
||||||
{
|
{
|
||||||
wear: 'Factory New',
|
wear: 'Factory New',
|
||||||
@@ -96,10 +98,14 @@ export function priceMultiplier(floatValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function computeValueCents(baseMarketValue, floatValue) {
|
export function computeValueCents(baseMarketValue, floatValue) {
|
||||||
const base = Math.max(0, Math.floor(Number(baseMarketValue) || 0));
|
// Always BigInt-parse — Number() silently corrupts values above ~9e15
|
||||||
if (base <= 0) return 0;
|
let base = toCentsBigInt(baseMarketValue);
|
||||||
const cents = Math.round(base * priceMultiplier(floatValue));
|
if (base < 0n) base = 0n;
|
||||||
return Math.max(1, cents);
|
if (base <= 0n) return 0n;
|
||||||
|
// Fixed-point multiplier (×10000) to avoid Number precision loss on huge bases
|
||||||
|
const multBp = Math.round(priceMultiplier(floatValue) * 10000);
|
||||||
|
const cents = (base * BigInt(Math.max(0, multBp)) + 5000n) / 10000n;
|
||||||
|
return cents < 1n ? 1n : cents;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rollFloat() {
|
export function rollFloat() {
|
||||||
|
|||||||
Reference in New Issue
Block a user