From b30399678f8f2b9d8c8f859c4b221549af2d3a2d Mon Sep 17 00:00:00 2001 From: MrUnknownDE Date: Sat, 22 Nov 2025 13:26:06 +0100 Subject: [PATCH] add free spins --- backend/src/routes/slot.ts | 150 ++++++++++++++++++------ backend/src/routes/wallet.ts | 10 +- backend/src/services/slotService.ts | 16 ++- backend/src/services/walletService.ts | 58 ++++++---- frontend/src/App.tsx | 160 +++++++++++++++++++------- frontend/src/api.ts | 7 ++ 6 files changed, 297 insertions(+), 104 deletions(-) diff --git a/backend/src/routes/slot.ts b/backend/src/routes/slot.ts index f50a2ea..80e5fb2 100644 --- a/backend/src/routes/slot.ts +++ b/backend/src/routes/slot.ts @@ -1,7 +1,6 @@ -// backend/src/routes/slot.ts import { Router } from "express"; import { pool } from "../db"; -import { spinBookOfBier } from "../services/slotService"; +import { spinBookOfBier, getFreeSpinsForBooks } from "../services/slotService"; export const slotRouter = Router(); @@ -16,18 +15,9 @@ function requireAuth(req: any, res: any, next: any) { // POST /slot/book-of-bier/spin slotRouter.post("/book-of-bier/spin", requireAuth, async (req: any, res) => { const userId = req.session.userId as number; - // --- KORREKTUR: Falscher Parametername --- - // Das Frontend sendet `bet_amount`, nicht `bet`. + const betRaw = req.body?.bet_amount; - - const bet = parseInt(betRaw, 10); - if (!Number.isFinite(bet) || bet <= 0) { - return res.status(400).json({ error: "Invalid bet amount" }); - } - - if (bet > 1000) { - return res.status(400).json({ error: "Bet too high (max 1000 Bierkästen)" }); - } + const requestedBet = parseInt(betRaw, 10); const client = await pool.connect(); try { @@ -36,9 +26,15 @@ slotRouter.post("/book-of-bier/spin", requireAuth, async (req: any, res) => { const walletRes = await client.query<{ user_id: number; balance: number; + free_spins_bob_remaining: number; + free_spins_bob_bet: number | null; }>( ` - SELECT user_id, balance + SELECT + user_id, + balance, + free_spins_bob_remaining, + free_spins_bob_bet FROM wallets WHERE user_id = $1 FOR UPDATE @@ -53,39 +49,108 @@ slotRouter.post("/book-of-bier/spin", requireAuth, async (req: any, res) => { const wallet = walletRes.rows[0]; - if (wallet.balance < bet) { - await client.query("ROLLBACK"); - return res.status(400).json({ error: "Nicht genug Bierkästen für diese Wette" }); + const hasFreeSpins = (wallet.free_spins_bob_remaining || 0) > 0; + + let isFreeSpin = false; + let effectiveBet: number; + + if (hasFreeSpins) { + isFreeSpin = true; + // Falls aus irgendeinem Grund kein Bet gespeichert ist, fallback auf 10 + effectiveBet = + wallet.free_spins_bob_bet && wallet.free_spins_bob_bet > 0 + ? wallet.free_spins_bob_bet + : Number.isFinite(requestedBet) && requestedBet > 0 + ? requestedBet + : 10; + } else { + if (!Number.isFinite(requestedBet) || requestedBet <= 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ error: "Invalid bet amount" }); + } + + if (requestedBet > 1000) { + await client.query("ROLLBACK"); + return res + .status(400) + .json({ error: "Bet too high (max 1000 Bierkästen)" }); + } + + if (wallet.balance < requestedBet) { + await client.query("ROLLBACK"); + return res + .status(400) + .json({ error: "Nicht genug Bierkästen für diese Wette" }); + } + + effectiveBet = requestedBet; } // Spin ausführen (reine Logik) - const spin = spinBookOfBier(bet); + const spin = spinBookOfBier(effectiveBet); const winAmount = spin.totalWin; - const newBalance = wallet.balance - bet + winAmount; + let newBalance = wallet.balance; + + if (isFreeSpin) { + // Bei Freispielen kein Abzug, nur Gewinne drauf + newBalance = wallet.balance + winAmount; + } else { + // Normales Spiel: Einsatz abziehen, Gewinn addieren + newBalance = wallet.balance - effectiveBet + winAmount; + } + + // Free-Spin-Zustand aktualisieren + let newFreeSpinsRemaining = wallet.free_spins_bob_remaining || 0; + let newFreeSpinsBet = wallet.free_spins_bob_bet; + let freeSpinsAwardedThisSpin = 0; + + if (isFreeSpin) { + newFreeSpinsRemaining = Math.max(0, newFreeSpinsRemaining - 1); + if (newFreeSpinsRemaining === 0) { + newFreeSpinsBet = null; + } + // In Freispielen selbst werden KEINE neuen Freispiele vergeben, + // um unendliche Ketten zu vermeiden. + } else { + // Normales Spiel: hier können Freispiele frisch gewonnen werden + const fs = getFreeSpinsForBooks(spin.bookCount); + if (fs > 0) { + freeSpinsAwardedThisSpin = fs; + newFreeSpinsRemaining = fs; + newFreeSpinsBet = effectiveBet; + } + } // Wallet aktualisieren const updatedWallet = await client.query<{ user_id: number; balance: number; + free_spins_bob_remaining: number; + free_spins_bob_bet: number | null; }>( ` UPDATE wallets - SET balance = $2 + SET balance = $2, + free_spins_bob_remaining = $3, + free_spins_bob_bet = $4 WHERE user_id = $1 - RETURNING user_id, balance + RETURNING user_id, balance, free_spins_bob_remaining, free_spins_bob_bet `, - [userId, newBalance] + [userId, newBalance, newFreeSpinsRemaining, newFreeSpinsBet] ); // Transaktionen loggen - await client.query( - ` - INSERT INTO wallet_transactions (user_id, amount, reason) - VALUES ($1, $2, $3) - `, - [userId, -bet, "slot_bet:book_of_bier"] - ); + if (!isFreeSpin) { + // Einsatz + await client.query( + ` + INSERT INTO wallet_transactions (user_id, amount, reason) + VALUES ($1, $2, $3) + `, + [userId, -effectiveBet, "slot_bet:book_of_bier"] + ); + } if (winAmount > 0) { await client.query( @@ -93,35 +158,46 @@ slotRouter.post("/book-of-bier/spin", requireAuth, async (req: any, res) => { INSERT INTO wallet_transactions (user_id, amount, reason) VALUES ($1, $2, $3) `, - [userId, winAmount, "slot_win:book_of_bier"] + [ + userId, + winAmount, + isFreeSpin ? "slot_win_free:book_of_bier" : "slot_win:book_of_bier" + ] ); } // Slot-Runde loggen await client.query( ` - INSERT INTO slot_rounds (user_id, game_name, bet_amount, win_amount, book_count, grid) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO slot_rounds (user_id, game_name, bet_amount, win_amount, book_count, grid, is_free_spin) + VALUES ($1, $2, $3, $4, $5, $6, $7) `, [ userId, "book_of_bier", - bet, + effectiveBet, winAmount, spin.bookCount, - JSON.stringify(spin.grid) + JSON.stringify(spin.grid), + isFreeSpin ] ); await client.query("COMMIT"); + const walletRow = updatedWallet.rows[0]; + res.json({ - bet_amount: bet, + bet_amount: effectiveBet, win_amount: winAmount, - balance_after: updatedWallet.rows[0].balance, + balance_after: walletRow.balance, book_count: spin.bookCount, grid: spin.grid, - line_wins: spin.lineWins + line_wins: spin.lineWins, + is_free_spin: isFreeSpin, + free_spins_remaining: walletRow.free_spins_bob_remaining, + free_spins_awarded: freeSpinsAwardedThisSpin, + free_spins_bet_amount: walletRow.free_spins_bob_bet }); } catch (err) { console.error("POST /slot/book-of-bier/spin error:", err); diff --git a/backend/src/routes/wallet.ts b/backend/src/routes/wallet.ts index 11daa05..4b8532a 100644 --- a/backend/src/routes/wallet.ts +++ b/backend/src/routes/wallet.ts @@ -23,7 +23,9 @@ walletRouter.get("/", requireAuth, async (req: any, res) => { user_id: wallet.user_id, balance: wallet.balance, last_claim_at: wallet.last_claim_at, - next_claim_in_ms: nextClaimInMs + next_claim_in_ms: nextClaimInMs, + free_spins_bob_remaining: wallet.free_spins_bob_remaining, + free_spins_bob_bet: wallet.free_spins_bob_bet }); } catch (err) { console.error("GET /wallet error:", err); @@ -42,10 +44,12 @@ walletRouter.post("/claim", requireAuth, async (req: any, res) => { balance: result.wallet.balance, last_claim_at: result.wallet.last_claim_at, claimed_amount: result.claimedAmount, - next_claim_in_ms: result.nextClaimInMs + next_claim_in_ms: result.nextClaimInMs, + free_spins_bob_remaining: result.wallet.free_spins_bob_remaining, + free_spins_bob_bet: result.wallet.free_spins_bob_bet }); } catch (err) { console.error("POST /wallet/claim error:", err); res.status(500).json({ error: "Failed to claim beer crates" }); } -}); +}); \ No newline at end of file diff --git a/backend/src/services/slotService.ts b/backend/src/services/slotService.ts index e1375fc..1821ab0 100644 --- a/backend/src/services/slotService.ts +++ b/backend/src/services/slotService.ts @@ -1,3 +1,6 @@ +// path: backend/src/services/slotService.ts +import { randomInt } from "crypto"; + // backend/src/services/slotService.ts export type SymbolId = | "TEN" | "J" | "Q" | "K" | "A" @@ -39,6 +42,17 @@ const BOOK_SCATTER: { [count: number]: number } = { 5: 20 }; +// Wie viele Freispiele gibt es bei x Büchern im Hauptspiel? +// 3 Bücher -> 10 Freispiele +// 4 Bücher -> 12 Freispiele +// 5+ Bücher -> 15 Freispiele +export function getFreeSpinsForBooks(bookCount: number): number { + if (bookCount >= 5) return 15; + if (bookCount === 4) return 12; + if (bookCount === 3) return 10; + return 0; +} + export interface LineWin { lineIndex: number; symbol: SymbolId; @@ -173,4 +187,4 @@ export function spinBookOfBier(bet: number): SpinResult { lineWins, bookCount: books }; -} +} \ No newline at end of file diff --git a/backend/src/services/walletService.ts b/backend/src/services/walletService.ts index 0c60e18..bbf95a3 100644 --- a/backend/src/services/walletService.ts +++ b/backend/src/services/walletService.ts @@ -5,19 +5,26 @@ export interface Wallet { user_id: number; balance: number; last_claim_at: string | null; + + free_spins_bob_remaining: number; + free_spins_bob_bet: number | null; } const HOURLY_RATE = 25; const CLAIM_INTERVAL_MS = 60 * 60 * 1000; // 1 Stunde -// Begrenzung: maximal so viele Stunden werden nachträglich gutgeschrieben. -// Beispiel: 24 => max 24 * 25 = 600 Bierkästen pro Claim. -const MAX_OFFLINE_HOURS = 24; +// Harte Obergrenze: Pro Claim maximal so viele Bierkästen gutschreiben. +const MAX_CLAIM_PER_CLAIM = 500; export async function getWalletForUser(userId: number): Promise { const rows = await query( ` - SELECT user_id, balance, last_claim_at + SELECT + user_id, + balance, + last_claim_at, + free_spins_bob_remaining, + free_spins_bob_bet FROM wallets WHERE user_id = $1 `, @@ -28,9 +35,9 @@ export async function getWalletForUser(userId: number): Promise { // Fallback, falls aus irgendeinem Grund noch kein Wallet existiert const created = await query( ` - INSERT INTO wallets (user_id, balance, last_claim_at) - VALUES ($1, 0, NULL) - RETURNING user_id, balance, last_claim_at + INSERT INTO wallets (user_id, balance, last_claim_at, free_spins_bob_remaining, free_spins_bob_bet) + VALUES ($1, 0, NULL, 0, NULL) + RETURNING user_id, balance, last_claim_at, free_spins_bob_remaining, free_spins_bob_bet `, [userId] ); @@ -46,6 +53,15 @@ export interface ClaimResult { nextClaimInMs: number; } +/** + * Claim-Logik: + * - Erste Claim: einmalig HOURLY_RATE. + * - Danach: wenn seit last_claim_at >= 1h vergangen ist, + * werden die vollen "nachholbaren" Stunden berechnet, + * aber pro Claim maximal MAX_CLAIM_PER_CLAIM gutgeschrieben. + * - Egal wie lange jemand AFK war -> pro Klick maximal MAX_CLAIM_PER_CLAIM. + * - Nach einem erfolgreichen Claim wird last_claim_at auf "jetzt" gesetzt. + */ export async function claimHourlyForUser(userId: number): Promise { const client = await pool.connect(); try { @@ -53,7 +69,12 @@ export async function claimHourlyForUser(userId: number): Promise { const res = await client.query( ` - SELECT user_id, balance, last_claim_at + SELECT + user_id, + balance, + last_claim_at, + free_spins_bob_remaining, + free_spins_bob_bet FROM wallets WHERE user_id = $1 FOR UPDATE @@ -65,9 +86,9 @@ export async function claimHourlyForUser(userId: number): Promise { if (res.rows.length === 0) { const inserted = await client.query( ` - INSERT INTO wallets (user_id, balance, last_claim_at) - VALUES ($1, 0, NULL) - RETURNING user_id, balance, last_claim_at + INSERT INTO wallets (user_id, balance, last_claim_at, free_spins_bob_remaining, free_spins_bob_bet) + VALUES ($1, 0, NULL, 0, NULL) + RETURNING user_id, balance, last_claim_at, free_spins_bob_remaining, free_spins_bob_bet `, [userId] ); @@ -83,19 +104,16 @@ export async function claimHourlyForUser(userId: number): Promise { let nextClaimInMs = 0; if (!lastClaim) { - // Erste Claim: direkt 25 geben claimedAmount = HOURLY_RATE; + nextClaimInMs = CLAIM_INTERVAL_MS; } else { const diffMs = now.getTime() - lastClaim.getTime(); if (diffMs >= CLAIM_INTERVAL_MS) { const rawIntervals = Math.floor(diffMs / CLAIM_INTERVAL_MS); - // Begrenzung, um Unreal-Sprünge (75k etc.) zu verhindern - const effectiveIntervals = Math.min(rawIntervals, MAX_OFFLINE_HOURS); + const rawClaim = rawIntervals * HOURLY_RATE; - claimedAmount = effectiveIntervals * HOURLY_RATE; - - // Nach einem erfolgreichen Claim: nächster in 1h + claimedAmount = Math.min(rawClaim, MAX_CLAIM_PER_CLAIM); nextClaimInMs = CLAIM_INTERVAL_MS; } else { claimedAmount = 0; @@ -116,14 +134,13 @@ export async function claimHourlyForUser(userId: number): Promise { SET balance = $2, last_claim_at = $3 WHERE user_id = $1 - RETURNING user_id, balance, last_claim_at + RETURNING user_id, balance, last_claim_at, free_spins_bob_remaining, free_spins_bob_bet `, [userId, newBalance, newLastClaim] ); wallet = updated.rows[0]; - // Transaktion für History await client.query( ` INSERT INTO wallet_transactions (user_id, amount, reason) @@ -131,9 +148,6 @@ export async function claimHourlyForUser(userId: number): Promise { `, [userId, claimedAmount, "hourly_claim"] ); - - // nach einem erfolgreichen Claim ist der nächste in 1h - nextClaimInMs = CLAIM_INTERVAL_MS; } await client.query("COMMIT"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 98331d4..2638107 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,3 @@ -// frontend/src/App.tsx - import React, { useEffect, useRef, useState, useMemo } from "react"; import { getMe, @@ -366,6 +364,8 @@ const App: React.FC = () => { balance: res.balance, last_claim_at: res.last_claim_at, next_claim_in_ms: res.next_claim_in_ms, + free_spins_bob_remaining: res.free_spins_bob_remaining, + free_spins_bob_bet: res.free_spins_bob_bet, }, })); // Claim kann Leaderboard ändern @@ -421,17 +421,26 @@ const App: React.FC = () => { const { wallet } = state; if (!wallet) return; - if (slotBet <= 0) { - setState((prev) => ({ ...prev, error: "Einsatz muss > 0 sein" })); - return; - } + const hasFreeSpins = wallet.free_spins_bob_remaining > 0; + const effectiveBetForDisplay = + hasFreeSpins && wallet.free_spins_bob_bet + ? wallet.free_spins_bob_bet + : slotBet; - if (slotBet > wallet.balance) { - setState((prev) => ({ - ...prev, - error: "Nicht genug Bierkästen für diesen Einsatz", - })); - return; + if (!hasFreeSpins) { + // Nur bei normalen Spins: Validierung + if (slotBet <= 0) { + setState((prev) => ({ ...prev, error: "Einsatz muss > 0 sein" })); + return; + } + + if (slotBet > wallet.balance) { + setState((prev) => ({ + ...prev, + error: "Nicht genug Bierkästen für diesen Einsatz", + })); + return; + } } setSlotSpinning(true); @@ -452,7 +461,7 @@ const App: React.FC = () => { }, 70); try { - const res = await spinBookOfBier(slotBet); + const res = await spinBookOfBier(effectiveBetForDisplay); pendingResultRef.current = res; const start = spinStartTimeRef.current || Date.now(); @@ -497,6 +506,8 @@ const App: React.FC = () => { wallet: { ...prev.wallet, balance: result.balance_after, + free_spins_bob_remaining: result.free_spins_remaining, + free_spins_bob_bet: result.free_spins_bet_amount, }, } : prev @@ -529,6 +540,11 @@ const App: React.FC = () => { const isBigWin = lastSpin && lastSpin.win_amount >= lastSpin.bet_amount * 20; // Schwelle justierbar + const hasFreeSpins = + wallet && wallet.free_spins_bob_remaining && wallet.free_spins_bob_remaining > 0; + const freeSpinBet = + hasFreeSpins && wallet?.free_spins_bob_bet ? wallet.free_spins_bob_bet : null; + return (
{ ? new Date(wallet.last_claim_at).toLocaleString("de-DE") : "noch nie"}

