Initial PlayBull release with Trilogy Hub integration.
Includes homelab deploy tooling, image position/zoom manifest persistence, and full trading/casino stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
70
server/auth.js
Normal file
70
server/auth.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from './config.js';
|
||||
import { Users } from './db.js';
|
||||
|
||||
const COOKIE = 'pb_token';
|
||||
|
||||
export function signToken(user) {
|
||||
return jwt.sign({ id: user.id, username: user.username }, config.JWT_SECRET, { expiresIn: '30d' });
|
||||
}
|
||||
|
||||
export function setAuthCookie(res, token) {
|
||||
res.cookie(COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearAuthCookie(res) {
|
||||
res.clearCookie(COOKIE);
|
||||
}
|
||||
|
||||
function readToken(req) {
|
||||
const c = req.cookies?.[COOKIE];
|
||||
if (c) return c;
|
||||
const h = req.headers.authorization;
|
||||
if (h && h.startsWith('Bearer ')) return h.slice(7);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Haengt req.user an, wenn eingeloggt (sonst null). Blockiert nicht.
|
||||
export function optionalAuth(req, _res, next) {
|
||||
req.user = null;
|
||||
const token = readToken(req);
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.JWT_SECRET);
|
||||
const u = Users.byId(decoded.id);
|
||||
if (u) req.user = u;
|
||||
} catch { /* ungueltig -> Gast */ }
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Erzwingt Login (fuer Gamble & Trades).
|
||||
export function requireAuth(req, res, next) {
|
||||
if (!req.user) return res.status(401).json({ error: 'Login erforderlich' });
|
||||
next();
|
||||
}
|
||||
|
||||
export async function registerUser(username, password) {
|
||||
username = String(username || '').trim();
|
||||
if (username.length < 3 || username.length > 20) throw new Error('Username: 3-20 Zeichen');
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(username)) throw new Error('Nur Buchstaben, Zahlen, _');
|
||||
if (String(password || '').length < 4) throw new Error('Passwort: min. 4 Zeichen');
|
||||
if (Users.byName(username)) throw new Error('Username bereits vergeben');
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
return Users.create(username, hash, config.START_BALANCE);
|
||||
}
|
||||
|
||||
export async function loginUser(username, password) {
|
||||
const u = Users.byName(String(username || '').trim());
|
||||
if (!u) throw new Error('Falscher Username oder Passwort');
|
||||
const ok = await bcrypt.compare(String(password || ''), u.password_hash);
|
||||
if (!ok) throw new Error('Falscher Username oder Passwort');
|
||||
return u;
|
||||
}
|
||||
|
||||
export { COOKIE };
|
||||
40
server/config.js
Normal file
40
server/config.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// Minimaler .env Parser (keine externe Abhaengigkeit noetig)
|
||||
function loadEnv() {
|
||||
const file = path.join(ROOT, '.env');
|
||||
if (!fs.existsSync(file)) return;
|
||||
const txt = fs.readFileSync(file, 'utf8');
|
||||
for (const line of txt.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const idx = trimmed.indexOf('=');
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const val = trimmed.slice(idx + 1).trim();
|
||||
if (!(key in process.env)) process.env[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnv();
|
||||
|
||||
export const config = {
|
||||
ROOT,
|
||||
PORT: Number(process.env.PORT || 3000),
|
||||
JWT_SECRET: process.env.JWT_SECRET || 'dev-secret-change-me',
|
||||
FINNHUB_API_KEY: process.env.FINNHUB_API_KEY || '',
|
||||
START_BALANCE: Number(process.env.START_BALANCE || 10000),
|
||||
DATA_DIR: path.join(ROOT, 'data'),
|
||||
PUBLIC_DIR: path.join(ROOT, 'public'),
|
||||
UPLOADS_DIR: path.join(ROOT, 'data', 'uploads', 'avatars'),
|
||||
FILES_DIR: path.join(ROOT, 'data', 'uploads', 'files'),
|
||||
};
|
||||
|
||||
if (!fs.existsSync(config.DATA_DIR)) fs.mkdirSync(config.DATA_DIR, { recursive: true });
|
||||
if (!fs.existsSync(config.UPLOADS_DIR)) fs.mkdirSync(config.UPLOADS_DIR, { recursive: true });
|
||||
if (!fs.existsSync(config.FILES_DIR)) fs.mkdirSync(config.FILES_DIR, { recursive: true });
|
||||
208
server/db.js
Normal file
208
server/db.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import path from 'node:path';
|
||||
import { config } from './config.js';
|
||||
|
||||
const db = new DatabaseSync(path.join(config.DATA_DIR, 'playbull.db'));
|
||||
|
||||
db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
balance REAL NOT NULL DEFAULT 0,
|
||||
avatar TEXT DEFAULT '🐂',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS holdings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
quantity REAL NOT NULL DEFAULT 0,
|
||||
avg_price REAL NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id, symbol)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
qty REAL NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
ts INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
game TEXT NOT NULL,
|
||||
bet REAL NOT NULL,
|
||||
payout REAL NOT NULL,
|
||||
result TEXT,
|
||||
ts INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feed (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uploads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
frame TEXT NOT NULL DEFAULT 'gallery',
|
||||
focus_x REAL DEFAULT 0.5,
|
||||
focus_y REAL DEFAULT 0.5,
|
||||
zoom REAL DEFAULT 1.0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: Avatar-Bild + Positions-Metadaten
|
||||
const userCols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name);
|
||||
if (!userCols.includes('avatar_image')) db.exec('ALTER TABLE users ADD COLUMN avatar_image TEXT');
|
||||
if (!userCols.includes('avatar_focus_x')) db.exec('ALTER TABLE users ADD COLUMN avatar_focus_x REAL DEFAULT 0.5');
|
||||
if (!userCols.includes('avatar_focus_y')) db.exec('ALTER TABLE users ADD COLUMN avatar_focus_y REAL DEFAULT 0.5');
|
||||
if (!userCols.includes('avatar_zoom')) db.exec('ALTER TABLE users ADD COLUMN avatar_zoom REAL DEFAULT 1.0');
|
||||
|
||||
export default db;
|
||||
|
||||
// ---- User Helpers ----
|
||||
export const Users = {
|
||||
create(username, hash, balance) {
|
||||
const info = db.prepare(
|
||||
'INSERT INTO users (username, password_hash, balance, created_at) VALUES (?,?,?,?)'
|
||||
).run(username, hash, balance, Date.now());
|
||||
return this.byId(info.lastInsertRowid);
|
||||
},
|
||||
byId(id) {
|
||||
return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
},
|
||||
byName(name) {
|
||||
return db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(name);
|
||||
},
|
||||
all() {
|
||||
return db.prepare(`
|
||||
SELECT id, username, balance, avatar, avatar_image, avatar_focus_x, avatar_focus_y, avatar_zoom, created_at
|
||||
FROM users ORDER BY balance DESC
|
||||
`).all();
|
||||
},
|
||||
setBalance(id, balance) {
|
||||
db.prepare('UPDATE users SET balance = ? WHERE id = ?').run(Math.max(0, balance), id);
|
||||
},
|
||||
addBalance(id, delta) {
|
||||
const u = this.byId(id);
|
||||
if (!u) return null;
|
||||
const nb = Math.max(0, u.balance + delta);
|
||||
db.prepare('UPDATE users SET balance = ? WHERE id = ?').run(nb, id);
|
||||
return nb;
|
||||
},
|
||||
setAvatar(id, avatar) {
|
||||
db.prepare('UPDATE users SET avatar = ? WHERE id = ?').run(avatar, id);
|
||||
},
|
||||
setAvatarImage(id, imagePath, focusX, focusY, zoom) {
|
||||
db.prepare(`
|
||||
UPDATE users SET avatar_image = ?, avatar_focus_x = ?, avatar_focus_y = ?, avatar_zoom = ?
|
||||
WHERE id = ?
|
||||
`).run(imagePath, focusX, focusY, zoom, id);
|
||||
},
|
||||
setAvatarPosition(id, focusX, focusY, zoom) {
|
||||
db.prepare(`
|
||||
UPDATE users SET avatar_focus_x = ?, avatar_focus_y = ?, avatar_zoom = ?
|
||||
WHERE id = ?
|
||||
`).run(focusX, focusY, zoom, id);
|
||||
},
|
||||
clearAvatarImage(id) {
|
||||
db.prepare(`
|
||||
UPDATE users SET avatar_image = NULL, avatar_focus_x = 0.5, avatar_focus_y = 0.5, avatar_zoom = 1.0
|
||||
WHERE id = ?
|
||||
`).run(id);
|
||||
},
|
||||
count() {
|
||||
return db.prepare('SELECT COUNT(*) c FROM users').get().c;
|
||||
},
|
||||
};
|
||||
|
||||
// ---- Holdings Helpers ----
|
||||
export const Holdings = {
|
||||
forUser(userId) {
|
||||
return db.prepare('SELECT * FROM holdings WHERE user_id = ?').all(userId);
|
||||
},
|
||||
get(userId, symbol) {
|
||||
return db.prepare('SELECT * FROM holdings WHERE user_id = ? AND symbol = ?').get(userId, symbol);
|
||||
},
|
||||
upsert(userId, symbol, quantity, avgPrice) {
|
||||
db.prepare(`
|
||||
INSERT INTO holdings (user_id, symbol, quantity, avg_price) VALUES (?,?,?,?)
|
||||
ON CONFLICT(user_id, symbol) DO UPDATE SET quantity = excluded.quantity, avg_price = excluded.avg_price
|
||||
`).run(userId, symbol, quantity, avgPrice);
|
||||
},
|
||||
remove(userId, symbol) {
|
||||
db.prepare('DELETE FROM holdings WHERE user_id = ? AND symbol = ?').run(userId, symbol);
|
||||
},
|
||||
};
|
||||
|
||||
export const Trades = {
|
||||
add(userId, symbol, side, qty, price) {
|
||||
db.prepare('INSERT INTO trades (user_id, symbol, side, qty, price, ts) VALUES (?,?,?,?,?,?)')
|
||||
.run(userId, symbol, side, qty, price, Date.now());
|
||||
},
|
||||
forUser(userId, limit = 50) {
|
||||
return db.prepare('SELECT * FROM trades WHERE user_id = ? ORDER BY ts DESC LIMIT ?').all(userId, limit);
|
||||
},
|
||||
};
|
||||
|
||||
export const Games = {
|
||||
add(userId, game, bet, payout, result) {
|
||||
db.prepare('INSERT INTO games (user_id, game, bet, payout, result, ts) VALUES (?,?,?,?,?,?)')
|
||||
.run(userId, game, bet, payout, JSON.stringify(result || {}), Date.now());
|
||||
},
|
||||
forUser(userId, limit = 50) {
|
||||
return db.prepare('SELECT * FROM games WHERE user_id = ? ORDER BY ts DESC LIMIT ?').all(userId, limit);
|
||||
},
|
||||
};
|
||||
|
||||
export const Feed = {
|
||||
add(kind, text) {
|
||||
db.prepare('INSERT INTO feed (kind, text, ts) VALUES (?,?,?)').run(kind, text, Date.now());
|
||||
// alte Eintraege aufraeumen
|
||||
db.prepare('DELETE FROM feed WHERE id NOT IN (SELECT id FROM feed ORDER BY ts DESC LIMIT 100)').run();
|
||||
},
|
||||
recent(limit = 30) {
|
||||
return db.prepare('SELECT * FROM feed ORDER BY ts DESC LIMIT ?').all(limit);
|
||||
},
|
||||
};
|
||||
|
||||
export const Uploads = {
|
||||
forUser(userId) {
|
||||
return db.prepare('SELECT * FROM uploads WHERE user_id = ? ORDER BY created_at DESC').all(userId);
|
||||
},
|
||||
byId(id) {
|
||||
return db.prepare('SELECT * FROM uploads WHERE id = ?').get(id);
|
||||
},
|
||||
create(userId, frame, focusX, focusY, zoom) {
|
||||
const info = db.prepare(`
|
||||
INSERT INTO uploads (user_id, url, frame, focus_x, focus_y, zoom, created_at)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
`).run(userId, 'pending', frame, focusX, focusY, zoom, Date.now());
|
||||
return this.byId(info.lastInsertRowid);
|
||||
},
|
||||
setUrl(id, url) {
|
||||
db.prepare('UPDATE uploads SET url = ? WHERE id = ?').run(url, id);
|
||||
return this.byId(id);
|
||||
},
|
||||
setPosition(id, focusX, focusY, zoom) {
|
||||
db.prepare('UPDATE uploads SET focus_x = ?, focus_y = ?, zoom = ? WHERE id = ?')
|
||||
.run(focusX, focusY, zoom, id);
|
||||
return this.byId(id);
|
||||
},
|
||||
remove(id) {
|
||||
db.prepare('DELETE FROM uploads WHERE id = ?').run(id);
|
||||
},
|
||||
};
|
||||
247
server/games.js
Normal file
247
server/games.js
Normal file
@@ -0,0 +1,247 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { Users, Games } from './db.js';
|
||||
import { pushFeed } from './realtime.js';
|
||||
|
||||
const HOUSE_EDGE = 0.99; // 1% Hausvorteil
|
||||
const rounds = new Map(); // roundId -> state
|
||||
|
||||
function rid() { return crypto.randomBytes(12).toString('hex'); }
|
||||
function rand() { return crypto.randomBytes(4).readUInt32BE(0) / 0xffffffff; }
|
||||
function round(n, d = 2) { const f = Math.pow(10, d); return Math.round(n * f) / f; }
|
||||
|
||||
function debit(userId, amount) {
|
||||
const u = Users.byId(userId);
|
||||
if (!u) return { ok: false, error: 'User fehlt' };
|
||||
if (amount <= 0) return { ok: false, error: 'Einsatz muss > 0 sein' };
|
||||
if (u.balance < amount) return { ok: false, error: 'Nicht genug Guthaben' };
|
||||
Users.setBalance(userId, u.balance - amount);
|
||||
return { ok: true, balance: u.balance - amount };
|
||||
}
|
||||
|
||||
function credit(userId, amount) {
|
||||
return Users.addBalance(userId, amount);
|
||||
}
|
||||
|
||||
function settle(userId, game, bet, payout, result) {
|
||||
Games.add(userId, game, bet, payout, result);
|
||||
const u = Users.byId(userId);
|
||||
if (payout >= bet * 5 && payout > 0) {
|
||||
pushFeed('win', `${u?.username || 'Spieler'} gewinnt ${round(payout)} 🪙 bei ${game} (${round(payout / bet, 2)}x)`);
|
||||
}
|
||||
return Users.byId(userId)?.balance;
|
||||
}
|
||||
|
||||
// ============ CHICKEN ROAD ============
|
||||
// Huhn ueberquert Fahrbahnen. Jede Spur erhoeht den Multiplikator,
|
||||
// aber es droht ein Crash (Auto). Cashout jederzeit moeglich.
|
||||
const CHICKEN_DIFF = {
|
||||
easy: { p: 0.04, lanes: 24 },
|
||||
medium: { p: 0.10, lanes: 20 },
|
||||
hard: { p: 0.20, lanes: 16 },
|
||||
insane: { p: 0.35, lanes: 12 },
|
||||
};
|
||||
|
||||
function chickenMultipliers(p, lanes) {
|
||||
// Faire Multiplikatoren: 1/(1-p)^k * HouseEdge
|
||||
const arr = [];
|
||||
for (let k = 1; k <= lanes; k++) {
|
||||
arr.push(round(HOUSE_EDGE / Math.pow(1 - p, k), 2));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function chickenStart(userId, bet, difficulty = 'medium') {
|
||||
bet = Number(bet);
|
||||
const diff = CHICKEN_DIFF[difficulty] || CHICKEN_DIFF.medium;
|
||||
const d = debit(userId, bet);
|
||||
if (!d.ok) return d;
|
||||
const mults = chickenMultipliers(diff.p, diff.lanes);
|
||||
const id = rid();
|
||||
rounds.set(id, { type: 'chicken', userId, bet, p: diff.p, lanes: diff.lanes, mults, lane: 0, alive: true, difficulty });
|
||||
return { ok: true, roundId: id, lanes: diff.lanes, multipliers: mults, balance: d.balance };
|
||||
}
|
||||
|
||||
export function chickenStep(userId, roundId) {
|
||||
const r = rounds.get(roundId);
|
||||
if (!r || r.type !== 'chicken' || r.userId !== userId || !r.alive) return { ok: false, error: 'Runde ungueltig' };
|
||||
// Crash-Check auf der naechsten Spur
|
||||
if (rand() < r.p) {
|
||||
r.alive = false;
|
||||
rounds.delete(roundId);
|
||||
const bal = settle(userId, 'chicken', r.bet, 0, { crashedAt: r.lane + 1, difficulty: r.difficulty });
|
||||
return { ok: true, dead: true, lane: r.lane + 1, balance: bal };
|
||||
}
|
||||
r.lane += 1;
|
||||
const multiplier = r.mults[r.lane - 1];
|
||||
if (r.lane >= r.lanes) {
|
||||
// Maximum erreicht -> automatischer Cashout
|
||||
const payout = round(r.bet * multiplier);
|
||||
credit(userId, payout);
|
||||
rounds.delete(roundId);
|
||||
const bal = settle(userId, 'chicken', r.bet, payout, { lane: r.lane, multiplier, maxed: true });
|
||||
return { ok: true, dead: false, maxed: true, lane: r.lane, multiplier, payout, balance: bal };
|
||||
}
|
||||
return { ok: true, dead: false, lane: r.lane, multiplier, nextMultiplier: r.mults[r.lane] };
|
||||
}
|
||||
|
||||
export function chickenCashout(userId, roundId) {
|
||||
const r = rounds.get(roundId);
|
||||
if (!r || r.type !== 'chicken' || r.userId !== userId || !r.alive) return { ok: false, error: 'Runde ungueltig' };
|
||||
if (r.lane === 0) return { ok: false, error: 'Erst mind. 1 Schritt gehen' };
|
||||
const multiplier = r.mults[r.lane - 1];
|
||||
const payout = round(r.bet * multiplier);
|
||||
credit(userId, payout);
|
||||
rounds.delete(roundId);
|
||||
const bal = settle(userId, 'chicken', r.bet, payout, { lane: r.lane, multiplier });
|
||||
return { ok: true, payout, multiplier, lane: r.lane, balance: bal };
|
||||
}
|
||||
|
||||
// ============ 10x10 MINES KARTEN-DECK ============
|
||||
const GRID = 100;
|
||||
function minesMultiplier(bombs, picks) {
|
||||
let m = 1;
|
||||
for (let i = 0; i < picks; i++) {
|
||||
m *= (GRID - i) / (GRID - bombs - i);
|
||||
}
|
||||
return round(m * HOUSE_EDGE, 4);
|
||||
}
|
||||
|
||||
export function minesStart(userId, bet, bombs = 10) {
|
||||
bet = Number(bet);
|
||||
bombs = Math.max(1, Math.min(95, Math.floor(Number(bombs))));
|
||||
const d = debit(userId, bet);
|
||||
if (!d.ok) return d;
|
||||
// Bomben-Positionen geheim ziehen
|
||||
const positions = new Set();
|
||||
while (positions.size < bombs) positions.add(Math.floor(rand() * GRID));
|
||||
const id = rid();
|
||||
rounds.set(id, { type: 'mines', userId, bet, bombs, mines: positions, revealed: new Set(), alive: true });
|
||||
return { ok: true, roundId: id, bombs, grid: GRID, balance: d.balance, nextMultiplier: minesMultiplier(bombs, 1) };
|
||||
}
|
||||
|
||||
export function minesReveal(userId, roundId, index) {
|
||||
const r = rounds.get(roundId);
|
||||
if (!r || r.type !== 'mines' || r.userId !== userId || !r.alive) return { ok: false, error: 'Runde ungueltig' };
|
||||
index = Math.floor(Number(index));
|
||||
if (index < 0 || index >= GRID) return { ok: false, error: 'Feld ungueltig' };
|
||||
if (r.revealed.has(index)) return { ok: false, error: 'Feld schon offen' };
|
||||
|
||||
if (r.mines.has(index)) {
|
||||
r.alive = false;
|
||||
rounds.delete(roundId);
|
||||
const bal = settle(userId, 'mines', r.bet, 0, { hitBomb: index, bombs: r.bombs });
|
||||
return { ok: true, bomb: true, index, mines: [...r.mines], balance: bal };
|
||||
}
|
||||
r.revealed.add(index);
|
||||
const picks = r.revealed.size;
|
||||
const safeLeft = GRID - r.bombs - picks;
|
||||
const multiplier = minesMultiplier(r.bombs, picks);
|
||||
const next = safeLeft > 0 ? minesMultiplier(r.bombs, picks + 1) : null;
|
||||
if (safeLeft === 0) {
|
||||
// Alle sicheren Felder gefunden -> Auto-Cashout
|
||||
const payout = round(r.bet * multiplier);
|
||||
credit(userId, payout);
|
||||
rounds.delete(roundId);
|
||||
const bal = settle(userId, 'mines', r.bet, payout, { picks, multiplier, cleared: true });
|
||||
return { ok: true, bomb: false, index, picks, multiplier, payout, cleared: true, balance: bal };
|
||||
}
|
||||
return { ok: true, bomb: false, index, picks, multiplier, nextMultiplier: next };
|
||||
}
|
||||
|
||||
export function minesCashout(userId, roundId) {
|
||||
const r = rounds.get(roundId);
|
||||
if (!r || r.type !== 'mines' || r.userId !== userId || !r.alive) return { ok: false, error: 'Runde ungueltig' };
|
||||
const picks = r.revealed.size;
|
||||
if (picks === 0) return { ok: false, error: 'Erst mind. 1 Karte aufdecken' };
|
||||
const multiplier = minesMultiplier(r.bombs, picks);
|
||||
const payout = round(r.bet * multiplier);
|
||||
credit(userId, payout);
|
||||
rounds.delete(roundId);
|
||||
const bal = settle(userId, 'mines', r.bet, payout, { picks, multiplier });
|
||||
return { ok: true, payout, multiplier, picks, mines: [...r.mines], balance: bal };
|
||||
}
|
||||
|
||||
// ============ COINFLIP ============
|
||||
export function coinflip(userId, bet, choice) {
|
||||
bet = Number(bet);
|
||||
const d = debit(userId, bet);
|
||||
if (!d.ok) return d;
|
||||
const flip = rand() < 0.5 ? 'heads' : 'tails';
|
||||
const win = flip === choice;
|
||||
const mult = 2 * HOUSE_EDGE;
|
||||
const payout = win ? round(bet * mult) : 0;
|
||||
if (win) credit(userId, payout);
|
||||
const bal = settle(userId, 'coinflip', bet, payout, { flip, choice });
|
||||
return { ok: true, win, flip, payout, multiplier: win ? mult : 0, balance: bal };
|
||||
}
|
||||
|
||||
// ============ DICE (roll under) ============
|
||||
export function dice(userId, bet, target, direction = 'under') {
|
||||
bet = Number(bet);
|
||||
target = Math.max(2, Math.min(98, Number(target)));
|
||||
const d = debit(userId, bet);
|
||||
if (!d.ok) return d;
|
||||
const roll = round(rand() * 100, 2);
|
||||
const win = direction === 'under' ? roll < target : roll > target;
|
||||
const chance = direction === 'under' ? target : 100 - target;
|
||||
const mult = round((100 / chance) * HOUSE_EDGE, 4);
|
||||
const payout = win ? round(bet * mult) : 0;
|
||||
if (win) credit(userId, payout);
|
||||
const bal = settle(userId, 'dice', bet, payout, { roll, target, direction });
|
||||
return { ok: true, win, roll, target, direction, multiplier: mult, payout, balance: bal };
|
||||
}
|
||||
|
||||
// ============ CRASH ============
|
||||
// Server-autoritativ: Multiplikator = e^(k*t). Crashpunkt geheim.
|
||||
const CRASH_K = 0.00006; // Wachstum pro ms
|
||||
function genCrashPoint() {
|
||||
const r = rand();
|
||||
if (r < 0.03) return 1.0; // Instant-Crash 3%
|
||||
// Schwerer Tail, Median ~2x
|
||||
return round(Math.max(1, (1 - 0.01) / (1 - r)), 2);
|
||||
}
|
||||
function crashMultAt(ms) {
|
||||
return round(Math.exp(CRASH_K * ms), 2);
|
||||
}
|
||||
|
||||
export function crashStart(userId, bet) {
|
||||
bet = Number(bet);
|
||||
const d = debit(userId, bet);
|
||||
if (!d.ok) return d;
|
||||
const id = rid();
|
||||
const crashPoint = genCrashPoint();
|
||||
rounds.set(id, { type: 'crash', userId, bet, crashPoint, start: Date.now(), done: false });
|
||||
return { ok: true, roundId: id, balance: d.balance, k: CRASH_K };
|
||||
}
|
||||
|
||||
export function crashCashout(userId, roundId) {
|
||||
const r = rounds.get(roundId);
|
||||
if (!r || r.type !== 'crash' || r.userId !== userId || r.done) return { ok: false, error: 'Runde ungueltig' };
|
||||
const elapsed = Date.now() - r.start;
|
||||
const current = crashMultAt(elapsed);
|
||||
r.done = true;
|
||||
rounds.delete(roundId);
|
||||
if (current >= r.crashPoint) {
|
||||
const bal = settle(userId, 'crash', r.bet, 0, { crashPoint: r.crashPoint, cashoutAt: current });
|
||||
return { ok: true, crashed: true, crashPoint: r.crashPoint, balance: bal };
|
||||
}
|
||||
const payout = round(r.bet * current);
|
||||
credit(userId, payout);
|
||||
const bal = settle(userId, 'crash', r.bet, payout, { multiplier: current, crashPoint: r.crashPoint });
|
||||
return { ok: true, crashed: false, multiplier: current, payout, balance: bal };
|
||||
}
|
||||
|
||||
// Status-Abfrage fuer die Crash-Animation (autoritativ).
|
||||
export function crashStatus(userId, roundId) {
|
||||
const r = rounds.get(roundId);
|
||||
if (!r || r.type !== 'crash' || r.userId !== userId) return { ok: false, error: 'Runde ungueltig' };
|
||||
const elapsed = Date.now() - r.start;
|
||||
const current = crashMultAt(elapsed);
|
||||
if (current >= r.crashPoint && !r.done) {
|
||||
r.done = true;
|
||||
rounds.delete(roundId);
|
||||
settle(userId, 'crash', r.bet, 0, { crashPoint: r.crashPoint, busted: true });
|
||||
return { ok: true, crashed: true, crashPoint: r.crashPoint, multiplier: r.crashPoint };
|
||||
}
|
||||
return { ok: true, crashed: false, multiplier: current };
|
||||
}
|
||||
399
server/index.js
Normal file
399
server/index.js
Normal file
@@ -0,0 +1,399 @@
|
||||
import express from 'express';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import multer from 'multer';
|
||||
import sharp from 'sharp';
|
||||
import { Server as SocketServer } from 'socket.io';
|
||||
|
||||
import { config } from './config.js';
|
||||
import { Users, Trades, Games, Feed, Uploads } from './db.js';
|
||||
import {
|
||||
registerUser, loginUser, signToken, setAuthCookie, clearAuthCookie,
|
||||
optionalAuth, requireAuth,
|
||||
} from './auth.js';
|
||||
import { initMarket, listAssets, getHistory, manipulate, getAsset } from './market.js';
|
||||
import { executeTrade, getPortfolio } from './trading.js';
|
||||
import * as G from './games.js';
|
||||
import { setIO, pushFeed } from './realtime.js';
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const io = new SocketServer(server, { cors: { origin: true } });
|
||||
setIO(io);
|
||||
|
||||
app.use(express.json({ limit: '30mb' }));
|
||||
app.use(cookieParser());
|
||||
app.use(optionalAuth);
|
||||
|
||||
// ---------- Trilogy Hub: Foto-Slot Persistenz (Bilder als Dateien) ----------
|
||||
const SITE_UPLOAD_DIR = path.join(config.PUBLIC_DIR, 'uploads');
|
||||
const SITE_MANIFEST = path.join(SITE_UPLOAD_DIR, 'manifest.json');
|
||||
const SITE_EXT_BY_MIME = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp' };
|
||||
fs.mkdirSync(SITE_UPLOAD_DIR, { recursive: true });
|
||||
if (!fs.existsSync(SITE_MANIFEST)) fs.writeFileSync(SITE_MANIFEST, '{}');
|
||||
function readSiteManifest() { try { return JSON.parse(fs.readFileSync(SITE_MANIFEST, 'utf8') || '{}'); } catch { return {}; } }
|
||||
function writeSiteManifest(o) { fs.writeFileSync(SITE_MANIFEST, JSON.stringify(o, null, 2)); }
|
||||
function sanitizeSlot(id) { return String(id).replace(/[^a-z0-9_-]+/gi, '_').slice(0, 120) || 'slot'; }
|
||||
|
||||
app.get('/api/images', (req, res) => res.json(readSiteManifest()));
|
||||
app.post('/api/upload', (req, res) => {
|
||||
try {
|
||||
const { id, dataUrl, focusX, focusY, zoom } = req.body || {};
|
||||
if (!id || !dataUrl) return res.status(400).json({ error: 'Missing id or dataUrl' });
|
||||
const m = /^data:([^;]+);base64,(.+)$/s.exec(dataUrl);
|
||||
if (!m) return res.status(400).json({ error: 'Invalid dataUrl' });
|
||||
const ext = SITE_EXT_BY_MIME[m[1].toLowerCase()] || '.png';
|
||||
const buf = Buffer.from(m[2], 'base64');
|
||||
const filename = sanitizeSlot(id) + ext;
|
||||
const manifest = readSiteManifest();
|
||||
if (manifest[id]) {
|
||||
const oldUrl = typeof manifest[id] === 'string' ? manifest[id] : manifest[id].url;
|
||||
if (oldUrl) {
|
||||
const old = path.join(config.PUBLIC_DIR, oldUrl.replace(/^\//, ''));
|
||||
if (old.startsWith(SITE_UPLOAD_DIR) && fs.existsSync(old)) { try { fs.unlinkSync(old); } catch {} }
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(path.join(SITE_UPLOAD_DIR, filename), buf);
|
||||
const publicUrl = '/uploads/' + filename;
|
||||
const fx = Number(focusX);
|
||||
const fy = Number(focusY);
|
||||
const z = Number(zoom);
|
||||
manifest[id] = {
|
||||
url: publicUrl,
|
||||
focus: {
|
||||
x: Number.isFinite(fx) ? Math.min(1, Math.max(0, fx)) : 0.5,
|
||||
y: Number.isFinite(fy) ? Math.min(1, Math.max(0, fy)) : 0.5,
|
||||
},
|
||||
zoom: Number.isFinite(z) ? Math.min(4, Math.max(1, z)) : 1,
|
||||
};
|
||||
writeSiteManifest(manifest);
|
||||
res.json({ ok: true, url: publicUrl, focus: manifest[id].focus, zoom: manifest[id].zoom });
|
||||
} catch (e) { res.status(500).json({ error: String(e?.message || e) }); }
|
||||
});
|
||||
|
||||
// ---------- Helper ----------
|
||||
function formatAvatar(u) {
|
||||
if (u.avatar_image) {
|
||||
return {
|
||||
type: 'image',
|
||||
url: u.avatar_image,
|
||||
focus: { x: u.avatar_focus_x ?? 0.5, y: u.avatar_focus_y ?? 0.5 },
|
||||
zoom: u.avatar_zoom ?? 1,
|
||||
emoji: u.avatar || '🐂',
|
||||
};
|
||||
}
|
||||
return { type: 'emoji', value: u.avatar || '🐂' };
|
||||
}
|
||||
function publicUser(u) {
|
||||
if (!u) return null;
|
||||
return { id: u.id, username: u.username, balance: round(u.balance), avatar: formatAvatar(u) };
|
||||
}
|
||||
function parseFocus(val, fallback = 0.5) {
|
||||
const n = Number(val);
|
||||
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : fallback;
|
||||
}
|
||||
function parseZoom(val) {
|
||||
const n = Number(val);
|
||||
return Number.isFinite(n) ? Math.min(4, Math.max(1, n)) : 1;
|
||||
}
|
||||
function avatarFilePath(userId) {
|
||||
return path.join(config.UPLOADS_DIR, `${userId}.webp`);
|
||||
}
|
||||
function avatarPublicPath(userId) {
|
||||
return `/uploads/avatars/${userId}.webp`;
|
||||
}
|
||||
async function saveAvatarImage(userId, buffer) {
|
||||
const out = avatarFilePath(userId);
|
||||
await sharp(buffer)
|
||||
.rotate()
|
||||
.resize(512, 512, { fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 85 })
|
||||
.toFile(out);
|
||||
return avatarPublicPath(userId);
|
||||
}
|
||||
function deleteAvatarFile(userId) {
|
||||
const p = avatarFilePath(userId);
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
}
|
||||
|
||||
const VALID_FRAMES = new Set(['gallery', 'banner', 'card', 'avatar']);
|
||||
|
||||
function formatUpload(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
url: row.url,
|
||||
frame: row.frame,
|
||||
focus: { x: row.focus_x ?? 0.5, y: row.focus_y ?? 0.5 },
|
||||
zoom: row.zoom ?? 1,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
function uploadFilePath(id) {
|
||||
return path.join(config.FILES_DIR, `${id}.webp`);
|
||||
}
|
||||
function uploadPublicPath(id) {
|
||||
return `/uploads/files/${id}.webp`;
|
||||
}
|
||||
async function saveUploadImage(id, buffer, frame) {
|
||||
const maxW = frame === 'banner' ? 1200 : frame === 'card' ? 600 : 800;
|
||||
const maxH = frame === 'banner' ? 400 : frame === 'card' ? 900 : 800;
|
||||
await sharp(buffer)
|
||||
.rotate()
|
||||
.resize(maxW, maxH, { fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: 85 })
|
||||
.toFile(uploadFilePath(id));
|
||||
return uploadPublicPath(id);
|
||||
}
|
||||
function deleteUploadFile(id) {
|
||||
const p = uploadFilePath(id);
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
}
|
||||
function parseFrame(val) {
|
||||
const f = String(val || 'gallery');
|
||||
return VALID_FRAMES.has(f) ? f : 'gallery';
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ok = /^image\/(jpeg|png|webp|gif)$/.test(file.mimetype);
|
||||
cb(ok ? null : new Error('Nur JPEG, PNG, WebP oder GIF'), ok);
|
||||
},
|
||||
});
|
||||
function round(n, d = 2) { const f = Math.pow(10, d); return Math.round(n * f) / f; }
|
||||
const ok = (res, data) => res.json({ ok: true, ...data });
|
||||
const fail = (res, msg, code = 400) => res.status(code).json({ ok: false, error: msg });
|
||||
|
||||
// ========== AUTH ==========
|
||||
app.post('/api/register', async (req, res) => {
|
||||
try {
|
||||
const u = await registerUser(req.body.username, req.body.password);
|
||||
const token = signToken(u);
|
||||
setAuthCookie(res, token);
|
||||
pushFeed('join', `🎉 ${u.username} ist PlayBull beigetreten!`);
|
||||
ok(res, { user: publicUser(u) });
|
||||
} catch (e) { fail(res, e.message); }
|
||||
});
|
||||
|
||||
app.post('/api/login', async (req, res) => {
|
||||
try {
|
||||
const u = await loginUser(req.body.username, req.body.password);
|
||||
const token = signToken(u);
|
||||
setAuthCookie(res, token);
|
||||
ok(res, { user: publicUser(u) });
|
||||
} catch (e) { fail(res, e.message, 401); }
|
||||
});
|
||||
|
||||
app.post('/api/logout', (req, res) => { clearAuthCookie(res); ok(res, {}); });
|
||||
|
||||
app.get('/api/me', (req, res) => {
|
||||
ok(res, { user: publicUser(req.user) });
|
||||
});
|
||||
|
||||
// ========== MARKT (oeffentlich) ==========
|
||||
app.get('/api/market', (req, res) => {
|
||||
ok(res, { assets: listAssets() });
|
||||
});
|
||||
|
||||
app.get('/api/market/:symbol/history', (req, res) => {
|
||||
const sym = req.params.symbol.toUpperCase();
|
||||
if (!getAsset(sym)) return fail(res, 'Unbekanntes Symbol', 404);
|
||||
ok(res, { symbol: sym, history: getHistory(sym) });
|
||||
});
|
||||
|
||||
// ========== TRADING (Login noetig) ==========
|
||||
app.get('/api/portfolio', requireAuth, (req, res) => {
|
||||
ok(res, { portfolio: getPortfolio(req.user.id) });
|
||||
});
|
||||
|
||||
app.post('/api/trade', requireAuth, (req, res) => {
|
||||
const { symbol, side, qty } = req.body;
|
||||
if (!['buy', 'sell'].includes(side)) return fail(res, 'side muss buy/sell sein');
|
||||
const r = executeTrade(req.user.id, String(symbol).toUpperCase(), side, qty);
|
||||
if (!r.ok) return fail(res, r.error);
|
||||
const a = getAsset(String(symbol).toUpperCase());
|
||||
pushFeed('trade', `${req.user.username} ${side === 'buy' ? 'kauft' : 'verkauft'} ${round(qty,4)} ${a?.emoji||''}${symbol.toUpperCase()} @ ${r.price}`);
|
||||
io.emit('portfolio_dirty', { userId: req.user.id });
|
||||
ok(res, r);
|
||||
});
|
||||
|
||||
app.get('/api/trades', requireAuth, (req, res) => {
|
||||
ok(res, { trades: Trades.forUser(req.user.id) });
|
||||
});
|
||||
|
||||
// ========== MANIPULATION (jeder eingeloggte User - Spassmodus) ==========
|
||||
app.post('/api/manipulate', requireAuth, (req, res) => {
|
||||
const { symbol, action, value } = req.body;
|
||||
const r = manipulate(String(symbol).toUpperCase(), action, Number(value), req.user.username);
|
||||
if (!r.ok) return fail(res, r.error);
|
||||
const verb = { pump: '🚀 pumpt', dump: '📉 dumpt', set: '✏️ setzt', halt: '⏸️ pausiert', resume: '▶️ aktiviert', reset: '♻️ resettet' }[action] || action;
|
||||
pushFeed('manip', `${req.user.username} ${verb} ${symbol.toUpperCase()}`);
|
||||
ok(res, r);
|
||||
});
|
||||
|
||||
// ========== SETTINGS / ADMIN (Spassmodus: jeder darf alles) ==========
|
||||
app.get('/api/users', requireAuth, (req, res) => {
|
||||
ok(res, { users: Users.all().map(u => ({ ...u, avatar: formatAvatar(u) })) });
|
||||
});
|
||||
|
||||
// Geld bearbeiten - bei sich selbst oder anderen
|
||||
app.post('/api/admin/balance', requireAuth, (req, res) => {
|
||||
const { userId, mode, amount } = req.body; // mode: 'set' | 'add'
|
||||
const target = Users.byId(Number(userId));
|
||||
if (!target) return fail(res, 'User nicht gefunden', 404);
|
||||
const amt = Number(amount);
|
||||
if (!Number.isFinite(amt)) return fail(res, 'Betrag ungueltig');
|
||||
let nb;
|
||||
if (mode === 'set') { Users.setBalance(target.id, amt); nb = amt; }
|
||||
else { nb = Users.addBalance(target.id, amt); }
|
||||
pushFeed('admin', `${req.user.username} ${mode === 'set' ? 'setzt' : 'aendert'} Guthaben von ${target.username} → ${round(nb)} 🪙`);
|
||||
io.emit('balance_changed', { userId: target.id, balance: round(nb) });
|
||||
ok(res, { userId: target.id, balance: round(nb) });
|
||||
});
|
||||
|
||||
app.post('/api/settings/avatar', requireAuth, (req, res) => {
|
||||
const avatar = String(req.body.avatar || '🐂').slice(0, 4);
|
||||
Users.setAvatar(req.user.id, avatar);
|
||||
ok(res, { avatar: formatAvatar(Users.byId(req.user.id)) });
|
||||
});
|
||||
|
||||
app.post('/api/settings/avatar/image', requireAuth, (req, res, next) => {
|
||||
upload.single('file')(req, res, (err) => {
|
||||
if (err) return fail(res, err.message || 'Upload fehlgeschlagen');
|
||||
next();
|
||||
});
|
||||
}, async (req, res) => {
|
||||
try {
|
||||
if (!req.file) return fail(res, 'Keine Bilddatei');
|
||||
const focusX = parseFocus(req.body.focusX);
|
||||
const focusY = parseFocus(req.body.focusY);
|
||||
const zoom = parseZoom(req.body.zoom);
|
||||
deleteAvatarFile(req.user.id);
|
||||
const url = await saveAvatarImage(req.user.id, req.file.buffer);
|
||||
Users.setAvatarImage(req.user.id, url, focusX, focusY, zoom);
|
||||
ok(res, { avatar: formatAvatar(Users.byId(req.user.id)) });
|
||||
} catch (e) {
|
||||
fail(res, e.message || 'Upload fehlgeschlagen');
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/settings/avatar/image/position', requireAuth, (req, res) => {
|
||||
const u = Users.byId(req.user.id);
|
||||
if (!u?.avatar_image) return fail(res, 'Kein Profilbild vorhanden', 404);
|
||||
const focusX = parseFocus(req.body.focusX);
|
||||
const focusY = parseFocus(req.body.focusY);
|
||||
const zoom = parseZoom(req.body.zoom);
|
||||
Users.setAvatarPosition(req.user.id, focusX, focusY, zoom);
|
||||
ok(res, { avatar: formatAvatar(Users.byId(req.user.id)) });
|
||||
});
|
||||
|
||||
app.delete('/api/settings/avatar/image', requireAuth, (req, res) => {
|
||||
deleteAvatarFile(req.user.id);
|
||||
Users.clearAvatarImage(req.user.id);
|
||||
ok(res, { avatar: formatAvatar(Users.byId(req.user.id)) });
|
||||
});
|
||||
|
||||
// ========== UPLOADS (Galerie) ==========
|
||||
app.get('/api/uploads', requireAuth, (req, res) => {
|
||||
ok(res, { uploads: Uploads.forUser(req.user.id).map(formatUpload) });
|
||||
});
|
||||
|
||||
app.post('/api/uploads', requireAuth, (req, res, next) => {
|
||||
upload.single('file')(req, res, (err) => {
|
||||
if (err) return fail(res, err.message || 'Upload fehlgeschlagen');
|
||||
next();
|
||||
});
|
||||
}, async (req, res) => {
|
||||
let row = null;
|
||||
try {
|
||||
if (!req.file) return fail(res, 'Keine Bilddatei');
|
||||
const frame = parseFrame(req.body.frame);
|
||||
const focusX = parseFocus(req.body.focusX);
|
||||
const focusY = parseFocus(req.body.focusY);
|
||||
const zoom = parseZoom(req.body.zoom);
|
||||
row = Uploads.create(req.user.id, frame, focusX, focusY, zoom);
|
||||
const url = await saveUploadImage(row.id, req.file.buffer, frame);
|
||||
Uploads.setUrl(row.id, url);
|
||||
ok(res, { upload: formatUpload(Uploads.byId(row.id)) });
|
||||
} catch (e) {
|
||||
if (row) { deleteUploadFile(row.id); Uploads.remove(row.id); }
|
||||
fail(res, e.message || 'Upload fehlgeschlagen');
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/uploads/:id/position', requireAuth, (req, res) => {
|
||||
const row = Uploads.byId(+req.params.id);
|
||||
if (!row || row.user_id !== req.user.id) return fail(res, 'Upload nicht gefunden', 404);
|
||||
const focusX = parseFocus(req.body.focusX);
|
||||
const focusY = parseFocus(req.body.focusY);
|
||||
const zoom = parseZoom(req.body.zoom);
|
||||
Uploads.setPosition(row.id, focusX, focusY, zoom);
|
||||
ok(res, { upload: formatUpload(Uploads.byId(row.id)) });
|
||||
});
|
||||
|
||||
app.delete('/api/uploads/:id', requireAuth, (req, res) => {
|
||||
const row = Uploads.byId(+req.params.id);
|
||||
if (!row || row.user_id !== req.user.id) return fail(res, 'Upload nicht gefunden', 404);
|
||||
deleteUploadFile(row.id);
|
||||
Uploads.remove(row.id);
|
||||
ok(res, {});
|
||||
});
|
||||
|
||||
// ========== FEED / LEADERBOARD ==========
|
||||
app.get('/api/feed', (req, res) => { ok(res, { feed: Feed.recent(30) }); });
|
||||
app.get('/api/leaderboard', (req, res) => {
|
||||
const users = Users.all().slice(0, 20).map(u => {
|
||||
const p = getPortfolio(u.id);
|
||||
return { username: u.username, avatar: formatAvatar(u), netWorth: p ? p.netWorth : u.balance };
|
||||
}).sort((a, b) => b.netWorth - a.netWorth);
|
||||
ok(res, { leaderboard: users });
|
||||
});
|
||||
|
||||
// ========== GAMES (Login noetig) ==========
|
||||
const gameRoute = (fn) => (req, res) => {
|
||||
const r = fn(req.user.id, req.body);
|
||||
if (!r || !r.ok) return fail(res, r?.error || 'Fehler');
|
||||
ok(res, r);
|
||||
};
|
||||
|
||||
app.post('/api/games/chicken/start', requireAuth, gameRoute((id, b) => G.chickenStart(id, b.bet, b.difficulty)));
|
||||
app.post('/api/games/chicken/step', requireAuth, gameRoute((id, b) => G.chickenStep(id, b.roundId)));
|
||||
app.post('/api/games/chicken/cashout', requireAuth, gameRoute((id, b) => G.chickenCashout(id, b.roundId)));
|
||||
|
||||
app.post('/api/games/mines/start', requireAuth, gameRoute((id, b) => G.minesStart(id, b.bet, b.bombs)));
|
||||
app.post('/api/games/mines/reveal', requireAuth, gameRoute((id, b) => G.minesReveal(id, b.roundId, b.index)));
|
||||
app.post('/api/games/mines/cashout', requireAuth, gameRoute((id, b) => G.minesCashout(id, b.roundId)));
|
||||
|
||||
app.post('/api/games/coinflip', requireAuth, gameRoute((id, b) => G.coinflip(id, b.bet, b.choice)));
|
||||
app.post('/api/games/dice', requireAuth, gameRoute((id, b) => G.dice(id, b.bet, b.target, b.direction)));
|
||||
|
||||
app.post('/api/games/crash/start', requireAuth, gameRoute((id, b) => G.crashStart(id, b.bet)));
|
||||
app.post('/api/games/crash/status', requireAuth, gameRoute((id, b) => G.crashStatus(id, b.roundId)));
|
||||
app.post('/api/games/crash/cashout', requireAuth, gameRoute((id, b) => G.crashCashout(id, b.roundId)));
|
||||
|
||||
app.get('/api/games/history', requireAuth, (req, res) => {
|
||||
ok(res, { history: Games.forUser(req.user.id) });
|
||||
});
|
||||
|
||||
// ---------- Static Frontend ----------
|
||||
app.use('/uploads/avatars', express.static(config.UPLOADS_DIR));
|
||||
app.use('/uploads/files', express.static(config.FILES_DIR));
|
||||
app.use(express.static(config.PUBLIC_DIR));
|
||||
app.get('*', (req, res) => res.sendFile(path.join(config.PUBLIC_DIR, 'index.html')));
|
||||
|
||||
// ---------- Socket.IO ----------
|
||||
io.on('connection', (socket) => {
|
||||
socket.emit('hello', { assets: listAssets(), feed: Feed.recent(20) });
|
||||
});
|
||||
|
||||
// ---------- Start ----------
|
||||
initMarket(io).then(() => {
|
||||
server.listen(config.PORT, () => {
|
||||
console.log(`\n 🐂 PlayBull laeuft auf http://localhost:${config.PORT}\n`);
|
||||
console.log(` Finnhub-Key: ${config.FINNHUB_API_KEY ? 'aktiv (echte Aktienkurse)' : 'fehlt (nur Simulation)'}`);
|
||||
});
|
||||
});
|
||||
184
server/market.js
Normal file
184
server/market.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import { config } from './config.js';
|
||||
|
||||
// Definierte handelbare Assets. emoji + sector nur fuer die UI.
|
||||
const ASSETS = [
|
||||
{ symbol: 'AAPL', name: 'Apple', emoji: '🍎', sector: 'Tech', type: 'stock', fallback: 195 },
|
||||
{ symbol: 'TSLA', name: 'Tesla', emoji: '🚗', sector: 'Auto', type: 'stock', fallback: 250 },
|
||||
{ symbol: 'NVDA', name: 'Nvidia', emoji: '🎮', sector: 'Chips', type: 'stock', fallback: 120 },
|
||||
{ symbol: 'AMZN', name: 'Amazon', emoji: '📦', sector: 'Retail', type: 'stock', fallback: 185 },
|
||||
{ symbol: 'MSFT', name: 'Microsoft', emoji: '🪟', sector: 'Tech', type: 'stock', fallback: 420 },
|
||||
{ symbol: 'GOOGL', name: 'Alphabet', emoji: '🔍', sector: 'Tech', type: 'stock', fallback: 175 },
|
||||
{ symbol: 'META', name: 'Meta', emoji: '👓', sector: 'Social', type: 'stock', fallback: 500 },
|
||||
{ symbol: 'AMD', name: 'AMD', emoji: '⚡', sector: 'Chips', type: 'stock', fallback: 160 },
|
||||
{ symbol: 'NFLX', name: 'Netflix', emoji: '🎬', sector: 'Media', type: 'stock', fallback: 650 },
|
||||
{ symbol: 'GME', name: 'GameStop', emoji: '🕹️', sector: 'Meme', type: 'stock', fallback: 25 },
|
||||
// Krypto + Meme (simuliert, hohe Volatilitaet)
|
||||
{ symbol: 'BTC', name: 'Bitcoin', emoji: '₿', sector: 'Krypto', type: 'crypto', fallback: 67000, vol: 2.2 },
|
||||
{ symbol: 'ETH', name: 'Ethereum', emoji: '💎', sector: 'Krypto', type: 'crypto', fallback: 3500, vol: 2.4 },
|
||||
{ symbol: 'DOGE', name: 'Dogecoin', emoji: '🐕', sector: 'Meme', type: 'crypto', fallback: 0.16, vol: 4 },
|
||||
{ symbol: 'BULL', name: 'PlayBull Coin',emoji: '🐂', sector: 'Haus', type: 'crypto', fallback: 1.00, vol: 5 },
|
||||
];
|
||||
|
||||
const HISTORY_LEN = 120;
|
||||
|
||||
/** @type {Map<string, any>} */
|
||||
const market = new Map();
|
||||
let io = null;
|
||||
|
||||
function rnd(min, max) { return min + Math.random() * (max - min); }
|
||||
|
||||
function initAsset(a) {
|
||||
const price = a.fallback;
|
||||
return {
|
||||
symbol: a.symbol,
|
||||
name: a.name,
|
||||
emoji: a.emoji,
|
||||
sector: a.sector,
|
||||
type: a.type,
|
||||
price,
|
||||
prevClose: price,
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
change: 0,
|
||||
changePct: 0,
|
||||
momentum: 0, // treibt die naechsten Ticks (Manipulation haengt sich hier rein)
|
||||
baseVol: a.vol || (a.type === 'crypto' ? 2 : 1), // Grund-Volatilitaet in %
|
||||
halted: false,
|
||||
history: Array.from({ length: HISTORY_LEN }, () => ({ t: Date.now(), p: price })),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRealPrice(symbol) {
|
||||
if (!config.FINNHUB_API_KEY) return null;
|
||||
try {
|
||||
const url = `https://finnhub.io/api/v1/quote?symbol=${encodeURIComponent(symbol)}&token=${config.FINNHUB_API_KEY}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(6000) });
|
||||
if (!res.ok) return null;
|
||||
const j = await res.json();
|
||||
// c = current, pc = previous close
|
||||
if (j && typeof j.c === 'number' && j.c > 0) {
|
||||
return { price: j.c, prevClose: j.pc > 0 ? j.pc : j.c, high: j.h || j.c, low: j.l || j.c, open: j.o || j.c };
|
||||
}
|
||||
} catch { /* ignore -> fallback */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function initMarket(socketServer) {
|
||||
io = socketServer;
|
||||
for (const a of ASSETS) market.set(a.symbol, initAsset(a));
|
||||
|
||||
// Echte Startkurse fuer Aktien von Finnhub laden (Krypto bleibt simuliert)
|
||||
const stockSymbols = ASSETS.filter(a => a.type === 'stock').map(a => a.symbol);
|
||||
await Promise.all(stockSymbols.map(async (sym) => {
|
||||
const real = await fetchRealPrice(sym);
|
||||
if (real) {
|
||||
const m = market.get(sym);
|
||||
m.price = real.price;
|
||||
m.prevClose = real.prevClose;
|
||||
m.open = real.open;
|
||||
m.high = real.high;
|
||||
m.low = real.low;
|
||||
m.history = Array.from({ length: HISTORY_LEN }, () => ({ t: Date.now(), p: real.price }));
|
||||
recalc(m);
|
||||
}
|
||||
}));
|
||||
|
||||
// Live-Tick alle 1s
|
||||
setInterval(tick, 1000);
|
||||
return market;
|
||||
}
|
||||
|
||||
function recalc(m) {
|
||||
m.change = m.price - m.prevClose;
|
||||
m.changePct = m.prevClose ? (m.change / m.prevClose) * 100 : 0;
|
||||
if (m.price > m.high) m.high = m.price;
|
||||
if (m.price < m.low) m.low = m.price;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const payload = [];
|
||||
for (const m of market.values()) {
|
||||
if (!m.halted) {
|
||||
// Random walk + Momentum (Manipulation) + leichte Rueckkehr zur Mitte
|
||||
const noise = rnd(-1, 1) * m.baseVol * 0.4; // % pro Tick
|
||||
const drift = m.momentum; // % pro Tick aus Manipulation
|
||||
const pctMove = (noise + drift) / 100;
|
||||
m.price = Math.max(0.0001, m.price * (1 + pctMove));
|
||||
m.momentum *= 0.85; // Momentum klingt ab
|
||||
if (Math.abs(m.momentum) < 0.0005) m.momentum = 0;
|
||||
recalc(m);
|
||||
m.history.push({ t: Date.now(), p: m.price });
|
||||
if (m.history.length > HISTORY_LEN) m.history.shift();
|
||||
}
|
||||
payload.push({
|
||||
s: m.symbol,
|
||||
p: round(m.price),
|
||||
c: round(m.change),
|
||||
cp: round(m.changePct, 2),
|
||||
h: round(m.halted ? 1 : 0),
|
||||
});
|
||||
}
|
||||
if (io) io.emit('prices', { t: Date.now(), data: payload });
|
||||
}
|
||||
|
||||
function round(n, d = 4) {
|
||||
const f = Math.pow(10, d);
|
||||
return Math.round(n * f) / f;
|
||||
}
|
||||
|
||||
// ---- Oeffentliche API ----
|
||||
export function getAsset(symbol) { return market.get(symbol); }
|
||||
export function getPrice(symbol) { const m = market.get(symbol); return m ? m.price : null; }
|
||||
|
||||
export function listAssets() {
|
||||
return [...market.values()].map(m => ({
|
||||
symbol: m.symbol, name: m.name, emoji: m.emoji, sector: m.sector, type: m.type,
|
||||
price: round(m.price), change: round(m.change), changePct: round(m.changePct, 2),
|
||||
high: round(m.high), low: round(m.low), prevClose: round(m.prevClose), halted: m.halted,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getHistory(symbol) {
|
||||
const m = market.get(symbol);
|
||||
if (!m) return [];
|
||||
return m.history.map(h => ({ t: h.t, p: round(h.p) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Globale Markt-Manipulation. Wirkt fuer ALLE Nutzer (geteilter Live-Status).
|
||||
* action: 'pump' | 'dump' | 'set' | 'halt' | 'resume' | 'reset'
|
||||
*/
|
||||
export function manipulate(symbol, action, value, byUser = 'Jemand') {
|
||||
const m = market.get(symbol);
|
||||
if (!m) return { ok: false, error: 'Unbekanntes Symbol' };
|
||||
switch (action) {
|
||||
case 'pump':
|
||||
m.momentum += (value && value > 0 ? value : 2); // % Boost pro Tick
|
||||
break;
|
||||
case 'dump':
|
||||
m.momentum -= (value && value > 0 ? value : 2);
|
||||
break;
|
||||
case 'set': {
|
||||
const v = Number(value);
|
||||
if (!(v > 0)) return { ok: false, error: 'Ungueltiger Preis' };
|
||||
m.price = v;
|
||||
recalc(m);
|
||||
m.history.push({ t: Date.now(), p: m.price });
|
||||
break;
|
||||
}
|
||||
case 'halt': m.halted = true; break;
|
||||
case 'resume': m.halted = false; break;
|
||||
case 'reset':
|
||||
m.momentum = 0; m.halted = false;
|
||||
m.price = m.prevClose;
|
||||
recalc(m);
|
||||
break;
|
||||
default:
|
||||
return { ok: false, error: 'Unbekannte Aktion' };
|
||||
}
|
||||
if (io) io.emit('manip', { symbol, action, by: byUser, price: round(m.price) });
|
||||
return { ok: true, symbol, price: round(m.price), momentum: m.momentum, halted: m.halted };
|
||||
}
|
||||
|
||||
export { ASSETS };
|
||||
15
server/realtime.js
Normal file
15
server/realtime.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Feed } from './db.js';
|
||||
|
||||
let io = null;
|
||||
export function setIO(server) { io = server; }
|
||||
export function getIO() { return io; }
|
||||
|
||||
export function broadcast(event, payload) {
|
||||
if (io) io.emit(event, payload);
|
||||
}
|
||||
|
||||
// Globaler Aktivitaets-Feed (Wins, Trades, Manipulationen) fuer alle sichtbar.
|
||||
export function pushFeed(kind, text) {
|
||||
Feed.add(kind, text);
|
||||
broadcast('feed', { kind, text, ts: Date.now() });
|
||||
}
|
||||
98
server/trading.js
Normal file
98
server/trading.js
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Users, Holdings, Trades } from './db.js';
|
||||
import { getAsset } from './market.js';
|
||||
|
||||
// Position-Netting: kombiniert bestehende Position mit neuem Trade.
|
||||
function applyPosition(q0, a0, delta, price) {
|
||||
if (q0 === 0 || Math.sign(q0) === Math.sign(delta)) {
|
||||
const newQ = q0 + delta;
|
||||
const newA = (Math.abs(q0) * a0 + Math.abs(delta) * price) / (Math.abs(q0) + Math.abs(delta));
|
||||
return { q: newQ, a: newA };
|
||||
}
|
||||
// Gegenrichtung
|
||||
if (Math.abs(delta) < Math.abs(q0)) return { q: q0 + delta, a: a0 };
|
||||
if (Math.abs(delta) === Math.abs(q0)) return { q: 0, a: 0 };
|
||||
return { q: q0 + delta, a: price }; // Flip
|
||||
}
|
||||
|
||||
/**
|
||||
* side: 'buy' | 'sell'. qty > 0. 'sell' kann Short eroeffnen.
|
||||
*/
|
||||
export function executeTrade(userId, symbol, side, qty) {
|
||||
const asset = getAsset(symbol);
|
||||
if (!asset) return { ok: false, error: 'Unbekanntes Symbol' };
|
||||
if (asset.halted) return { ok: false, error: `${symbol} ist pausiert (halted)` };
|
||||
qty = Number(qty);
|
||||
if (!(qty > 0)) return { ok: false, error: 'Menge muss > 0 sein' };
|
||||
|
||||
const user = Users.byId(userId);
|
||||
if (!user) return { ok: false, error: 'User nicht gefunden' };
|
||||
|
||||
const price = asset.price;
|
||||
const notional = qty * price;
|
||||
const delta = side === 'buy' ? qty : -qty;
|
||||
|
||||
const h = Holdings.get(userId, symbol) || { quantity: 0, avg_price: 0 };
|
||||
|
||||
let newBalance;
|
||||
if (side === 'buy') {
|
||||
if (user.balance < notional) return { ok: false, error: 'Nicht genug Guthaben' };
|
||||
newBalance = user.balance - notional;
|
||||
} else {
|
||||
// Verkauf bringt Cash. Short-Eroeffnung erlaubt, aber Sicherheit pruefen.
|
||||
const result = applyPosition(h.quantity, h.avg_price, delta, price);
|
||||
if (result.q < 0 && user.balance < Math.abs(result.q) * price * 0.5) {
|
||||
return { ok: false, error: 'Nicht genug Sicherheit fuer Short (50% Margin)' };
|
||||
}
|
||||
newBalance = user.balance + notional;
|
||||
}
|
||||
|
||||
const pos = applyPosition(h.quantity, h.avg_price, delta, price);
|
||||
Users.setBalance(userId, newBalance);
|
||||
if (Math.abs(pos.q) < 1e-9) Holdings.remove(userId, symbol);
|
||||
else Holdings.upsert(userId, symbol, pos.q, pos.a);
|
||||
Trades.add(userId, symbol, side, qty, price);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
symbol, side, qty, price: round(price),
|
||||
balance: round(newBalance),
|
||||
position: { quantity: round(pos.q, 6), avgPrice: round(pos.a) },
|
||||
};
|
||||
}
|
||||
|
||||
export function getPortfolio(userId) {
|
||||
const user = Users.byId(userId);
|
||||
if (!user) return null;
|
||||
const holdings = Holdings.forUser(userId).map(h => {
|
||||
const a = getAsset(h.symbol);
|
||||
const price = a ? a.price : h.avg_price;
|
||||
const value = h.quantity * price;
|
||||
const cost = h.quantity * h.avg_price;
|
||||
const pnl = value - cost; // funktioniert fuer Long & Short
|
||||
const pnlPct = cost !== 0 ? (pnl / Math.abs(cost)) * 100 : 0;
|
||||
return {
|
||||
symbol: h.symbol,
|
||||
name: a?.name || h.symbol,
|
||||
emoji: a?.emoji || '',
|
||||
quantity: round(h.quantity, 6),
|
||||
avgPrice: round(h.avg_price),
|
||||
price: round(price),
|
||||
value: round(value),
|
||||
pnl: round(pnl),
|
||||
pnlPct: round(pnlPct, 2),
|
||||
side: h.quantity >= 0 ? 'long' : 'short',
|
||||
};
|
||||
});
|
||||
const holdingsValue = holdings.reduce((s, h) => s + h.value, 0);
|
||||
return {
|
||||
balance: round(user.balance),
|
||||
holdingsValue: round(holdingsValue),
|
||||
netWorth: round(user.balance + holdingsValue),
|
||||
holdings,
|
||||
};
|
||||
}
|
||||
|
||||
function round(n, d = 2) {
|
||||
const f = Math.pow(10, d);
|
||||
return Math.round(n * f) / f;
|
||||
}
|
||||
Reference in New Issue
Block a user