This commit is contained in:
gpatruno
2026-07-18 16:26:08 +02:00
parent 49c444e8b9
commit 2614d679c8
85 changed files with 8074 additions and 710 deletions
+18
View File
@@ -0,0 +1,18 @@
node_modules
**/node_modules
.git
.gitignore
*.md
.env
.env.*
!.env.prod.example
server/.env
server/uploads/**
!server/uploads/.gitkeep
server/*.db
server/*.db-journal
client/dist
.cursor
agent-transcripts
scripts
docker-compose*.yml
+37
View File
@@ -0,0 +1,37 @@
# Production environment example — copy to .env.prod and edit:
# cp .env.prod.example .env.prod
# Must match the URL you open in the browser (scheme + host + port).
# HTTP direct (Docker port 3009):
CLIENT_ORIGIN=http://localhost:3009
# HTTPS behind reverse proxy:
# CLIENT_ORIGIN=https://casegambling.example.com
# Extra allowed origins (comma-separated), optional
# CORS_ORIGINS=http://192.168.1.10:3009
# Session secret — use a long random string in real prod
SESSION_SECRET=change-me-to-a-long-random-secret
# Admin panel login (required — created/updated on every container start)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-strong-password
# Starting balances for new accounts (credits in cents, osu as units)
STARTING_BALANCE=10000
STARTING_OSU=100
# Jackpot
JACKPOT_ROUND_SECONDS=30
JACKPOT_MAX_ITEMS=3
# Cookie Secure:
# false / auto → OK for plain HTTP (:3009)
# true → only with HTTPS (otherwise login cookie is dropped by the browser)
COOKIE_SECURE=false
# Trust X-Forwarded-* (only behind nginx/caddy/traefik)
TRUST_PROXY=false
# Host port mapped in docker-compose
HOST_PORT=3009
+5 -1
View File
@@ -1,9 +1,13 @@
node_modules/
dist/
.env
.env.prod
*.db
*.db-journal
.DS_Store
server/sessions.db
server/uploads/
!server/uploads/.gitkeep
client/dist/
backups/
scripts/backupscasegambling/
+38
View File
@@ -0,0 +1,38 @@
# CaseGambling — multi-stage production image
# Docker Hub: foufure/casegambling (repository names must be lowercase)
FROM node:20-alpine AS client-build
WORKDIR /app/client
COPY client/package.json client/package-lock.json ./
RUN npm ci
COPY client/ ./
RUN npm run build
FROM node:20-alpine AS server-deps
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY server/package.json server/package-lock.json ./
RUN npm ci
COPY server/prisma ./prisma
RUN npx prisma generate
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3001
RUN apk add --no-cache openssl libc6-compat wget \
&& mkdir -p /data /app/uploads
COPY --from=server-deps /app/node_modules ./node_modules
COPY --from=server-deps /app/package.json ./
COPY server/ ./
COPY --from=client-build /app/client/dist ./public
EXPOSE 3001
VOLUME ["/data", "/app/uploads"]
# --accept-data-loss: needed for safe widenings (e.g. Int → BigInt on money columns).
# Without it, prisma db push refuses to apply and the container restart-loops.
CMD ["sh", "-c", "npx prisma db push --accept-data-loss && node src/index.js"]
+67 -11
View File
@@ -29,7 +29,7 @@ CLIENT_ORIGIN=http://localhost:5173
SESSION_SECRET=dev-secret
STARTING_BALANCE=10000
STARTING_OSU=100
JACKPOT_ROUND_SECONDS=60
JACKPOT_ROUND_SECONDS=30
JACKPOT_MAX_ITEMS=3
```
@@ -40,28 +40,84 @@ JACKPOT_MAX_ITEMS=3
| Admin | `admin` | `admin123` |
| Player| `player` | `player123`|
Local seed defaults; override admin via `ADMIN_USERNAME` / `ADMIN_PASSWORD`. In Docker, those env vars in `.env.prod` are required and applied on every start.
New registrations receive **100.00** starting credits and **100 osu** (`STARTING_BALANCE` / `STARTING_OSU` in `server/.env`; credits stored in cents).
## Features
- Login / register (username + password)
- Inventory: sort by recent or value, multi-select and sell at market value
- **Feed Drop**: toggle in nav — live left column of latest case unboxes + player
- **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)
- **Auto-sell**: optional threshold (shared with bulk sell); on **case opens**, credits are applied after the reel finishes; on **battle wins**, items taken from other players below the threshold are sold immediately
- **Wear (float)**: each case drop gets a random float `01` and paint seed; grades FN / MW / FT / WW / BS (CS ranges). Catalog `marketValue` is the **Field-Tested** reference; instance `valueCents` scales by float within the wear band (FN up to +50%, MW +1530%, FT mid ≈ catalog, WW down to 40%, BS lower)
- **Feed Drop**: toggle in nav — live left column of latest case unboxes + MultiPlayers Battle winners
- Cases page: browse and open crates (CS-style reel, result rolled server-side)
- **Bet Item** hub:
- **MultiPlayers Case** — live shared pot, up to 3 items, win chance by value, Socket.IO sync
- **Case keys** (per crate): **1 key****x10**; each **100 opens** of that case earns +1 key up to **x20** (keys 1→11, +1 open per key: x11…x20); from **x20** onward each next key costs **200, 300, 400…** opens (100 × tier) and adds +1 max open (x21, x22…) — no cap
- **Battle** hub (`/battle`):
- **MultiPlayers Case** — live shared pot preview on the hub (top items, pot total, players), up to 3 items, win chance by value, Socket.IO sync
- **1vX Case** — custom rooms (player/item limits, min value), spectate in-progress
- **Coinflip** — stub (coming soon)
- **Shop**: spend osu on credit packs; watch an ad every 1 min for 100.00200.00 cr
- Profile (via username in the nav): avatar, bio, password, stats
- Leaderboard: richest players (balance + inventory market value)
- **Shop** (nav balance pill → `/shop`): spend osu on credit packs; watch an ad every 1 min for 100.00200.00 cr
- Profile (username in nav): avatar, bio, password, stats, logout
- **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
- **Leaderboard**: richest players (balance + inventory value); click a row to open a public **player profile** (`/player/:username`) with stats and inventory
- Admin: manage cases, items, images (URL or upload), drop weights, enable/disable cases
## Docker (production)
```bash
# Build & push to Docker Hub (foufure/casegambling)
docker login
./scripts/docker-build-push.sh
# optional version tag:
./scripts/docker-build-push.sh v1.0.0
# Run
cp .env.prod.example .env.prod # edit secrets / CLIENT_ORIGIN / ADMIN_*
docker compose --env-file .env.prod up -d
```
Set **`ADMIN_USERNAME`** and **`ADMIN_PASSWORD`** in `.env.prod` (required). On each container start the admin account is created or updated from these values.
App listens on `HOST_PORT` (default **3009** → container 3001). Data: volume `casegambling_data` (SQLite), uploads: `casegambling_uploads`.
### Backup / restore
**On the Docker prod server** (export DB to bring home):
```bash
# Copy these two scripts onto the server next to docker-compose.yml, then:
./scripts/docker-db-export.sh
# → backups/casegambling-… (+ .tar.gz)
# Optional: restore a backup into Docker
./scripts/docker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
```
**On your laptop** (local `server/prisma/dev.db`):
```bash
# Import the archive you copied from prod (stop `npm run dev` first)
./scripts/local-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
npm run db:push # sync schema if needed
npm run dev
# Export local DB (e.g. to push back to Docker)
./scripts/local-db-export.sh
./scripts/docker-db-import.sh ./backups/casegambling-….tar.gz
```
Typical **prod → local** flow: `docker-db-export.sh` on the server → `scp` the `.tar.gz``local-db-import.sh` on the laptop.
## Notes
- Balances and prices are integers in **cents**; **osu** is a separate demo currency for the shop
- Catalog item price = Field-Tested reference; each inventory row stores `floatValue`, `wear`, `paintSeed`, `valueCents`, `favorite`
- Per-user auto-sell settings: `autoSellEnabled`, `autoSellThresholdCents` (default threshold **10.00** cr)
- Case key progress stored in `CaseKeyProgress` (per user + case); backfill: `npm run db:backfill-case-keys --prefix server`
- Inventory items locked while deposited in a bet; unlocked or transferred on resolve
- `Transaction` types include `open_case`, `seed_credit`, `seed_osu`, `sell_item`, `shop_purchase`, `ad_reward`, `jackpot_bet`, `jackpot_win`, `duel_bet`, `duel_win`
- Avatars stored under `server/uploads/` and served at `/uploads`
- Real-time state via Socket.IO (`jackpot`, `duel:{id}`, `duels` rooms)
- Avatars / uploads under `server/uploads/` (volume in Docker)
- Real-time via Socket.IO; Docker Hub image name is lowercase: `foufure/casegambling`
- Max **10** simultaneous browser windows/tabs per account (Socket.IO); override with `MAX_SOCKETS_PER_USER`
- Swap `DATABASE_URL` to PostgreSQL later without changing app logic
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon.png" sizes="512x512" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

+25 -7
View File
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { Navigate, Route, Routes, useParams } from 'react-router-dom';
import Layout from './components/Layout';
import { useAuth } from './AuthContext';
import Admin from './pages/Admin';
@@ -8,12 +8,16 @@ import Dashboard from './pages/Dashboard';
import Leaderboard from './pages/Leaderboard';
import Login from './pages/Login';
import Register from './pages/Register';
import BetHub from './pages/BetHub';
import BattleHub from './pages/BattleHub';
import MultiPlayersPage from './pages/MultiPlayersPage';
import DuelListPage from './pages/DuelListPage';
import DuelRoomPage from './pages/DuelRoomPage';
import CoinflipStub from './pages/CoinflipStub';
import ProfilePage from './pages/ProfilePage';
import PlayerProfilePage from './pages/PlayerProfilePage';
import CatalogPage from './pages/CatalogPage';
import PrestigePage from './pages/PrestigePage';
import AchievementsPage from './pages/AchievementsPage';
import ShopPage from './pages/ShopPage';
function HomeRedirect() {
@@ -24,6 +28,11 @@ function HomeRedirect() {
return <Navigate to="/dashboard" replace />;
}
function BetDuelRedirect() {
const { id } = useParams();
return <Navigate to={`/battle/1vx/${id}`} replace />;
}
export default function App() {
return (
<Routes>
@@ -35,12 +44,21 @@ export default function App() {
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/cases" element={<CasesPage />} />
<Route path="/cases/:id" element={<CasePage />} />
<Route path="/bet" element={<BetHub />} />
<Route path="/bet/multi" element={<MultiPlayersPage />} />
<Route path="/bet/1vx" element={<DuelListPage />} />
<Route path="/bet/1vx/:id" element={<DuelRoomPage />} />
<Route path="/bet/coinflip" element={<CoinflipStub />} />
<Route path="/catalog" element={<CatalogPage />} />
<Route path="/prestige" element={<PrestigePage />} />
<Route path="/achievements" element={<AchievementsPage />} />
<Route path="/battle" element={<BattleHub />} />
<Route path="/battle/multi" element={<MultiPlayersPage />} />
<Route path="/battle/1vx" element={<DuelListPage />} />
<Route path="/battle/1vx/:id" element={<DuelRoomPage />} />
<Route path="/battle/coinflip" element={<CoinflipStub />} />
<Route path="/bet" element={<Navigate to="/battle" replace />} />
<Route path="/bet/multi" element={<Navigate to="/battle/multi" replace />} />
<Route path="/bet/1vx" element={<Navigate to="/battle/1vx" replace />} />
<Route path="/bet/1vx/:id" element={<BetDuelRedirect />} />
<Route path="/bet/coinflip" element={<Navigate to="/battle/coinflip" replace />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/player/:username" element={<PlayerProfilePage />} />
<Route path="/shop" element={<ShopPage />} />
<Route path="/leaderboard" element={<Leaderboard />} />
</Route>
+15 -5
View File
@@ -45,13 +45,23 @@ export function AuthProvider({ children }) {
setUser(null);
};
const updateBalance = (balance) => {
const updateBalance = useCallback((balance) => {
setUser((prev) => (prev ? { ...prev, balance } : prev));
};
}, []);
const patchUser = (partial) => {
setUser((prev) => (prev ? { ...prev, ...partial } : prev));
};
const patchUser = useCallback((partial) => {
setUser((prev) => {
if (!prev) return prev;
let changed = false;
for (const key of Object.keys(partial)) {
if (prev[key] !== partial[key]) {
changed = true;
break;
}
}
return changed ? { ...prev, ...partial } : prev;
});
}, []);
return (
<AuthContext.Provider
+65 -2
View File
@@ -32,13 +32,39 @@ export const api = {
logout: () => request('/api/auth/logout', { method: 'POST' }),
cases: () => request('/api/cases'),
case: (id) => request(`/api/cases/${id}`),
openCase: (id) => request(`/api/cases/${id}/open`, { method: 'POST' }),
catalog: () => request('/api/catalog'),
prestige: () => request('/api/prestige'),
doPrestige: (choice) =>
request('/api/prestige', {
method: 'POST',
body: JSON.stringify({ choice }),
}),
openCase: (id, count = 1) =>
request(`/api/cases/${id}/open`, {
method: 'POST',
body: JSON.stringify({ count }),
}),
inventory: () => request('/api/inventory'),
sellInventory: (inventoryItemIds) =>
request('/api/inventory/sell', {
method: 'POST',
body: JSON.stringify({ inventoryItemIds }),
}),
updateAutoSell: (body) =>
request('/api/inventory/auto-sell', {
method: 'PATCH',
body: JSON.stringify(body),
}),
applyPendingAutoSell: (inventoryItemIds) =>
request('/api/inventory/auto-sell/apply', {
method: 'POST',
body: JSON.stringify({ inventoryItemIds }),
}),
setInventoryFavorite: (inventoryItemIds, favorite) =>
request('/api/inventory/favorite', {
method: 'PATCH',
body: JSON.stringify({ inventoryItemIds, favorite }),
}),
shop: () => request('/api/shop'),
shopBuy: (packageId) =>
request('/api/shop/buy', {
@@ -47,6 +73,15 @@ export const api = {
}),
shopAd: () => request('/api/shop/ad', { method: 'POST' }),
feed: () => request('/api/feed'),
feedAnnounce: (inventoryItemIds, caseName) => {
const ids = Array.isArray(inventoryItemIds)
? inventoryItemIds
: [inventoryItemIds];
return request('/api/feed/announce', {
method: 'POST',
body: JSON.stringify({ inventoryItemIds: ids, caseName }),
});
},
leaderboard: () => request('/api/leaderboard'),
jackpot: () => request('/api/jackpot'),
jackpotBet: (inventoryItemIds) =>
@@ -72,6 +107,9 @@ export const api = {
duelCancel: (id) => request(`/api/duels/${id}/cancel`, { method: 'POST' }),
duelStart: (id) => request(`/api/duels/${id}/start`, { method: 'POST' }),
profile: () => request('/api/profile'),
playerProfile: (username) =>
request(`/api/profile/player/${encodeURIComponent(username)}`),
achievements: () => request('/api/achievements'),
updateProfile: (body) =>
request('/api/profile', { method: 'PATCH', body: JSON.stringify(body) }),
changePassword: (currentPassword, newPassword) =>
@@ -122,9 +160,34 @@ export const api = {
};
export function formatCredits(cents) {
return (Number(cents) / 100).toFixed(2);
const fixed = (Number(cents) / 100).toFixed(2);
const neg = fixed.startsWith('-');
const body = neg ? fixed.slice(1) : fixed;
const [intPart, dec] = body.split('.');
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '\u202f');
return `${neg ? '-' : ''}${grouped}.${dec}`;
}
export function formatChance(pct) {
return `${Number(pct || 0).toFixed(1)}%`;
}
/** Format seconds as e.g. "2h 15m" or "45m". */
export function formatPlayTime(seconds) {
const s = Math.max(0, Math.floor(Number(seconds) || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
if (h <= 0 && m <= 0) return s > 0 ? `${s}s` : '0m';
if (h <= 0) return `${m}m`;
if (m <= 0) return `${h}h`;
return `${h}h ${m}m`;
}
export function formatLastConnection(iso) {
if (!iso) return 'Never';
try {
return new Date(iso).toLocaleString();
} catch {
return '—';
}
}
+106 -21
View File
@@ -2,60 +2,142 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { rarityColor } from '../rarities';
const ITEM_WIDTH = 120;
const ITEM_WIDTH_COMPACT = 72;
const SPIN_DURATION_MS = 5500;
const SPIN_SLOTS = 42;
const TAIL_SLOTS = 8;
function buildTrack(items, winnerId, spins = 42) {
const track = [];
for (let i = 0; i < spins; i += 1) {
track.push(items[i % items.length]);
function pickRandom(items, avoidId) {
if (!items.length) return null;
if (items.length === 1) return items[0];
let pick = items[Math.floor(Math.random() * items.length)];
for (let i = 0; i < 10 && pick.id === avoidId; i += 1) {
pick = items[Math.floor(Math.random() * items.length)];
}
return pick;
}
function shuffledPool(items) {
const pool = [...items];
for (let i = pool.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
return pool;
}
function buildTrack(items, winnerId, spins = SPIN_SLOTS) {
if (!items?.length) return { track: [], winnerIndex: 0 };
const winner = items.find((i) => i.id === winnerId) || items[0];
track.push(winner);
// pad a few after so the reel doesn't end abruptly
for (let i = 0; i < 8; i += 1) {
track.push(items[(i + 3) % items.length]);
const track = [];
let lastId = null;
let bag = shuffledPool(items);
let bagIndex = 0;
const nextItem = () => {
// Draw from a reshuffled bag so the strip covers the pool, not a fixed cycle
if (bagIndex >= bag.length) {
bag = shuffledPool(items);
bagIndex = 0;
if (bag.length > 1 && bag[0].id === lastId) {
const swap = 1 + Math.floor(Math.random() * (bag.length - 1));
[bag[0], bag[swap]] = [bag[swap], bag[0]];
}
}
let item = bag[bagIndex];
bagIndex += 1;
if (item.id === lastId && items.length > 1) {
item = pickRandom(items, lastId);
}
return item;
};
for (let i = 0; i < spins; i += 1) {
const item = nextItem();
track.push(item);
lastId = item.id;
}
track.push(winner);
lastId = winner.id;
for (let i = 0; i < TAIL_SLOTS; i += 1) {
const item = nextItem();
track.push(item);
lastId = item.id;
}
return { track, winnerIndex: spins };
}
export default function CaseReel({ items, winnerId, spinning, onDone, showOwners = false }) {
export default function CaseReel({
items,
winnerId,
spinning,
onDone,
showOwners = false,
compact = false,
}) {
const trackRef = useRef(null);
const [offset, setOffset] = useState(0);
const [locked, setLocked] = useState(null); // { track, winnerIndex, key }
const onDoneRef = useRef(onDone);
onDoneRef.current = onDone;
const itemWidth = compact ? ITEM_WIDTH_COMPACT : ITEM_WIDTH;
const { track, winnerIndex } = useMemo(() => {
if (!items?.length || !winnerId) {
return { track: items || [], winnerIndex: 0 };
}
return buildTrack(items, winnerId);
}, [items, winnerId]);
const spinKey =
winnerId && items?.length ? `${winnerId}:${items.map((i) => i.id).join(',')}` : '';
const idleTrack = useMemo(() => {
if (!items?.length) return [];
return shuffledPool(items);
}, [items]);
const track =
locked?.key === spinKey && locked.track.length ? locked.track : idleTrack;
useEffect(() => {
if (!spinning || !winnerId || !items?.length) return undefined;
if (!spinning) return undefined;
if (!items?.length || !winnerId) return undefined;
const key = `${winnerId}:${items.map((i) => i.id).join(',')}`;
const built = buildTrack(items, winnerId);
setLocked({ ...built, key });
let finished = false;
const viewportCenter = (trackRef.current?.parentElement?.clientWidth || 600) / 2;
const target =
winnerIndex * ITEM_WIDTH - viewportCenter + ITEM_WIDTH / 2 + (Math.random() * 40 - 20);
built.winnerIndex * itemWidth -
viewportCenter +
itemWidth / 2 +
(Math.random() * 40 - 20);
// force reflow then animate
setOffset(0);
const id = requestAnimationFrame(() => {
requestAnimationFrame(() => setOffset(target));
});
const timer = setTimeout(() => {
if (finished) return;
finished = true;
onDoneRef.current?.();
}, SPIN_DURATION_MS + 80);
return () => {
cancelAnimationFrame(id);
clearTimeout(timer);
finished = true;
};
}, [spinning, winnerId, winnerIndex, items]);
// intentionally only re-run when a new spin starts
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spinning, winnerId, itemWidth]);
return (
<div className={`reel-wrap${showOwners ? ' reel-wrap-owners' : ''}`} ref={trackRef}>
<div
className={`reel-wrap${showOwners ? ' reel-wrap-owners' : ''}${compact ? ' reel-wrap-compact' : ''}`}
ref={trackRef}
>
<div className="reel-marker" />
<div
className="reel-track"
@@ -70,7 +152,10 @@ export default function CaseReel({ items, winnerId, spinning, onDone, showOwners
<div
key={`${item.id}-${idx}`}
className="reel-item"
style={{ '--rarity': rarityColor(item.rarity) }}
style={{
'--rarity': rarityColor(item.rarity),
...(compact ? { flex: `0 0 ${itemWidth}px`, width: itemWidth } : null),
}}
>
<div className="glyph" style={{ color: rarityColor(item.rarity) }}>
{item.imageUrl ? (
+68 -18
View File
@@ -36,31 +36,57 @@ export default function DropFeed() {
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.feed();
if (!cancelled) setDrops(pruneDrops(data.drops || []));
} catch {
/* ignore — socket history may fill */
}
})();
const socket = getSocket();
const onHistory = (list) => {
setDrops(pruneDrops(Array.isArray(list) ? list.slice(0, MAX_BUFFER) : []));
};
const onDrop = (entry) => {
setDrops((prev) => pruneDrops([entry, ...prev].slice(0, MAX_BUFFER)));
const applyList = (list) => {
if (cancelled) return;
const seen = new Set();
const unique = [];
for (const d of list || []) {
if (!d?.id || seen.has(d.id)) continue;
seen.add(d.id);
unique.push(d);
}
setDrops(pruneDrops(unique.slice(0, MAX_BUFFER)));
};
const onHistory = (list) => applyList(list);
const onDrop = (entry) => {
if (!entry?.id) return;
setDrops((prev) => {
if (prev.some((d) => d.id === entry.id)) return prev;
// Also skip near-identical batch (same user + item + count) within a few seconds
const nearDup = prev.find(
(d) =>
d.username === entry.username &&
d.item?.id === entry.item?.id &&
(d.count || 1) === (entry.count || 1) &&
(d.openCount || 1) === (entry.openCount || 1) &&
Math.abs(new Date(d.at) - new Date(entry.at)) < 15000
);
if (nearDup) return prev;
return pruneDrops([entry, ...prev].slice(0, MAX_BUFFER));
});
};
// Prefer socket history; HTTP is fallback if socket is slow
socket.emit('feed:subscribe');
socket.on('feed:history', onHistory);
socket.on('feed:drop', onDrop);
socket.on('connect', () => socket.emit('feed:subscribe'));
const t = setTimeout(() => {
if (cancelled) return;
api.feed()
.then((data) => {
setDrops((prev) => (prev.length ? prev : pruneDrops(data.drops || [])));
})
.catch(() => {});
}, 400);
return () => {
cancelled = true;
clearTimeout(t);
socket.emit('feed:unsubscribe');
socket.off('feed:history', onHistory);
socket.off('feed:drop', onDrop);
@@ -100,7 +126,13 @@ export default function DropFeed() {
)}
</div>
<div className="drop-feed-meta">
<div className="drop-feed-item-name">{d.item?.name}</div>
<div className="drop-feed-item-name">
{d.item?.name}
{d.count > 1 ? <span className="drop-feed-count"> x{d.count}</span> : null}
</div>
{d.item?.wear ? (
<div className="drop-feed-wear muted">{d.item.wear}</div>
) : null}
<div className="drop-feed-player">
{d.avatarUrl ? (
<img src={d.avatarUrl} alt="" className="drop-feed-avatar" />
@@ -109,11 +141,29 @@ export default function DropFeed() {
{(d.username || '?').slice(0, 1).toUpperCase()}
</span>
)}
<span>{d.username}</span>
<span>
{d.source === 'jackpot' ? (
<>
<span className="drop-feed-winner-label">Battle Winner</span>{' '}
{d.username}
</>
) : (
d.username
)}
</span>
</div>
<div className="muted drop-feed-sub">
{d.caseName ? `${d.caseName} · ` : ''}
{d.source === 'jackpot'
? 'MultiPlayers Case · '
: d.caseName
? `${d.caseName} · `
: ''}
{formatCredits(d.item?.marketValue || 0)} cr
{d.openCount > 1
? d.source === 'jackpot'
? ` · ${d.openCount} items`
: ` · ${d.openCount}× open`
: ''}
</div>
<div className="muted drop-feed-time">{formatDropTime(d.at)}</div>
</div>
+36 -4
View File
@@ -1,26 +1,52 @@
import { formatCredits } from '../api';
import { rarityColor } from '../rarities';
/** Instance sell/bet price, else catalog FT reference */
export function itemValueCents(item) {
if (!item) return 0;
if (item.valueCents != null && Number.isFinite(Number(item.valueCents))) {
return Number(item.valueCents);
}
return Number(item.marketValue) || 0;
}
export function formatFloat(f) {
const n = Number(f);
if (!Number.isFinite(n)) return '';
return n.toFixed(6);
}
export default function ItemTile({
item,
showValue = true,
showWear = true,
selected = false,
favorite = false,
onClick,
disabled = false,
}) {
const color = rarityColor(item.rarity);
const clickable = typeof onClick === 'function';
const hasImage = Boolean(item.imageUrl);
const value = itemValueCents(item);
const wear = item.wear;
const floatTitle =
wear && item.floatValue != null
? `${wear} · ${formatFloat(item.floatValue)}${
item.paintSeed != null ? ` · seed ${item.paintSeed}` : ''
}`
: wear || undefined;
return (
<div
className={`item-tile${selected ? ' selected' : ''}${clickable ? ' selectable' : ''}${
disabled ? ' disabled' : ''
}`}
className={`item-tile${selected ? ' selected' : ''}${favorite ? ' favorite' : ''}${
clickable ? ' selectable' : ''
}${disabled ? ' disabled' : ''}`}
style={{ borderTopColor: color }}
onClick={disabled ? undefined : onClick}
role={clickable ? 'button' : undefined}
tabIndex={clickable && !disabled ? 0 : undefined}
title={favorite ? `Favorite · ${floatTitle || item.name}` : floatTitle}
onKeyDown={
clickable && !disabled
? (e) => {
@@ -33,6 +59,7 @@ export default function ItemTile({
}
>
<div className={`item-visual${hasImage ? ' has-img' : ''}`} style={{ color }}>
{favorite ? <span className="item-favorite-badge" aria-label="Favorite"></span> : null}
{hasImage ? (
<img src={item.imageUrl} alt="" loading="lazy" />
) : (
@@ -43,8 +70,13 @@ export default function ItemTile({
<div className="item-meta" style={{ color }}>
{item.rarity}
</div>
{showWear && wear ? (
<div className="item-wear" title={floatTitle}>
{wear}
</div>
) : null}
{showValue && (
<div className="item-meta">{formatCredits(item.marketValue)} cr</div>
<div className="item-meta">{formatCredits(value)} cr</div>
)}
</div>
);
+193 -30
View File
@@ -1,13 +1,50 @@
import { useEffect, useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '../AuthContext';
import { formatCredits } from '../api';
import { api, formatCredits } from '../api';
import DropFeed from './DropFeed';
import SessionLimitOverlay from './SessionLimitOverlay';
import { onInventoryChanged } from '../inventoryEvents';
import { getSocket, onSessionLimitChange } from '../socket';
const FEED_KEY = 'caseforge-feed-drop';
function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}
function countWaitingBattles(jackpotRound, duelOpen) {
let n = 0;
// MultiPlayers: only if someone has already bet and round still waits for more players
const jpPlayers = jackpotRound?.players?.length ?? 0;
const jpDeposits = jackpotRound?.deposits?.length ?? 0;
if (
jackpotRound?.waitingForPlayers &&
jackpotRound.status !== 'spinning' &&
(jpPlayers > 0 || jpDeposits > 0)
) {
n += 1;
}
// 1vX: each open room that already has at least one player with a bet
for (const room of duelOpen || []) {
if (room.status !== 'open') continue;
const hasBet =
(room.playerCount ?? room.players?.length ?? 0) > 0 ||
(room.deposits?.length ?? 0) > 0;
if (hasBet) n += 1;
}
return n;
}
export default function Layout() {
const { user, logout } = useAuth();
const { user } = useAuth();
const [feedOpen, setFeedOpen] = useState(() => {
try {
return localStorage.getItem(FEED_KEY) === '1';
@@ -15,6 +52,9 @@ export default function Layout() {
return false;
}
});
const [waitingBattles, setWaitingBattles] = useState(0);
const [prestigeVisible, setPrestigeVisible] = useState(false);
const [sessionLimit, setSessionLimit] = useState(null);
useEffect(() => {
try {
@@ -24,53 +64,176 @@ export default function Layout() {
}
}, [feedOpen]);
useEffect(() => {
if (!user || user.role === 'admin') {
setPrestigeVisible(false);
return undefined;
}
if ((user.prestigeCount ?? 0) > 0) {
setPrestigeVisible(true);
}
let cancelled = false;
const refreshVisibility = async () => {
try {
const data = await api.prestige();
if (!cancelled) setPrestigeVisible(Boolean(data.pageVisible));
} catch {
/* nav link optional */
}
};
refreshVisibility();
const offInv = onInventoryChanged(() => {
refreshVisibility();
});
return () => {
cancelled = true;
offInv();
};
}, [user?.id, user?.role, user?.balance, user?.prestigeCount]);
useEffect(() => {
if (!user || user.role === 'admin') {
setWaitingBattles(0);
return undefined;
}
let jackpotRound = null;
let duelOpen = [];
let cancelled = false;
const sync = () => {
if (cancelled) return;
setWaitingBattles((prev) => {
const next = countWaitingBattles(jackpotRound, duelOpen);
return prev === next ? prev : next;
});
};
(async () => {
try {
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
if (cancelled) return;
jackpotRound = jp.round;
duelOpen = duels.open || [];
sync();
} catch {
/* nav badge is optional */
}
})();
const socket = getSocket();
const offLimitChange = onSessionLimitChange((payload) => {
setSessionLimit(payload);
});
const onJackpot = (state) => {
jackpotRound = state;
sync();
};
const onDuels = (list) => {
duelOpen = list.open || [];
sync();
};
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
socket.on('jackpot:state', onJackpot);
socket.on('jackpot:spin', onJackpot);
socket.on('jackpot:result', onJackpot);
socket.on('duels:list', onDuels);
socket.on('connect', () => {
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
});
return () => {
cancelled = true;
offLimitChange();
socket.emit('jackpot:unsubscribe');
socket.emit('duels:unsubscribe');
socket.off('jackpot:state', onJackpot);
socket.off('jackpot:spin', onJackpot);
socket.off('jackpot:result', onJackpot);
socket.off('duels:list', onDuels);
};
}, [user?.id, user?.role]);
const isPlayer = user && user.role !== 'admin';
return (
<div className={`app-shell${feedOpen ? ' feed-open' : ''}`}>
<div
className={`app-shell${feedOpen ? ' feed-open' : ''}${sessionLimit ? ' session-locked' : ''}`}
>
<ScrollToTop />
{sessionLimit && <SessionLimitOverlay max={sessionLimit.max || 10} />}
<header className="topnav">
<NavLink to="/dashboard" className="brand">
CaseForge
</NavLink>
<div className="topnav-left">
<NavLink to="/dashboard" className="brand">
CaseForge
</NavLink>
{isPlayer && (
<button
type="button"
className={`nav-toggle${feedOpen ? ' active' : ''}`}
onClick={() => setFeedOpen((v) => !v)}
aria-pressed={feedOpen}
>
Feed Drop
</button>
)}
</div>
<nav className="nav-links">
{user && user.role !== 'admin' && (
{isPlayer && (
<>
<NavLink to="/dashboard">Inventory</NavLink>
<NavLink to="/cases">Cases</NavLink>
<NavLink to="/bet">Bet Item</NavLink>
<NavLink to="/shop">Shop</NavLink>
<button
type="button"
className={`nav-toggle${feedOpen ? ' active' : ''}`}
onClick={() => setFeedOpen((v) => !v)}
aria-pressed={feedOpen}
<NavLink to="/catalog">Catalog</NavLink>
{prestigeVisible && <NavLink to="/prestige">Prestige</NavLink>}
<NavLink
to="/battle"
className={({ isActive }) =>
[
'nav-battle',
isActive ? 'active' : '',
waitingBattles > 0 ? 'nav-battle-waiting' : '',
]
.filter(Boolean)
.join(' ')
}
>
Feed Drop
</button>
Battle
{waitingBattles > 0 && (
<span className="nav-battle-badge" aria-label={`${waitingBattles} waiting`}>
{waitingBattles}
</span>
)}
</NavLink>
</>
)}
<NavLink to="/leaderboard">Leaderboard</NavLink>
{user?.role === 'admin' && <NavLink to="/admin">Admin</NavLink>}
{user && (
<span className="balance-pill">{formatCredits(user.balance)} cr</span>
isPlayer ? (
<NavLink to="/shop" className="balance-pill balance-pill-link">
{formatCredits(user.balance)} cr
</NavLink>
) : (
<span className="balance-pill">{formatCredits(user.balance)} cr</span>
)
)}
{user ? (
<>
<NavLink to="/profile" className="nav-user" title="Profile">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="nav-avatar" />
) : null}
<span className="muted">{user.username}</span>
</NavLink>
<button type="button" className="btn ghost" onClick={logout}>
Logout
</button>
</>
<NavLink to="/profile" className="nav-user" title="Profile">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="nav-avatar" />
) : null}
<span className="muted">{user.username}</span>
</NavLink>
) : (
<NavLink to="/login">Login</NavLink>
)}
</nav>
</header>
<div className="app-body">
{feedOpen && user && user.role !== 'admin' && <DropFeed />}
{feedOpen && isPlayer && <DropFeed />}
<main className="main">
<Outlet />
</main>
@@ -0,0 +1,51 @@
import { Link } from 'react-router-dom';
export default function ProfileAchievementBadges({
achievements = [],
linkToAchievements = false,
}) {
if (!achievements.length) return null;
return (
<div className="achievement-badges-block">
<div className="catalog-badges-head">
<h3>Achievements</h3>
{linkToAchievements && (
<Link to="/achievements" className="achievement-badges-link">
View all
</Link>
)}
</div>
<div className="achievement-badges" aria-label="Unlocked achievements">
{achievements.map((a) => {
const inner = (
<>
<span className="achievement-badge-icon" aria-hidden>
{a.icon || '★'}
</span>
<span className="achievement-badge-name">{a.name}</span>
</>
);
return linkToAchievements ? (
<Link
key={a.key}
to="/achievements"
className="achievement-badge"
title={a.description}
>
{inner}
</Link>
) : (
<div
key={a.key}
className="achievement-badge"
title={a.description}
>
{inner}
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { Link } from 'react-router-dom';
export default function ProfileCaseStats({ caseOpens = [] }) {
if (!caseOpens.length) {
return <p className="muted case-stats-empty">No cases opened yet.</p>;
}
return (
<ul className="case-stat-list">
{caseOpens.map((row) => (
<li key={row.caseId}>
<Link to={`/cases/${row.caseId}`} className="case-stat-row">
<span className="case-stat-visual">
{row.imageUrl ? (
<img src={row.imageUrl} alt="" loading="lazy" />
) : (
'▣'
)}
</span>
<span className="case-stat-name">{row.caseName}</span>
<span className="case-stat-count">
<strong>{row.count}</strong>
<span className="muted"> opened</span>
</span>
<span className="case-stat-keys muted">
{row.keys} key{row.keys === 1 ? '' : 's'}
</span>
</Link>
</li>
))}
</ul>
);
}
@@ -0,0 +1,55 @@
import { Link } from 'react-router-dom';
export default function ProfileCatalogBadges({
completed = [],
luckPercent = 0,
linkToCatalog = false,
}) {
if (!completed.length) return null;
return (
<div className="catalog-badges-block">
<div className="catalog-badges-head">
<h3>Catalog</h3>
{luckPercent > 0 && (
<span className="catalog-badges-luck">+{luckPercent}% luck</span>
)}
</div>
<div className="catalog-badges" aria-label="Completed catalog chapters">
{completed.map((ch) => {
const inner = (
<>
{ch.imageUrl ? (
<img src={ch.imageUrl} alt="" loading="lazy" />
) : (
<span className="catalog-badge-fallback"></span>
)}
<span className="catalog-badge-check" aria-hidden>
</span>
<span className="catalog-badge-name">{ch.name}</span>
</>
);
return linkToCatalog ? (
<Link
key={ch.caseId}
to="/catalog"
className="catalog-badge"
title={`${ch.name} · chapter complete`}
>
{inner}
</Link>
) : (
<div
key={ch.caseId}
className="catalog-badge"
title={`${ch.name} · chapter complete`}
>
{inner}
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,23 @@
export default function ProfilePrestigeStats({ prestige }) {
if (!prestige || !prestige.count) return null;
return (
<div className="prestige-profile-block">
<div className="catalog-badges-head">
<h3>Prestige</h3>
<span className="catalog-badges-luck">×{prestige.count}</span>
</div>
<ul className="stat-list prestige-profile-stats">
<li>
Chance <strong>+{prestige.chanceBonus}%</strong>
</li>
<li>
Sell gains <strong>+{prestige.gainBonus}%</strong>
</li>
<li>
Key opens <strong>{prestige.keyOpenReduction}</strong>
</li>
</ul>
</div>
);
}
@@ -0,0 +1,104 @@
import { useEffect, useState } from 'react';
const FAKE_ADS = [
{
brand: 'ServerFuel™',
tagline: 'Keep the forge warm',
blurb: 'Fictional energy drink for fictional servers. 0 calories, 100% vibes.',
},
{
brand: 'CloudCrate Hosting',
tagline: 'Boxes in the sky',
blurb: 'Not a real host. Just a polite reminder that bandwidth isnt free.',
},
{
brand: 'FairPlay Shield',
tagline: 'One account, few tabs',
blurb: 'Demo ad. Thanks for not multi-boxing the backend into orbit.',
},
];
export default function SessionLimitOverlay({ max = 10 }) {
const [ad] = useState(
() => FAKE_ADS[Math.floor(Math.random() * FAKE_ADS.length)]
);
const [phase, setPhase] = useState('idle'); // idle | watching | done
const [secondsLeft, setSecondsLeft] = useState(5);
useEffect(() => {
if (phase !== 'watching') return undefined;
if (secondsLeft <= 0) {
setPhase('done');
return undefined;
}
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [phase, secondsLeft]);
const startAd = () => {
setSecondsLeft(5);
setPhase('watching');
};
return (
<div className="session-limit-overlay" role="alertdialog" aria-modal="true" aria-labelledby="session-limit-title">
<div className="session-limit-box">
<p className="session-limit-kicker">Connection limit</p>
<h2 id="session-limit-title">Too many windows</h2>
<p className="session-limit-lead">
This account already has <strong>{max}</strong> tabs/windows connected.
Close another one, then reload this page.
</p>
<p className="session-limit-respect">
Merci de ne pas abuser ni exploiter de bugs pour multiplier les sessions
ça charge le serveur pour tout le monde. Un compte, un usage raisonnable.
</p>
<div className="session-limit-ad">
<div className="session-limit-ad-label">Sponsored · fictif</div>
<div className={`session-limit-ad-screen${phase === 'watching' ? ' playing' : ''}`}>
{phase === 'idle' && (
<>
<div className="session-limit-ad-brand">{ad.brand}</div>
<div className="session-limit-ad-tag">{ad.tagline}</div>
<p className="muted">{ad.blurb}</p>
<button type="button" className="btn" onClick={startAd}>
Watch ad (5s) · support the servers
</button>
</>
)}
{phase === 'watching' && (
<>
<div className="session-limit-ad-brand">{ad.brand}</div>
<div className="session-limit-ad-progress" aria-hidden>
<div
className="session-limit-ad-progress-fill"
style={{ width: `${((5 - secondsLeft) / 5) * 100}%` }}
/>
</div>
<p className="session-limit-ad-countdown">Ad playing {secondsLeft}s</p>
<p className="muted">Fictional spot thanks for the support gesture.</p>
</>
)}
{phase === 'done' && (
<>
<div className="session-limit-ad-brand">Thanks!</div>
<p>
Pub fictive terminée. Pour débloquer cette page : ferme un autre onglet,
puis recharge.
</p>
<button
type="button"
className="btn secondary"
onClick={() => window.location.reload()}
>
Reload page
</button>
</>
)}
</div>
</div>
</div>
</div>
);
}
+1602 -3
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
export function notifyInventoryChanged() {
if (typeof window === 'undefined') return;
window.dispatchEvent(new Event('inventory:changed'));
}
export function onInventoryChanged(handler) {
if (typeof window === 'undefined') return () => {};
window.addEventListener('inventory:changed', handler);
return () => window.removeEventListener('inventory:changed', handler);
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api } from '../api';
import { useAuth } from '../AuthContext';
function formatUnlockedAt(iso) {
if (!iso) return null;
try {
return new Date(iso).toLocaleString();
} catch {
return null;
}
}
export default function AchievementsPage() {
const { user, loading } = useAuth();
const [achievements, setAchievements] = useState([]);
const [unlocked, setUnlocked] = useState(0);
const [total, setTotal] = useState(0);
const [error, setError] = useState('');
const [busy, setBusy] = useState(true);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
const data = await api.achievements();
if (cancelled) return;
setAchievements(data.achievements || []);
setUnlocked(data.unlocked || 0);
setTotal(data.total || 0);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [user?.id, user?.role]);
if (loading || busy) 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>Achievements</h1>
<p className="muted">
Unlock badges by playing. Earned badges show on your{' '}
<Link to="/profile">profile</Link>.
</p>
</div>
<div className="catalog-luck-pill">
{unlocked}/{total} unlocked
</div>
</div>
{error && <div className="error">{error}</div>}
{achievements.length === 0 && !error && (
<div className="empty">No achievements defined yet.</div>
)}
<div className="achievement-grid">
{achievements.map((a) => {
const done = Boolean(a.unlockedAt);
return (
<article
key={a.key}
className={`achievement-card${done ? ' unlocked' : ' locked'}`}
>
<div className="achievement-card-icon" aria-hidden>
{a.icon || '★'}
</div>
<div className="achievement-card-body">
<h2>{a.name}</h2>
<p className="muted">{a.description}</p>
{done ? (
<span className="achievement-card-meta">
Unlocked {formatUnlockedAt(a.unlockedAt)}
</span>
) : (
<span className="achievement-card-meta locked-label">Locked</span>
)}
</div>
</article>
);
})}
</div>
</section>
);
}
+2 -2
View File
@@ -432,7 +432,7 @@ export default function Admin() {
</select>
</label>
<label>
Value (cents)
Value (cents, Field-Tested ref)
<input
type="number"
value={itemForm.marketValue}
@@ -480,7 +480,7 @@ export default function Admin() {
</select>
</label>
<label>
Value (cents)
Value (cents, Field-Tested ref)
<input
type="number"
value={itemEdit.marketValue}
+257
View File
@@ -0,0 +1,257 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
import CaseReel from '../components/CaseReel';
import { useCountdown } from '../hooks/useCountdown';
import { rarityColor } from '../rarities';
import { getSocket } from '../socket';
const TOP_ITEMS = 5;
function waitingDuelRooms(open) {
return (open || []).filter(
(r) =>
r.status === 'open' &&
((r.playerCount ?? r.players?.length ?? 0) > 0 || (r.deposits?.length ?? 0) > 0)
);
}
function depositsToReelItems(deposits) {
return (deposits || [])
.filter((d) => d.item)
.map((d) => ({
...d.item,
id: d.id,
ownerUsername: d.username,
ownerAvatarUrl: d.avatarUrl || '',
}));
}
export default function BattleHub() {
const { user, loading } = useAuth();
const [round, setRound] = useState(null);
const [duelOpen, setDuelOpen] = useState([]);
const [spinning, setSpinning] = useState(false);
const [spinActive, setSpinActive] = useState(false);
const [spinDeposits, setSpinDeposits] = useState([]);
const [reelWinnerItemId, setReelWinnerItemId] = useState(null);
const [lastWinner, setLastWinner] = useState(null);
const [revealWinner, setRevealWinner] = useState(false);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
const [jp, duels] = await Promise.all([api.jackpot(), api.duels()]);
if (cancelled) return;
setRound(jp.round);
setDuelOpen(duels.open || []);
if (jp.round?.status === 'spinning') {
setSpinDeposits(jp.round.deposits || []);
setSpinActive(true);
setSpinning(true);
setReelWinnerItemId(jp.round.reelWinnerItemId || null);
setLastWinner(jp.round.winnerUsername || null);
}
} catch {
/* hub still usable without live preview */
}
})();
const socket = getSocket();
const onState = (state) => setRound(state);
const onSpin = (state) => {
setRound(state);
setSpinDeposits(state.deposits || []);
setSpinActive(true);
setSpinning(true);
setRevealWinner(false);
setReelWinnerItemId(state.reelWinnerItemId);
setLastWinner(state.winnerUsername);
};
const onResult = (state) => setRound(state);
const onDuels = (list) => setDuelOpen(list.open || []);
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
socket.on('jackpot:state', onState);
socket.on('jackpot:spin', onSpin);
socket.on('jackpot:result', onResult);
socket.on('duels:list', onDuels);
socket.on('connect', () => {
socket.emit('jackpot:subscribe');
socket.emit('duels:subscribe');
});
return () => {
cancelled = true;
socket.emit('jackpot:unsubscribe');
socket.emit('duels:unsubscribe');
socket.off('jackpot:state', onState);
socket.off('jackpot:spin', onSpin);
socket.off('jackpot:result', onResult);
socket.off('duels:list', onDuels);
};
}, [user]);
useEffect(() => {
if (!revealWinner) return undefined;
const t = setTimeout(() => {
setSpinActive(false);
setRevealWinner(false);
}, 4000);
return () => clearTimeout(t);
}, [revealWinner]);
const topItems = useMemo(() => {
const deposits = [...(round?.deposits || [])].filter((d) => d.item);
deposits.sort((a, b) => (b.valueCents || 0) - (a.valueCents || 0));
return deposits.slice(0, TOP_ITEMS);
}, [round]);
const potItems = useMemo(
() => depositsToReelItems(spinActive && spinDeposits.length ? spinDeposits : round?.deposits),
[round, spinDeposits, spinActive]
);
const showReel = spinActive && potItems.length > 0;
const waitingRooms = useMemo(() => waitingDuelRooms(duelOpen), [duelOpen]);
const waitingRoomCount = waitingRooms.length;
const liveTimerEndsAt =
round?.status === 'open' && round?.endsAt && !round?.waitingForPlayers && !spinning
? round.endsAt
: null;
const { label: liveTimerLabel } = useCountdown(liveTimerEndsAt);
const playerCount = round?.players?.length ?? 0;
const potValue = round?.totalValue ?? 0;
const itemCount = round?.deposits?.length ?? 0;
const statusLabel = !round
? '…'
: round.status === 'spinning' || spinning
? 'Spinning'
: round.waitingForPlayers
? 'Waiting for players'
: round.status === 'open'
? liveTimerEndsAt
? `Live ${liveTimerLabel}`
: 'Live'
: round.status;
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<section className="section">
<div className="section-head">
<div>
<h1>Battle</h1>
<p className="muted">Multiplayer item gambling modes.</p>
</div>
</div>
<div className="mode-grid">
<Link to="/battle/multi" className="mode-card mode-card-live">
<div className="mode-card-top">
<h2>MultiPlayers Case</h2>
<span className={`mode-status${round?.status === 'spinning' || spinning ? ' spinning' : ''}`}>
{statusLabel}
</span>
</div>
<p className="muted mode-blurb">
Join the live pot. Bet up to {round?.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 || round?.status === 'spinning'}
showOwners
compact
onDone={() => {
setSpinning(false);
setRevealWinner(true);
}}
/>
{revealWinner && lastWinner && (
<p className="mode-card-winner">
Winner: <strong>{lastWinner}</strong>
</p>
)}
</div>
) : topItems.length > 0 ? (
<div className="mode-pot-items" aria-label="Top items in pot">
{topItems.map((d) => (
<div
key={d.id}
className="mode-pot-thumb"
style={{ borderColor: rarityColor(d.item.rarity) }}
title={`${d.item.name} · ${formatCredits(d.valueCents)} cr`}
>
{d.item.imageUrl ? (
<img src={d.item.imageUrl} alt="" loading="lazy" />
) : (
<span>{d.item.name.slice(0, 2).toUpperCase()}</span>
)}
</div>
))}
{itemCount > topItems.length && (
<span className="mode-pot-more muted">+{itemCount - topItems.length}</span>
)}
</div>
) : (
<p className="muted mode-pot-empty">No items in the pot yet be the first.</p>
)}
</Link>
<Link to="/battle/1vx" className="mode-card">
<div className="mode-card-top">
<h2>1vX Case</h2>
{waitingRoomCount > 0 && (
<span className="mode-status">
{waitingRoomCount} waiting
</span>
)}
</div>
<p className="muted mode-blurb">
Create or join custom rooms. Set player/item limits and min value. Spectate live duels.
</p>
<div className="mode-pot-stats">
<div>
<span className="muted">Open rooms</span>
<strong>{waitingRoomCount}</strong>
</div>
</div>
</Link>
<div className="mode-card stub">
<h2>Coinflip</h2>
<p className="muted">Classic 1v1 item flip. Coming soon.</p>
<span className="badge">Soon</span>
</div>
</div>
</section>
);
}
-58
View File
@@ -1,58 +0,0 @@
import { Link, Navigate } from 'react-router-dom';
import { useAuth } from '../AuthContext';
const MODES = [
{
to: '/bet/multi',
title: 'MultiPlayers Case',
blurb: 'Join the live pot. Bet up to 3 items — chance scales with value. Winner takes all.',
ready: true,
},
{
to: '/bet/1vx',
title: '1vX Case',
blurb: 'Create or join custom rooms. Set player/item limits and min value. Spectate live duels.',
ready: true,
},
{
to: '/bet/coinflip',
title: 'Coinflip',
blurb: 'Classic 1v1 item flip. Coming soon.',
ready: false,
},
];
export default function BetHub() {
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>Bet Item</h1>
<p className="muted">Multiplayer item gambling modes.</p>
</div>
</div>
<div className="mode-grid">
{MODES.map((m) =>
m.ready ? (
<Link key={m.to} to={m.to} className="mode-card">
<h2>{m.title}</h2>
<p className="muted">{m.blurb}</p>
</Link>
) : (
<div key={m.to} className="mode-card stub">
<h2>{m.title}</h2>
<p className="muted">{m.blurb}</p>
<span className="badge">Soon</span>
</div>
)
)}
</div>
</section>
);
}
+409 -42
View File
@@ -1,27 +1,159 @@
import { useEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
import CaseReel from '../components/CaseReel';
import ItemTile from '../components/ItemTile';
import { notifyInventoryChanged } from '../inventoryEvents';
import { rarityColor } from '../rarities';
import { loadCaseOpenQty, resolveOpenQty, saveCaseOpenQty } from '../userPrefs';
const DEFAULT_MAX_OPENS = 10;
/** 15, then steps of 5 (10, 15…), plus max if not already listed. */
function buildOpenQuantityOptions(maxOpens) {
const cap = Math.max(1, maxOpens);
const options = [];
for (let n = 1; n <= Math.min(5, cap); n += 1) options.push(n);
for (let n = 10; n <= cap; n += 5) options.push(n);
if (cap > 5 && !options.includes(cap)) options.push(cap);
return options;
}
export default function CasePage() {
const { id } = useParams();
const { user, loading, updateBalance } = useAuth();
const [crate, setCrate] = useState(null);
const [error, setError] = useState('');
const [spinning, setSpinning] = useState(false);
const [winnerId, setWinnerId] = useState(null);
const [wonItem, setWonItem] = useState(null);
const [showResult, setShowResult] = useState(false);
const [keyMessage, setKeyMessage] = useState('');
const [catalogMessage, setCatalogMessage] = useState('');
const [quantity, setQuantity] = useState(1);
const [opening, setOpening] = useState(false);
const [spins, setSpins] = useState([]);
const [caseName, setCaseName] = useState('');
const announcedRef = useRef(false);
const finishingRef = useRef(false);
const pendingAnnounceRef = useRef({ ids: [], caseName: '' });
const pendingAutoSellRef = useRef([]);
const quantityRef = useRef(1);
const caseIdRef = useRef(null);
const prefReadyRef = useRef(false);
const keyProgress = crate?.keyProgress ?? null;
const maxOpens = keyProgress?.maxOpens ?? DEFAULT_MAX_OPENS;
quantityRef.current = quantity;
const persistQty = (caseId, qty) => {
if (user?.id && caseId != null) saveCaseOpenQty(user.id, caseId, qty);
};
useEffect(() => {
return () => {
if (user?.id && caseIdRef.current != null) {
saveCaseOpenQty(user.id, caseIdRef.current, quantityRef.current);
}
};
}, [user?.id]);
const announcePendingDrop = async (useKeepalive = false) => {
const ids = pendingAnnounceRef.current.ids.filter(Boolean);
const announceCaseName = pendingAnnounceRef.current.caseName || caseName || crate?.name || '';
if (!ids.length || announcedRef.current) return;
announcedRef.current = true;
const body = JSON.stringify({ inventoryItemIds: ids, caseName: announceCaseName });
if (useKeepalive) {
fetch('/api/feed/announce', {
method: 'POST',
credentials: 'include',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body,
}).catch(() => {
announcedRef.current = false;
});
return;
}
try {
await api.feedAnnounce(ids, announceCaseName);
} catch {
announcedRef.current = false;
}
};
const applyPendingAutoSellKeepalive = () => {
const ids = pendingAutoSellRef.current.filter(Boolean);
if (!ids.length) return;
pendingAutoSellRef.current = [];
fetch('/api/inventory/auto-sell/apply', {
method: 'POST',
credentials: 'include',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inventoryItemIds: ids }),
}).catch(() => {});
};
const finalizeOpens = async (completedSpins, announceCaseName) => {
if (finishingRef.current) return;
finishingRef.current = true;
const ids = completedSpins.map((s) => s.item?.inventoryId).filter(Boolean);
pendingAnnounceRef.current = { ids, caseName: announceCaseName };
try {
await announcePendingDrop(false);
const toSell = completedSpins
.filter((s) => s.item?.willAutoSell)
.map((s) => s.item.inventoryId)
.filter(Boolean);
pendingAutoSellRef.current = [];
if (toSell.length) {
const data = await api.applyPendingAutoSell(toSell);
updateBalance(data.user.balance);
notifyInventoryChanged();
const soldSet = new Set(data.soldIds);
setSpins((curr) =>
curr.map((s) =>
soldSet.has(s.item.inventoryId)
? {
...s,
item: {
...s.item,
autoSold: true,
soldForCents: s.item.valueCents,
},
}
: s
)
);
}
} catch (err) {
setError(err.message);
announcedRef.current = false;
} finally {
finishingRef.current = false;
}
};
useEffect(() => {
let cancelled = false;
prefReadyRef.current = false;
caseIdRef.current = null;
setCrate(null);
(async () => {
try {
const data = await api.case(id);
if (!cancelled) setCrate(data.case);
if (!cancelled) {
setCrate(data.case);
caseIdRef.current = data.case.id;
}
} catch (err) {
if (!cancelled) setError(err.message);
}
@@ -31,33 +163,185 @@ export default function CasePage() {
};
}, [id]);
const maxAffordable = useMemo(() => {
if (!crate || !user) return 1;
if (crate.price <= 0) return maxOpens;
const affordable = Math.floor(user.balance / crate.price);
return Math.min(maxOpens, Math.max(affordable, 0));
}, [crate, user, maxOpens]);
const openOptions = useMemo(() => buildOpenQuantityOptions(maxOpens), [maxOpens]);
useLayoutEffect(() => {
if (!crate || !user?.id) return;
const saved = loadCaseOpenQty(user.id, crate.id);
const resolved = resolveOpenQty(saved, openOptions, maxAffordable);
if (!prefReadyRef.current) {
prefReadyRef.current = true;
setQuantity(resolved);
return;
}
setQuantity((q) => {
const allowed = openOptions.filter((n) => n <= Math.max(1, maxAffordable));
if (!allowed.length) return 1;
if (allowed.includes(q)) return q;
const below = allowed.filter((n) => n <= q);
return below.length ? below[below.length - 1] : allowed[0];
});
}, [crate, maxAffordable, openOptions, user?.id]);
useEffect(() => {
if (!prefReadyRef.current || !user?.id || crate?.id == null) return;
persistQty(crate.id, quantity);
}, [quantity, user?.id, crate?.id]);
useEffect(() => {
return () => {
announcePendingDrop(true);
applyPendingAutoSellKeepalive();
};
}, []);
const possibleDrops = useMemo(() => {
if (!crate?.items?.length) return [];
return [...crate.items].sort((a, b) => {
const ca = Number(a.chance ?? a.dropRate ?? 0);
const cb = Number(b.chance ?? b.dropRate ?? 0);
if (ca !== cb) return ca - cb;
return (b.marketValue || 0) - (a.marketValue || 0);
});
}, [crate]);
const progressPct = useMemo(() => {
if (!keyProgress?.opensRequired) return 0;
return Math.min(
100,
Math.round((keyProgress.opensSinceLastKey / keyProgress.opensRequired) * 100)
);
}, [keyProgress]);
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
const totalCost = crate ? crate.price * quantity : 0;
const canOpen =
crate && !spinning && user.balance >= crate.price && crate.items.length > 0;
crate &&
!opening &&
crate.items.length > 0 &&
quantity >= 1 &&
quantity <= maxOpens &&
user.balance >= totalCost;
const allDone = spins.length > 0 && spins.every((s) => s.done);
const openCase = async () => {
if (!canOpen) return;
setError('');
setWonItem(null);
setShowResult(false);
setWinnerId(null);
setSpinning(false);
setKeyMessage('');
setCatalogMessage('');
setSpins([]);
setOpening(true);
announcedRef.current = false;
finishingRef.current = false;
try {
const result = await api.openCase(crate.id);
const result = await api.openCase(crate.id, quantity);
updateBalance(result.user.balance);
setWonItem(result.item);
setWinnerId(result.item.id);
// start spin on next frame so reel rebuilds with winner
requestAnimationFrame(() => setSpinning(true));
notifyInventoryChanged();
persistQty(crate.id, quantity);
if (result.keyProgress) {
setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
}
if (result.keysGained > 0) {
setKeyMessage(
`+${result.keysGained} key${result.keysGained === 1 ? '' : 's'} unlocked · now x${result.keyProgress.maxOpens} max`
);
}
if (result.catalog) {
const parts = [];
if (result.catalog.newlyFilled?.length) {
const n = result.catalog.newlyFilled.length;
parts.push(
`${n} catalog slot${n === 1 ? '' : 's'} filled`
);
}
if (result.catalog.chapterJustCompleted) {
parts.push('chapter complete · +1% luck');
} else if (result.catalog.luckPercent > 0 && result.catalog.newlyFilled?.length) {
parts.push(`luck +${result.catalog.luckPercent}%`);
}
if (parts.length) setCatalogMessage(parts.join(' · '));
if (result.catalog.luckPercent != null) {
setCrate((prev) =>
prev ? { ...prev, catalogLuckPercent: result.catalog.luckPercent } : prev
);
if (result.catalog.chapterJustCompleted) {
api.case(crate.id).then((data) => {
setCrate((prev) =>
prev
? {
...prev,
...data.case,
keyProgress: result.keyProgress || data.case.keyProgress,
}
: data.case
);
}).catch(() => {});
}
}
}
const resolvedCaseName = result.caseName || crate.name;
setCaseName(resolvedCaseName);
const items = result.items?.length ? result.items : result.item ? [result.item] : [];
const inventoryIds = items.map((item) => item?.inventoryId).filter(Boolean);
pendingAnnounceRef.current = {
ids: inventoryIds,
caseName: resolvedCaseName || '',
};
pendingAutoSellRef.current = items
.filter((item) => item?.willAutoSell)
.map((item) => item.inventoryId)
.filter(Boolean);
const next = items.map((item, idx) => ({
key: `${item.inventoryId || item.id}-${idx}-${Date.now()}`,
item,
spinning: false,
done: false,
}));
setSpins(next);
requestAnimationFrame(() => {
setSpins((prev) => prev.map((s) => ({ ...s, spinning: true })));
});
} catch (err) {
setError(err.message);
setOpening(false);
}
};
const onReelDone = (key) => {
setSpins((prev) => {
const alreadyDone = prev.find((s) => s.key === key)?.done;
if (alreadyDone) return prev;
const next = prev.map((s) =>
s.key === key ? { ...s, spinning: false, done: true } : s
);
const wasComplete = prev.length > 0 && prev.every((s) => s.done);
const nowComplete = next.length > 0 && next.every((s) => s.done);
if (nowComplete && !wasComplete) {
setOpening(false);
void finalizeOpens(next, caseName || crate?.name || '');
}
return next;
});
};
return (
<div className="case-page">
<Link to="/cases" className="muted">
@@ -66,14 +350,63 @@ export default function CasePage() {
{!crate && !error && <p className="muted">Loading case</p>}
{error && <div className="error">{error}</div>}
{keyMessage && <div className="ok-msg">{keyMessage}</div>}
{catalogMessage && <div className="ok-msg">{catalogMessage}</div>}
{crate && (
<>
<div className="case-hero">
<h1>{crate.name}</h1>
<p className="muted">
Cost {formatCredits(crate.price)} cr · Balance {formatCredits(user.balance)} cr
Cost {formatCredits(crate.price)} cr each · Balance{' '}
{formatCredits(user.balance)} cr
{crate.catalogLuckPercent > 0
? ` · Catalog luck +${crate.catalogLuckPercent}%`
: ''}
</p>
{keyProgress && (
<div className="case-key-progress">
<div className="case-key-head">
<span>
Keys <strong>{keyProgress.keys}</strong> · Max{' '}
<strong>x{keyProgress.maxOpens}</strong>
</span>
<span className="muted">
Next key: {keyProgress.opensSinceLastKey} / {keyProgress.opensRequired} opens
</span>
</div>
<div className="case-key-bar" role="progressbar" aria-valuenow={progressPct}>
<div className="case-key-bar-fill" style={{ width: `${progressPct}%` }} />
</div>
</div>
)}
<div className="open-qty">
<label className="muted">
Open
<select
value={quantity}
disabled={opening}
onChange={(e) => {
const next = Number(e.target.value);
setQuantity(next);
persistQty(crate.id, next);
}}
>
{openOptions.map((n) => (
<option key={n} value={n} disabled={n > maxAffordable && maxAffordable > 0}>
{n}×
</option>
))}
</select>
</label>
<span className="muted">
Total {formatCredits(totalCost)} cr
{maxAffordable < quantity ? ' · insufficient balance' : ''}
</span>
</div>
<button
type="button"
className={`case-icon ${canOpen ? '' : 'disabled'}${crate.imageUrl ? ' has-img' : ''}`}
@@ -84,34 +417,62 @@ export default function CasePage() {
{crate.imageUrl ? <img src={crate.imageUrl} alt="" /> : '▣'}
</button>
<button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
{spinning ? 'Opening…' : `Open for ${formatCredits(crate.price)} cr`}
{opening
? `Opening ${quantity}×…`
: `Open ${quantity}× for ${formatCredits(totalCost)} cr`}
</button>
</div>
{(spinning || winnerId) && (
<CaseReel
items={crate.items}
winnerId={winnerId}
spinning={spinning}
onDone={() => {
setSpinning(false);
setShowResult(true);
}}
/>
{spins.length > 0 && (
<div className={`multi-reels${spins.length > 1 ? ' many' : ''}`}>
{spins.map((spin, idx) => (
<div key={spin.key} className="multi-reel-slot">
{spins.length > 1 && (
<div className="muted multi-reel-label">#{idx + 1}</div>
)}
<CaseReel
items={crate.items}
winnerId={spin.item.id}
spinning={spin.spinning}
onDone={() => onReelDone(spin.key)}
/>
{spin.done && (
<div
className="won-banner compact"
style={{ '--rarity': rarityColor(spin.item.rarity) }}
>
{spin.item.imageUrl ? (
<img src={spin.item.imageUrl} alt="" className="won-img" />
) : null}
<strong>{spin.item.name}</strong>
<div style={{ color: rarityColor(spin.item.rarity) }}>
{spin.item.rarity}
</div>
<div className="muted">
{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}` : ''}
</>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
{showResult && wonItem && (
<div
className="won-banner"
style={{ '--rarity': rarityColor(wonItem.rarity) }}
>
{wonItem.imageUrl ? (
<img src={wonItem.imageUrl} alt="" className="won-img" />
) : null}
<div className="muted">You unboxed</div>
<strong>{wonItem.name}</strong>
<div style={{ color: rarityColor(wonItem.rarity) }}>{wonItem.rarity}</div>
<div className="muted">{formatCredits(wonItem.marketValue)} cr</div>
{allDone && spins.length > 1 && (
<div className="won-banner section">
<div className="muted">You unboxed {spins.length} items</div>
<div className="grid" style={{ marginTop: '0.75rem' }}>
{spins.map((s) => (
<ItemTile key={s.key} item={s.item} />
))}
</div>
</div>
)}
@@ -119,11 +480,17 @@ export default function CasePage() {
<div className="section-head">
<div>
<h2>Possible drops</h2>
<p className="muted">Relative drop odds for this case.</p>
<p className="muted">
Relative drop odds
{crate.catalogLuckPercent > 0
? ` (with +${crate.catalogLuckPercent}% catalog luck)`
: ''}{' '}
· prices shown at Field-Tested reference.
</p>
</div>
</div>
<div className="grid">
{crate.items.map((item) => (
{possibleDrops.map((item) => (
<div key={item.id}>
<ItemTile item={item} />
<div className="item-meta" style={{ marginTop: '0.35rem' }}>
+6
View File
@@ -46,6 +46,12 @@ export default function CasesPage() {
<div className="case-name">{c.name}</div>
<div className="case-meta">{formatCredits(c.price)} cr</div>
<div className="case-meta">{c.items.length} possible drops</div>
{c.keyProgress && (
<div className="case-meta case-key-badge">
{c.keyProgress.keys} key{c.keyProgress.keys === 1 ? '' : 's'} · x
{c.keyProgress.maxOpens}
</div>
)}
</Link>
))}
</div>
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
import { rarityColor } from '../rarities';
export default function CatalogPage() {
const { user, loading } = useAuth();
const [chapters, setChapters] = useState([]);
const [luckPercent, setLuckPercent] = useState(0);
const [error, setError] = useState('');
const [busy, setBusy] = useState(true);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
const data = await api.catalog();
if (cancelled) return;
setChapters(data.chapters || []);
setLuckPercent(data.luckPercent || 0);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [user?.id, user?.role]);
if (loading || busy) 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>Catalog</h1>
<p className="muted">
Fill every slot in a case chapter. Each completed chapter grants +1% catalog luck on
opens.
</p>
</div>
<div className="catalog-luck-pill">
{luckPercent > 0 ? `+${luckPercent}% luck` : 'No luck bonus yet'}
</div>
</div>
{error && <div className="error">{error}</div>}
{chapters.length === 0 && !error && (
<div className="empty">No active cases to collect.</div>
)}
<div className="catalog-chapters">
{chapters.map((ch) => (
<article
key={ch.caseId}
className={`catalog-chapter${ch.completed ? ' completed' : ''}`}
>
<div className="catalog-chapter-head">
<div className="catalog-chapter-title">
{ch.imageUrl ? (
<img src={ch.imageUrl} alt="" className="catalog-case-thumb" />
) : (
<span className="catalog-case-thumb placeholder"></span>
)}
<div>
<h2>
<Link to={`/cases/${ch.caseId}`}>{ch.name}</Link>
</h2>
<p className="muted">
{ch.filled}/{ch.total} slots · {formatCredits(ch.price)} cr
</p>
</div>
</div>
{ch.completed ? (
<span className="catalog-complete-badge">Complete · +1%</span>
) : (
<div className="catalog-progress-bar" aria-hidden>
<div
className="catalog-progress-fill"
style={{
width: `${ch.total ? Math.round((ch.filled / ch.total) * 100) : 0}%`,
}}
/>
</div>
)}
</div>
<div className="catalog-slots">
{ch.slots.map((slot) => (
<div
key={slot.itemId}
className={`catalog-slot${slot.filled ? ' filled' : ' empty'}`}
style={slot.filled ? { '--rarity': rarityColor(slot.rarity) } : undefined}
title={slot.filled ? slot.name : 'Not collected yet'}
>
{slot.filled ? (
<>
{slot.imageUrl ? (
<img src={slot.imageUrl} alt="" loading="lazy" />
) : (
<span className="catalog-slot-fallback">
{slot.name.slice(0, 2).toUpperCase()}
</span>
)}
<span className="catalog-slot-name">{slot.name}</span>
</>
) : (
<span className="catalog-slot-empty">?</span>
)}
</div>
))}
</div>
</article>
))}
</div>
</section>
);
}
+3 -3
View File
@@ -15,17 +15,17 @@ export default function CoinflipStub() {
<h1>Coinflip</h1>
<p className="muted">1v1 item flip coming soon.</p>
</div>
<Link to="/bet" className="btn secondary">
<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="/bet/multi" className="btn">
<Link to="/battle/multi" className="btn">
MultiPlayers
</Link>
<Link to="/bet/1vx" className="btn secondary">
<Link to="/battle/1vx" className="btn secondary">
1vX Case
</Link>
</div>
+314 -34
View File
@@ -3,14 +3,36 @@ import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile';
import { onInventoryChanged } from '../inventoryEvents';
import { loadInventorySort, saveInventorySort } from '../userPrefs';
const PAGE_SIZE = 50;
export default function Dashboard() {
const { user, loading, patchUser } = useAuth();
const [inventory, setInventory] = useState([]);
const [error, setError] = useState('');
const [sort, setSort] = useState('recent');
const [sort, setSort] = useState('value');
const [page, setPage] = useState(1);
const [selected, setSelected] = useState([]);
const [busy, setBusy] = useState(false);
const [maxValue, setMaxValue] = useState('10');
const [autoSellEnabled, setAutoSellEnabled] = useState(false);
const [settingsBusy, setSettingsBusy] = useState(false);
useEffect(() => {
if (!user?.id) return;
const saved = loadInventorySort(user.id);
if (saved) setSort(saved);
}, [user?.id]);
useEffect(() => {
if (!user) return;
setAutoSellEnabled(Boolean(user.autoSellEnabled));
if (user.autoSellThresholdCents != null) {
setMaxValue((user.autoSellThresholdCents / 100).toFixed(2).replace(/\.?0+$/, '') || '0');
}
}, [user?.id, user?.autoSellEnabled, user?.autoSellThresholdCents]);
const load = async () => {
const inv = await api.inventory();
@@ -32,38 +54,98 @@ export default function Dashboard() {
};
}, [user]);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
return onInventoryChanged(() => {
load().catch(() => {});
});
}, [user]);
const sorted = useMemo(() => {
const list = [...inventory];
if (sort === 'value') {
list.sort((a, b) => b.item.marketValue - a.item.marketValue);
list.sort(
(a, b) =>
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
);
} else {
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
}
return list;
}, [inventory, sort]);
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageItems = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return sorted.slice(start, start + PAGE_SIZE);
}, [sorted, currentPage]);
useEffect(() => {
setPage(1);
}, [sort]);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const selectedValue = useMemo(
() =>
inventory
.filter((inv) => selected.includes(inv.id))
.reduce((sum, inv) => sum + inv.item.marketValue, 0),
.reduce((sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0), 0),
[inventory, selected]
);
const thresholdCents = useMemo(() => {
const credits = Number(maxValue);
if (!Number.isFinite(credits) || credits < 0) return null;
return Math.round(credits * 100);
}, [maxValue]);
const belowThreshold = useMemo(() => {
if (thresholdCents == null) return [];
return inventory.filter(
(inv) =>
!inv.favorite &&
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
);
}, [inventory, thresholdCents]);
const selectedItems = useMemo(
() => inventory.filter((inv) => selected.includes(inv.id)),
[inventory, selected]
);
const selectedHasFavorite = selectedItems.some((inv) => inv.favorite);
const selectedAllFavorite =
selectedItems.length > 0 && selectedItems.every((inv) => inv.favorite);
const selectedCanSell = selectedItems.some((inv) => !inv.favorite);
const belowValue = useMemo(
() =>
belowThreshold.reduce(
(sum, inv) => sum + (inv.valueCents ?? inv.item?.valueCents ?? 0),
0
),
[belowThreshold]
);
const toggle = (id) => {
setSelected((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
const sell = async () => {
if (!selected.length) return;
const sellIds = async (ids) => {
if (!ids.length) return;
setBusy(true);
setError('');
try {
const data = await api.sellInventory(selected);
const data = await api.sellInventory(ids);
patchUser({ balance: data.user.balance });
setSelected([]);
setSelected((prev) => prev.filter((id) => !ids.includes(id)));
await load();
} catch (err) {
setError(err.message);
@@ -72,6 +154,71 @@ export default function Dashboard() {
}
};
const sell = async () => {
const ids = selectedItems.filter((inv) => !inv.favorite).map((inv) => inv.id);
if (!ids.length) return;
await sellIds(ids);
};
const setFavorite = async (favorite) => {
if (!selected.length) return;
setBusy(true);
setError('');
try {
await api.setInventoryFavorite(selected, favorite);
await load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const sellBelow = async () => {
const ids = belowThreshold.map((inv) => inv.id);
if (!ids.length || thresholdCents == null) return;
const favSkipped = inventory.filter(
(inv) =>
inv.favorite && (inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
).length;
const ok = window.confirm(
`Sell ${ids.length} item${ids.length === 1 ? '' : 's'} below ${formatCredits(thresholdCents)} cr for ${formatCredits(belowValue)} cr?` +
(favSkipped ? ` (${favSkipped} favorite item${favSkipped === 1 ? '' : 's'} excluded)` : '')
);
if (!ok) return;
await sellIds(ids);
};
const saveAutoSell = async (enabled, thresholdCredits) => {
setSettingsBusy(true);
setError('');
try {
const data = await api.updateAutoSell({ enabled, thresholdCredits });
patchUser(data.user);
} catch (err) {
setError(err.message);
} finally {
setSettingsBusy(false);
}
};
const onAutoSellToggle = async (enabled) => {
setAutoSellEnabled(enabled);
const credits = Number(maxValue);
if (!Number.isFinite(credits) || credits < 0) return;
await saveAutoSell(enabled, credits);
};
const onThresholdBlur = async () => {
const credits = Number(maxValue);
if (!Number.isFinite(credits) || credits < 0) return;
const savedCents = user?.autoSellThresholdCents ?? 1000;
if (Math.round(credits * 100) === savedCents && autoSellEnabled === Boolean(user?.autoSellEnabled)) {
return;
}
await saveAutoSell(autoSellEnabled, credits);
};
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
@@ -84,31 +231,44 @@ export default function Dashboard() {
<h1>Inventory</h1>
<p className="muted">Select items to sell at market value.</p>
</div>
<div className="balance-pill">{formatCredits(user.balance)} cr</div>
</div>
<div className="inv-toolbar">
<div className="sort-toggle" role="group" aria-label="Sort inventory">
<button
type="button"
className={sort === 'recent' ? 'active' : ''}
onClick={() => setSort('recent')}
>
Most recent
</button>
<button
type="button"
className={sort === 'value' ? 'active' : ''}
onClick={() => setSort('value')}
>
Highest value
</button>
<div className="inv-toolbar-left">
<div className="sort-toggle" role="group" aria-label="Sort inventory">
<button
type="button"
className={sort === 'value' ? 'active' : ''}
onClick={() => {
setSort('value');
saveInventorySort(user.id, 'value');
}}
>
Highest value
</button>
<button
type="button"
className={sort === 'recent' ? 'active' : ''}
onClick={() => {
setSort('recent');
saveInventorySort(user.id, 'recent');
}}
>
Most recent
</button>
</div>
{inventory.length > 0 && (
<span className="muted inv-count">
{inventory.length} item{inventory.length === 1 ? '' : 's'}
</span>
)}
</div>
<div className="row-actions">
{selected.length > 0 && (
<>
<span className="muted">
{selected.length} selected · {formatCredits(selectedValue)} cr
{selectedHasFavorite ? ' · includes favorites' : ''}
</span>
<button
type="button"
@@ -118,7 +278,33 @@ export default function Dashboard() {
>
Clear
</button>
<button type="button" className="btn" onClick={sell} disabled={busy}>
{!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 ? 'Favorites cannot be sold' : undefined}
>
Sell
</button>
</>
@@ -126,6 +312,74 @@ export default function Dashboard() {
</div>
</div>
<div className="inv-bulk-sell">
{inventory.length > 0 && (
<>
<button
type="button"
className="btn"
disabled={busy || belowThreshold.length === 0}
onClick={sellBelow}
>
Sell below
</button>
<label className="inv-bulk-label">
<input
type="number"
min="0"
step="0.01"
value={maxValue}
disabled={busy || settingsBusy}
onChange={(e) => setMaxValue(e.target.value)}
onBlur={onThresholdBlur}
aria-label="Maximum market value in credits"
/>
<span className="muted">cr</span>
</label>
<span className="muted inv-bulk-preview">
{belowThreshold.length} item{belowThreshold.length === 1 ? '' : 's'} ·{' '}
{formatCredits(belowValue)} cr
</span>
</>
)}
{inventory.length === 0 && (
<label className="inv-bulk-label">
Threshold
<input
type="number"
min="0"
step="0.01"
value={maxValue}
disabled={busy || settingsBusy}
onChange={(e) => setMaxValue(e.target.value)}
onBlur={onThresholdBlur}
aria-label="Auto-sell threshold in credits"
/>
<span className="muted">cr</span>
</label>
)}
<div className="inv-auto-sell-box">
<label className="inv-auto-sell-toggle">
<input
type="checkbox"
checked={autoSellEnabled}
disabled={busy || settingsBusy}
onChange={(e) => onAutoSellToggle(e.target.checked)}
/>
<span>Auto-sell</span>
</label>
<span className="inv-auto-sell-at muted">
at{' '}
{formatCredits(
Number.isFinite(Number(maxValue)) && Number(maxValue) >= 0
? Math.round(Number(maxValue) * 100)
: 0
)}{' '}
cr
</span>
</div>
</div>
{error && <div className="error">{error}</div>}
{inventory.length === 0 ? (
<div className="empty">
@@ -136,16 +390,42 @@ export default function Dashboard() {
.
</div>
) : (
<div className="grid">
{sorted.map((inv) => (
<ItemTile
key={inv.id}
item={inv.item}
selected={selected.includes(inv.id)}
onClick={() => toggle(inv.id)}
/>
))}
</div>
<>
<div className="grid">
{pageItems.map((inv) => (
<ItemTile
key={inv.id}
item={inv.item}
favorite={inv.favorite}
selected={selected.includes(inv.id)}
onClick={() => toggle(inv.id)}
/>
))}
</div>
{totalPages > 1 && (
<div className="inv-pager">
<button
type="button"
className="btn ghost"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<span className="muted">
Page {currentPage} / {totalPages}
</span>
<button
type="button"
className="btn ghost"
disabled={currentPage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
</button>
</div>
)}
</>
)}
</section>
</div>
+4 -4
View File
@@ -63,7 +63,7 @@ export default function DuelListPage() {
minItemValue: Math.round(Number(form.minItemValue) * 100),
minPlayersToStart: Number(form.minPlayersToStart),
});
navigate(`/bet/1vx/${data.room.id}`);
navigate(`/battle/1vx/${data.room.id}`);
} catch (err) {
setError(err.message);
} finally {
@@ -82,7 +82,7 @@ export default function DuelListPage() {
<h1>1vX Case</h1>
<p className="muted">Create a room or join an open duel.</p>
</div>
<Link to="/bet" className="btn secondary">
<Link to="/battle" className="btn secondary">
Modes
</Link>
</div>
@@ -145,7 +145,7 @@ export default function DuelListPage() {
) : (
<div className="room-list">
{open.map((r) => (
<Link key={r.id} to={`/bet/1vx/${r.id}`} className="room-card">
<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 ·{' '}
@@ -165,7 +165,7 @@ export default function DuelListPage() {
) : (
<div className="room-list">
{inProgress.map((r) => (
<Link key={r.id} to={`/bet/1vx/${r.id}`} className="room-card live">
<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>
+25 -5
View File
@@ -2,8 +2,9 @@ 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 from '../components/ItemTile';
import ItemTile, { itemValueCents } from '../components/ItemTile';
import CaseReel from '../components/CaseReel';
import { onInventoryChanged } from '../inventoryEvents';
import { getSocket } from '../socket';
export default function DuelRoomPage() {
@@ -22,6 +23,16 @@ export default function DuelRoomPage() {
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;
@@ -75,6 +86,14 @@ export default function DuelRoomPage() {
};
}, [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') &&
@@ -176,7 +195,7 @@ export default function DuelRoomPage() {
{isSpectator ? ' · Spectating' : ''}
</p>
</div>
<Link to="/bet/1vx" className="btn secondary">
<Link to="/battle/1vx" className="btn secondary">
All rooms
</Link>
</div>
@@ -307,20 +326,21 @@ export default function DuelRoomPage() {
Deposit
</button>
</div>
{inventory.length === 0 ? (
{inventoryByValue.length === 0 ? (
<div className="empty">
No free items <Link to="/cases">open a case</Link>.
</div>
) : (
<div className="grid">
{inventory.map((inv) => (
{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)) ||
inv.item.marketValue < (room.minItemValue || 0)
itemValueCents(inv.item) < (room.minItemValue || 0)
}
onClick={() => toggleSelect(inv.id)}
/>
+148 -24
View File
@@ -1,10 +1,69 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
const PODIUM_META = {
1: {
label: 'Champion',
bonus: 'Crown of the Forge',
placeClass: 'lb-place-1',
},
2: {
label: 'Challenger',
bonus: 'Silver Crest',
placeClass: 'lb-place-2',
},
3: {
label: 'Contender',
bonus: 'Bronze Mark',
placeClass: 'lb-place-3',
},
};
function PlayerAvatar({ username, avatarUrl, size = 'md' }) {
const initials = (username || '?').slice(0, 2).toUpperCase();
return (
<div className={`lb-avatar lb-avatar-${size}`} aria-hidden>
{avatarUrl ? <img src={avatarUrl} alt="" /> : <span>{initials}</span>}
</div>
);
}
function WealthBreakdown({ balance, inventoryValue, totalWealth, compact }) {
if (compact) {
return (
<div className="lb-wealth-compact">
<strong>{formatCredits(totalWealth)} cr</strong>
</div>
);
}
return (
<div className="lb-wealth">
<div className="lb-wealth-total">
<span className="lb-wealth-label">Total</span>
<strong>{formatCredits(totalWealth)} cr</strong>
</div>
<div className="lb-wealth-parts">
<span>
Balance <em>{formatCredits(balance)}</em>
</span>
<span>
Inventory <em>{formatCredits(inventoryValue)}</em>
</span>
</div>
</div>
);
}
export default function Leaderboard() {
const navigate = useNavigate();
const [rows, setRows] = useState([]);
const [error, setError] = useState('');
const openPlayer = (username) => {
navigate(`/player/${encodeURIComponent(username)}`);
};
useEffect(() => {
let cancelled = false;
(async () => {
@@ -20,40 +79,105 @@ export default function Leaderboard() {
};
}, []);
const top3 = rows.slice(0, 3);
const rest = rows.slice(3);
// Podium visual order: 2nd | 1st | 3rd
const podiumOrder = [top3[1], top3[0], top3[2]].filter(Boolean);
return (
<div>
<div className="lb-page">
<div className="section-head">
<div>
<h1>Leaderboard</h1>
<p className="muted">Richest players by balance + inventory value.</p>
</div>
</div>
{error && <div className="error">{error}</div>}
{rows.length === 0 && !error ? (
<div className="empty">No players yet.</div>
) : (
<table className="table">
<thead>
<tr>
<th>#</th>
<th>Player</th>
<th>Balance</th>
<th>Inventory</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td className="rank">{row.rank}</td>
<td>{row.username}</td>
<td>{formatCredits(row.balance)}</td>
<td>{formatCredits(row.inventoryValue)}</td>
<td>{formatCredits(row.totalWealth)} cr</td>
</tr>
))}
</tbody>
</table>
<>
{top3.length > 0 && (
<section className="lb-podium" aria-label="Top three">
{podiumOrder.map((row) => {
const meta = PODIUM_META[row.rank];
return (
<button
key={row.id}
type="button"
className={`lb-podium-card ${meta.placeClass}`}
onClick={() => openPlayer(row.username)}
aria-label={`View ${row.username}'s profile, rank ${row.rank}`}
>
{row.rank === 1 && (
<span className="lb-fire" aria-hidden>
<span className="lb-fire-ring" />
</span>
)}
<div className="lb-podium-rank" aria-hidden>
#{row.rank}
</div>
<div className="lb-podium-frame">
<PlayerAvatar
username={row.username}
avatarUrl={row.avatarUrl}
size={row.rank === 1 ? 'xl' : 'lg'}
/>
</div>
<div className="lb-podium-title">{meta.label}</div>
<div className="lb-podium-name">@{row.username}</div>
<div className="lb-podium-bonus">{meta.bonus}</div>
<WealthBreakdown
balance={row.balance}
inventoryValue={row.inventoryValue}
totalWealth={row.totalWealth}
/>
</button>
);
})}
</section>
)}
{rest.length > 0 && (
<section className="lb-list" aria-label="Rankings">
<div className="lb-list-head" aria-hidden>
<span>Rank</span>
<span>Player</span>
<span className="lb-list-head-wealth">Total</span>
</div>
<ul className="lb-list-rows">
{rest.map((row) => (
<li key={row.id}>
<button
type="button"
className="lb-row"
onClick={() => openPlayer(row.username)}
aria-label={`View ${row.username}'s profile, rank ${row.rank}`}
>
<span className="lb-row-rank">{row.rank}</span>
<PlayerAvatar
username={row.username}
avatarUrl={row.avatarUrl}
size="sm"
/>
<span className="lb-row-name">@{row.username}</span>
<span className="lb-row-meta muted">
{row.itemCount} item{row.itemCount === 1 ? '' : 's'}
</span>
<WealthBreakdown
balance={row.balance}
inventoryValue={row.inventoryValue}
totalWealth={row.totalWealth}
compact
/>
</button>
</li>
))}
</ul>
</section>
)}
</>
)}
</div>
);
+44 -14
View File
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits, formatChance } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile';
import ItemTile, { itemValueCents } from '../components/ItemTile';
import CaseReel from '../components/CaseReel';
import { useCountdown } from '../hooks/useCountdown';
import { onInventoryChanged } from '../inventoryEvents';
import { getSocket } from '../socket';
export default function MultiPlayersPage() {
@@ -22,7 +23,9 @@ export default function MultiPlayersPage() {
const [spinActive, setSpinActive] = useState(false);
const { label: timerLabel, remaining } = useCountdown(
round?.status === 'open' ? round.endsAt : null
round?.status === 'open' && round?.endsAt && !round?.waitingForPlayers
? round.endsAt
: null
);
const loadInventory = useCallback(async () => {
@@ -30,6 +33,16 @@ export default function MultiPlayersPage() {
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;
@@ -46,8 +59,6 @@ export default function MultiPlayersPage() {
const onState = (state) => {
setRound(state);
if (state?.status === 'open') {
setSpinning(false);
setReelWinnerItemId(null);
loadInventory().catch(() => {});
}
};
@@ -62,7 +73,6 @@ export default function MultiPlayersPage() {
};
const onResult = (state) => {
setRound(state);
setSpinning(false);
loadInventory().catch(() => {});
refresh().catch(() => {});
};
@@ -82,6 +92,14 @@ export default function MultiPlayersPage() {
};
}, [user, loadInventory, refresh]);
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
return onInventoryChanged(() => {
loadInventory().catch(() => {});
refresh().catch(() => {});
});
}, [user, loadInventory, refresh]);
const maxItems = round?.maxItemsPerPlayer || 3;
const myDeposits = useMemo(
() => (round?.deposits || []).filter((d) => d.userId === user?.id),
@@ -158,7 +176,7 @@ export default function MultiPlayersPage() {
<h1>MultiPlayers Case</h1>
<p className="muted">Live shared pot win chance = your value / total pot.</p>
</div>
<Link to="/bet" className="btn secondary">
<Link to="/battle" className="btn secondary">
Modes
</Link>
</div>
@@ -174,11 +192,24 @@ export default function MultiPlayersPage() {
<div className="timer-block">
<span className="muted">Timer</span>
<strong className="timer-digits">
{round?.status === 'open' ? timerLabel : round?.status === 'spinning' ? 'SPIN' : '—'}
{round?.status === 'spinning' || spinning
? 'SPIN'
: round?.waitingForPlayers
? 'WAIT'
: round?.status === 'open' && round?.endsAt
? timerLabel
: '—'}
</strong>
{round?.status === 'open' && remaining <= 5 && remaining > 0 && (
<span className="muted">closing</span>
{round?.waitingForPlayers && round?.status === 'open' && (
<span className="muted">
Need {round.minPlayers || 2} players ({round.players?.length || 0}/
{round.minPlayers || 2})
</span>
)}
{round?.status === 'open' &&
!round?.waitingForPlayers &&
remaining <= 5 &&
remaining > 0 && <span className="muted">closing</span>}
</div>
<div className="timer-block">
<span className="muted">Pot</span>
@@ -289,20 +320,19 @@ export default function MultiPlayersPage() {
Place bet
</button>
</div>
{inventory.length === 0 ? (
{inventoryByValue.length === 0 ? (
<div className="empty">
No free items <Link to="/cases">open a case</Link>.
</div>
) : (
<div className="grid">
{inventory.map((inv) => (
{inventoryByValue.map((inv) => (
<ItemTile
key={inv.id}
item={inv.item}
favorite={inv.favorite}
selected={selected.includes(inv.id)}
disabled={
mySlotsLeft === 0 && !selected.includes(inv.id)
}
disabled={mySlotsLeft === 0 && !selected.includes(inv.id)}
onClick={() => toggleSelect(inv.id)}
/>
))}
+257
View File
@@ -0,0 +1,257 @@
import { api, formatCredits, formatLastConnection, formatPlayTime } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile';
import ProfileCaseStats from '../components/ProfileCaseStats';
import ProfileCatalogBadges from '../components/ProfileCatalogBadges';
import ProfileAchievementBadges from '../components/ProfileAchievementBadges';
import ProfilePrestigeStats from '../components/ProfilePrestigeStats';
import { Link, Navigate, useParams } from 'react-router-dom';
import { useEffect, useMemo, useState } from 'react';
const PAGE_SIZE = 50;
export default function PlayerProfilePage() {
const { username } = useParams();
const { user } = useAuth();
const [data, setData] = useState(null);
const [error, setError] = useState('');
const [sort, setSort] = useState('value');
const [page, setPage] = useState(1);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const result = await api.playerProfile(username);
if (!cancelled) setData(result);
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
return () => {
cancelled = true;
};
}, [username]);
const inventory = data?.inventory ?? [];
const sorted = useMemo(() => {
const list = [...inventory];
if (sort === 'value') {
list.sort(
(a, b) =>
(b.valueCents ?? b.item?.valueCents ?? b.item?.marketValue) -
(a.valueCents ?? a.item?.valueCents ?? a.item?.marketValue)
);
} else {
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
}
return list;
}, [inventory, sort]);
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageItems = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return sorted.slice(start, start + PAGE_SIZE);
}, [sorted, currentPage]);
useEffect(() => {
setPage(1);
}, [sort, username]);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
if (user?.username === username) {
return <Navigate to="/profile" replace />;
}
if (error) {
return (
<div>
<div className="section-head">
<div>
<h1>Player profile</h1>
<p className="muted">@{username}</p>
</div>
</div>
<div className="error">{error}</div>
<Link to="/leaderboard" className="btn secondary" style={{ marginTop: '1rem' }}>
Back to leaderboard
</Link>
</div>
);
}
if (!data) return <p className="muted">Loading</p>;
const { user: player, profile } = data;
return (
<div>
<section className="section">
<div className="section-head">
<div>
<h1>{player.username}</h1>
<p className="muted">Player profile</p>
</div>
<Link to="/leaderboard" className="btn ghost">
Leaderboard
</Link>
</div>
<div className="profile-layout">
<div className="profile-avatar-block admin-block">
<div className="avatar-preview">
{player.avatarUrl ? (
<img src={player.avatarUrl} alt="" />
) : (
<span>{player.username.slice(0, 2).toUpperCase()}</span>
)}
</div>
{player.bio ? (
<p style={{ marginTop: '0.75rem' }}>{player.bio}</p>
) : (
<p className="muted" style={{ marginTop: '0.75rem' }}>
No bio yet.
</p>
)}
<div className="muted" style={{ marginTop: '0.75rem' }}>
Balance {formatCredits(player.balance)} cr
</div>
</div>
<div className="profile-stats admin-block">
<h2>Stats</h2>
<ul className="stat-list">
<li>
Status{' '}
<strong
className={
profile.presence?.isConnected
? 'presence-online'
: 'presence-offline'
}
>
{profile.presence?.isConnected ? 'Online' : 'Offline'}
</strong>
</li>
<li>
Play time{' '}
<strong>{formatPlayTime(profile.presence?.playTimeSeconds)}</strong>
</li>
<li>
Last connection{' '}
<strong>
{formatLastConnection(profile.presence?.lastConnection)}
</strong>
</li>
<li>
Joined <strong>{new Date(profile.createdAt).toLocaleDateString()}</strong>
</li>
<li>
Inventory{' '}
<strong>
{profile.inventoryCount} items · {formatCredits(profile.inventoryValue)} cr
</strong>
</li>
<li>
Cases opened <strong>{profile.casesOpened}</strong>
</li>
<li>
Jackpot wins <strong>{profile.jackpotWins}</strong>
</li>
<li>
Duel wins <strong>{profile.duelWins}</strong>
</li>
</ul>
<ProfileAchievementBadges achievements={profile.achievements} />
<ProfileCatalogBadges
completed={profile.catalogCompleted}
luckPercent={profile.catalogLuckPercent}
/>
<ProfilePrestigeStats prestige={profile.prestige} />
{profile.caseOpens?.length > 0 && (
<div className="case-stats-block">
<h3>Opens by case</h3>
<ProfileCaseStats caseOpens={profile.caseOpens} />
</div>
)}
</div>
</div>
</section>
<section className="section">
<div className="section-head">
<div>
<h2>Inventory</h2>
<p className="muted">Items currently held by this player.</p>
</div>
</div>
{inventory.length > 0 && (
<div className="inv-toolbar">
<div className="inv-toolbar-left">
<div className="sort-toggle" role="group" aria-label="Sort inventory">
<button
type="button"
className={sort === 'value' ? 'active' : ''}
onClick={() => setSort('value')}
>
Highest value
</button>
<button
type="button"
className={sort === 'recent' ? 'active' : ''}
onClick={() => setSort('recent')}
>
Most recent
</button>
</div>
<span className="muted inv-count">
{inventory.length} item{inventory.length === 1 ? '' : 's'}
</span>
</div>
</div>
)}
{inventory.length === 0 ? (
<div className="empty">No items in inventory.</div>
) : (
<>
<div className="grid">
{pageItems.map((inv) => (
<ItemTile key={inv.id} item={inv.item} favorite={inv.favorite} />
))}
</div>
{totalPages > 1 && (
<div className="inv-pager">
<button
type="button"
className="btn ghost"
disabled={currentPage <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<span className="muted">
Page {currentPage} / {totalPages}
</span>
<button
type="button"
className="btn ghost"
disabled={currentPage >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
</button>
</div>
)}
</>
)}
</section>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
const CHOICE_LABELS = {
chance: 'Chance',
gain: 'Gain',
key: 'Key progress',
};
export default function PrestigePage() {
const { user, loading, patchUser, refresh } = useAuth();
const [data, setData] = useState(null);
const [selected, setSelected] = useState(null);
const [confirmText, setConfirmText] = useState('');
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const load = async () => {
const result = await api.prestige();
setData(result);
};
useEffect(() => {
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
const result = await api.prestige();
if (!cancelled) setData(result);
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
return () => {
cancelled = true;
};
}, [user?.id, user?.role]);
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 (!data && !error) return <p className="muted">Loading</p>;
if (data && data.pageVisible === false) {
return <Navigate to="/dashboard" replace />;
}
const cost = data?.prestige?.costCents ?? 0;
const p = data?.prestige;
const unlockAt = data?.prestige?.visibleAtCents ?? 0;
const onPrestige = async () => {
if (!selected || confirmText !== 'PRESTIGE') return;
setBusy(true);
setError('');
setMessage('');
try {
const result = await api.doPrestige(selected);
patchUser(result.user);
await refresh();
setMessage(
`Prestige #${result.prestige.count} complete · chose ${CHOICE_LABELS[result.choice] || result.choice}. Account reset.`
);
setSelected(null);
setConfirmText('');
await load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<section className="section">
<div className="section-head">
<div>
<h1>Prestige</h1>
<p className="muted">
Pay {formatCredits(cost)} cr, pick one permanent boost, then reset balance, inventory,
keys, and catalog. History is kept. Unlocks at {formatCredits(unlockAt)} cr total wealth.
</p>
</div>
{p && (
<div className="prestige-count-pill">
Prestige <strong>{p.count}</strong>
</div>
)}
</div>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
{p && (
<div className="prestige-totals">
<div>
<span className="muted">Chance</span>
<strong>+{p.chanceBonus}%</strong>
</div>
<div>
<span className="muted">Gains</span>
<strong>+{p.gainBonus}%</strong>
</div>
<div>
<span className="muted">Key opens</span>
<strong>{p.keyOpenReduction}</strong>
</div>
</div>
)}
<div className="prestige-cost-row">
<span className="muted">Cost</span>
<strong className="gold">{formatCredits(cost)} cr</strong>
<span className="muted">Your balance</span>
<strong>{formatCredits(user.balance)} cr</strong>
</div>
{!data?.canAfford && (
<p className="muted">You need {formatCredits(cost)} cr to prestige.</p>
)}
{data?.lockedItems > 0 && (
<div className="error">
Withdraw {data.lockedItems} locked item{data.lockedItems === 1 ? '' : 's'} from battles
before prestiging.
</div>
)}
<div className="prestige-choices">
{(data?.choices || []).map((c) => (
<button
key={c.id}
type="button"
className={`prestige-choice${selected === c.id ? ' selected' : ''}`}
onClick={() => setSelected(c.id)}
disabled={busy || !data?.canPrestige}
>
<h2>{c.title}</h2>
<p className="muted">{c.description}</p>
<div className="prestige-choice-meta">
<span>
Now <strong>+{c.current}{c.id === 'key' ? '' : '%'}</strong>
{c.id === 'key' ? ' opens reduced' : ''}
</span>
<span className="prestige-delta">
+{c.current + c.delta}
{c.id === 'key' ? '' : '%'}
</span>
</div>
</button>
))}
</div>
<div className="prestige-confirm admin-block">
<p>
This permanently spends {formatCredits(cost)} cr and wipes your balance, inventory, case
keys, and catalog. Type <strong>PRESTIGE</strong> to confirm.
</p>
<label>
Confirmation
<input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="PRESTIGE"
disabled={busy || !selected}
autoComplete="off"
/>
</label>
<button
type="button"
className="btn"
disabled={
busy || !data?.canPrestige || !selected || confirmText !== 'PRESTIGE'
}
onClick={onPrestige}
>
{busy ? 'Prestiging…' : 'Prestige now'}
</button>
</div>
{data?.history?.length > 0 && (
<div className="prestige-history">
<h2>History</h2>
<ul className="stat-list">
{data.history.map((h) => (
<li key={h.id}>
{CHOICE_LABELS[h.choice] || h.choice}{' '}
<span className="muted">
· {new Date(h.createdAt).toLocaleString()}
</span>
</li>
))}
</ul>
</div>
)}
</section>
);
}
+94 -25
View File
@@ -1,10 +1,15 @@
import { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { api, formatCredits, formatLastConnection, formatPlayTime } from '../api';
import { useAuth } from '../AuthContext';
import ProfileCaseStats from '../components/ProfileCaseStats';
import ProfileCatalogBadges from '../components/ProfileCatalogBadges';
import ProfileAchievementBadges from '../components/ProfileAchievementBadges';
import ProfilePrestigeStats from '../components/ProfilePrestigeStats';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
export default function ProfilePage() {
const { user, loading, refresh } = useAuth();
const { user, loading, refresh, logout } = useAuth();
const navigate = useNavigate();
const [profile, setProfile] = useState(null);
const [bio, setBio] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
@@ -83,6 +88,17 @@ export default function ProfilePage() {
}
};
const onLogout = async () => {
setBusy(true);
try {
await logout();
navigate('/login', { replace: true });
} catch (err) {
setError(err.message);
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 />;
@@ -95,6 +111,9 @@ export default function ProfilePage() {
<h1>Profile</h1>
<p className="muted">@{user.username}</p>
</div>
<Link to="/achievements" className="btn ghost">
Achievements
</Link>
</div>
{error && <div className="error">{error}</div>}
@@ -121,27 +140,67 @@ export default function ProfilePage() {
<div className="profile-stats admin-block">
<h2>Stats</h2>
{profile ? (
<ul className="stat-list">
<li>
Joined{' '}
<strong>{new Date(profile.createdAt).toLocaleDateString()}</strong>
</li>
<li>
Inventory{' '}
<strong>
{profile.inventoryCount} items · {formatCredits(profile.inventoryValue)} cr
</strong>
</li>
<li>
Cases opened <strong>{profile.casesOpened}</strong>
</li>
<li>
Jackpot wins <strong>{profile.jackpotWins}</strong>
</li>
<li>
Duel wins <strong>{profile.duelWins}</strong>
</li>
</ul>
<>
<ul className="stat-list">
<li>
Status{' '}
<strong
className={
profile.presence?.isConnected
? 'presence-online'
: 'presence-offline'
}
>
{profile.presence?.isConnected ? 'Online' : 'Offline'}
</strong>
</li>
<li>
Play time{' '}
<strong>{formatPlayTime(profile.presence?.playTimeSeconds)}</strong>
</li>
<li>
Last connection{' '}
<strong>
{formatLastConnection(profile.presence?.lastConnection)}
</strong>
</li>
<li>
Joined{' '}
<strong>{new Date(profile.createdAt).toLocaleDateString()}</strong>
</li>
<li>
Inventory{' '}
<strong>
{profile.inventoryCount} items · {formatCredits(profile.inventoryValue)} cr
</strong>
</li>
<li>
Cases opened <strong>{profile.casesOpened}</strong>
</li>
<li>
Jackpot wins <strong>{profile.jackpotWins}</strong>
</li>
<li>
Duel wins <strong>{profile.duelWins}</strong>
</li>
</ul>
<ProfileAchievementBadges
achievements={profile.achievements}
linkToAchievements
/>
<ProfileCatalogBadges
completed={profile.catalogCompleted}
luckPercent={profile.catalogLuckPercent}
linkToCatalog
/>
<ProfilePrestigeStats prestige={profile.prestige} />
{profile.caseOpens?.length > 0 && (
<div className="case-stats-block">
<h3>Opens by case</h3>
<ProfileCaseStats caseOpens={profile.caseOpens} />
</div>
)}
</>
) : (
<p className="muted">Loading stats</p>
)}
@@ -194,6 +253,16 @@ export default function ProfilePage() {
</button>
</form>
</section>
<section className="section admin-block">
<h2>Session</h2>
<p className="muted" style={{ marginBottom: '0.75rem' }}>
Sign out of CaseForge on this device.
</p>
<button type="button" className="btn danger" onClick={onLogout} disabled={busy}>
Logout
</button>
</section>
</div>
);
}
+16
View File
@@ -7,6 +7,7 @@ export default function Register() {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
@@ -17,6 +18,10 @@ export default function Register() {
const onSubmit = async (e) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match.');
return;
}
setBusy(true);
try {
await register(username, password);
@@ -56,6 +61,17 @@ export default function Register() {
minLength={6}
/>
</label>
<label>
Confirm password
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
minLength={6}
/>
</label>
{error && <div className="error">{error}</div>}
<button className="btn" type="submit" disabled={busy}>
{busy ? 'Creating…' : 'Create account'}
+2 -2
View File
@@ -46,7 +46,7 @@ export default function ShopPage() {
}, [patchUser]);
useEffect(() => {
if (!user || user.role === 'admin') return;
if (!user || user.role === 'admin') return undefined;
let cancelled = false;
(async () => {
try {
@@ -58,7 +58,7 @@ export default function ShopPage() {
return () => {
cancelled = true;
};
}, [user, load]);
}, [user?.id, user?.role, load]);
const buy = async (packageId) => {
setBusy(true);
+62
View File
@@ -1,6 +1,33 @@
import { io } from 'socket.io-client';
let socket = null;
let limited = false;
let limitPayload = null;
const limitListeners = new Set();
function notifyLimit(payload) {
limited = true;
limitPayload = payload || { max: 10 };
for (const fn of limitListeners) {
try {
fn(limitPayload);
} catch {
/* ignore */
}
}
}
function notifyOk() {
limited = false;
limitPayload = null;
for (const fn of limitListeners) {
try {
fn(null);
} catch {
/* ignore */
}
}
}
export function getSocket() {
if (!socket) {
@@ -12,6 +39,41 @@ export function getSocket() {
reconnectionDelay: 500,
reconnectionAttempts: Infinity,
});
socket.on('session:limit', (payload) => {
socket.io.reconnection(false);
notifyLimit(payload);
});
socket.on('session:ok', () => {
if (!socket.io.reconnection()) {
socket.io.reconnection(true);
}
notifyOk();
});
}
return socket;
}
export function isSessionLimited() {
return limited;
}
export function getSessionLimitPayload() {
return limitPayload;
}
/** Subscribe to limit changes. Callback receives payload or null when cleared. */
export function onSessionLimitChange(fn) {
limitListeners.add(fn);
if (limited) fn(limitPayload);
return () => limitListeners.delete(fn);
}
/** Allow reconnect after the user closes other tabs and reloads. */
export function clearSessionLimit() {
notifyOk();
if (socket) {
socket.io.reconnection(true);
}
}
+63
View File
@@ -0,0 +1,63 @@
const PREFIX = 'caseforge-pref';
function storageKey(userId, suffix) {
return `${PREFIX}-${userId}-${suffix}`;
}
export function loadPref(userId, suffix, fallback = null) {
if (!userId || typeof window === 'undefined') return fallback;
try {
const raw = localStorage.getItem(storageKey(userId, suffix));
return raw ?? fallback;
} catch {
return fallback;
}
}
export function savePref(userId, suffix, value) {
if (!userId || typeof window === 'undefined') return;
try {
localStorage.setItem(storageKey(userId, suffix), String(value));
} catch {
/* ignore quota / private mode */
}
}
export function loadInventorySort(userId) {
const v = loadPref(userId, 'inventory-sort');
return v === 'value' || v === 'recent' ? v : null;
}
export function saveInventorySort(userId, sort) {
if (sort === 'value' || sort === 'recent') {
savePref(userId, 'inventory-sort', sort);
}
}
export function loadCaseOpenQty(userId, caseId) {
const id = Number(caseId);
if (!Number.isFinite(id)) return null;
const raw = loadPref(userId, `case-qty-${id}`);
if (raw == null) return null;
const n = Number(raw);
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : null;
}
export function saveCaseOpenQty(userId, caseId, qty) {
const id = Number(caseId);
if (!Number.isFinite(id)) return;
savePref(userId, `case-qty-${id}`, Math.floor(qty));
}
/** Pick best allowed open qty from saved preference. */
export function resolveOpenQty(saved, openOptions, maxAffordable) {
const affordable = Math.max(1, maxAffordable || 1);
const allowed = openOptions.filter((n) => n <= affordable);
if (!allowed.length) return 1;
if (saved == null) {
return allowed.includes(1) ? 1 : allowed[0];
}
if (allowed.includes(saved)) return saved;
const below = allowed.filter((n) => n <= saved);
return below.length ? below[below.length - 1] : allowed[0];
}
+37
View File
@@ -0,0 +1,37 @@
# Example production stack for CaseGambling
# Usage:
# cp .env.prod.example .env.prod
# # edit .env.prod (SESSION_SECRET, CLIENT_ORIGIN, ADMIN_USERNAME, ADMIN_PASSWORD, …)
# docker compose --env-file .env.prod up -d
services:
casegambling:
image: foufure/casegambling:latest
# Or build locally instead of pulling:
# build: .
container_name: casegambling
restart: unless-stopped
ports:
- "${HOST_PORT:-3009}:3001"
env_file:
- .env.prod
environment:
NODE_ENV: production
PORT: "3001"
DATABASE_URL: file:/data/prod.db
# Override image entrypoint so Int→BigInt (etc.) schema pushes can apply.
# Safe widening; do not use --force-reset (wipes DB).
command: ["sh", "-c", "npx prisma db push --accept-data-loss && node src/index.js"]
volumes:
- casegambling_data:/data
- casegambling_uploads:/app/uploads
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3001/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
casegambling_data:
casegambling_uploads:
+9 -64
View File
@@ -1,67 +1,12 @@
#!/usr/bin/env bash
# Export local project SQLite DB (+ uploads) for backup / Docker import
# Usage:
# ./scripts/db-export.sh
# ./scripts/db-export.sh ./backups/my-backup
#
# Sources (local):
# server/prisma/dev.db (+ -wal / -shm if present)
# server/uploads/
#
# Restore into Docker with:
# ./scripts/db-import.sh ./backups/...
# 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
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
LOCAL_DB="${LOCAL_DB:-$ROOT/server/prisma/dev.db}"
LOCAL_UPLOADS="${LOCAL_UPLOADS:-$ROOT/server/uploads}"
STAMP="$(date +%Y%m%d-%H%M%S)"
OUT="${1:-$ROOT/backups/casegambling-$STAMP}"
if [[ ! -f "$LOCAL_DB" ]]; then
echo "Error: local database not found: $LOCAL_DB" >&2
echo "Tip: run the app once or set LOCAL_DB=/path/to/dev.db" >&2
exit 1
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
mkdir -p "$OUT"
echo "==> Exporting local database → $OUT/prod.db"
echo " source: $LOCAL_DB"
cp -f "$LOCAL_DB" "$OUT/prod.db"
# keep a copy under original name too (import accepts both)
cp -f "$LOCAL_DB" "$OUT/dev.db"
if [[ -f "${LOCAL_DB}-wal" ]]; then
cp -f "${LOCAL_DB}-wal" "$OUT/prod.db-wal"
cp -f "${LOCAL_DB}-wal" "$OUT/dev.db-wal"
fi
if [[ -f "${LOCAL_DB}-shm" ]]; then
cp -f "${LOCAL_DB}-shm" "$OUT/prod.db-shm"
cp -f "${LOCAL_DB}-shm" "$OUT/dev.db-shm"
fi
echo "==> Exporting local uploads → $OUT/uploads"
mkdir -p "$OUT/uploads"
if [[ -d "$LOCAL_UPLOADS" ]]; then
# copy contents; ignore empty/.gitkeep-only quietly
cp -a "$LOCAL_UPLOADS"/. "$OUT/uploads/" 2>/dev/null || true
else
echo " (no uploads dir at $LOCAL_UPLOADS)"
fi
{
echo "exported_at=$STAMP"
echo "source=local"
echo "local_db=$LOCAL_DB"
echo "local_uploads=$LOCAL_UPLOADS"
echo "database=prod.db"
echo "uploads=uploads/"
} > "$OUT/manifest.txt"
TAR="$OUT.tar.gz"
tar -czf "$TAR" -C "$(dirname "$OUT")" "$(basename "$OUT")"
echo "==> Archive: $TAR"
echo "==> Done. Import to Docker: ./scripts/db-import.sh $OUT"
echo "Note: forwarding to local-db-export.sh." >&2
exec "$DIR/local-db-export.sh" "$@"
+8 -112
View File
@@ -1,115 +1,11 @@
#!/usr/bin/env bash
# Import SQLite DB (+ uploads) into the CaseGambling Docker container
# Usage:
# ./scripts/db-import.sh ./backups/casegambling-20260116-203000
# ./db-import.sh ./casegambling-20260116-203000.tar.gz
# Deprecated alias — use docker-db-import.sh (Docker) or local-db-import.sh (laptop).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Project root: folder that contains docker-compose.yml
if [[ -f "$SCRIPT_DIR/docker-compose.yml" ]]; then
ROOT="$SCRIPT_DIR"
elif [[ -f "$SCRIPT_DIR/../docker-compose.yml" ]]; then
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
else
ROOT="$SCRIPT_DIR"
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
# Resolve backup path from the caller's working directory (before any cd)
if [[ -z "${1:-}" ]]; then
echo "Usage: $0 <backup-dir-or-tar.gz>" >&2
echo "Example: $0 ./backups/casegambling-20260116-203000.tar.gz" >&2
exit 1
fi
if [[ "$1" = /* ]]; then
SRC="$1"
else
SRC="$(pwd)/$1"
fi
cd "$ROOT"
ENV_FILE="${ENV_FILE:-.env.prod}"
COMPOSE=(docker compose)
if [[ -f "$ENV_FILE" ]]; then
COMPOSE=(docker compose --env-file "$ENV_FILE")
fi
SERVICE="${SERVICE:-casegambling}"
TMP=""
cleanup() {
if [[ -n "$TMP" && -d "$TMP" ]]; then
rm -rf "$TMP"
fi
}
trap cleanup EXIT
is_archive() {
case "$1" in
*.tar.gz|*.tgz) return 0 ;;
*) return 1 ;;
esac
}
if [[ -f "$SRC" ]] && is_archive "$SRC"; then
echo "==> Extracting $(basename "$SRC")"
TMP="$(mktemp -d)"
tar -xzf "$SRC" -C "$TMP"
# Prefer a single top-level directory from the archive
mapfile -t DIRS < <(find "$TMP" -mindepth 1 -maxdepth 1 -type d | sort)
if [[ ${#DIRS[@]} -ge 1 ]]; then
SRC="${DIRS[0]}"
else
SRC="$TMP"
fi
elif [[ -f "$SRC" ]]; then
echo "Error: not a directory or .tar.gz archive: $SRC" >&2
exit 1
fi
if [[ ! -d "$SRC" ]]; then
echo "Error: backup not found: $SRC" >&2
echo "Pass a folder or a .tar.gz produced by ./scripts/db-export.sh" >&2
exit 1
fi
DB="$SRC/prod.db"
if [[ ! -f "$DB" ]]; then
if [[ -f "$SRC/dev.db" ]]; then
DB="$SRC/dev.db"
else
echo "Error: no prod.db (or dev.db) in $SRC" >&2
ls -la "$SRC" >&2 || true
exit 1
fi
fi
echo "==> Stopping $SERVICE (safe SQLite replace)"
"${COMPOSE[@]}" stop "$SERVICE" 2>/dev/null || docker stop "$SERVICE" 2>/dev/null || true
echo "==> Clearing old SQLite sidecars on volume"
"${COMPOSE[@]}" run --rm --no-deps --entrypoint sh "$SERVICE" -c \
'rm -f /data/prod.db-wal /data/prod.db-shm' >/dev/null
echo "==> Importing database ← $DB"
"${COMPOSE[@]}" cp "$DB" "$SERVICE:/data/prod.db"
if [[ -f "$SRC/prod.db-wal" ]]; then
"${COMPOSE[@]}" cp "$SRC/prod.db-wal" "$SERVICE:/data/prod.db-wal"
fi
if [[ -f "$SRC/prod.db-shm" ]]; then
"${COMPOSE[@]}" cp "$SRC/prod.db-shm" "$SERVICE:/data/prod.db-shm"
fi
if [[ -d "$SRC/uploads" ]]; then
echo "==> Importing uploads"
"${COMPOSE[@]}" run --rm --no-deps --entrypoint sh "$SERVICE" -c \
'rm -rf /app/uploads/* /app/uploads/.[!.]* 2>/dev/null; mkdir -p /app/uploads' >/dev/null
"${COMPOSE[@]}" cp "$SRC/uploads/." "$SERVICE:/app/uploads/"
fi
echo "==> Starting $SERVICE"
"${COMPOSE[@]}" start "$SERVICE" 2>/dev/null || "${COMPOSE[@]}" up -d "$SERVICE"
echo "==> Done. Check: ${COMPOSE[*]} logs -f --tail=50"
echo "Note: forwarding to local-db-import.sh." >&2
exec "$DIR/local-db-import.sh" "$@"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Export local project SQLite DB (+ uploads) for backup / Docker import.
#
# Usage:
# ./scripts/local-db-export.sh
# ./scripts/local-db-export.sh ./backups/my-backup
#
# Sources (local):
# server/prisma/dev.db (+ -wal / -shm if present)
# server/uploads/
#
# Restore locally:
# ./scripts/local-db-import.sh ./backups/casegambling-….tar.gz
# Restore into Docker (prod):
# ./scripts/docker-db-import.sh ./backups/casegambling-….tar.gz
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
LOCAL_DB="${LOCAL_DB:-$ROOT/server/prisma/dev.db}"
LOCAL_UPLOADS="${LOCAL_UPLOADS:-$ROOT/server/uploads}"
STAMP="$(date +%Y%m%d-%H%M%S)"
OUT="${1:-$ROOT/backups/casegambling-$STAMP}"
if [[ ! -f "$LOCAL_DB" ]]; then
echo "Error: local database not found: $LOCAL_DB" >&2
echo "Tip: run the app once, or set LOCAL_DB=/path/to/dev.db" >&2
exit 1
fi
mkdir -p "$OUT"
echo "==> Exporting local database → $OUT/dev.db"
echo " source: $LOCAL_DB"
# Prefer a consistent SQLite snapshot when sqlite3 is available
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "$LOCAL_DB" ".backup '$OUT/dev.db'"
cp -f "$OUT/dev.db" "$OUT/prod.db"
else
cp -f "$LOCAL_DB" "$OUT/dev.db"
cp -f "$LOCAL_DB" "$OUT/prod.db"
if [[ -f "${LOCAL_DB}-wal" ]]; then
cp -f "${LOCAL_DB}-wal" "$OUT/dev.db-wal"
cp -f "${LOCAL_DB}-wal" "$OUT/prod.db-wal"
fi
if [[ -f "${LOCAL_DB}-shm" ]]; then
cp -f "${LOCAL_DB}-shm" "$OUT/dev.db-shm"
cp -f "${LOCAL_DB}-shm" "$OUT/prod.db-shm"
fi
fi
echo "==> Exporting local uploads → $OUT/uploads"
mkdir -p "$OUT/uploads"
if [[ -d "$LOCAL_UPLOADS" ]]; then
cp -a "$LOCAL_UPLOADS"/. "$OUT/uploads/" 2>/dev/null || true
else
echo " (no uploads dir at $LOCAL_UPLOADS)"
fi
{
echo "exported_at=$STAMP"
echo "source=local"
echo "local_db=$LOCAL_DB"
echo "local_uploads=$LOCAL_UPLOADS"
echo "database=dev.db"
echo "uploads=uploads/"
} > "$OUT/manifest.txt"
TAR="$OUT.tar.gz"
tar -czf "$TAR" -C "$(dirname "$OUT")" "$(basename "$OUT")"
DB_SIZE="$(du -h "$OUT/dev.db" | awk '{print $1}')"
UPLOAD_COUNT="$(find "$OUT/uploads" -type f 2>/dev/null | wc -l | tr -d ' ')"
echo "==> Archive: $TAR"
echo " db size: $DB_SIZE · uploads files: $UPLOAD_COUNT"
echo "==> Done."
echo " Local restore: ./scripts/local-db-import.sh $TAR"
echo " Docker restore: ./scripts/docker-db-import.sh $TAR"
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# Import SQLite DB (+ uploads) into the LOCAL project
# (server/prisma/dev.db + server/uploads/).
#
# Usage:
# ./scripts/local-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS
# ./scripts/local-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
#
# Typical flow (prod → laptop):
# # on prod:
# ./scripts/docker-db-export.sh
# # copy the .tar.gz here, then:
# ./scripts/local-db-import.sh ./backups/casegambling-….tar.gz
#
# Stop `npm run dev` before importing so SQLite is not locked.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
if [[ -z "${1:-}" ]]; then
echo "Usage: $0 <backup-dir-or-tar.gz>" >&2
echo "Example: $0 ./backups/casegambling-20260116-203000.tar.gz" >&2
echo "Create a backup from Docker: ./scripts/docker-db-export.sh" >&2
exit 1
fi
# Resolve backup path from the caller's cwd
if [[ "$1" = /* ]]; then
SRC="$1"
else
SRC="$(pwd)/$1"
fi
LOCAL_DB="${LOCAL_DB:-$ROOT/server/prisma/dev.db}"
LOCAL_UPLOADS="${LOCAL_UPLOADS:-$ROOT/server/uploads}"
TMP=""
cleanup() {
if [[ -n "$TMP" && -d "$TMP" ]]; then
rm -rf "$TMP"
fi
}
trap cleanup EXIT
is_archive() {
case "$1" in
*.tar.gz|*.tgz) return 0 ;;
*) return 1 ;;
esac
}
if [[ -f "$SRC" ]] && is_archive "$SRC"; then
echo "==> Extracting $(basename "$SRC")"
TMP="$(mktemp -d)"
tar -xzf "$SRC" -C "$TMP"
mapfile -t DIRS < <(find "$TMP" -mindepth 1 -maxdepth 1 -type d | sort)
if [[ ${#DIRS[@]} -ge 1 ]]; then
SRC="${DIRS[0]}"
else
SRC="$TMP"
fi
elif [[ -f "$SRC" ]]; then
echo "Error: not a directory or .tar.gz archive: $SRC" >&2
exit 1
fi
if [[ ! -d "$SRC" ]]; then
echo "Error: backup not found: $SRC" >&2
echo "Pass a folder or a .tar.gz from docker-db-export.sh / local-db-export.sh" >&2
exit 1
fi
DB=""
for candidate in "$SRC/dev.db" "$SRC/prod.db"; do
if [[ -f "$candidate" ]]; then
DB="$candidate"
break
fi
done
if [[ -z "$DB" ]]; then
echo "Error: no dev.db or prod.db in $SRC" >&2
ls -la "$SRC" >&2 || true
exit 1
fi
mkdir -p "$(dirname "$LOCAL_DB")"
echo "==> Importing database → $LOCAL_DB"
echo " source: $DB"
# Drop local WAL/SHM so the new file is the sole source of truth
rm -f "${LOCAL_DB}-wal" "${LOCAL_DB}-shm"
if command -v sqlite3 >/dev/null 2>&1; then
# Normalize into a clean single-file DB
sqlite3 "$DB" ".backup '$LOCAL_DB'"
else
cp -f "$DB" "$LOCAL_DB"
WAL=""
SHM=""
[[ -f "$SRC/dev.db-wal" ]] && WAL="$SRC/dev.db-wal"
[[ -z "$WAL" && -f "$SRC/prod.db-wal" ]] && WAL="$SRC/prod.db-wal"
[[ -f "$SRC/dev.db-shm" ]] && SHM="$SRC/dev.db-shm"
[[ -z "$SHM" && -f "$SRC/prod.db-shm" ]] && SHM="$SRC/prod.db-shm"
[[ -n "$WAL" ]] && cp -f "$WAL" "${LOCAL_DB}-wal"
[[ -n "$SHM" ]] && cp -f "$SHM" "${LOCAL_DB}-shm"
fi
if [[ -d "$SRC/uploads" ]]; then
echo "==> Importing uploads → $LOCAL_UPLOADS"
mkdir -p "$LOCAL_UPLOADS"
# Keep .gitkeep if present; replace other files
find "$LOCAL_UPLOADS" -mindepth 1 ! -name '.gitkeep' -exec rm -rf {} + 2>/dev/null || true
cp -a "$SRC/uploads"/. "$LOCAL_UPLOADS/" 2>/dev/null || true
fi
DB_SIZE="$(du -h "$LOCAL_DB" | awk '{print $1}')"
UPLOAD_COUNT="$(find "$LOCAL_UPLOADS" -type f ! -name '.gitkeep' 2>/dev/null | wc -l | tr -d ' ')"
echo "==> Done."
echo " db: $LOCAL_DB ($DB_SIZE)"
echo " uploads files: $UPLOAD_COUNT"
echo " Tip: stop npm run dev before import if the DB was locked; then restart:"
echo " npm run db:push && npm run dev"
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Export CaseGambling database (+ uploads) FROM Docker (production).
# Run this on the prod server next to docker-compose.yml.
#
# Usage:
# ./scripts/docker-db-export.sh
# ./scripts/docker-db-export.sh ./backups/my-backup
#
# Output:
# backups/casegambling-YYYYMMDD-HHMMSS/
# prod.db (+ optional -wal/-shm)
# uploads/
# manifest.txt
# backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
#
# Then copy the .tar.gz to your machine and import locally:
# ./scripts/local-db-import.sh ./backups/casegambling-….tar.gz
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [[ -f "$SCRIPT_DIR/../docker-compose.yml" ]]; then
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
elif [[ -f "$SCRIPT_DIR/docker-compose.yml" ]]; then
ROOT="$SCRIPT_DIR"
else
echo "Error: docker-compose.yml not found near $SCRIPT_DIR" >&2
exit 1
fi
cd "$ROOT"
ENV_FILE="${ENV_FILE:-.env.prod}"
COMPOSE=(docker compose)
if [[ -f "$ENV_FILE" ]]; then
COMPOSE=(docker compose --env-file "$ENV_FILE")
fi
SERVICE="${SERVICE:-casegambling}"
STAMP="$(date +%Y%m%d-%H%M%S)"
OUT="${1:-$ROOT/backups/casegambling-$STAMP}"
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker not found" >&2
exit 1
fi
if ! "${COMPOSE[@]}" ps -q "$SERVICE" >/dev/null 2>&1; then
echo "Error: compose service '$SERVICE' not found" >&2
echo "Tip: run from the folder that has docker-compose.yml" >&2
exit 1
fi
mkdir -p "$OUT"
echo "==> Exporting Docker database → $OUT/prod.db"
echo " service: $SERVICE · volume path: /data/prod.db"
TMP_UPLOADS="$(mktemp -d)"
cleanup() {
rm -rf "$TMP_UPLOADS"
}
trap cleanup EXIT
WAS_RUNNING=0
if [[ -n "$("${COMPOSE[@]}" ps -q --status running "$SERVICE" 2>/dev/null || true)" ]]; then
WAS_RUNNING=1
fi
# Prefer a consistent SQLite snapshot inside a running container
if [[ "$WAS_RUNNING" -eq 1 ]] \
&& "${COMPOSE[@]}" exec -T "$SERVICE" sh -c 'command -v sqlite3 >/dev/null && test -f /data/prod.db'; then
"${COMPOSE[@]}" exec -T "$SERVICE" sh -c "sqlite3 /data/prod.db \".backup '/tmp/prod-export.db'\""
"${COMPOSE[@]}" cp "$SERVICE:/tmp/prod-export.db" "$OUT/prod.db"
"${COMPOSE[@]}" exec -T "$SERVICE" rm -f /tmp/prod-export.db >/dev/null 2>&1 || true
else
# Safe file copy: stop if running so WAL is flushed
if [[ "$WAS_RUNNING" -eq 1 ]]; then
echo "==> Stopping $SERVICE for a consistent copy"
"${COMPOSE[@]}" stop "$SERVICE" >/dev/null
fi
"${COMPOSE[@]}" cp "$SERVICE:/data/prod.db" "$OUT/prod.db"
"${COMPOSE[@]}" cp "$SERVICE:/data/prod.db-wal" "$OUT/prod.db-wal" 2>/dev/null || true
"${COMPOSE[@]}" cp "$SERVICE:/data/prod.db-shm" "$OUT/prod.db-shm" 2>/dev/null || true
if [[ "$WAS_RUNNING" -eq 1 ]]; then
echo "==> Starting $SERVICE"
"${COMPOSE[@]}" start "$SERVICE" >/dev/null 2>&1 || "${COMPOSE[@]}" up -d "$SERVICE" >/dev/null
fi
fi
# Also write as dev.db so local import can use either name
cp -f "$OUT/prod.db" "$OUT/dev.db"
[[ -f "$OUT/prod.db-wal" ]] && cp -f "$OUT/prod.db-wal" "$OUT/dev.db-wal"
[[ -f "$OUT/prod.db-shm" ]] && cp -f "$OUT/prod.db-shm" "$OUT/dev.db-shm"
echo "==> Exporting Docker uploads → $OUT/uploads"
mkdir -p "$OUT/uploads"
if "${COMPOSE[@]}" cp "$SERVICE:/app/uploads/." "$TMP_UPLOADS/" 2>/dev/null; then
cp -a "$TMP_UPLOADS"/. "$OUT/uploads/" 2>/dev/null || true
else
echo " (no uploads or empty volume)"
fi
{
echo "exported_at=$STAMP"
echo "source=docker"
echo "service=$SERVICE"
echo "database=prod.db"
echo "uploads=uploads/"
} > "$OUT/manifest.txt"
TAR="$OUT.tar.gz"
tar -czf "$TAR" -C "$(dirname "$OUT")" "$(basename "$OUT")"
DB_SIZE="$(du -h "$OUT/prod.db" | awk '{print $1}')"
UPLOAD_COUNT="$(find "$OUT/uploads" -type f 2>/dev/null | wc -l | tr -d ' ')"
echo "==> Archive: $TAR"
echo " db size: $DB_SIZE · uploads files: $UPLOAD_COUNT"
echo "==> Done. On your laptop:"
echo " scp user@prod:$TAR ./backups/"
echo " ./scripts/local-db-import.sh ./backups/$(basename "$TAR")"
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Import SQLite DB (+ uploads) INTO Docker (production / compose).
# Run this on the machine that runs docker compose.
#
# Usage:
# ./scripts/docker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS
# ./scripts/docker-db-import.sh ./backups/casegambling-YYYYMMDD-HHMMSS.tar.gz
#
# Expects a backup from docker-db-export.sh or local-db-export.sh
# (prod.db or dev.db + uploads/).
#
# Docker paths (see docker-compose.yml):
# DATABASE_URL=file:/data/prod.db
# uploads → /app/uploads
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [[ -f "$SCRIPT_DIR/../docker-compose.yml" ]]; then
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
elif [[ -f "$SCRIPT_DIR/docker-compose.yml" ]]; then
ROOT="$SCRIPT_DIR"
else
echo "Error: docker-compose.yml not found near $SCRIPT_DIR" >&2
exit 1
fi
if [[ -z "${1:-}" ]]; then
echo "Usage: $0 <backup-dir-or-tar.gz>" >&2
echo "Example: $0 ./backups/casegambling-20260116-203000.tar.gz" >&2
echo "Create a backup first: ./scripts/docker-db-export.sh (or local-db-export.sh)" >&2
exit 1
fi
# Resolve backup path from the caller's cwd (before any cd)
if [[ "$1" = /* ]]; then
SRC="$1"
else
SRC="$(pwd)/$1"
fi
cd "$ROOT"
ENV_FILE="${ENV_FILE:-.env.prod}"
COMPOSE=(docker compose)
if [[ -f "$ENV_FILE" ]]; then
COMPOSE=(docker compose --env-file "$ENV_FILE")
elif [[ -f ".env.prod.example" ]]; then
echo "Warning: $ENV_FILE missing — using compose defaults." >&2
echo " Copy .env.prod.example → .env.prod for production settings." >&2
fi
SERVICE="${SERVICE:-casegambling}"
TMP=""
cleanup() {
if [[ -n "$TMP" && -d "$TMP" ]]; then
rm -rf "$TMP"
fi
}
trap cleanup EXIT
is_archive() {
case "$1" in
*.tar.gz|*.tgz) return 0 ;;
*) return 1 ;;
esac
}
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker not found" >&2
exit 1
fi
if [[ -f "$SRC" ]] && is_archive "$SRC"; then
echo "==> Extracting $(basename "$SRC")"
TMP="$(mktemp -d)"
tar -xzf "$SRC" -C "$TMP"
mapfile -t DIRS < <(find "$TMP" -mindepth 1 -maxdepth 1 -type d | sort)
if [[ ${#DIRS[@]} -ge 1 ]]; then
SRC="${DIRS[0]}"
else
SRC="$TMP"
fi
elif [[ -f "$SRC" ]]; then
echo "Error: not a directory or .tar.gz archive: $SRC" >&2
exit 1
fi
if [[ ! -d "$SRC" ]]; then
echo "Error: backup not found: $SRC" >&2
echo "Pass a folder or a .tar.gz produced by docker-db-export.sh / local-db-export.sh" >&2
exit 1
fi
DB=""
for candidate in "$SRC/prod.db" "$SRC/dev.db"; do
if [[ -f "$candidate" ]]; then
DB="$candidate"
break
fi
done
if [[ -z "$DB" ]]; then
echo "Error: no prod.db or dev.db in $SRC" >&2
ls -la "$SRC" >&2 || true
exit 1
fi
echo "==> Ensuring compose service exists"
"${COMPOSE[@]}" up -d --no-start "$SERVICE" >/dev/null
echo "==> Stopping $SERVICE (safe SQLite replace)"
"${COMPOSE[@]}" stop "$SERVICE" >/dev/null 2>&1 || true
echo "==> Clearing old SQLite sidecars on volume"
"${COMPOSE[@]}" run --rm --no-deps --entrypoint sh "$SERVICE" -c \
'rm -f /data/prod.db-wal /data/prod.db-shm' >/dev/null
echo "==> Importing database ← $DB"
"${COMPOSE[@]}" cp "$DB" "$SERVICE:/data/prod.db"
WAL=""
SHM=""
[[ -f "$SRC/prod.db-wal" ]] && WAL="$SRC/prod.db-wal"
[[ -z "$WAL" && -f "$SRC/dev.db-wal" ]] && WAL="$SRC/dev.db-wal"
[[ -f "$SRC/prod.db-shm" ]] && SHM="$SRC/prod.db-shm"
[[ -z "$SHM" && -f "$SRC/dev.db-shm" ]] && SHM="$SRC/dev.db-shm"
if [[ -n "$WAL" ]]; then
"${COMPOSE[@]}" cp "$WAL" "$SERVICE:/data/prod.db-wal"
fi
if [[ -n "$SHM" ]]; then
"${COMPOSE[@]}" cp "$SHM" "$SERVICE:/data/prod.db-shm"
fi
if [[ -d "$SRC/uploads" ]]; then
echo "==> Importing uploads"
"${COMPOSE[@]}" run --rm --no-deps --entrypoint sh "$SERVICE" -c \
'rm -rf /app/uploads/* /app/uploads/.[!.]* 2>/dev/null; mkdir -p /app/uploads' >/dev/null
"${COMPOSE[@]}" cp "$SRC/uploads/." "$SERVICE:/app/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."
echo " App: http://localhost:${HOST_PORT_VAL}"
echo " Logs: ${COMPOSE[*]} logs -f --tail=50"
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
# Delete a user by username from the Docker (production) SQLite DB.
# Run this on the prod server next to docker-compose.yml.
#
# Usage:
# ./scripts/xdocker-delete-user.sh <username>
# ./scripts/xdocker-delete-user.sh <username> --yes # skip confirmation
#
# Removes the User and related data (inventory, transactions, deposits,
# case-key progress, duel rooms they created). Nulls jackpot/duel winner
# pointers that referenced them.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [[ -f "$SCRIPT_DIR/../docker-compose.yml" ]]; then
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
elif [[ -f "$SCRIPT_DIR/docker-compose.yml" ]]; then
ROOT="$SCRIPT_DIR"
else
echo "Error: docker-compose.yml not found near $SCRIPT_DIR" >&2
exit 1
fi
cd "$ROOT"
ENV_FILE="${ENV_FILE:-.env.prod}"
COMPOSE=(docker compose)
if [[ -f "$ENV_FILE" ]]; then
COMPOSE=(docker compose --env-file "$ENV_FILE")
fi
SERVICE="${SERVICE:-casegambling}"
USERNAME="${1:-}"
ASSUME_YES=0
if [[ -z "$USERNAME" || "$USERNAME" == "-h" || "$USERNAME" == "--help" ]]; then
echo "Usage: $0 <username> [--yes]" >&2
echo "Example: $0 badplayer" >&2
echo " $0 badplayer --yes" >&2
exit 1
fi
shift || true
for arg in "$@"; do
case "$arg" in
--yes|-y) ASSUME_YES=1 ;;
*)
echo "Error: unknown argument: $arg" >&2
exit 1
;;
esac
done
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker not found" >&2
exit 1
fi
if [[ -z "$("${COMPOSE[@]}" ps -q --status running "$SERVICE" 2>/dev/null || true)" ]]; then
echo "Error: compose service '$SERVICE' is not running" >&2
echo "Tip: docker compose --env-file .env.prod up -d" >&2
exit 1
fi
run_node() {
local mode="$1"
"${COMPOSE[@]}" exec -T \
-e DEL_USERNAME="$USERNAME" \
-e DEL_MODE="$mode" \
"$SERVICE" node <<'NODE'
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
(async () => {
const username = process.env.DEL_USERNAME;
const mode = process.env.DEL_MODE;
const user = await prisma.user.findUnique({
where: { username },
select: {
id: true,
username: true,
role: true,
balance: true,
osuBalance: true,
createdAt: true,
_count: {
select: {
inventory: true,
transactions: true,
jackpotDeposits: true,
duelDeposits: true,
duelRoomsCreated: true,
caseKeyProgress: true,
},
},
},
});
if (!user) {
console.error(`Error: no user with username '${username}'`);
process.exit(2);
}
if (mode === 'info') {
console.log(`id: ${user.id}`);
console.log(`username: ${user.username}`);
console.log(`role: ${user.role}`);
console.log(`balance: ${user.balance.toString()}`);
console.log(`osuBalance: ${user.osuBalance}`);
console.log(`createdAt: ${user.createdAt.toISOString()}`);
console.log(`inventory: ${user._count.inventory}`);
console.log(`transactions: ${user._count.transactions}`);
console.log(`jackpot deps: ${user._count.jackpotDeposits}`);
console.log(`duel deps: ${user._count.duelDeposits}`);
console.log(`duel rooms: ${user._count.duelRoomsCreated}`);
console.log(`case keys: ${user._count.caseKeyProgress}`);
if (user.role === 'admin') {
console.log('WARNING: role=admin');
}
process.exit(0);
}
if (mode !== 'delete') {
console.error(`Unknown mode: ${mode}`);
process.exit(1);
}
await prisma.$transaction(async (tx) => {
// Optional FKs without onDelete Cascade
await tx.jackpotRound.updateMany({
where: { winnerUserId: user.id },
data: { winnerUserId: null },
});
await tx.duelRoom.updateMany({
where: { winnerUserId: user.id },
data: { winnerUserId: null },
});
// Deposits reference InventoryItem — remove before user/inventory cascade
await tx.jackpotDeposit.deleteMany({ where: { userId: user.id } });
await tx.duelDeposit.deleteMany({ where: { userId: user.id } });
// Rooms created by this user (other deposits in those rooms cascade with room)
await tx.duelRoom.deleteMany({ where: { creatorId: user.id } });
await tx.user.delete({ where: { id: user.id } });
});
console.log(`Deleted user id=${user.id} username=${user.username}`);
})().catch((e) => {
console.error(e);
process.exit(1);
}).finally(() => prisma.$disconnect());
NODE
}
echo "==> Looking up user '$USERNAME' …"
set +e
INFO_OUT="$(run_node info 2>&1)"
INFO_RC=$?
set -e
echo "$INFO_OUT"
if [[ "$INFO_RC" -ne 0 ]]; then
exit "$INFO_RC"
fi
if [[ "$ASSUME_YES" -ne 1 ]]; then
echo ""
printf "Delete user '%s' permanently? [y/N] " "$USERNAME"
read -r REPLY
case "$REPLY" in
y|Y|yes|YES) ;;
*)
echo "Aborted."
exit 0
;;
esac
fi
echo "==> Deleting …"
run_node delete
echo "==> Done."
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env bash
# Update a user's username and/or password in the Docker (production) SQLite DB.
# Run this on the prod server next to docker-compose.yml.
#
# Usage:
# ./scripts/xdocker-update-user.sh <current-username> --username <new>
# ./scripts/xdocker-update-user.sh <current-username> --password <new>
# ./scripts/xdocker-update-user.sh <current-username> --username <new> --password <new>
# ./scripts/xdocker-update-user.sh <current-username> --username <new> --password <new> --yes
#
# Password is hashed with bcrypt (cost 10), same as the app.
# Username rules match registration: 332 chars, [a-zA-Z0-9_], unique.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [[ -f "$SCRIPT_DIR/../docker-compose.yml" ]]; then
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
elif [[ -f "$SCRIPT_DIR/docker-compose.yml" ]]; then
ROOT="$SCRIPT_DIR"
else
echo "Error: docker-compose.yml not found near $SCRIPT_DIR" >&2
exit 1
fi
cd "$ROOT"
ENV_FILE="${ENV_FILE:-.env.prod}"
COMPOSE=(docker compose)
if [[ -f "$ENV_FILE" ]]; then
COMPOSE=(docker compose --env-file "$ENV_FILE")
fi
SERVICE="${SERVICE:-casegambling}"
CURRENT_USERNAME="${1:-}"
NEW_USERNAME=""
NEW_PASSWORD=""
ASSUME_YES=0
usage() {
echo "Usage: $0 <current-username> [--username <new>] [--password <new>] [--yes]" >&2
echo "Example: $0 oldname --username newname --password 's3cret'" >&2
echo " $0 oldname --password 's3cret' --yes" >&2
exit 1
}
if [[ -z "$CURRENT_USERNAME" || "$CURRENT_USERNAME" == "-h" || "$CURRENT_USERNAME" == "--help" ]]; then
usage
fi
shift || true
while [[ $# -gt 0 ]]; do
case "$1" in
--username|-u)
[[ $# -ge 2 ]] || { echo "Error: $1 needs a value" >&2; exit 1; }
NEW_USERNAME="$2"
shift 2
;;
--password|-p)
[[ $# -ge 2 ]] || { echo "Error: $1 needs a value" >&2; exit 1; }
NEW_PASSWORD="$2"
shift 2
;;
--yes|-y)
ASSUME_YES=1
shift
;;
*)
echo "Error: unknown argument: $1" >&2
usage
;;
esac
done
if [[ -z "$NEW_USERNAME" && -z "$NEW_PASSWORD" ]]; then
echo "Error: provide at least --username and/or --password" >&2
usage
fi
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker not found" >&2
exit 1
fi
if [[ -z "$("${COMPOSE[@]}" ps -q --status running "$SERVICE" 2>/dev/null || true)" ]]; then
echo "Error: compose service '$SERVICE' is not running" >&2
echo "Tip: docker compose --env-file .env.prod up -d" >&2
exit 1
fi
run_node() {
local mode="$1"
"${COMPOSE[@]}" exec -T \
-e UPD_CURRENT="$CURRENT_USERNAME" \
-e UPD_NEW_USERNAME="$NEW_USERNAME" \
-e UPD_NEW_PASSWORD="$NEW_PASSWORD" \
-e UPD_MODE="$mode" \
"$SERVICE" node <<'NODE'
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcrypt');
const prisma = new PrismaClient();
(async () => {
const currentUsername = process.env.UPD_CURRENT;
const newUsername = (process.env.UPD_NEW_USERNAME || '').trim();
const newPassword = process.env.UPD_NEW_PASSWORD || '';
const mode = process.env.UPD_MODE;
const user = await prisma.user.findUnique({
where: { username: currentUsername },
select: {
id: true,
username: true,
role: true,
balance: true,
createdAt: true,
},
});
if (!user) {
console.error(`Error: no user with username '${currentUsername}'`);
process.exit(2);
}
if (mode === 'info') {
console.log(`id: ${user.id}`);
console.log(`username: ${user.username}`);
console.log(`role: ${user.role}`);
console.log(`balance: ${user.balance.toString()}`);
console.log(`createdAt: ${user.createdAt.toISOString()}`);
if (newUsername) console.log(`new user: ${newUsername}`);
if (newPassword) console.log(`password: (will be changed)`);
process.exit(0);
}
if (mode !== 'update') {
console.error(`Unknown mode: ${mode}`);
process.exit(1);
}
const data = {};
if (newUsername) {
if (newUsername.length < 3 || newUsername.length > 32) {
console.error('Error: username must be 332 characters');
process.exit(1);
}
if (!/^[a-zA-Z0-9_]+$/.test(newUsername)) {
console.error('Error: username may only contain letters, numbers, and underscores');
process.exit(1);
}
if (newUsername !== user.username) {
const taken = await prisma.user.findUnique({ where: { username: newUsername } });
if (taken) {
console.error(`Error: username '${newUsername}' already taken`);
process.exit(1);
}
data.username = newUsername;
}
}
if (newPassword) {
if (newPassword.length < 6) {
console.error('Error: password must be at least 6 characters');
process.exit(1);
}
data.passwordHash = await bcrypt.hash(newPassword, 10);
}
if (Object.keys(data).length === 0) {
console.log('Nothing to change (same username, no password).');
process.exit(0);
}
const updated = await prisma.user.update({
where: { id: user.id },
data,
select: { id: true, username: true },
});
const parts = [];
if (data.username) parts.push(`username → ${updated.username}`);
if (data.passwordHash) parts.push('password updated');
console.log(`Updated user id=${updated.id}: ${parts.join(', ')}`);
})().catch((e) => {
console.error(e);
process.exit(1);
}).finally(() => prisma.$disconnect());
NODE
}
echo "==> Looking up user '$CURRENT_USERNAME' …"
set +e
INFO_OUT="$(run_node info 2>&1)"
INFO_RC=$?
set -e
echo "$INFO_OUT"
if [[ "$INFO_RC" -ne 0 ]]; then
exit "$INFO_RC"
fi
if [[ "$ASSUME_YES" -ne 1 ]]; then
echo ""
printf "Apply changes to '%s'? [y/N] " "$CURRENT_USERNAME"
read -r REPLY
case "$REPLY" in
y|Y|yes|YES) ;;
*)
echo "Aborted."
exit 0
;;
esac
fi
echo "==> Updating …"
run_node update
echo "==> Done."
+2 -37
View File
@@ -14,11 +14,10 @@
"express": "^5.1.0",
"express-session": "^1.18.1",
"multer": "^2.2.0",
"prisma": "^6.11.1",
"socket.io": "^4.8.3"
},
"devDependencies": {
"prisma": "^6.11.1"
}
"devDependencies": {}
},
"node_modules/@prisma/client": {
"version": "6.19.3",
@@ -46,7 +45,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
@@ -59,14 +57,12 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -80,14 +76,12 @@
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.3",
@@ -99,7 +93,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.3"
@@ -115,7 +108,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/cors": {
@@ -254,7 +246,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
@@ -312,7 +303,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
@@ -328,7 +318,6 @@
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
@@ -353,14 +342,12 @@
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
@@ -444,7 +431,6 @@
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
@@ -454,7 +440,6 @@
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/depd": {
@@ -470,14 +455,12 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -510,7 +493,6 @@
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -521,7 +503,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -745,14 +726,12 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/fast-check": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -860,7 +839,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
@@ -971,7 +949,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -1122,7 +1099,6 @@
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
"devOptional": true,
"license": "MIT"
},
"node_modules/node-gyp-build": {
@@ -1140,7 +1116,6 @@
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz",
"integrity": "sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"citty": "^0.2.2",
@@ -1158,7 +1133,6 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/object-assign": {
@@ -1186,7 +1160,6 @@
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/on-finished": {
@@ -1242,21 +1215,18 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/pkg-types": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.4",
@@ -1268,7 +1238,6 @@
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1307,7 +1276,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
"devOptional": true,
"funding": [
{
"type": "individual",
@@ -1377,7 +1345,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
@@ -1402,7 +1369,6 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
@@ -1691,7 +1657,6 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18"
+5 -3
View File
@@ -8,6 +8,9 @@
"start": "node --env-file=.env src/index.js",
"db:push": "prisma db push",
"db:seed": "node prisma/seed.js",
"db:backfill-wear": "node prisma/backfill-wear.js",
"db:backfill-case-keys": "node prisma/backfill-case-keys.js",
"db:backfill-catalog": "node prisma/backfill-catalog.js",
"db:setup": "prisma db push && node prisma/seed.js"
},
"prisma": {
@@ -20,9 +23,8 @@
"express": "^5.1.0",
"express-session": "^1.18.1",
"multer": "^2.2.0",
"prisma": "^6.11.1",
"socket.io": "^4.8.3"
},
"devDependencies": {
"prisma": "^6.11.1"
}
"devDependencies": {}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Backfill CaseKeyProgress from open_case transactions.
* Run after: npx prisma db push
*
* npm run db:backfill-case-keys --prefix server
*/
import { prisma } from '../src/db.js';
import { progressFromTotalOpens } from '../src/caseKeys.js';
async function main() {
const txs = await prisma.transaction.findMany({
where: { type: 'open_case' },
select: { userId: true, meta: true },
orderBy: { createdAt: 'asc' },
});
const counts = new Map();
for (const tx of txs) {
let meta;
try {
meta = JSON.parse(tx.meta || '{}');
} catch {
continue;
}
const caseId = Number(meta.caseId);
if (!caseId) continue;
const key = `${tx.userId}:${caseId}`;
counts.set(key, (counts.get(key) || 0) + 1);
}
let upserted = 0;
for (const [key, totalOpens] of counts) {
const [userId, caseId] = key.split(':').map(Number);
const { keys, opensSinceLastKey } = progressFromTotalOpens(totalOpens);
await prisma.caseKeyProgress.upsert({
where: { userId_caseId: { userId, caseId } },
create: { userId, caseId, keys, opensSinceLastKey, totalOpens },
update: { keys, opensSinceLastKey, totalOpens },
});
upserted += 1;
}
console.log(`Backfilled ${upserted} user/case key progress rows from ${txs.length} open_case transactions`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+66
View File
@@ -0,0 +1,66 @@
/**
* Backfill CatalogEntry from open_case transactions.
* Run after: npx prisma db push
*
* npm run db:backfill-catalog --prefix server
*/
import { prisma } from '../src/db.js';
async function main() {
const txs = await prisma.transaction.findMany({
where: { type: 'open_case' },
select: { userId: true, meta: true, createdAt: true },
orderBy: { createdAt: 'asc' },
});
const pairs = new Map();
for (const tx of txs) {
let meta;
try {
meta = JSON.parse(tx.meta || '{}');
} catch {
continue;
}
const caseId = Number(meta.caseId);
const itemId = Number(meta.itemId);
if (!caseId || !itemId) continue;
const key = `${tx.userId}:${caseId}:${itemId}`;
if (!pairs.has(key)) {
pairs.set(key, {
userId: tx.userId,
caseId,
itemId,
filledAt: tx.createdAt,
});
}
}
let created = 0;
let skipped = 0;
for (const row of pairs.values()) {
try {
await prisma.catalogEntry.create({
data: {
userId: row.userId,
caseId: row.caseId,
itemId: row.itemId,
filledAt: row.filledAt,
},
});
created += 1;
} catch {
skipped += 1;
}
}
console.log(
`Backfilled catalog: ${created} new entries, ${skipped} already present (${pairs.size} unique drops from ${txs.length} opens)`
);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+38
View File
@@ -0,0 +1,38 @@
/**
* Backfill existing InventoryItem rows with FT mid float + catalog marketValue.
* Run after: npx prisma db push
*
* npm run db:backfill-wear --prefix server
*/
import { prisma } from '../src/db.js';
import { FT_MID_FLOAT } from '../src/wear.js';
async function main() {
const rows = await prisma.inventoryItem.findMany({
include: { item: true },
});
let updated = 0;
for (const inv of rows) {
if (inv.valueCents !== 0) continue;
await prisma.inventoryItem.update({
where: { id: inv.id },
data: {
floatValue: FT_MID_FLOAT,
wear: 'Field-Tested',
paintSeed: 0,
valueCents: inv.item.marketValue,
},
});
updated += 1;
}
console.log(`Backfilled ${updated} / ${rows.length} inventory items`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
+90 -14
View File
@@ -11,12 +11,20 @@ model User {
id Int @id @default(autoincrement())
username String @unique
passwordHash String
balance Int @default(0)
balance BigInt @default(0)
osuBalance Int @default(0)
lastAdAt DateTime?
role String @default("user")
avatarUrl String @default("")
bio String @default("")
autoSellEnabled Boolean @default(false)
autoSellThresholdCents BigInt @default(1000)
prestigeCount Int @default(0)
prestigeChanceBonus Int @default(0)
prestigeGainBonus Int @default(0)
prestigeKeyOpenReduction Int @default(0)
lastConnection DateTime?
playTimeSeconds BigInt @default(0)
createdAt DateTime @default(now())
inventory InventoryItem[]
transactions Transaction[]
@@ -25,16 +33,22 @@ model User {
duelRoomsCreated DuelRoom[] @relation("DuelCreator")
duelWins DuelRoom[] @relation("DuelWinner")
duelDeposits DuelDeposit[]
caseKeyProgress CaseKeyProgress[]
catalogEntries CatalogEntry[]
prestigeLogs PrestigeLog[]
achievements UserAchievement[]
}
model Case {
id Int @id @default(autoincrement())
name String
imageUrl String @default("")
price Int
price BigInt
active Boolean @default(true)
createdAt DateTime @default(now())
items CaseItem[]
keyProgress CaseKeyProgress[]
catalogEntries CatalogEntry[]
}
model Item {
@@ -42,10 +56,11 @@ model Item {
name String
imageUrl String @default("")
rarity String
marketValue Int
marketValue BigInt
createdAt DateTime @default(now())
cases CaseItem[]
inventory InventoryItem[]
catalogEntries CatalogEntry[]
}
model CaseItem {
@@ -60,13 +75,18 @@ model CaseItem {
}
model InventoryItem {
id Int @id @default(autoincrement())
userId Int
itemId Int
locked Boolean @default(false)
obtainedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
userId Int
itemId Int
locked Boolean @default(false)
floatValue Float @default(0.265)
wear String @default("Field-Tested")
paintSeed Int @default(0)
valueCents BigInt @default(0)
favorite Boolean @default(false)
obtainedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
jackpotDeposits JackpotDeposit[]
duelDeposits DuelDeposit[]
}
@@ -75,7 +95,7 @@ model Transaction {
id Int @id @default(autoincrement())
userId Int
type String
amount Int
amount BigInt
meta String @default("{}")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -97,7 +117,7 @@ model JackpotDeposit {
roundId Int
userId Int
inventoryItemId Int
valueCents Int
valueCents BigInt
createdAt DateTime @default(now())
round JackpotRound @relation(fields: [roundId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -112,7 +132,7 @@ model DuelRoom {
status String @default("open")
maxPlayers Int
maxItemsPerPlayer Int
minItemValue Int @default(0)
minItemValue BigInt @default(0)
minPlayersToStart Int @default(2)
winnerUserId Int?
createdAt DateTime @default(now())
@@ -128,7 +148,7 @@ model DuelDeposit {
roomId Int
userId Int
inventoryItemId Int
valueCents 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)
@@ -136,3 +156,59 @@ model DuelDeposit {
@@unique([roomId, inventoryItemId])
}
model CaseKeyProgress {
id Int @id @default(autoincrement())
userId Int
caseId Int
keys Int @default(1)
opensSinceLastKey Int @default(0)
totalOpens Int @default(0)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
@@unique([userId, caseId])
}
model CatalogEntry {
id Int @id @default(autoincrement())
userId Int
caseId Int
itemId Int
filledAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@unique([userId, caseId, itemId])
}
model PrestigeLog {
id Int @id @default(autoincrement())
userId Int
choice String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Achievement {
id Int @id @default(autoincrement())
key String @unique
name String
description String
icon String @default("")
sortOrder Int @default(0)
createdAt DateTime @default(now())
unlocks UserAchievement[]
}
model UserAchievement {
id Int @id @default(autoincrement())
userId Int
achievementId Int
unlockedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
achievement Achievement @relation(fields: [achievementId], references: [id], onDelete: Cascade)
@@unique([userId, achievementId])
}
+13 -3
View File
@@ -25,6 +25,8 @@ const ITEMS = [
];
async function main() {
await prisma.userAchievement.deleteMany();
await prisma.achievement.deleteMany();
await prisma.transaction.deleteMany();
await prisma.inventoryItem.deleteMany();
await prisma.caseItem.deleteMany();
@@ -32,12 +34,19 @@ async function main() {
await prisma.item.deleteMany();
await prisma.user.deleteMany();
const adminHash = await bcrypt.hash('admin123', 10);
const { ACHIEVEMENT_DEFS } = await import('../src/achievements.js');
for (const def of ACHIEVEMENT_DEFS) {
await prisma.achievement.create({ data: def });
}
const adminUsername = String(process.env.ADMIN_USERNAME || 'admin').trim() || 'admin';
const adminPassword = String(process.env.ADMIN_PASSWORD || 'admin123');
const adminHash = await bcrypt.hash(adminPassword, 10);
const userHash = await bcrypt.hash('player123', 10);
await prisma.user.create({
data: {
username: 'admin',
username: adminUsername,
passwordHash: adminHash,
balance: 0,
role: 'admin',
@@ -144,8 +153,9 @@ async function main() {
}
console.log('Seed complete.');
console.log(' admin / admin123');
console.log(` admin: ${adminUsername} / ${adminPassword}`);
console.log(' player / player123 (balance: 100.00, osu: 100)');
console.log(` achievements: ${ACHIEVEMENT_DEFS.length} definitions`);
}
main()
+200
View File
@@ -0,0 +1,200 @@
import { prisma } from './db.js';
import { countCompletedChapters } from './catalog.js';
import { livePlayTimeSeconds } from './presence.js';
/** First batch of achievements — upserted on seed / boot. */
export const ACHIEVEMENT_DEFS = [
{
key: 'welcome',
name: 'Welcome Aboard',
description: 'Create your CaseForge account.',
icon: '★',
sortOrder: 1,
},
{
key: 'first_open',
name: 'First Crack',
description: 'Open your first case.',
icon: '▣',
sortOrder: 2,
},
{
key: 'case_25',
name: 'Case Regular',
description: 'Open 25 cases.',
icon: '25',
sortOrder: 3,
},
{
key: 'case_100',
name: 'Case Addict',
description: 'Open 100 cases.',
icon: '100',
sortOrder: 4,
},
{
key: 'jackpot_win',
name: 'Pot Lucky',
description: 'Win a MultiPlayers Case round.',
icon: '♠',
sortOrder: 5,
},
{
key: 'duel_win',
name: 'Duel Victor',
description: 'Win a 1vX Case battle.',
icon: '⚔',
sortOrder: 6,
},
{
key: 'catalog_chapter',
name: 'Chapter Complete',
description: 'Finish a full catalog chapter.',
icon: '◆',
sortOrder: 7,
},
{
key: 'first_prestige',
name: 'Prestige Bound',
description: 'Prestige for the first time.',
icon: '✦',
sortOrder: 8,
},
{
key: 'shop_customer',
name: 'Shop Regular',
description: 'Buy a shop pack or claim an ad reward.',
icon: '◈',
sortOrder: 9,
},
{
key: 'playtime_hour',
name: 'Hourglass',
description: 'Spend 1 hour playing on CaseForge.',
icon: '⌛',
sortOrder: 10,
},
];
export async function ensureAchievements() {
for (const def of ACHIEVEMENT_DEFS) {
await prisma.achievement.upsert({
where: { key: def.key },
create: def,
update: {
name: def.name,
description: def.description,
icon: def.icon,
sortOrder: def.sortOrder,
},
});
}
}
function serializeAchievement(row, unlockedAt = null) {
return {
id: row.id,
key: row.key,
name: row.name,
description: row.description,
icon: row.icon || '',
sortOrder: row.sortOrder,
unlockedAt,
};
}
export async function getUnlockedAchievements(userId) {
const rows = await prisma.userAchievement.findMany({
where: { userId },
include: { achievement: true },
orderBy: { unlockedAt: 'asc' },
});
return rows.map((r) => serializeAchievement(r.achievement, r.unlockedAt));
}
export async function listAchievementsForUser(userId) {
const [defs, unlocks] = await Promise.all([
prisma.achievement.findMany({ orderBy: { sortOrder: 'asc' } }),
prisma.userAchievement.findMany({
where: { userId },
select: { achievementId: true, unlockedAt: true },
}),
]);
const byId = new Map(unlocks.map((u) => [u.achievementId, u.unlockedAt]));
return defs.map((d) => serializeAchievement(d, byId.get(d.id) || null));
}
/**
* Unlock by key if not already owned. Returns the achievement if newly unlocked.
*/
export async function unlockAchievement(userId, key) {
const achievement = await prisma.achievement.findUnique({ where: { key } });
if (!achievement) return null;
try {
const row = await prisma.userAchievement.create({
data: { userId, achievementId: achievement.id },
include: { achievement: true },
});
return serializeAchievement(row.achievement, row.unlockedAt);
} catch {
// Unique constraint — already unlocked
return null;
}
}
async function unlockMany(userId, keys) {
const newly = [];
for (const key of keys) {
const unlocked = await unlockAchievement(userId, key);
if (unlocked) newly.push(unlocked);
}
return newly;
}
/**
* Evaluate progress-based achievements and unlock any that now qualify.
* Safe to call after game events or when loading profile / achievements page.
*/
export async function checkAchievements(userId) {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user || user.role === 'admin') return [];
const [casesOpened, jackpotWins, duelWins, chapters, shopOrAd, owned] =
await Promise.all([
prisma.transaction.count({ where: { userId, type: 'open_case' } }),
prisma.transaction.count({ where: { userId, type: 'jackpot_win' } }),
prisma.transaction.count({ where: { userId, type: 'duel_win' } }),
countCompletedChapters(prisma, userId),
prisma.transaction.count({
where: {
userId,
type: { in: ['shop_purchase', 'ad_reward'] },
},
}),
prisma.userAchievement.findMany({
where: { userId },
select: { achievement: { select: { key: true } } },
}),
]);
const have = new Set(owned.map((o) => o.achievement.key));
const candidates = [];
if (!have.has('welcome')) candidates.push('welcome');
if (casesOpened >= 1 && !have.has('first_open')) candidates.push('first_open');
if (casesOpened >= 25 && !have.has('case_25')) candidates.push('case_25');
if (casesOpened >= 100 && !have.has('case_100')) candidates.push('case_100');
if (jackpotWins >= 1 && !have.has('jackpot_win')) candidates.push('jackpot_win');
if (duelWins >= 1 && !have.has('duel_win')) candidates.push('duel_win');
if (chapters >= 1 && !have.has('catalog_chapter')) candidates.push('catalog_chapter');
if ((user.prestigeCount ?? 0) >= 1 && !have.has('first_prestige')) {
candidates.push('first_prestige');
}
if (shopOrAd >= 1 && !have.has('shop_customer')) candidates.push('shop_customer');
if (livePlayTimeSeconds(user) >= 3600 && !have.has('playtime_hour')) {
candidates.push('playtime_hour');
}
return unlockMany(userId, candidates);
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Auto-sell inventory items below the user's configured threshold.
* Returns sold items and total credits added (caller updates balance).
* Prestige gain bonus multiplies credited amount (not the sell threshold check).
*/
import { applyGainMultiplier } from './prestige.js';
export function shouldAutoSell(user, valueCents) {
if (!user?.autoSellEnabled) return false;
const threshold = user.autoSellThresholdCents ?? 0;
return valueCents < threshold;
}
export async function applyAutoSell(tx, user, inventoryItems) {
if (!user?.autoSellEnabled) {
return { sold: [], balanceDelta: 0 };
}
const threshold = user.autoSellThresholdCents ?? 0;
const gainBonus = user.prestigeGainBonus ?? 0;
const sold = [];
let balanceDelta = 0;
for (const inv of inventoryItems) {
if (!inv || inv.locked || inv.favorite || inv.valueCents >= threshold) continue;
const payout = applyGainMultiplier(inv.valueCents, gainBonus);
await tx.inventoryItem.delete({ where: { id: inv.id } });
await tx.transaction.create({
data: {
userId: user.id,
type: 'sell_item',
amount: payout,
meta: JSON.stringify({
inventoryItemId: inv.id,
itemId: inv.itemId,
autoSell: true,
valueCents: inv.valueCents,
prestigeGainBonus: gainBonus,
payout,
}),
},
});
sold.push(inv);
balanceDelta += payout;
}
return { sold, balanceDelta };
}
+89
View File
@@ -0,0 +1,89 @@
/** Base simultaneous opens at key 1 (x10). Each +1 key adds +1 open until x20, then continues +1 per key. */
export const BASE_MAX_OPENS = 10;
/** Keys 110 → next key always costs this many opens. */
export const OPENS_PER_KEY_EARLY = 100;
/** From key 11 onward: cost = OPENS_PER_KEY_EARLY × (keys - 9). */
export const ESCALATION_KEY_THRESHOLD = 11;
export function maxOpensForKeys(keys) {
const k = Math.max(1, keys);
return k + (BASE_MAX_OPENS - 1);
}
export function opensRequiredForNextKey(keys, keyOpenReduction = 0) {
const k = Math.max(1, keys);
const base =
k < ESCALATION_KEY_THRESHOLD
? OPENS_PER_KEY_EARLY
: OPENS_PER_KEY_EARLY * (k - (ESCALATION_KEY_THRESHOLD - 2));
const reduction = Math.max(0, Number(keyOpenReduction) || 0);
return Math.max(1, base - reduction);
}
export function serializeKeyProgress(progress, keyOpenReduction = 0) {
const keys = progress?.keys ?? 1;
const opensSinceLastKey = progress?.opensSinceLastKey ?? 0;
const totalOpens = progress?.totalOpens ?? 0;
const opensRequired = opensRequiredForNextKey(keys, keyOpenReduction);
return {
keys,
maxOpens: maxOpensForKeys(keys),
opensSinceLastKey,
opensRequired,
totalOpens,
};
}
/**
* Apply opens to progress state. Returns updated fields + keysGained.
*/
export function applyOpens(progress, count, keyOpenReduction = 0) {
let keys = Math.max(1, progress.keys ?? 1);
let opensSinceLastKey = progress.opensSinceLastKey ?? 0;
let totalOpens = progress.totalOpens ?? 0;
let keysGained = 0;
opensSinceLastKey += count;
totalOpens += count;
while (opensSinceLastKey >= opensRequiredForNextKey(keys, keyOpenReduction)) {
opensSinceLastKey -= opensRequiredForNextKey(keys, keyOpenReduction);
keys += 1;
keysGained += 1;
}
return { keys, opensSinceLastKey, totalOpens, keysGained };
}
/**
* Rebuild keys/opensSinceLastKey from a lifetime open count (backfill).
* Prestige reduction is ignored here — backfill uses base costs.
*/
export function progressFromTotalOpens(totalOpens) {
let keys = 1;
let remaining = totalOpens;
while (remaining >= opensRequiredForNextKey(keys, 0)) {
remaining -= opensRequiredForNextKey(keys, 0);
keys += 1;
}
return {
keys,
opensSinceLastKey: remaining,
totalOpens,
};
}
export async function getOrCreateProgress(tx, userId, caseId) {
const existing = await tx.caseKeyProgress.findUnique({
where: { userId_caseId: { userId, caseId } },
});
if (existing) return existing;
return tx.caseKeyProgress.create({
data: { userId, caseId, keys: 1, opensSinceLastKey: 0, totalOpens: 0 },
});
}
+176
View File
@@ -0,0 +1,176 @@
import { prisma } from './db.js';
import { applyCatalogLuck, normalizeRates } from './weighted.js';
/**
* Count how many active cases the user has fully collected.
* @param {import('@prisma/client').Prisma.TransactionClient | typeof prisma} db
*/
export async function countCompletedChapters(db, userId) {
const cases = await db.case.findMany({
where: { active: true },
include: { items: { select: { itemId: true } } },
});
if (!cases.length) return 0;
const entries = await db.catalogEntry.findMany({
where: { userId },
select: { caseId: true, itemId: true },
});
const filledByCase = new Map();
for (const e of entries) {
if (!filledByCase.has(e.caseId)) filledByCase.set(e.caseId, new Set());
filledByCase.get(e.caseId).add(e.itemId);
}
let completed = 0;
for (const c of cases) {
if (!c.items.length) continue;
const filled = filledByCase.get(c.id);
if (!filled) continue;
if (c.items.every((ci) => filled.has(ci.itemId))) completed += 1;
}
return completed;
}
/**
* Fill catalog slots for newly dropped items. Idempotent upserts.
* @returns {{ newlyFilled: object[], chapterJustCompleted: boolean, luckPercent: number }}
*/
export async function fillCatalogSlots(db, userId, caseId, itemIds) {
const uniqueIds = [...new Set(itemIds.filter((id) => Number.isFinite(id)))];
if (!uniqueIds.length) {
const luckPercent = await countCompletedChapters(db, userId);
return { newlyFilled: [], chapterJustCompleted: false, luckPercent };
}
const existing = await db.catalogEntry.findMany({
where: {
userId,
caseId,
itemId: { in: uniqueIds },
},
select: { itemId: true },
});
const already = new Set(existing.map((e) => e.itemId));
const toCreate = uniqueIds.filter((id) => !already.has(id));
const newlyFilled = [];
for (const itemId of toCreate) {
const entry = await db.catalogEntry.create({
data: { userId, caseId, itemId },
include: { item: true },
});
newlyFilled.push({
itemId: entry.itemId,
name: entry.item.name,
imageUrl: entry.item.imageUrl,
rarity: entry.item.rarity,
filledAt: entry.filledAt,
});
}
const caseItems = await db.caseItem.findMany({
where: { caseId },
select: { itemId: true },
});
const allFilled = await db.catalogEntry.findMany({
where: { userId, caseId },
select: { itemId: true },
});
const filledSet = new Set(allFilled.map((e) => e.itemId));
const chapterComplete =
caseItems.length > 0 && caseItems.every((ci) => filledSet.has(ci.itemId));
const filledBefore = new Set(already);
const completeBefore =
caseItems.length > 0 && caseItems.every((ci) => filledBefore.has(ci.itemId));
const chapterJustCompleted = chapterComplete && !completeBefore;
const luckPercent = await countCompletedChapters(db, userId);
return {
newlyFilled,
chapterJustCompleted,
luckPercent,
};
}
export async function getCatalogState(userId) {
const cases = await prisma.case.findMany({
where: { active: true },
include: {
items: {
include: { item: true },
orderBy: { dropRate: 'asc' },
},
},
orderBy: { price: 'asc' },
});
const entries = await prisma.catalogEntry.findMany({
where: { userId },
select: { caseId: true, itemId: true, filledAt: true },
});
const filledMap = new Map();
for (const e of entries) {
const key = `${e.caseId}:${e.itemId}`;
filledMap.set(key, e.filledAt);
}
const chapters = cases.map((c) => {
const slots = c.items.map((ci) => {
const filledAt = filledMap.get(`${c.id}:${ci.itemId}`) || null;
return {
itemId: ci.item.id,
name: ci.item.name,
imageUrl: ci.item.imageUrl,
rarity: ci.item.rarity,
marketValue: ci.item.marketValue,
dropRate: ci.dropRate,
filled: Boolean(filledAt),
filledAt,
};
});
const filled = slots.filter((s) => s.filled).length;
const total = slots.length;
const completed = total > 0 && filled === total;
return {
caseId: c.id,
name: c.name,
imageUrl: c.imageUrl,
price: c.price,
filled,
total,
completed,
slots,
};
});
const luckPercent = chapters.filter((ch) => ch.completed).length;
return { chapters, luckPercent, completedChapters: luckPercent };
}
/** Compact list of completed case chapters for profile badges. */
export async function getCompletedCatalogBadges(userId) {
const { chapters, luckPercent } = await getCatalogState(userId);
return {
luckPercent,
completed: chapters
.filter((ch) => ch.completed)
.map((ch) => ({
caseId: ch.caseId,
name: ch.name,
imageUrl: ch.imageUrl || '',
})),
};
}
/** Format case items with optional catalog luck applied to displayed chances. */
export function formatCaseItemsWithLuck(items, luckPercent = 0) {
const base = items.map((i) => ({ ...i, dropRate: i.dropRate }));
const boosted = applyCatalogLuck(base, luckPercent);
return normalizeRates(boosted).map(({ dropRate, chance, ...rest }) => ({
...rest,
dropRate,
chance: Number(chance.toFixed(4)),
}));
}
+21 -1
View File
@@ -1,3 +1,23 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
/** Prisma BigInt → Number so the rest of the app can keep using number arithmetic / JSON. */
function reviveMoney(value) {
if (typeof value === 'bigint') return Number(value);
if (value == null || typeof value !== 'object') return value;
if (value instanceof Date) return value;
if (Array.isArray(value)) return value.map(reviveMoney);
for (const key of Object.keys(value)) {
value[key] = reviveMoney(value[key]);
}
return value;
}
const base = new PrismaClient();
export const prisma = base.$extends({
query: {
$allOperations({ args, query }) {
return query(args).then(reviveMoney);
},
},
});
+54
View File
@@ -0,0 +1,54 @@
import bcrypt from 'bcrypt';
import { prisma } from './db.js';
/**
* Sync admin credentials from ADMIN_USERNAME / ADMIN_PASSWORD.
* Required in production (Docker); optional in local dev (seed still creates admin).
*/
export async function ensureAdminFromEnv() {
const username = String(process.env.ADMIN_USERNAME || '').trim();
const password = String(process.env.ADMIN_PASSWORD || '');
const required = process.env.NODE_ENV === 'production';
if (!username || !password) {
if (required) {
throw new Error(
'ADMIN_USERNAME and ADMIN_PASSWORD must be set in .env.prod for production',
);
}
return;
}
if (password.length < 6) {
throw new Error('ADMIN_PASSWORD must be at least 6 characters');
}
const passwordHash = await bcrypt.hash(password, 10);
const existing = await prisma.user.findUnique({ where: { username } });
if (existing) {
await prisma.user.update({
where: { id: existing.id },
data: { passwordHash, role: 'admin' },
});
} else {
const soleAdmin = await prisma.user.findFirst({ where: { role: 'admin' } });
if (soleAdmin) {
await prisma.user.update({
where: { id: soleAdmin.id },
data: { username, passwordHash },
});
} else {
await prisma.user.create({
data: {
username,
passwordHash,
balance: 0,
role: 'admin',
},
});
}
}
console.log(`Admin account ready: ${username}`);
}
+31 -4
View File
@@ -1,22 +1,49 @@
import { getIO } from './realtime.js';
const MAX_FEED = 40;
const DEDUPE_MS = 15_000;
const feed = [];
export function getDropFeed() {
return [...feed];
return feed.map(({ _fp, ...publicEvent }) => publicEvent);
}
function fingerprint(entry) {
const ids = (entry.inventoryIds || [entry.item?.inventoryId]).filter(Boolean).sort();
return [
entry.username || '',
entry.item?.id || '',
entry.count || 1,
entry.openCount || 1,
ids.join(','),
].join('|');
}
export function pushDrop(entry) {
const fp = fingerprint(entry);
const now = Date.now();
// Ignore duplicate announces for the same open batch
const dup = feed.find(
(d) => d._fp === fp && now - new Date(d.at).getTime() < DEDUPE_MS
);
if (dup) return dup;
const event = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
id: `${now}-${Math.random().toString(36).slice(2, 8)}`,
at: new Date().toISOString(),
_fp: fp,
...entry,
};
feed.unshift(event);
if (feed.length > MAX_FEED) feed.length = MAX_FEED;
const io = getIO();
if (io) io.to('feed').emit('feed:drop', event);
return event;
if (io) {
const { _fp, ...publicEvent } = event;
io.to('feed').emit('feed:drop', publicEvent);
return publicEvent;
}
const { _fp, ...publicEvent } = event;
return publicEvent;
}
+94 -10
View File
@@ -17,11 +17,17 @@ import duelsRoutes from './routes/duels.js';
import profileRoutes from './routes/profile.js';
import shopRoutes from './routes/shop.js';
import feedRoutes from './routes/feed.js';
import catalogRoutes from './routes/catalog.js';
import prestigeRoutes from './routes/prestige.js';
import achievementsRoutes from './routes/achievements.js';
import { setIO } from './realtime.js';
import { getDropFeed } from './feed.js';
import { ensureJackpotRound } from './services/jackpot.js';
import { recoverDuelSpins, getDuelRoom, listDuelRooms } from './services/duel.js';
import { recoverDuelSpins, getDuelRoom, listDuelRooms, startEmptyDuelRoomPurge } from './services/duel.js';
import { getJackpotState } from './services/jackpot.js';
import { ensureAdminFromEnv } from './ensureAdmin.js';
import { ensureAchievements, checkAchievements } from './achievements.js';
import { trackConnect, trackDisconnect } from './presence.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const uploadsDir = path.join(__dirname, '../uploads');
@@ -33,10 +39,28 @@ const app = express();
const server = http.createServer(app);
const PORT = Number(process.env.PORT || 3001);
const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173';
const TRUST_PROXY = process.env.TRUST_PROXY === 'true' || process.env.TRUST_PROXY === '1';
/** true | false | 'auto' (secure cookie only on HTTPS) */
function cookieSecureOption() {
const v = String(process.env.COOKIE_SECURE ?? 'auto').toLowerCase();
if (v === 'true' || v === '1') return true;
if (v === 'false' || v === '0') return false;
return 'auto';
}
if (TRUST_PROXY) {
app.set('trust proxy', 1);
}
function isAllowedOrigin(origin) {
if (!origin) return true;
if (origin === CLIENT_ORIGIN) return true;
const extra = (process.env.CORS_ORIGINS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (extra.includes(origin)) return true;
if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return true;
// Private LAN ranges (dev access from phones / other PCs)
if (/^http:\/\/192\.168\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(origin)) return true;
@@ -58,10 +82,11 @@ const sessionMiddleware = session({
secret: process.env.SESSION_SECRET || 'dev-secret',
resave: false,
saveUninitialized: false,
proxy: TRUST_PROXY,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: false,
secure: cookieSecureOption(),
maxAge: 7 * 24 * 60 * 60 * 1000,
},
});
@@ -85,6 +110,9 @@ app.use('/api/duels', duelsRoutes);
app.use('/api/profile', profileRoutes);
app.use('/api/shop', shopRoutes);
app.use('/api/feed', feedRoutes);
app.use('/api/catalog', catalogRoutes);
app.use('/api/prestige', prestigeRoutes);
app.use('/api/achievements', achievementsRoutes);
app.use((err, _req, res, _next) => {
console.error(err);
@@ -100,12 +128,30 @@ io.use((socket, next) => {
sessionMiddleware(socket.request, {}, next);
});
io.on('connection', (socket) => {
io.on('connection', async (socket) => {
const userId = socket.request.session?.userId || null;
socket.data.userId = userId;
if (userId) {
socket.join(`user:${userId}`);
try {
const result = await trackConnect(userId, socket.id);
if (!result.ok) {
socket.emit('session:limit', {
max: result.max,
count: result.count,
message: `Too many open windows for this account (max ${result.max}). Close another tab and reload.`,
});
socket.disconnect(true);
return;
}
socket.join(`user:${userId}`);
socket.emit('session:ok', {
count: result.count,
max: result.max,
});
} catch (err) {
console.error('presence connect', err);
}
}
socket.on('jackpot:subscribe', async () => {
@@ -162,14 +208,52 @@ io.on('connection', (socket) => {
socket.on('feed:unsubscribe', () => {
socket.leave('feed');
});
socket.on('disconnect', () => {
if (userId) {
trackDisconnect(userId, socket.id)
.then(() => checkAchievements(userId))
.catch((err) => console.error(err));
}
});
});
server.listen(PORT, async () => {
console.log(`API listening on http://localhost:${PORT}`);
// Production: serve Vite build (SPA)
const publicDir = path.join(__dirname, '../public');
if (process.env.NODE_ENV === 'production' && fs.existsSync(publicDir)) {
app.use(express.static(publicDir));
app.use((req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
if (
req.path.startsWith('/api') ||
req.path.startsWith('/uploads') ||
req.path.startsWith('/socket.io')
) {
return next();
}
res.sendFile(path.join(publicDir, 'index.html'));
});
}
async function bootstrap() {
try {
await ensureJackpotRound();
await recoverDuelSpins();
await ensureAdminFromEnv();
await ensureAchievements();
} catch (err) {
console.error('Failed to bootstrap game services', err);
console.error(err.message || err);
process.exit(1);
}
});
server.listen(PORT, async () => {
console.log(`API listening on http://localhost:${PORT}`);
try {
await ensureJackpotRound();
await recoverDuelSpins();
startEmptyDuelRoomPurge();
} catch (err) {
console.error('Failed to bootstrap game services', err);
}
});
}
bootstrap();
+8
View File
@@ -25,5 +25,13 @@ export function publicUser(user) {
role: user.role,
avatarUrl: user.avatarUrl || '',
bio: user.bio || '',
autoSellEnabled: Boolean(user.autoSellEnabled),
autoSellThresholdCents: user.autoSellThresholdCents ?? 1000,
prestigeCount: user.prestigeCount ?? 0,
prestigeChanceBonus: user.prestigeChanceBonus ?? 0,
prestigeGainBonus: user.prestigeGainBonus ?? 0,
prestigeKeyOpenReduction: user.prestigeKeyOpenReduction ?? 0,
lastConnection: user.lastConnection || null,
playTimeSeconds: Number(user.playTimeSeconds ?? 0),
};
}
+108
View File
@@ -0,0 +1,108 @@
import { prisma } from './db.js';
export const MAX_SOCKETS_PER_USER = Math.max(
1,
Number(process.env.MAX_SOCKETS_PER_USER || 10)
);
/** @type {Map<number, { sockets: Set<string>, sessionStartedAt: number }>} */
const online = new Map();
export function isConnected(userId) {
return online.has(Number(userId));
}
export function connectionCount(userId) {
return online.get(Number(userId))?.sockets.size || 0;
}
/**
* Track an authenticated socket. Updates lastConnection on first socket for this user.
* Rejects when the account already has MAX_SOCKETS_PER_USER live windows.
* Slot claim is synchronous so concurrent connects cannot exceed the max.
* @returns {Promise<{ ok: true, count: number, max: number } | { ok: false, count: number, max: number }>}
*/
export async function trackConnect(userId, socketId) {
const id = Number(userId);
const max = MAX_SOCKETS_PER_USER;
if (!id || !socketId) {
return { ok: false, count: 0, max };
}
let entry = online.get(id);
if (!entry) {
entry = { sockets: new Set(), sessionStartedAt: Date.now() };
online.set(id, entry);
}
if (entry.sockets.has(socketId)) {
return { ok: true, count: entry.sockets.size, max };
}
if (entry.sockets.size >= max) {
return { ok: false, count: entry.sockets.size, max };
}
// Claim slot before any await (avoids race past the limit)
entry.sockets.add(socketId);
const isFirst = entry.sockets.size === 1;
if (isFirst) {
try {
await prisma.user.update({
where: { id },
data: { lastConnection: new Date() },
});
} catch (err) {
console.error('presence lastConnection', err);
}
}
return { ok: true, count: entry.sockets.size, max };
}
/**
* Remove a socket. When the last socket for a user drops, add session time to playTimeSeconds.
*/
export async function trackDisconnect(userId, socketId) {
const id = Number(userId);
if (!id || !socketId) return;
const entry = online.get(id);
if (!entry) return;
entry.sockets.delete(socketId);
if (entry.sockets.size > 0) return;
online.delete(id);
const elapsed = Math.max(0, Math.floor((Date.now() - entry.sessionStartedAt) / 1000));
if (elapsed <= 0) return;
try {
await prisma.user.update({
where: { id },
data: { playTimeSeconds: { increment: elapsed } },
});
} catch (err) {
console.error('presence playTime', err);
}
}
/** Live playtime including the current open session (if any). */
export function livePlayTimeSeconds(user) {
const stored = Number(user?.playTimeSeconds ?? 0);
const entry = online.get(Number(user?.id));
if (!entry) return stored;
const live = Math.max(0, Math.floor((Date.now() - entry.sessionStartedAt) / 1000));
return stored + live;
}
export function serializePresence(user) {
return {
isConnected: isConnected(user.id),
playTimeSeconds: livePlayTimeSeconds(user),
lastConnection: user.lastConnection || null,
openWindows: connectionCount(user.id),
maxWindows: MAX_SOCKETS_PER_USER,
};
}
+52
View File
@@ -0,0 +1,52 @@
/** 1_000_000_000_000.00 cr in cents */
export const PRESTIGE_COST_CENTS = 100_000_000_000_000n;
/** Page unlock: 10_000_000_000.00 cr total wealth (balance + unlocked inventory). */
export const PRESTIGE_VISIBLE_AT_CENTS = 1_000_000_000_000;
export const PRESTIGE_CHOICES = {
chance: { field: 'prestigeChanceBonus', delta: 1, label: 'chance' },
gain: { field: 'prestigeGainBonus', delta: 2, label: 'gain' },
key: { field: 'prestigeKeyOpenReduction', delta: 10, label: 'key' },
};
export function serializePrestige(user) {
return {
count: user?.prestigeCount ?? 0,
chanceBonus: user?.prestigeChanceBonus ?? 0,
gainBonus: user?.prestigeGainBonus ?? 0,
keyOpenReduction: user?.prestigeKeyOpenReduction ?? 0,
costCents: Number(PRESTIGE_COST_CENTS),
visibleAtCents: PRESTIGE_VISIBLE_AT_CENTS,
};
}
/** Apply lifetime gain % to a credit amount (cents). */
export function applyGainMultiplier(cents, gainBonusPct = 0) {
const base = Number(cents) || 0;
const pct = Number(gainBonusPct) || 0;
if (pct <= 0) return Math.floor(base);
return Math.floor(base * (1 + pct / 100));
}
export function totalLuckPercent(catalogLuck = 0, prestigeChanceBonus = 0) {
return (Number(catalogLuck) || 0) + (Number(prestigeChanceBonus) || 0);
}
export function isValidPrestigeChoice(choice) {
return Boolean(PRESTIGE_CHOICES[choice]);
}
/** Visible if already prestiged, or total wealth reaches the unlock threshold. */
export function isPrestigePageVisible(user, totalCents) {
if ((user?.prestigeCount ?? 0) > 0) return true;
return (Number(totalCents) || 0) >= PRESTIGE_VISIBLE_AT_CENTS;
}
export async function getUnlockedInventoryValueCents(db, userId) {
const rows = await db.inventoryItem.findMany({
where: { userId, locked: false },
select: { valueCents: true },
});
return rows.reduce((sum, r) => sum + (Number(r.valueCents) || 0), 0);
}
+48
View File
@@ -0,0 +1,48 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth } from '../middleware.js';
import {
checkAchievements,
getUnlockedAchievements,
listAchievementsForUser,
} from '../achievements.js';
const router = Router();
/** Full list for the logged-in player (locked + unlocked). */
router.get('/', requireAuth, async (req, res) => {
try {
await checkAchievements(req.session.userId);
const achievements = await listAchievementsForUser(req.session.userId);
const unlocked = achievements.filter((a) => a.unlockedAt).length;
res.json({
achievements,
unlocked,
total: achievements.length,
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load achievements' });
}
});
/** Public unlocked badges for a username. */
router.get('/player/:username', async (req, res) => {
try {
const username = String(req.params.username || '').trim();
const user = await prisma.user.findFirst({
where: { username, role: 'user' },
select: { id: true },
});
if (!user) return res.status(404).json({ error: 'Player not found' });
await checkAchievements(user.id);
const achievements = await getUnlockedAchievements(user.id);
res.json({ achievements });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load achievements' });
}
});
export default router;
+14 -2
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import bcrypt from 'bcrypt';
import { prisma } from '../db.js';
import { publicUser, requireAuth } from '../middleware.js';
import { unlockAchievement } from '../achievements.js';
const router = Router();
const STARTING_BALANCE = Number(process.env.STARTING_BALANCE || 10000);
@@ -59,7 +60,13 @@ router.post('/register', async (req, res) => {
req.session.userId = user.id;
req.session.role = user.role;
res.status(201).json({ user: publicUser(user) });
const withConn = await prisma.user.update({
where: { id: user.id },
data: { lastConnection: new Date() },
});
await unlockAchievement(user.id, 'welcome');
res.status(201).json({ user: publicUser(withConn) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Registration failed' });
@@ -84,7 +91,12 @@ router.post('/login', async (req, res) => {
req.session.userId = user.id;
req.session.role = user.role;
res.json({ user: publicUser(user) });
const withConn = await prisma.user.update({
where: { id: user.id },
data: { lastConnection: new Date() },
});
res.json({ user: publicUser(withConn) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Login failed' });
+182 -58
View File
@@ -1,12 +1,26 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { normalizeRates, pickWeighted } from '../weighted.js';
import { pushDrop } from '../feed.js';
import { applyCatalogLuck, pickWeighted } from '../weighted.js';
import { rollInstanceValue } from '../wear.js';
import { shouldAutoSell } from '../autoSell.js';
import {
applyOpens,
getOrCreateProgress,
maxOpensForKeys,
serializeKeyProgress,
} from '../caseKeys.js';
import {
countCompletedChapters,
fillCatalogSlots,
formatCaseItemsWithLuck,
} from '../catalog.js';
import { totalLuckPercent } from '../prestige.js';
import { checkAchievements } from '../achievements.js';
const router = Router();
function formatCase(c, includeRates = false) {
function formatCase(c, luckPercent = 0) {
const items = (c.items || []).map((ci) => ({
id: ci.item.id,
name: ci.item.name,
@@ -14,29 +28,51 @@ function formatCase(c, includeRates = false) {
rarity: ci.item.rarity,
marketValue: ci.item.marketValue,
dropRate: ci.dropRate,
...(includeRates ? {} : {}),
}));
const withChance = normalizeRates(
items.map((i) => ({ ...i, dropRate: i.dropRate }))
);
return {
id: c.id,
name: c.name,
imageUrl: c.imageUrl,
price: c.price,
active: c.active,
items: withChance.map(({ dropRate, chance, ...rest }) => ({
...rest,
dropRate,
chance: Number(chance.toFixed(4)),
})),
catalogLuckPercent: luckPercent,
items: formatCaseItemsWithLuck(items, luckPercent),
};
}
router.get('/', async (_req, res) => {
async function loadUserLuckAndKeys(userId) {
if (!userId) {
return { luckPercent: 0, keyOpenReduction: 0, user: null };
}
const [catalogLuck, user] = await Promise.all([
countCompletedChapters(prisma, userId),
prisma.user.findUnique({
where: { id: userId },
select: {
prestigeChanceBonus: true,
prestigeKeyOpenReduction: true,
},
}),
]);
return {
luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus),
keyOpenReduction: user?.prestigeKeyOpenReduction ?? 0,
user,
};
}
async function loadKeyProgressMap(userId) {
if (!userId) return new Map();
const rows = await prisma.caseKeyProgress.findMany({
where: { userId },
});
return new Map(rows.map((r) => [r.caseId, r]));
}
router.get('/', async (req, res) => {
try {
const userId = req.session?.userId;
const cases = await prisma.case.findMany({
where: { active: true },
include: {
@@ -44,7 +80,20 @@ router.get('/', async (_req, res) => {
},
orderBy: { price: 'asc' },
});
res.json({ cases: cases.map((c) => formatCase(c)) });
const progressMap = await loadKeyProgressMap(userId);
const { luckPercent, keyOpenReduction } = await loadUserLuckAndKeys(userId);
res.json({
catalogLuckPercent: luckPercent,
cases: cases.map((c) => {
const formatted = formatCase(c, luckPercent);
if (!userId) return formatted;
const progress = progressMap.get(c.id);
return {
...formatted,
keyProgress: serializeKeyProgress(progress, keyOpenReduction),
};
}),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load cases' });
@@ -54,6 +103,7 @@ router.get('/', async (_req, res) => {
router.get('/:id', async (req, res) => {
try {
const id = Number(req.params.id);
const userId = req.session?.userId;
const c = await prisma.case.findUnique({
where: { id },
include: {
@@ -63,7 +113,15 @@ router.get('/:id', async (req, res) => {
if (!c || !c.active) {
return res.status(404).json({ error: 'Case not found' });
}
res.json({ case: formatCase(c, true) });
const { luckPercent, keyOpenReduction } = await loadUserLuckAndKeys(userId);
const formatted = formatCase(c, luckPercent);
if (userId) {
const progress = await prisma.caseKeyProgress.findUnique({
where: { userId_caseId: { userId, caseId: id } },
});
formatted.keyProgress = serializeKeyProgress(progress, keyOpenReduction);
}
res.json({ case: formatted });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load case' });
@@ -74,6 +132,9 @@ router.post('/:id/open', requireAuth, async (req, res) => {
try {
const caseId = Number(req.params.id);
const userId = req.session.userId;
let count = Number(req.body?.count ?? 1);
if (!Number.isFinite(count)) count = 1;
count = Math.max(1, Math.floor(count));
const result = await prisma.$transaction(async (tx) => {
const c = await tx.case.findUnique({
@@ -100,72 +161,135 @@ router.post('/:id/open', requireAuth, async (req, res) => {
err.status = 401;
throw err;
}
if (user.balance < c.price) {
const progressRow = await getOrCreateProgress(tx, userId, caseId);
const maxOpens = maxOpensForKeys(progressRow.keys);
if (count > maxOpens) {
const err = new Error(`You can open at most ${maxOpens} at once for this case`);
err.status = 400;
throw err;
}
const totalCost = c.price * count;
if (user.balance < totalCost) {
const err = new Error('Insufficient balance');
err.status = 400;
throw err;
}
const winner = pickWeighted(
const catalogLuck = await countCompletedChapters(tx, userId);
const keyOpenReduction = user.prestigeKeyOpenReduction ?? 0;
const luckPercent = totalLuckPercent(catalogLuck, user.prestigeChanceBonus);
const weighted = applyCatalogLuck(
c.items.map((ci) => ({
caseItem: ci,
dropRate: ci.dropRate,
}))
})),
luckPercent
);
const item = winner.caseItem.item;
const drops = [];
for (let i = 0; i < count; i += 1) {
const winner = pickWeighted(weighted);
const item = winner.caseItem.item;
const rolled = rollInstanceValue(item.marketValue);
const updatedUser = await tx.user.update({
where: { id: userId },
data: { balance: { decrement: c.price } },
});
const inventoryItem = await tx.inventoryItem.create({
data: {
userId,
itemId: item.id,
},
include: { item: true },
});
await tx.transaction.create({
data: {
userId,
type: 'open_case',
amount: -c.price,
meta: JSON.stringify({
caseId: c.id,
caseName: c.name,
const inventoryItem = await tx.inventoryItem.create({
data: {
userId,
itemId: item.id,
itemName: item.name,
inventoryItemId: inventoryItem.id,
}),
},
});
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
},
});
return {
user: publicUser(updatedUser),
caseName: c.name,
item: {
const willAutoSell = shouldAutoSell(user, rolled.valueCents);
await tx.transaction.create({
data: {
userId,
type: 'open_case',
amount: -c.price,
meta: JSON.stringify({
caseId: c.id,
caseName: c.name,
itemId: item.id,
itemName: item.name,
inventoryItemId: inventoryItem.id,
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
willAutoSell,
openIndex: i,
openCount: count,
}),
},
});
drops.push({
inventoryId: inventoryItem.id,
id: item.id,
name: item.name,
imageUrl: item.imageUrl,
rarity: item.rarity,
marketValue: item.marketValue,
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
willAutoSell,
});
}
const catalog = await fillCatalogSlots(
tx,
userId,
caseId,
drops.map((d) => d.id)
);
const applied = applyOpens(progressRow, count, keyOpenReduction);
const updatedProgress = await tx.caseKeyProgress.update({
where: { id: progressRow.id },
data: {
keys: applied.keys,
opensSinceLastKey: applied.opensSinceLastKey,
totalOpens: applied.totalOpens,
},
});
const updatedUser = await tx.user.update({
where: { id: userId },
data: {
balance: { decrement: totalCost },
},
});
return {
user: publicUser(updatedUser),
caseName: c.name,
items: drops,
keyProgress: serializeKeyProgress(updatedProgress, keyOpenReduction),
keysGained: applied.keysGained,
catalog,
};
});
pushDrop({
source: 'case',
caseName: result.caseName,
username: result.user.username,
avatarUrl: result.user.avatarUrl || '',
item: result.item,
});
checkAchievements(userId).catch((err) => console.error('achievements', err));
res.json({ user: result.user, item: result.item });
res.json({
user: result.user,
caseName: result.caseName,
count: result.items.length,
items: result.items,
keyProgress: result.keyProgress,
keysGained: result.keysGained,
catalog: result.catalog,
item: result.items[0],
});
} catch (err) {
console.error(err);
const status = err.status || 500;
+17
View File
@@ -0,0 +1,17 @@
import { Router } from 'express';
import { requireAuth } from '../middleware.js';
import { getCatalogState } from '../catalog.js';
const router = Router();
router.get('/', requireAuth, async (req, res) => {
try {
const state = await getCatalogState(req.session.userId);
res.json(state);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load catalog' });
}
});
export default router;
+80 -1
View File
@@ -1,5 +1,7 @@
import { Router } from 'express';
import { getDropFeed } from '../feed.js';
import { prisma } from '../db.js';
import { requireAuth } from '../middleware.js';
import { getDropFeed, pushDrop } from '../feed.js';
const router = Router();
@@ -7,4 +9,81 @@ router.get('/', (_req, res) => {
res.json({ drops: getDropFeed() });
});
/**
* Announce case drop(s) to the live feed (after client spin finishes).
* For multi-opens: send all inventoryItemIds — only the best drop is shown,
* with count of how many times that item appeared (e.g. x3).
*/
router.post('/announce', requireAuth, async (req, res) => {
try {
let ids = [];
if (Array.isArray(req.body.inventoryItemIds)) {
ids = req.body.inventoryItemIds.map(Number).filter(Boolean);
} else if (req.body.inventoryItemId) {
ids = [Number(req.body.inventoryItemId)].filter(Boolean);
}
ids = [...new Set(ids)];
if (!ids.length) {
return res.status(400).json({ error: 'inventoryItemId(s) required' });
}
const invItems = await prisma.inventoryItem.findMany({
where: { id: { in: ids }, userId: req.session.userId },
include: {
item: true,
user: { select: { username: true, avatarUrl: true } },
},
});
if (invItems.length !== ids.length) {
return res.status(404).json({ error: 'Inventory item not found' });
}
const now = Date.now();
for (const inv of invItems) {
const ageMs = now - new Date(inv.obtainedAt).getTime();
if (ageMs > 2 * 60 * 1000) {
return res.status(400).json({ error: 'Drop too old to announce' });
}
}
// Best drop = highest instance value (tie-break: first in batch)
let best = invItems[0];
for (const inv of invItems) {
if (inv.valueCents > best.valueCents) best = inv;
}
const count = invItems.filter((inv) => inv.itemId === best.itemId).length;
const caseName = String(req.body.caseName || '').slice(0, 80);
const drop = pushDrop({
source: 'case',
caseName,
count,
openCount: invItems.length,
inventoryIds: invItems.map((i) => i.id),
username: best.user.username,
avatarUrl: best.user.avatarUrl || '',
item: {
inventoryId: best.id,
id: best.item.id,
name: best.item.name,
imageUrl: best.item.imageUrl,
rarity: best.item.rarity,
marketValue: best.valueCents,
valueCents: best.valueCents,
wear: best.wear,
floatValue: best.floatValue,
paintSeed: best.paintSeed,
},
});
res.json({ drop });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to announce drop' });
}
});
export default router;
+208 -41
View File
@@ -1,9 +1,36 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { applyAutoSell } from '../autoSell.js';
import { applyGainMultiplier } from '../prestige.js';
const router = Router();
function serializeInv(inv) {
return {
id: inv.id,
obtainedAt: inv.obtainedAt,
locked: inv.locked,
floatValue: inv.floatValue,
wear: inv.wear,
paintSeed: inv.paintSeed,
valueCents: inv.valueCents,
favorite: Boolean(inv.favorite),
item: {
id: inv.item.id,
name: inv.item.name,
imageUrl: inv.item.imageUrl,
rarity: inv.item.rarity,
marketValue: inv.item.marketValue,
// Instance price for UI (prefer over catalog FT reference)
valueCents: inv.valueCents,
floatValue: inv.floatValue,
wear: inv.wear,
paintSeed: inv.paintSeed,
},
};
}
router.get('/', requireAuth, async (req, res) => {
try {
const items = await prisma.inventoryItem.findMany({
@@ -13,18 +40,7 @@ router.get('/', requireAuth, async (req, res) => {
});
res.json({
inventory: items.map((inv) => ({
id: inv.id,
obtainedAt: inv.obtainedAt,
locked: inv.locked,
item: {
id: inv.item.id,
name: inv.item.name,
imageUrl: inv.item.imageUrl,
rarity: inv.item.rarity,
marketValue: inv.item.marketValue,
},
})),
inventory: items.map(serializeInv),
});
} catch (err) {
console.error(err);
@@ -32,6 +48,88 @@ router.get('/', requireAuth, async (req, res) => {
}
});
router.patch('/auto-sell', requireAuth, async (req, res) => {
try {
const data = {};
if (req.body.enabled != null) {
data.autoSellEnabled = Boolean(req.body.enabled);
}
if (req.body.thresholdCredits != null) {
const credits = Number(req.body.thresholdCredits);
if (!Number.isFinite(credits) || credits < 0) {
return res.status(400).json({ error: 'Invalid threshold' });
}
data.autoSellThresholdCents = Math.round(credits * 100);
}
if (!Object.keys(data).length) {
return res.status(400).json({ error: 'Nothing to update' });
}
const user = await prisma.user.update({
where: { id: req.session.userId },
data,
});
res.json({ user: publicUser(user) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to update auto-sell settings' });
}
});
router.post('/auto-sell/apply', requireAuth, async (req, res) => {
try {
const ids = [...new Set((req.body.inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) {
return res.json({ user: publicUser(await prisma.user.findUnique({ where: { id: req.session.userId } })), soldIds: [], total: 0 });
}
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
const invItems = await tx.inventoryItem.findMany({
where: {
id: { in: ids },
userId: req.session.userId,
locked: false,
},
});
const { sold, balanceDelta } = await applyAutoSell(tx, user, invItems);
if (!balanceDelta) {
return { user, soldIds: [], total: 0 };
}
const updatedUser = await tx.user.update({
where: { id: req.session.userId },
data: { balance: { increment: balanceDelta } },
});
return {
user: updatedUser,
soldIds: sold.map((inv) => inv.id),
total: balanceDelta,
};
});
res.json({
user: publicUser(result.user),
soldIds: result.soldIds,
total: result.total,
});
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to apply auto-sell' });
}
});
router.post('/sell', requireAuth, async (req, res) => {
try {
const ids = [...new Set((req.body.inventoryItemIds || []).map(Number).filter(Boolean))];
@@ -39,6 +137,93 @@ router.post('/sell', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Select at least one item to sell' });
}
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
const invItems = await tx.inventoryItem.findMany({
where: {
id: { in: ids },
userId: req.session.userId,
locked: false,
},
include: { item: true },
});
if (invItems.length !== ids.length) {
const err = new Error('Invalid or locked inventory items');
err.status = 400;
throw err;
}
if (invItems.some((inv) => inv.favorite)) {
const err = new Error('Favorite items cannot be sold');
err.status = 400;
throw err;
}
const gainBonus = user.prestigeGainBonus ?? 0;
let total = 0;
for (const inv of invItems) {
const payout = applyGainMultiplier(inv.valueCents, gainBonus);
total += payout;
await tx.inventoryItem.delete({ where: { id: inv.id } });
await tx.transaction.create({
data: {
userId: req.session.userId,
type: 'sell_item',
amount: payout,
meta: JSON.stringify({
inventoryItemId: inv.id,
itemId: inv.itemId,
itemName: inv.item.name,
valueCents: inv.valueCents,
floatValue: inv.floatValue,
wear: inv.wear,
paintSeed: inv.paintSeed,
prestigeGainBonus: gainBonus,
payout,
}),
},
});
}
const updatedUser = await tx.user.update({
where: { id: req.session.userId },
data: { balance: { increment: total } },
});
return { user: updatedUser, total, soldCount: invItems.length };
});
res.json({
user: publicUser(result.user),
total: result.total,
soldCount: result.soldCount,
});
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to sell items' });
}
});
router.patch('/favorite', requireAuth, async (req, res) => {
try {
const ids = [...new Set((req.body.inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) {
return res.status(400).json({ error: 'Select at least one item' });
}
if (req.body.favorite == null) {
return res.status(400).json({ error: 'favorite flag required' });
}
const favorite = Boolean(req.body.favorite);
const result = await prisma.$transaction(async (tx) => {
const invItems = await tx.inventoryItem.findMany({
where: {
@@ -55,42 +240,24 @@ router.post('/sell', requireAuth, async (req, res) => {
throw err;
}
const total = invItems.reduce((sum, inv) => sum + inv.item.marketValue, 0);
for (const inv of invItems) {
await tx.inventoryItem.delete({ where: { id: inv.id } });
await tx.transaction.create({
data: {
userId: req.session.userId,
type: 'sell_item',
amount: inv.item.marketValue,
meta: JSON.stringify({
inventoryItemId: inv.id,
itemId: inv.itemId,
itemName: inv.item.name,
valueCents: inv.item.marketValue,
}),
},
});
}
const user = await tx.user.update({
where: { id: req.session.userId },
data: { balance: { increment: total } },
await tx.inventoryItem.updateMany({
where: { id: { in: ids }, userId: req.session.userId },
data: { favorite },
});
return { user, total, soldCount: invItems.length };
const updated = await tx.inventoryItem.findMany({
where: { id: { in: ids }, userId: req.session.userId },
include: { item: true },
});
return updated;
});
res.json({
user: publicUser(result.user),
total: result.total,
soldCount: result.soldCount,
});
res.json({ inventory: result.map(serializeInv) });
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to sell items' });
res.status(status).json({ error: err.message || 'Failed to update favorites' });
}
});
+2 -1
View File
@@ -17,12 +17,13 @@ router.get('/', async (_req, res) => {
const ranked = users
.map((u) => {
const inventoryValue = u.inventory.reduce(
(sum, inv) => sum + inv.item.marketValue,
(sum, inv) => sum + inv.valueCents,
0
);
return {
id: u.id,
username: u.username,
avatarUrl: u.avatarUrl || '',
balance: u.balance,
inventoryValue,
totalWealth: u.balance + inventoryValue,
+168
View File
@@ -0,0 +1,168 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import {
PRESTIGE_CHOICES,
PRESTIGE_COST_CENTS,
getUnlockedInventoryValueCents,
isPrestigePageVisible,
isValidPrestigeChoice,
serializePrestige,
} from '../prestige.js';
import { checkAchievements } from '../achievements.js';
const router = Router();
const COST = Number(PRESTIGE_COST_CENTS);
router.get('/', requireAuth, async (req, res) => {
try {
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
if (!user) return res.status(404).json({ error: 'User not found' });
const [lockedCount, inventoryValue, logs] = await Promise.all([
prisma.inventoryItem.count({
where: { userId: user.id, locked: true },
}),
getUnlockedInventoryValueCents(prisma, user.id),
prisma.prestigeLog.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
take: 20,
}),
]);
const totalCents = (Number(user.balance) || 0) + inventoryValue;
const pageVisible = isPrestigePageVisible(user, totalCents);
const canAfford = user.balance >= COST;
const canPrestige = pageVisible && canAfford && lockedCount === 0;
res.json({
prestige: serializePrestige(user),
pageVisible,
totalCents,
canAfford,
lockedItems: lockedCount,
canPrestige,
choices: [
{
id: 'chance',
title: 'Chance',
description: '+1% catalog-style drop luck (permanent, stacks)',
delta: PRESTIGE_CHOICES.chance.delta,
current: user.prestigeChanceBonus ?? 0,
},
{
id: 'gain',
title: 'Gain multiplier',
description: '+2% credits on sell / auto-sell (permanent, stacks)',
delta: PRESTIGE_CHOICES.gain.delta,
current: user.prestigeGainBonus ?? 0,
},
{
id: 'key',
title: 'Key progress',
description: '10 opens required for next key on all cases (permanent, stacks)',
delta: PRESTIGE_CHOICES.key.delta,
current: user.prestigeKeyOpenReduction ?? 0,
},
],
history: logs.map((l) => ({
id: l.id,
choice: l.choice,
createdAt: l.createdAt,
})),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load prestige' });
}
});
router.post('/', requireAuth, async (req, res) => {
try {
const choice = String(req.body?.choice || '');
if (!isValidPrestigeChoice(choice)) {
return res.status(400).json({ error: 'Choose chance, gain, or key' });
}
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
if (user.role === 'admin') {
const err = new Error('Admins cannot prestige');
err.status = 403;
throw err;
}
if (user.balance < COST) {
const err = new Error('Insufficient balance for prestige');
err.status = 400;
throw err;
}
const lockedCount = await tx.inventoryItem.count({
where: { userId: user.id, locked: true },
});
if (lockedCount > 0) {
const err = new Error(
'Withdraw items from battles before prestiging (locked inventory)'
);
err.status = 400;
throw err;
}
const boost = PRESTIGE_CHOICES[choice];
const prestigeData = {
prestigeCount: (user.prestigeCount ?? 0) + 1,
[boost.field]: (user[boost.field] ?? 0) + boost.delta,
balance: 0,
};
await tx.inventoryItem.deleteMany({ where: { userId: user.id } });
await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } });
await tx.catalogEntry.deleteMany({ where: { userId: user.id } });
await tx.transaction.create({
data: {
userId: user.id,
type: 'prestige',
amount: -COST,
meta: JSON.stringify({
choice,
prestigeCount: prestigeData.prestigeCount,
delta: boost.delta,
field: boost.field,
}),
},
});
await tx.prestigeLog.create({
data: { userId: user.id, choice },
});
const updated = await tx.user.update({
where: { id: user.id },
data: prestigeData,
});
return updated;
});
checkAchievements(result.id).catch((err) => console.error('achievements', err));
res.json({
user: publicUser(result),
prestige: serializePrestige(result),
choice,
});
} catch (err) {
console.error(err);
const status = err.status || 500;
res.status(status).json({ error: err.message || 'Failed to prestige' });
}
});
export default router;
+136 -12
View File
@@ -6,6 +6,13 @@ import fs from 'fs';
import { fileURLToPath } from 'url';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { getCompletedCatalogBadges } from '../catalog.js';
import { serializePrestige } from '../prestige.js';
import { serializePresence } from '../presence.js';
import {
checkAchievements,
getUnlockedAchievements,
} from '../achievements.js';
const router = Router();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -35,6 +42,111 @@ const upload = multer({
},
});
async function loadCaseOpenStats(userId) {
const rows = await prisma.caseKeyProgress.findMany({
where: { userId, totalOpens: { gt: 0 } },
include: {
case: { select: { id: true, name: true, imageUrl: true } },
},
orderBy: { totalOpens: 'desc' },
});
return rows.map((row) => ({
caseId: row.caseId,
caseName: row.case.name,
imageUrl: row.case.imageUrl || '',
count: row.totalOpens,
keys: row.keys,
}));
}
function serializeInv(inv) {
return {
id: inv.id,
obtainedAt: inv.obtainedAt,
locked: inv.locked,
floatValue: inv.floatValue,
wear: inv.wear,
paintSeed: inv.paintSeed,
valueCents: inv.valueCents,
favorite: Boolean(inv.favorite),
item: {
id: inv.item.id,
name: inv.item.name,
imageUrl: inv.item.imageUrl,
rarity: inv.item.rarity,
marketValue: inv.item.marketValue,
valueCents: inv.valueCents,
floatValue: inv.floatValue,
wear: inv.wear,
paintSeed: inv.paintSeed,
},
};
}
router.get('/player/:username', async (req, res) => {
try {
const username = String(req.params.username || '').trim();
if (!username) {
return res.status(400).json({ error: 'Username required' });
}
const user = await prisma.user.findFirst({
where: { username, role: 'user' },
include: {
inventory: {
where: { locked: false },
include: { item: true },
orderBy: { obtainedAt: 'desc' },
},
},
});
if (!user) return res.status(404).json({ error: 'Player not found' });
const inventoryValue = user.inventory.reduce((sum, inv) => sum + inv.valueCents, 0);
await checkAchievements(user.id);
const [jackpotWins, duelWins, casesOpened, caseOpens, catalog, achievements] =
await Promise.all([
prisma.transaction.count({
where: { userId: user.id, type: 'jackpot_win' },
}),
prisma.transaction.count({
where: { userId: user.id, type: 'duel_win' },
}),
prisma.transaction.count({
where: { userId: user.id, type: 'open_case' },
}),
loadCaseOpenStats(user.id),
getCompletedCatalogBadges(user.id),
getUnlockedAchievements(user.id),
]);
res.json({
user: publicUser(user),
profile: {
createdAt: user.createdAt,
inventoryValue,
inventoryCount: user.inventory.length,
jackpotWins,
duelWins,
casesOpened,
caseOpens,
catalogCompleted: catalog.completed,
catalogLuckPercent: catalog.luckPercent,
prestige: serializePrestige(user),
presence: serializePresence(user),
achievements,
},
inventory: user.inventory.map(serializeInv),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load player profile' });
}
});
router.get('/', requireAuth, async (req, res) => {
try {
const user = await prisma.user.findUnique({
@@ -46,21 +158,27 @@ router.get('/', requireAuth, async (req, res) => {
if (!user) return res.status(404).json({ error: 'User not found' });
const inventoryValue = user.inventory.reduce(
(sum, inv) => sum + (inv.locked ? 0 : inv.item.marketValue),
(sum, inv) => sum + (inv.locked ? 0 : inv.valueCents),
0
);
const [jackpotWins, duelWins, casesOpened] = await Promise.all([
prisma.transaction.count({
where: { userId: user.id, type: 'jackpot_win' },
}),
prisma.transaction.count({
where: { userId: user.id, type: 'duel_win' },
}),
prisma.transaction.count({
where: { userId: user.id, type: 'open_case' },
}),
]);
await checkAchievements(user.id);
const [jackpotWins, duelWins, casesOpened, caseOpens, catalog, achievements] =
await Promise.all([
prisma.transaction.count({
where: { userId: user.id, type: 'jackpot_win' },
}),
prisma.transaction.count({
where: { userId: user.id, type: 'duel_win' },
}),
prisma.transaction.count({
where: { userId: user.id, type: 'open_case' },
}),
loadCaseOpenStats(user.id),
getCompletedCatalogBadges(user.id),
getUnlockedAchievements(user.id),
]);
res.json({
user: publicUser(user),
@@ -71,6 +189,12 @@ router.get('/', requireAuth, async (req, res) => {
jackpotWins,
duelWins,
casesOpened,
caseOpens,
catalogCompleted: catalog.completed,
catalogLuckPercent: catalog.luckPercent,
prestige: serializePrestige(user),
presence: serializePresence(user),
achievements,
},
});
} catch (err) {
+5
View File
@@ -1,6 +1,7 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { checkAchievements } from '../achievements.js';
const router = Router();
@@ -88,6 +89,8 @@ router.post('/buy', requireAuth, async (req, res) => {
return updated;
});
checkAchievements(result.id).catch((err) => console.error('achievements', err));
res.json({ user: publicUser(result), package: pack });
} catch (err) {
const status = err.status || 500;
@@ -140,6 +143,8 @@ router.post('/ad', requireAuth, async (req, res) => {
return { user: updated, reward };
});
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
res.json({
user: publicUser(result.user),
reward: result.reward,
+94 -5
View File
@@ -1,9 +1,14 @@
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 {
@@ -36,7 +41,11 @@ export function serializeDuelRoom(room) {
name: d.inventoryItem.item.name,
imageUrl: d.inventoryItem.item.imageUrl,
rarity: d.inventoryItem.item.rarity,
marketValue: d.inventoryItem.item.marketValue,
marketValue: d.valueCents,
valueCents: d.valueCents,
wear: d.inventoryItem.wear,
floatValue: d.inventoryItem.floatValue,
paintSeed: d.inventoryItem.paintSeed,
}
: null,
}));
@@ -95,6 +104,7 @@ export async function getDuelRoom(id) {
}
export async function listDuelRooms() {
await purgeStaleEmptyDuelRooms();
const rooms = await prisma.duelRoom.findMany({
where: { status: { in: ['open', 'in_progress', 'spinning'] } },
include: roomInclude(),
@@ -107,6 +117,57 @@ export async function listDuelRooms() {
};
}
/**
* 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);
@@ -188,7 +249,7 @@ export async function placeDuelBet(userId, roomId, inventoryItemIds) {
}
for (const inv of invItems) {
if (inv.item.marketValue < current.minItemValue) {
if (inv.valueCents < current.minItemValue) {
throw Object.assign(
new Error(`Items must be worth at least ${current.minItemValue} cents`),
{ status: 400 }
@@ -203,7 +264,7 @@ export async function placeDuelBet(userId, roomId, inventoryItemIds) {
roomId: current.id,
userId,
inventoryItemId: inv.id,
valueCents: inv.item.marketValue,
valueCents: inv.valueCents,
},
});
await tx.transaction.create({
@@ -215,7 +276,9 @@ export async function placeDuelBet(userId, roomId, inventoryItemIds) {
roomId: current.id,
inventoryItemId: inv.id,
itemId: inv.itemId,
valueCents: inv.item.marketValue,
valueCents: inv.valueCents,
wear: inv.wear,
floatValue: inv.floatValue,
}),
},
});
@@ -401,6 +464,7 @@ function scheduleDuelFinish(roomId) {
async function finishDuelSpin(roomId) {
pendingSpins.delete(roomId);
let winnerId = null;
const room = await prisma.$transaction(async (tx) => {
const current = await tx.duelRoom.findUnique({
@@ -409,7 +473,7 @@ async function finishDuelSpin(roomId) {
});
if (!current || current.status !== 'spinning') return null;
const winnerId = current.winnerUserId;
winnerId = current.winnerUserId;
for (const d of current.deposits) {
await tx.inventoryItem.update({
where: { id: d.inventoryItemId },
@@ -417,6 +481,19 @@ async function finishDuelSpin(roomId) {
});
}
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: {
@@ -426,10 +503,18 @@ async function finishDuelSpin(roomId) {
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() },
@@ -439,6 +524,10 @@ async function finishDuelSpin(roomId) {
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);
+217 -26
View File
@@ -1,14 +1,28 @@
import { prisma } from '../db.js';
import { pickWeighted } from '../weighted.js';
import { applyAutoSell } from '../autoSell.js';
import { emitJackpot } from '../realtime.js';
import { pushDrop } from '../feed.js';
import { checkAchievements } from '../achievements.js';
const ROUND_SECONDS = Number(process.env.JACKPOT_ROUND_SECONDS || 60);
const ROUND_SECONDS = Number(process.env.JACKPOT_ROUND_SECONDS || 30);
const MAX_ITEMS = Number(process.env.JACKPOT_MAX_ITEMS || 3);
const MIN_PLAYERS = 2;
const SPIN_MS = 5500;
/** Sentinel endsAt while waiting for enough players (timer not armed). */
const WAITING_ENDS_AT = new Date('2099-01-01T00:00:00.000Z');
let resolveTimer = null;
let spinFollowUp = null;
function isTimerWaiting(endsAt) {
return new Date(endsAt).getFullYear() >= 2099;
}
function uniquePlayerCount(deposits) {
return new Set((deposits || []).map((d) => d.userId)).size;
}
function depositInclude() {
return {
user: { select: { id: true, username: true, avatarUrl: true } },
@@ -32,7 +46,11 @@ export function serializeJackpotRound(round) {
name: d.inventoryItem.item.name,
imageUrl: d.inventoryItem.item.imageUrl,
rarity: d.inventoryItem.item.rarity,
marketValue: d.inventoryItem.item.marketValue,
marketValue: d.valueCents,
valueCents: d.valueCents,
wear: d.inventoryItem.wear,
floatValue: d.inventoryItem.floatValue,
paintSeed: d.inventoryItem.paintSeed,
}
: null,
}));
@@ -58,10 +76,16 @@ export function serializeJackpotRound(round) {
chance: totalValue > 0 ? (p.valueCents / totalValue) * 100 : 0,
}));
const waitingForPlayers =
players.length < MIN_PLAYERS || isTimerWaiting(round.endsAt);
return {
id: round.id,
status: round.status,
endsAt: round.endsAt,
endsAt: waitingForPlayers ? null : round.endsAt,
waitingForPlayers,
roundSeconds: ROUND_SECONDS,
minPlayers: MIN_PLAYERS,
createdAt: round.createdAt,
resolvedAt: round.resolvedAt || null,
winnerUserId: round.winnerUserId || null,
@@ -105,9 +129,8 @@ export async function broadcastJackpot(extra = {}) {
}
async function createOpenRound() {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
return prisma.jackpotRound.create({
data: { status: 'open', endsAt },
data: { status: 'open', endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
@@ -115,11 +138,15 @@ async function createOpenRound() {
});
}
function clearTimers() {
function clearResolveTimer() {
if (resolveTimer) {
clearTimeout(resolveTimer);
resolveTimer = null;
}
}
function clearTimers() {
clearResolveTimer();
if (spinFollowUp) {
clearTimeout(spinFollowUp);
spinFollowUp = null;
@@ -127,7 +154,8 @@ function clearTimers() {
}
function scheduleResolve(endsAt) {
clearTimers();
clearResolveTimer();
if (isTimerWaiting(endsAt)) return;
const delay = Math.max(0, new Date(endsAt).getTime() - Date.now());
resolveTimer = setTimeout(() => {
resolveJackpotRound().catch((err) => console.error('jackpot resolve', err));
@@ -137,6 +165,7 @@ function scheduleResolve(endsAt) {
export async function ensureJackpotRound() {
let round = await prisma.jackpotRound.findFirst({
where: { status: 'open' },
include: { deposits: true },
orderBy: { id: 'desc' },
});
@@ -146,14 +175,39 @@ export async function ensureJackpotRound() {
orderBy: { id: 'desc' },
});
if (spinning) {
// Server restarted mid-spin — finish after a short delay
spinFollowUp = setTimeout(() => {
finishJackpotSpin(spinning.id).catch((err) => console.error(err));
}, 1000);
return getJackpotState();
}
round = await createOpenRound();
} else if (new Date(round.endsAt).getTime() <= Date.now()) {
return getJackpotState();
}
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();
return getJackpotState();
}
if (isTimerWaiting(round.endsAt)) {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
await prisma.jackpotRound.update({
where: { id: round.id },
data: { endsAt },
});
scheduleResolve(endsAt);
return getJackpotState();
}
if (new Date(round.endsAt).getTime() <= Date.now()) {
await resolveJackpotRound();
return getJackpotState();
}
@@ -179,7 +233,7 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
if (!open) {
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
}
if (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 });
}
@@ -211,7 +265,7 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
roundId: open.id,
userId,
inventoryItemId: inv.id,
valueCents: inv.item.marketValue,
valueCents: inv.valueCents,
},
});
await tx.transaction.create({
@@ -223,21 +277,48 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
roundId: open.id,
inventoryItemId: inv.id,
itemId: inv.itemId,
valueCents: inv.item.marketValue,
valueCents: inv.valueCents,
wear: inv.wear,
floatValue: inv.floatValue,
}),
},
});
}
return tx.jackpotRound.findUnique({
let next = await tx.jackpotRound.findUnique({
where: { id: open.id },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
// Arm countdown once we have enough distinct players
if (
uniquePlayerCount(next.deposits) >= MIN_PLAYERS &&
isTimerWaiting(next.endsAt)
) {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
next = await tx.jackpotRound.update({
where: { id: next.id },
data: { endsAt },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
}
return next;
});
if (
uniquePlayerCount(round.deposits) >= MIN_PLAYERS &&
!isTimerWaiting(round.endsAt)
) {
scheduleResolve(round.endsAt);
}
const state = serializeJackpotRound(round);
emitJackpot('jackpot:state', state);
return state;
@@ -257,7 +338,7 @@ export async function withdrawJackpotBet(userId, inventoryItemIds) {
if (!open) {
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
}
if (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 });
}
@@ -280,31 +361,53 @@ export async function withdrawJackpotBet(userId, inventoryItemIds) {
});
}
return tx.jackpotRound.findUnique({
let next = await tx.jackpotRound.findUnique({
where: { id: open.id },
include: {
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)) {
next = await tx.jackpotRound.update({
where: { id: next.id },
data: { endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
}
return next;
});
if (isTimerWaiting(round.endsAt) || uniquePlayerCount(round.deposits) < MIN_PLAYERS) {
clearResolveTimer();
}
const state = serializeJackpotRound(round);
emitJackpot('jackpot:state', state);
return state;
}
async function finishJackpotSpin(roundId) {
let feedEntry = null;
let winnerId = null;
const result = await prisma.$transaction(async (tx) => {
const round = await tx.jackpotRound.findUnique({
where: { id: roundId },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true, avatarUrl: true } },
},
});
if (!round || round.status !== 'spinning') return null;
const winnerId = round.winnerUserId;
winnerId = round.winnerUserId;
for (const d of round.deposits) {
await tx.inventoryItem.update({
where: { id: d.inventoryItemId },
@@ -312,6 +415,19 @@ async function finishJackpotSpin(roundId) {
});
}
const acquiredIds = round.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 = round.deposits.reduce((s, d) => s + d.valueCents, 0);
await tx.transaction.create({
data: {
@@ -321,22 +437,86 @@ async function finishJackpotSpin(roundId) {
meta: JSON.stringify({
roundId: round.id,
depositIds: round.deposits.map((d) => d.id),
autoSellTotal,
}),
},
});
if (autoSellTotal > 0) {
await tx.user.update({
where: { id: winnerId },
data: { balance: { increment: autoSellTotal } },
});
}
await tx.jackpotRound.update({
where: { id: round.id },
data: { status: 'resolved', resolvedAt: new Date() },
});
// Best item in the pot (highest instance value) for Feed Drop, with xN count
let best = null;
for (const d of round.deposits) {
const inv = d.inventoryItem;
const item = inv?.item;
if (!item) continue;
if (!best || d.valueCents > best.deposit.valueCents) {
best = { deposit: d, item, inv };
}
}
const winnerUser =
round.winner ||
round.deposits.find((d) => d.userId === winnerId)?.user ||
null;
if (best && winnerUser) {
const count = round.deposits.filter(
(d) => d.inventoryItem?.itemId === best.item.id
).length;
feedEntry = {
source: 'jackpot',
caseName: 'MultiPlayers Case',
count,
openCount: round.deposits.length,
inventoryIds: round.deposits.map((d) => d.inventoryItemId).filter(Boolean),
username: winnerUser.username,
avatarUrl: winnerUser.avatarUrl || '',
item: {
inventoryId: best.deposit.inventoryItemId,
id: best.item.id,
name: best.item.name,
imageUrl: best.item.imageUrl,
rarity: best.item.rarity,
marketValue: best.deposit.valueCents,
valueCents: best.deposit.valueCents,
wear: best.inv.wear,
floatValue: best.inv.floatValue,
paintSeed: best.inv.paintSeed,
},
};
}
return createOpenRoundInTx(tx);
});
if (!result) return;
if (winnerId) {
checkAchievements(winnerId).catch((err) => console.error('achievements', err));
}
if (feedEntry) {
try {
pushDrop(feedEntry);
} catch (err) {
console.error('jackpot feed drop', err);
}
}
const state = serializeJackpotRound(result);
scheduleResolve(result.endsAt);
// New round waits for players — do not arm timer yet
clearResolveTimer();
emitJackpot('jackpot:result', {
previousRoundId: roundId,
...state,
@@ -345,9 +525,8 @@ async function finishJackpotSpin(roundId) {
}
async function createOpenRoundInTx(tx) {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
return tx.jackpotRound.create({
data: { status: 'open', endsAt },
data: { status: 'open', endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
@@ -368,17 +547,29 @@ export async function resolveJackpotRound() {
});
if (!open) return null;
if (!open.deposits.length) {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
const extended = await tx.jackpotRound.update({
const players = uniquePlayerCount(open.deposits);
if (players < MIN_PLAYERS) {
const waiting = await tx.jackpotRound.update({
where: { id: open.id },
data: { endsAt },
data: { endsAt: WAITING_ENDS_AT },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
return { kind: 'extend', updated: extended };
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 };
}
const byUser = new Map();
@@ -408,9 +599,9 @@ export async function resolveJackpotRound() {
if (!spun) return;
if (spun.kind === 'extend') {
if (spun.kind === 'waiting' || spun.kind === 'extend') {
const state = serializeJackpotRound(spun.updated);
scheduleResolve(state.endsAt);
clearResolveTimer();
emitJackpot('jackpot:state', state);
return state;
}
+122
View File
@@ -0,0 +1,122 @@
/**
* CS-style float / wear for inventory instances.
*
* Item.marketValue = Field-Tested reference price (mid-band 1.00×).
* Per-drop float f [0, 1) drives wear grade and price multiplier.
*/
export const WEAR_BANDS = [
{
wear: 'Factory New',
min: 0,
max: 0.07,
multBest: 1.5,
multWorst: 1.35,
},
{
wear: 'Minimal Wear',
min: 0.07,
max: 0.15,
multBest: 1.3,
multWorst: 1.15,
},
{
wear: 'Field-Tested',
min: 0.15,
max: 0.38,
multBest: 1.05,
multWorst: 0.8,
},
{
wear: 'Well-Worn',
min: 0.38,
max: 0.45,
multBest: 0.8,
multWorst: 0.6,
},
{
wear: 'Battle-Scarred',
min: 0.45,
max: 1,
multBest: 0.55,
multWorst: 0.4,
},
];
/** Mid Field-Tested float → ~1.00× catalog price */
export const FT_MID_FLOAT = 0.265;
export function clampFloat(f) {
const n = Number(f);
if (!Number.isFinite(n)) return 0;
if (n < 0) return 0;
if (n >= 1) return 0.999999;
return n;
}
export function wearFromFloat(floatValue) {
const f = clampFloat(floatValue);
for (const band of WEAR_BANDS) {
if (f >= band.min && f < band.max) return band.wear;
}
return 'Battle-Scarred';
}
function bandForFloat(floatValue) {
const f = clampFloat(floatValue);
for (const band of WEAR_BANDS) {
if (f >= band.min && f < band.max) return band;
}
return WEAR_BANDS[WEAR_BANDS.length - 1];
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
/** Position in wear band: 0 = best (low float), 1 = worst (high float) */
export function priceMultiplier(floatValue) {
const f = clampFloat(floatValue);
const band = bandForFloat(f);
const span = band.max - band.min;
// Field-Tested: pin mid-band to 1.00× catalog (better half → 1.05, worse → 0.80)
if (band.wear === 'Field-Tested') {
const mid = (band.min + band.max) / 2;
if (f <= mid) {
const t = mid > band.min ? (f - band.min) / (mid - band.min) : 0;
return lerp(band.multBest, 1, Math.min(1, Math.max(0, t)));
}
const t = band.max > mid ? (f - mid) / (band.max - mid) : 0;
return lerp(1, band.multWorst, Math.min(1, Math.max(0, t)));
}
const t = span > 0 ? (f - band.min) / span : 0;
return lerp(band.multBest, band.multWorst, Math.min(1, Math.max(0, t)));
}
export function computeValueCents(baseMarketValue, floatValue) {
const base = Math.max(0, Math.floor(Number(baseMarketValue) || 0));
if (base <= 0) return 0;
const cents = Math.round(base * priceMultiplier(floatValue));
return Math.max(1, cents);
}
export function rollFloat() {
return Math.random();
}
export function rollPaintSeed() {
return Math.floor(Math.random() * 1000);
}
/** Full roll for a new inventory instance from catalog marketValue */
export function rollInstanceValue(baseMarketValue) {
const floatValue = rollFloat();
return {
floatValue,
wear: wearFromFloat(floatValue),
paintSeed: rollPaintSeed(),
valueCents: computeValueCents(baseMarketValue, floatValue),
};
}
+20
View File
@@ -22,3 +22,23 @@ export function normalizeRates(entries) {
chance: (e.dropRate / total) * 100,
}));
}
/**
* Boost drop rates below the median by (1 + luckPercent/100).
* Same factor on all weights would not change odds only rares get the boost.
*/
export function applyCatalogLuck(entries, luckPercent = 0) {
const luck = Number(luckPercent) || 0;
if (luck <= 0 || !entries?.length) return entries;
const rates = entries.map((e) => e.dropRate).sort((a, b) => a - b);
const mid = Math.floor(rates.length / 2);
const median =
rates.length % 2 === 0 ? (rates[mid - 1] + rates[mid]) / 2 : rates[mid];
const factor = 1 + luck / 100;
return entries.map((e) => ({
...e,
dropRate: e.dropRate < median ? e.dropRate * factor : e.dropRate,
}));
}