add free spins

This commit is contained in:
2025-11-22 13:26:06 +01:00
parent e6b76add98
commit b30399678f
6 changed files with 297 additions and 104 deletions
+113 -37
View File
@@ -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);
+7 -3
View File
@@ -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" });
}
});
});
+15 -1
View File
@@ -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
};
}
}
+36 -22
View File
@@ -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<Wallet> {
const rows = await query<Wallet>(
`
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<Wallet> {
// Fallback, falls aus irgendeinem Grund noch kein Wallet existiert
const created = await query<Wallet>(
`
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<ClaimResult> {
const client = await pool.connect();
try {
@@ -53,7 +69,12 @@ export async function claimHourlyForUser(userId: number): Promise<ClaimResult> {
const res = await client.query<Wallet>(
`
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<ClaimResult> {
if (res.rows.length === 0) {
const inserted = await client.query<Wallet>(
`
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<ClaimResult> {
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<ClaimResult> {
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<ClaimResult> {
`,
[userId, claimedAmount, "hourly_claim"]
);
// nach einem erfolgreichen Claim ist der nächste in 1h
nextClaimInMs = CLAIM_INTERVAL_MS;
}
await client.query("COMMIT");