+ {wallet.free_spins_bob_remaining > 0 && ( +

+ 🎁 Aktive Freispiele:{" "} + {wallet.free_spins_bob_remaining} + {wallet.free_spins_bob_bet + ? ` (Einsatz: ${wallet.free_spins_bob_bet} Bierkästen)` + : ""} +

+ )}
{ >

🎰 Book of Bier

- 5 Walzen, 3 Reihen, 10 Gewinnlinien. BOOK ( + 5 Walzen, 3 Reihen, 10 Gewinnlinien. BOOK( {renderSymbol("BOOK")}) ist Scatter: 3+ Bücher geben - Bonus-Gewinne. + Bonus-Gewinne und können Freispiele auslösen.

{ }} >
- Einsatz:  - - ) => { - const v = parseInt(e.target.value || "0", 10); - setSlotBet(Number.isFinite(v) ? v : 0); - }} - style={{ - width: 90, - padding: "4px 6px", - borderRadius: 6, - border: "1px solid #555", - background: "#090910", - color: "#f5f5f5", - textAlign: "center", - }} - />{" "} - Bierkästen + {!hasFreeSpins ? ( + <> + Einsatz:  + + ) => { + const v = parseInt(e.target.value || "0", 10); + setSlotBet(Number.isFinite(v) ? v : 0); + }} + style={{ + width: 90, + padding: "4px 6px", + borderRadius: 6, + border: "1px solid #555", + background: "#090910", + color: "#f5f5f5", + textAlign: "center", + }} + />{" "} + Bierkästen + + ) : ( + <> + 🎁 Freispiel-Einsatz:  + + {freeSpinBet + ? freeSpinBet + : "unbekannt"} + {" "} + Bierkästen + + )}
@@ -857,6 +909,19 @@ const App: React.FC = () => {
+ {lastSpin && lastSpin.free_spins_awarded > 0 && ( +
+ 🎉 Du hast{" "} + {lastSpin.free_spins_awarded} Freispiele gewonnen! +
+ )} + {gridToShow ? (
{ textAlign: "center", }} > - Letzter Spin: + Letzter Spin + {lastSpin?.is_free_spin ? " (Freispiel)" : ""}
{ textAlign: "left", }} > +

+ Spin-Typ:{" "} + + {lastSpin.is_free_spin ? "Freispiel" : "Normaler Spin"} + +

Einsatz: {lastSpin.bet_amount}

@@ -971,6 +1043,12 @@ const App: React.FC = () => {

Bücher im Feld: {lastSpin.book_count}

+ {lastSpin.free_spins_remaining > 0 && ( +

+ Noch aktive Freispiele:{" "} + {lastSpin.free_spins_remaining} +

+ )} {lastSpin.line_wins.length > 0 ? (