mirror of
https://github.com/MrUnknownDE/bierkasten-casino.git
synced 2026-04-19 23:03:44 +02:00
Version 1
This commit is contained in:
18
.env
Normal file
18
.env
Normal file
@@ -0,0 +1,18 @@
|
||||
# Postgres
|
||||
POSTGRES_USER=bierbaron_casino
|
||||
POSTGRES_PASSWORD=7g61eWrF8TqjkHOD7wRADAQOEEDiMYAk
|
||||
POSTGRES_DB=bierbaron_casino
|
||||
DATABASE_URL=postgres://bierbaron_casino:7g61eWrF8TqjkHOD7wRADAQOEEDiMYAk@markus.mrunk.de:5432/bierbaron_casino
|
||||
|
||||
# App
|
||||
APP_PORT=3000
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
FRONTEND_ORIGIN=http://localhost:5173
|
||||
|
||||
# Discord OAuth (Platzhalter, trägst du später ein)
|
||||
DISCORD_CLIENT_ID=1431281331551211712
|
||||
DISCORD_CLIENT_SECRET=TwqM5vjKzAD1lwC1cHs7CWgj-0taugJd
|
||||
DISCORD_REDIRECT_URI=http://localhost:3000/auth/discord/callback
|
||||
|
||||
# Sessions / JWT
|
||||
SESSION_SECRET=qquZXyTC9e8wNTvVrIVqMarfQ92HX9tt
|
||||
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# Postgres
|
||||
POSTGRES_USER=bierbaron
|
||||
POSTGRES_PASSWORD=verysecret
|
||||
POSTGRES_DB=bierbaron_casino
|
||||
DATABASE_URL=postgres://bierbaron:verysecret@db:5432/bierbaron_casino
|
||||
|
||||
# App
|
||||
APP_PORT=3000
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
FRONTEND_ORIGIN=http://localhost:5173
|
||||
|
||||
# Discord OAuth (Platzhalter, trägst du später ein)
|
||||
DISCORD_CLIENT_ID=your_discord_client_id
|
||||
DISCORD_CLIENT_SECRET=your_discord_client_secret
|
||||
DISCORD_REDIRECT_URI=http://localhost:3000/auth/discord/callback
|
||||
|
||||
# Sessions / JWT
|
||||
SESSION_SECRET=change_me_please
|
||||
34
backend/Dockerfile
Normal file
34
backend/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
# --- Build Stage ---
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Nur package-Dateien zuerst kopieren für besseren Cache
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
# Restlicher Code
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
|
||||
# TypeScript bauen
|
||||
RUN npm run build
|
||||
|
||||
# --- Runtime Stage ---
|
||||
FROM node:22-alpine AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Nur nötige Files rüberziehen
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY --from=build /app/dist ./dist
|
||||
|
||||
# Port (aus .env: APP_PORT, default 3000)
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
29
backend/package.json
Normal file
29
backend/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "bierbaron-backend",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.0",
|
||||
"express": "^4.19.0",
|
||||
"express-session": "^1.17.3",
|
||||
"pg": "^8.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.17.8",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.11.6",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
14
backend/src/config.ts
Normal file
14
backend/src/config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.APP_PORT || "3000", 10),
|
||||
databaseUrl: process.env.DATABASE_URL as string,
|
||||
discord: {
|
||||
clientId: process.env.DISCORD_CLIENT_ID as string,
|
||||
clientSecret: process.env.DISCORD_CLIENT_SECRET as string,
|
||||
redirectUri: process.env.DISCORD_REDIRECT_URI as string
|
||||
},
|
||||
sessionSecret: process.env.SESSION_SECRET || "dev-secret",
|
||||
frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173"
|
||||
};
|
||||
12
backend/src/db.ts
Normal file
12
backend/src/db.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Pool } from "pg";
|
||||
import { config } from "./config";
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: config.databaseUrl
|
||||
});
|
||||
|
||||
// kleine Helper
|
||||
export async function query<T = any>(text: string, params?: any[]): Promise<T[]> {
|
||||
const res = await pool.query(text, params);
|
||||
return res.rows as T[];
|
||||
}
|
||||
108
backend/src/index.ts
Normal file
108
backend/src/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// backend/src/index.ts
|
||||
import express from "express";
|
||||
import session from "express-session";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { pool } from "./db";
|
||||
import { config } from "./config";
|
||||
import { authRouter } from "./routes/auth";
|
||||
import { meRouter } from "./routes/me";
|
||||
import { walletRouter } from "./routes/wallet";
|
||||
import { slotRouter } from "./routes/slot";
|
||||
import { BalanceLeaderboardEntry, BigWinLeaderboardEntry} from "./routes/leaderboard";
|
||||
|
||||
const app = express();
|
||||
|
||||
// --- KORREKTUR: Reverse Proxy ---
|
||||
// Dem Server mitteilen, dass er hinter einem Proxy läuft (z.B. Nginx).
|
||||
// '1' bedeutet, dass wir dem ersten vorgeschalteten Proxy vertrauen.
|
||||
// Dies ist entscheidend, damit `req.secure` und `req.protocol` korrekt funktionieren.
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
app.use(cors({
|
||||
origin: config.frontendOrigin,
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(
|
||||
session({
|
||||
secret: config.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
// --- KORREKTUR: Reverse Proxy ---
|
||||
// Das Cookie wird nur über HTTPS gesendet, wenn die App in Produktion läuft.
|
||||
// Die `trust proxy` Einstellung oben sorgt dafür, dass dies auch hinter
|
||||
// einem HTTPS-Proxy korrekt erkannt wird.
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
// 'lax' ist ein guter Standard für die SameSite-Policy.
|
||||
sameSite: "lax",
|
||||
maxAge: 1000 * 60 * 60 * 24 * 7
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Simple healthcheck
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
app.get("/api/leaderboard/balance", async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query<BalanceLeaderboardEntry>(
|
||||
`
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.discord_name,
|
||||
u.avatar_url,
|
||||
w.balance
|
||||
FROM wallets w
|
||||
JOIN users u ON u.id = w.user_id
|
||||
ORDER BY w.balance DESC
|
||||
LIMIT 20
|
||||
`
|
||||
);
|
||||
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error("Error fetching balance leaderboard", err);
|
||||
res.status(500).json({ error: "Failed to fetch balance leaderboard" });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Leaderboard: Größter Einzelgewinn pro User ---
|
||||
app.get("/api/leaderboard/bigwin", async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query<BigWinLeaderboardEntry>(
|
||||
`
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.discord_name,
|
||||
u.avatar_url,
|
||||
MAX(sr.win_amount) AS biggest_win
|
||||
FROM slot_rounds sr
|
||||
JOIN users u ON u.id = sr.user_id
|
||||
GROUP BY u.id, u.discord_name, u.avatar_url
|
||||
HAVING MAX(sr.win_amount) > 0
|
||||
ORDER BY biggest_win DESC
|
||||
LIMIT 20
|
||||
`
|
||||
);
|
||||
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error("Error fetching bigwin leaderboard", err);
|
||||
res.status(500).json({ error: "Failed to fetch bigwin leaderboard" });
|
||||
}
|
||||
});
|
||||
|
||||
app.use("/auth", authRouter);
|
||||
app.use("/me", meRouter);
|
||||
app.use("/wallet", walletRouter);
|
||||
app.use("/slot", slotRouter);
|
||||
|
||||
app.listen(config.port, () => {
|
||||
console.log(`Bierbaron backend läuft auf Port ${config.port}`);
|
||||
});
|
||||
82
backend/src/routes/auth.ts
Normal file
82
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Router } from "express";
|
||||
import axios from "axios";
|
||||
import { config } from "../config";
|
||||
import { upsertDiscordUser } from "../services/userService";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
// 1) Redirect zu Discord
|
||||
authRouter.get("/discord", (req, res) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.discord.clientId,
|
||||
redirect_uri: config.discord.redirectUri,
|
||||
response_type: "code",
|
||||
scope: "identify"
|
||||
});
|
||||
|
||||
res.redirect(`https://discord.com/api/oauth2/authorize?${params.toString()}`);
|
||||
});
|
||||
|
||||
// 2) Callback von Discord
|
||||
authRouter.get("/discord/callback", async (req, res) => {
|
||||
const code = req.query.code as string | undefined;
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: "Missing code" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Token holen
|
||||
const tokenRes = await axios.post(
|
||||
"https://discord.com/api/oauth2/token",
|
||||
new URLSearchParams({
|
||||
client_id: config.discord.clientId,
|
||||
client_secret: config.discord.clientSecret,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: config.discord.redirectUri
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const accessToken = tokenRes.data.access_token as string;
|
||||
|
||||
// User-Info holen
|
||||
const userRes = await axios.get("https://discord.com/api/users/@me", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
const discordUser = userRes.data as any;
|
||||
const discordId = discordUser.id;
|
||||
const discordName =
|
||||
discordUser.global_name ||
|
||||
`${discordUser.username}#${discordUser.discriminator}`;
|
||||
const avatarUrl = discordUser.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${discordUser.id}/${discordUser.avatar}.png`
|
||||
: null;
|
||||
|
||||
const user = await upsertDiscordUser(discordId, discordName, avatarUrl);
|
||||
|
||||
// In Session speichern (super simpel)
|
||||
// @ts-ignore
|
||||
req.session.userId = user.id;
|
||||
|
||||
res.redirect(config.frontendOrigin);
|
||||
} catch (err: any) {
|
||||
console.error("Discord OAuth error:", err.response?.data || err.message);
|
||||
res.status(500).json({ error: "OAuth failed" });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
authRouter.post("/logout", (req, res) => {
|
||||
// @ts-ignore
|
||||
req.session.destroy(() => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
});
|
||||
13
backend/src/routes/leaderboard.ts
Normal file
13
backend/src/routes/leaderboard.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface BalanceLeaderboardEntry {
|
||||
user_id: number;
|
||||
discord_name: string;
|
||||
avatar_url: string | null;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface BigWinLeaderboardEntry {
|
||||
user_id: number;
|
||||
discord_name: string;
|
||||
avatar_url: string | null;
|
||||
biggest_win: number;
|
||||
}
|
||||
26
backend/src/routes/me.ts
Normal file
26
backend/src/routes/me.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Router } from "express";
|
||||
import { query } from "../db";
|
||||
|
||||
export const meRouter = Router();
|
||||
|
||||
meRouter.get("/", async (req, res) => {
|
||||
// @ts-ignore
|
||||
const userId = req.session.userId as number | undefined;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Not logged in" });
|
||||
}
|
||||
|
||||
const [user] = await query(
|
||||
`
|
||||
SELECT u.id, u.discord_id, u.discord_name, u.avatar_url,
|
||||
w.balance, w.last_claim_at
|
||||
FROM users u
|
||||
LEFT JOIN wallets w ON w.user_id = u.id
|
||||
WHERE u.id = $1;
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.json(user);
|
||||
});
|
||||
133
backend/src/routes/slot.ts
Normal file
133
backend/src/routes/slot.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// backend/src/routes/slot.ts
|
||||
import { Router } from "express";
|
||||
import { pool } from "../db";
|
||||
import { spinBookOfBier } from "../services/slotService";
|
||||
|
||||
export const slotRouter = Router();
|
||||
|
||||
function requireAuth(req: any, res: any, next: any) {
|
||||
const userId = req.session?.userId as number | undefined;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Not logged in" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// 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 client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const walletRes = await client.query<{
|
||||
user_id: number;
|
||||
balance: number;
|
||||
}>(
|
||||
`
|
||||
SELECT user_id, balance
|
||||
FROM wallets
|
||||
WHERE user_id = $1
|
||||
FOR UPDATE
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (walletRes.rows.length === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(400).json({ error: "Wallet not found" });
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
// Spin ausführen (reine Logik)
|
||||
const spin = spinBookOfBier(bet);
|
||||
const winAmount = spin.totalWin;
|
||||
|
||||
const newBalance = wallet.balance - bet + winAmount;
|
||||
|
||||
// Wallet aktualisieren
|
||||
const updatedWallet = await client.query<{
|
||||
user_id: number;
|
||||
balance: number;
|
||||
}>(
|
||||
`
|
||||
UPDATE wallets
|
||||
SET balance = $2
|
||||
WHERE user_id = $1
|
||||
RETURNING user_id, balance
|
||||
`,
|
||||
[userId, newBalance]
|
||||
);
|
||||
|
||||
// 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 (winAmount > 0) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO wallet_transactions (user_id, amount, reason)
|
||||
VALUES ($1, $2, $3)
|
||||
`,
|
||||
[userId, winAmount, "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)
|
||||
`,
|
||||
[
|
||||
userId,
|
||||
"book_of_bier",
|
||||
bet,
|
||||
winAmount,
|
||||
spin.bookCount,
|
||||
JSON.stringify(spin.grid)
|
||||
]
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
res.json({
|
||||
bet_amount: bet,
|
||||
win_amount: winAmount,
|
||||
balance_after: updatedWallet.rows[0].balance,
|
||||
book_count: spin.bookCount,
|
||||
grid: spin.grid,
|
||||
line_wins: spin.lineWins
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("POST /slot/book-of-bier/spin error:", err);
|
||||
await client.query("ROLLBACK");
|
||||
res.status(500).json({ error: "Slot spin failed" });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
51
backend/src/routes/wallet.ts
Normal file
51
backend/src/routes/wallet.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Router } from "express";
|
||||
import { getWalletForUser, claimHourlyForUser, computeNextClaimMs } from "../services/walletService";
|
||||
|
||||
export const walletRouter = Router();
|
||||
|
||||
// Auth-Guard (simpel)
|
||||
function requireAuth(req: any, res: any, next: any) {
|
||||
const userId = req.session?.userId as number | undefined;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Not logged in" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// GET /wallet -> aktuelles Wallet + Zeit bis zum nächsten Claim
|
||||
walletRouter.get("/", requireAuth, async (req: any, res) => {
|
||||
const userId = req.session.userId as number;
|
||||
try {
|
||||
const wallet = await getWalletForUser(userId);
|
||||
const nextClaimInMs = computeNextClaimMs(wallet.last_claim_at);
|
||||
|
||||
res.json({
|
||||
user_id: wallet.user_id,
|
||||
balance: wallet.balance,
|
||||
last_claim_at: wallet.last_claim_at,
|
||||
next_claim_in_ms: nextClaimInMs
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("GET /wallet error:", err);
|
||||
res.status(500).json({ error: "Failed to load wallet" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /wallet/claim -> versucht, stündliche Bierkästen zu claimen
|
||||
walletRouter.post("/claim", requireAuth, async (req: any, res) => {
|
||||
const userId = req.session.userId as number;
|
||||
try {
|
||||
const result = await claimHourlyForUser(userId);
|
||||
|
||||
res.json({
|
||||
user_id: result.wallet.user_id,
|
||||
balance: result.wallet.balance,
|
||||
last_claim_at: result.wallet.last_claim_at,
|
||||
claimed_amount: result.claimedAmount,
|
||||
next_claim_in_ms: result.nextClaimInMs
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("POST /wallet/claim error:", err);
|
||||
res.status(500).json({ error: "Failed to claim beer crates" });
|
||||
}
|
||||
});
|
||||
176
backend/src/services/slotService.ts
Normal file
176
backend/src/services/slotService.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// backend/src/services/slotService.ts
|
||||
export type SymbolId =
|
||||
| "TEN" | "J" | "Q" | "K" | "A"
|
||||
| "MUG" | "BARREL" | "BARON"
|
||||
| "BOOK";
|
||||
|
||||
interface SymbolDefinition {
|
||||
id: SymbolId;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
const SYMBOLS: SymbolDefinition[] = [
|
||||
{ id: "TEN", weight: 40 },
|
||||
{ id: "J", weight: 40 },
|
||||
{ id: "Q", weight: 35 },
|
||||
{ id: "K", weight: 30 },
|
||||
{ id: "A", weight: 30 },
|
||||
{ id: "MUG", weight: 15 },
|
||||
{ id: "BARREL", weight: 10 },
|
||||
{ id: "BARON", weight: 5 },
|
||||
{ id: "BOOK", weight: 5 }
|
||||
];
|
||||
|
||||
const PAYTABLE: Record<Exclude<SymbolId, "BOOK">, { [count: number]: number }> = {
|
||||
TEN: { 3: 5, 4: 10, 5: 20 },
|
||||
J: { 3: 5, 4: 10, 5: 20 },
|
||||
Q: { 3: 5, 4: 10, 5: 20 },
|
||||
K: { 3: 10, 4: 20, 5: 40 },
|
||||
A: { 3: 10, 4: 20, 5: 40 },
|
||||
MUG: { 3: 20, 4: 40, 5: 80 },
|
||||
BARREL: { 3: 30, 4: 60, 5: 120 },
|
||||
BARON: { 3: 50, 4: 100, 5: 200 }
|
||||
};
|
||||
|
||||
// Scatter-Bonus: 3+ BOOK irgendwo auf dem Board
|
||||
const BOOK_SCATTER: { [count: number]: number } = {
|
||||
3: 2,
|
||||
4: 5,
|
||||
5: 20
|
||||
};
|
||||
|
||||
export interface LineWin {
|
||||
lineIndex: number;
|
||||
symbol: SymbolId;
|
||||
count: number;
|
||||
win: number;
|
||||
}
|
||||
|
||||
export interface SpinResult {
|
||||
grid: SymbolId[][];
|
||||
totalWin: number;
|
||||
lineWins: LineWin[];
|
||||
bookCount: number;
|
||||
}
|
||||
|
||||
function randomSymbol(): SymbolId {
|
||||
const totalWeight = SYMBOLS.reduce((s, sym) => s + sym.weight, 0);
|
||||
let r = Math.random() * totalWeight;
|
||||
for (const sym of SYMBOLS) {
|
||||
r -= sym.weight;
|
||||
if (r <= 0) return sym.id;
|
||||
}
|
||||
return SYMBOLS[0].id;
|
||||
}
|
||||
|
||||
// 5 Walzen x 3 Reihen
|
||||
function generateGrid(): SymbolId[][] {
|
||||
const reels = 5;
|
||||
const rows = 3;
|
||||
const grid: SymbolId[][] = [];
|
||||
for (let r = 0; r < reels; r++) {
|
||||
const col: SymbolId[] = [];
|
||||
for (let row = 0; row < rows; row++) {
|
||||
col.push(randomSymbol());
|
||||
}
|
||||
grid.push(col);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
// 10 einfache Gewinnlinien (je 5 Koordinaten [reel,row])
|
||||
const PAYLINES: [number, number][][] = [
|
||||
// 0: gerade Mitte
|
||||
[[0,1],[1,1],[2,1],[3,1],[4,1]],
|
||||
// 1: gerade oben
|
||||
[[0,0],[1,0],[2,0],[3,0],[4,0]],
|
||||
// 2: gerade unten
|
||||
[[0,2],[1,2],[2,2],[3,2],[4,2]],
|
||||
// 3: V oben -> unten -> oben
|
||||
[[0,0],[1,1],[2,2],[3,1],[4,0]],
|
||||
// 4: V unten -> oben -> unten
|
||||
[[0,2],[1,1],[2,0],[3,1],[4,2]],
|
||||
// 5: Diagonale oben links -> unten rechts
|
||||
[[0,0],[1,1],[2,2],[3,2],[4,2]],
|
||||
// 6: Diagonale unten links -> oben rechts
|
||||
[[0,2],[1,1],[2,0],[3,0],[4,0]],
|
||||
// 7: Z-Mitte
|
||||
[[0,1],[1,0],[2,1],[3,2],[4,1]],
|
||||
// 8: Z gespiegelt
|
||||
[[0,1],[1,2],[2,1],[3,0],[4,1]],
|
||||
// 9: W-förmig
|
||||
[[0,0],[1,1],[2,0],[3,1],[4,0]]
|
||||
];
|
||||
|
||||
function countBooks(grid: SymbolId[][]): number {
|
||||
let count = 0;
|
||||
for (let r = 0; r < grid.length; r++) {
|
||||
for (let c = 0; c < grid[r].length; c++) {
|
||||
if (grid[r][c] === "BOOK") count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function evaluateLines(grid: SymbolId[][], bet: number): LineWin[] {
|
||||
const lineWins: LineWin[] = [];
|
||||
|
||||
PAYLINES.forEach((line, lineIndex) => {
|
||||
const [startReel, startRow] = line[0];
|
||||
const firstSymbol = grid[startReel][startRow];
|
||||
|
||||
if (firstSymbol === "BOOK") {
|
||||
return; // keine Liniengewinne für Scatter
|
||||
}
|
||||
|
||||
let count = 1;
|
||||
for (let i = 1; i < line.length; i++) {
|
||||
const [reel, row] = line[i];
|
||||
if (grid[reel][row] === firstSymbol) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (count >= 3 && firstSymbol in PAYTABLE) {
|
||||
const payConfig = PAYTABLE[firstSymbol as Exclude<SymbolId, "BOOK">];
|
||||
const multiplier = payConfig[count];
|
||||
if (multiplier && multiplier > 0) {
|
||||
const win = bet * multiplier;
|
||||
lineWins.push({
|
||||
lineIndex,
|
||||
symbol: firstSymbol,
|
||||
count,
|
||||
win
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return lineWins;
|
||||
}
|
||||
|
||||
export function spinBookOfBier(bet: number): SpinResult {
|
||||
const grid = generateGrid();
|
||||
const lineWins = evaluateLines(grid, bet);
|
||||
const lineWinTotal = lineWins.reduce((s, lw) => s + lw.win, 0);
|
||||
|
||||
const books = countBooks(grid);
|
||||
let scatterWin = 0;
|
||||
|
||||
if (books >= 3) {
|
||||
const key = books > 5 ? 5 : books;
|
||||
const mult = BOOK_SCATTER[key] || BOOK_SCATTER[3];
|
||||
scatterWin = bet * mult;
|
||||
}
|
||||
|
||||
const totalWin = lineWinTotal + scatterWin;
|
||||
|
||||
return {
|
||||
grid,
|
||||
totalWin,
|
||||
lineWins,
|
||||
bookCount: books
|
||||
};
|
||||
}
|
||||
40
backend/src/services/userService.ts
Normal file
40
backend/src/services/userService.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { query } from "../db";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
discord_id: string;
|
||||
discord_name: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export async function upsertDiscordUser(
|
||||
discordId: string,
|
||||
discordName: string,
|
||||
avatarUrl: string | null
|
||||
): Promise<User> {
|
||||
const rows = await query<User>(
|
||||
`
|
||||
INSERT INTO users (discord_id, discord_name, avatar_url)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (discord_id)
|
||||
DO UPDATE SET
|
||||
discord_name = EXCLUDED.discord_name,
|
||||
avatar_url = EXCLUDED.avatar_url,
|
||||
updated_at = now()
|
||||
RETURNING *;
|
||||
`,
|
||||
[discordId, discordName, avatarUrl]
|
||||
);
|
||||
|
||||
// Wallet für neuen User anlegen, falls nicht existiert
|
||||
await query(
|
||||
`
|
||||
INSERT INTO wallets (user_id, balance)
|
||||
SELECT $1, 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM wallets WHERE user_id = $1);
|
||||
`,
|
||||
[rows[0].id]
|
||||
);
|
||||
|
||||
return rows[0];
|
||||
}
|
||||
151
backend/src/services/walletService.ts
Normal file
151
backend/src/services/walletService.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { pool, query } from "../db";
|
||||
|
||||
export interface Wallet {
|
||||
user_id: number;
|
||||
balance: number;
|
||||
last_claim_at: string | null;
|
||||
}
|
||||
|
||||
const HOURLY_RATE = 25;
|
||||
const CLAIM_INTERVAL_MS = 60 * 60 * 1000; // 1 Stunde
|
||||
|
||||
export async function getWalletForUser(userId: number): Promise<Wallet> {
|
||||
const rows = await query<Wallet>(
|
||||
`
|
||||
SELECT user_id, balance, last_claim_at
|
||||
FROM wallets
|
||||
WHERE user_id = $1
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
// 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
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
return created[0];
|
||||
}
|
||||
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export interface ClaimResult {
|
||||
wallet: Wallet;
|
||||
claimedAmount: number;
|
||||
nextClaimInMs: number;
|
||||
}
|
||||
|
||||
export async function claimHourlyForUser(userId: number): Promise<ClaimResult> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const res = await client.query<Wallet>(
|
||||
`
|
||||
SELECT user_id, balance, last_claim_at
|
||||
FROM wallets
|
||||
WHERE user_id = $1
|
||||
FOR UPDATE
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
let wallet: Wallet;
|
||||
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
|
||||
`,
|
||||
[userId]
|
||||
);
|
||||
wallet = inserted.rows[0];
|
||||
} else {
|
||||
wallet = res.rows[0];
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const lastClaim = wallet.last_claim_at ? new Date(wallet.last_claim_at) : null;
|
||||
|
||||
let claimedAmount = 0;
|
||||
let nextClaimInMs = 0;
|
||||
|
||||
if (!lastClaim) {
|
||||
// Erste Claim: direkt 25 geben
|
||||
claimedAmount = HOURLY_RATE;
|
||||
} else {
|
||||
const diffMs = now.getTime() - lastClaim.getTime();
|
||||
const intervals = Math.floor(diffMs / CLAIM_INTERVAL_MS); // volle Stunden
|
||||
|
||||
if (intervals >= 1) {
|
||||
claimedAmount = intervals * HOURLY_RATE;
|
||||
} else {
|
||||
claimedAmount = 0;
|
||||
nextClaimInMs = CLAIM_INTERVAL_MS - diffMs;
|
||||
}
|
||||
}
|
||||
|
||||
let newBalance = wallet.balance;
|
||||
let newLastClaim = wallet.last_claim_at;
|
||||
|
||||
if (claimedAmount > 0) {
|
||||
newBalance = wallet.balance + claimedAmount;
|
||||
newLastClaim = now.toISOString();
|
||||
|
||||
const updated = await client.query<Wallet>(
|
||||
`
|
||||
UPDATE wallets
|
||||
SET balance = $2,
|
||||
last_claim_at = $3
|
||||
WHERE user_id = $1
|
||||
RETURNING user_id, balance, last_claim_at
|
||||
`,
|
||||
[userId, newBalance, newLastClaim]
|
||||
);
|
||||
|
||||
wallet = updated.rows[0];
|
||||
|
||||
// Transaktion für History
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO wallet_transactions (user_id, amount, reason)
|
||||
VALUES ($1, $2, $3)
|
||||
`,
|
||||
[userId, claimedAmount, "hourly_claim"]
|
||||
);
|
||||
|
||||
// nach einem erfolgreichen Claim ist der nächste in 1h
|
||||
nextClaimInMs = CLAIM_INTERVAL_MS;
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
return {
|
||||
wallet,
|
||||
claimedAmount,
|
||||
nextClaimInMs
|
||||
};
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export function computeNextClaimMs(last_claim_at: string | null): number {
|
||||
if (!last_claim_at) return 0;
|
||||
const now = new Date();
|
||||
const last = new Date(last_claim_at);
|
||||
const diffMs = now.getTime() - last.getTime();
|
||||
|
||||
if (diffMs >= CLAIM_INTERVAL_MS) return 0;
|
||||
return CLAIM_INTERVAL_MS - diffMs;
|
||||
}
|
||||
14
backend/tsconfig.json
Normal file
14
backend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: bierbaron_backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
- bierbaron_net
|
||||
frontend:
|
||||
build: ./frontend
|
||||
container_name: bierbaron_frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- VITE_API_BASE_URL=http://localhost:3000
|
||||
ports:
|
||||
- "5173:5173"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- bierbaron_net
|
||||
|
||||
networks:
|
||||
bierbaron_net:
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
19
frontend/Dockerfile
Normal file
19
frontend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
# frontend/Dockerfile
|
||||
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Nur package-Dateien zuerst für Cache
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
# Rest kopieren
|
||||
COPY tsconfig.json vite.config.ts index.html ./
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
# Vite Dev-Server nach außen freigeben
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Bierbaron Casino</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</head>
|
||||
<body style="margin:0;background:#0b0b10;color:#f5f5f5;font-family:system-ui,sans-serif;">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1686
frontend/package-lock.json
generated
Normal file
1686
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "bierbaron-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/sounds/spin.mp3
Normal file
BIN
frontend/public/sounds/spin.mp3
Normal file
Binary file not shown.
1286
frontend/src/App.tsx
Normal file
1286
frontend/src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
132
frontend/src/api.ts
Normal file
132
frontend/src/api.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// frontend/src/api.ts
|
||||
// API-Calls (JSON)
|
||||
const API_BASE =
|
||||
// --- KORREKTUR: Falscher Variablenname ---
|
||||
// Die Variable in docker-compose.yml heißt VITE_API_BASE_URL.
|
||||
import.meta.env.VITE_API_BASE_URL ||
|
||||
`http://localhost:3000`;
|
||||
|
||||
async function apiGet<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
credentials: "include",
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json().catch(() => ({ error: "Request failed" }));
|
||||
throw new Error(errorBody.error || `Request failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function apiPost<T>(
|
||||
path: string,
|
||||
body?: any,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorBody = await res.json().catch(() => ({ error: "Request failed" }));
|
||||
throw new Error(errorBody.error || `Request failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface MeResponse {
|
||||
id: number;
|
||||
discord_id: string;
|
||||
discord_name: string;
|
||||
avatar_url: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WalletResponse {
|
||||
user_id: number;
|
||||
balance: number;
|
||||
last_claim_at: string | null;
|
||||
next_claim_in_ms: number;
|
||||
}
|
||||
|
||||
export interface SlotSpinLineWin {
|
||||
lineIndex: number;
|
||||
symbol: string;
|
||||
count: number;
|
||||
win: number;
|
||||
}
|
||||
|
||||
export interface SlotSpinResponse {
|
||||
bet_amount: number;
|
||||
win_amount: number;
|
||||
balance_after: number;
|
||||
book_count: number;
|
||||
grid: string[][]; // [reel][row]
|
||||
line_wins: SlotSpinLineWin[];
|
||||
}
|
||||
|
||||
export interface BalanceLeaderboardEntry {
|
||||
user_id: number;
|
||||
discord_name: string;
|
||||
avatar_url: string | null;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface BigWinLeaderboardEntry {
|
||||
user_id: number;
|
||||
discord_name: string;
|
||||
avatar_url: string | null;
|
||||
biggest_win: number;
|
||||
}
|
||||
|
||||
// --- Auth / User ---
|
||||
|
||||
export async function getMe(): Promise<MeResponse> {
|
||||
return apiGet<MeResponse>("/me");
|
||||
}
|
||||
|
||||
export function getLoginUrl(): string {
|
||||
// --- KORREKTUR: Falsche Login-URL ---
|
||||
// Die Route im Backend lautet /auth/discord, nicht /auth/login/discord.
|
||||
return `${API_BASE}/auth/discord`;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await apiPost<{}>("/auth/logout");
|
||||
}
|
||||
|
||||
// --- Wallet ---
|
||||
|
||||
export async function getWallet(): Promise<WalletResponse> {
|
||||
return apiGet<WalletResponse>("/wallet");
|
||||
}
|
||||
|
||||
export async function claimWallet(): Promise<WalletResponse> {
|
||||
return apiPost<WalletResponse>("/wallet/claim");
|
||||
}
|
||||
|
||||
// --- Slot: Book of Bier ---
|
||||
|
||||
export async function spinBookOfBier(betAmount: number): Promise<SlotSpinResponse> {
|
||||
return apiPost<SlotSpinResponse>("/slot/book-of-bier/spin", {
|
||||
bet_amount: betAmount,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Leaderboards ---
|
||||
|
||||
export async function getBalanceLeaderboard(): Promise<BalanceLeaderboardEntry[]> {
|
||||
return apiGet<BalanceLeaderboardEntry[]>("/api/leaderboard/balance");
|
||||
}
|
||||
|
||||
export async function getBigWinLeaderboard(): Promise<BigWinLeaderboardEntry[]> {
|
||||
return apiGet<BigWinLeaderboardEntry[]>("/api/leaderboard/bigwin");
|
||||
}
|
||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
9
frontend/vite.config.ts
Normal file
9
frontend/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user