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:
2026-07-02 08:31:36 +02:00
commit 94b6cc3be9
60 changed files with 9206 additions and 0 deletions

70
server/auth.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}