Version 1

This commit is contained in:
2025-11-22 00:53:48 +01:00
parent 75b187b60d
commit 0ed2962a45
27 changed files with 4142 additions and 0 deletions

18
.env Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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}`);
});

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

View 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
View 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
View 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();
}
});

View 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" });
}
});

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

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

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

21
frontend/package.json Normal file
View 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"
}
}

Binary file not shown.

1286
frontend/src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

132
frontend/src/api.ts Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173
}
});