Files
playbull/server/games.js
Mike 94b6cc3be9 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>
2026-07-02 08:31:36 +02:00

248 lines
10 KiB
JavaScript

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 };
}