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:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user