From 19619cb57c1cc6e0384f8b8cb6467d829a2b40e5 Mon Sep 17 00:00:00 2001 From: Dicken Date: Sun, 5 Jul 2026 00:08:26 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Schocken=20-=20erstes=20spielbares=20Gr?= =?UTF-8?q?undger=C3=BCst=20(admin=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/tools/schocken/routes.js | 581 ++++++++++++++++++++++++++ frontend/src/App.jsx | 12 +- frontend/src/icons.jsx | 9 + frontend/src/toolRegistry.js | 15 +- frontend/src/tools/schocken.jsx | 595 +++++++++++++++++++++++++++ 5 files changed, 1204 insertions(+), 8 deletions(-) create mode 100644 backend/src/tools/schocken/routes.js create mode 100644 frontend/src/tools/schocken.jsx diff --git a/backend/src/tools/schocken/routes.js b/backend/src/tools/schocken/routes.js new file mode 100644 index 0000000..d5a2719 --- /dev/null +++ b/backend/src/tools/schocken/routes.js @@ -0,0 +1,581 @@ +const express = require('express'); +const db = require('../../db'); +const { authenticate } = require('../../middleware/auth'); +const router = express.Router(); + +// ── DB-Migration ────────────────────────────────────────────────────────────── +(function migrate() { + db.exec(` + CREATE TABLE IF NOT EXISTS schocken_games ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + players TEXT NOT NULL, -- JSON: [{id, username, order}] + status TEXT NOT NULL DEFAULT 'lobby', + -- status: lobby | half1 | half2 | endkampf | finished + phase_status TEXT NOT NULL DEFAULT 'playing', + -- phase_status: playing | finished + stock INTEGER NOT NULL DEFAULT 15, -- Scheiben auf dem Stock + player_chips TEXT NOT NULL DEFAULT '{}', -- JSON: {userId: chipCount} + has_16th INTEGER, -- userId der die 16. Scheibe hat + loser_h1 INTEGER, -- userId Verlierer Hälfte 1 + loser_h2 INTEGER, -- userId Verlierer Hälfte 2 + current_round TEXT, -- JSON: aktueller Rundenstand + round_number INTEGER NOT NULL DEFAULT 1, + beginner_id INTEGER, -- wer fängt die Runde an + current_player_idx INTEGER NOT NULL DEFAULT 0, + max_rolls INTEGER, -- vom Beginner bestimmt + first_round INTEGER NOT NULL DEFAULT 1, -- 1 = erste Runde der Hälfte + drink_losses TEXT NOT NULL DEFAULT '{}', -- {userId: count} + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) + ); + `); +})(); + +// ── Würfellogik ─────────────────────────────────────────────────────────────── +function rollDice(count = 3) { + return Array.from({ length: count }, () => Math.floor(Math.random() * 6) + 1); +} + +// Würfel klassifizieren +function classifyDice(dice) { + const sorted = [...dice].sort((a,b) => a-b); + const [a,b,c] = sorted; + + // Schock aus: 1-1-1 + if (a===1 && b===1 && c===1) return { type:'schock_aus', scheiben: 0, label:'Schock aus! 💀' }; + + // Julchen: 4-2-1 (unsortiert erkannt) + if (dice.includes(1) && dice.includes(2) && dice.includes(4)) return { type:'julchen', scheiben:7, label:'Julchen! 🎉' }; + + // General: 3x gleich (außer 3x1) + if (a===b && b===c) return { type:'general', scheiben:3, label:`General ${a}er! 🎲`, value: a }; + + // Schock: 2x Eins + andere + if (a===1 && b===1) { + const schockVal = c; + const label = schockVal === 2 ? 'Schock doof 😅' : `Schock ${schockVal}!`; + return { type:'schock', scheiben: schockVal, label, value: schockVal }; + } + + // Straße: aufeinanderfolgende Zahlen (nur wenn alle 3 auf einmal gewürfelt) + if (c-a===2 && b-a===1) return { type:'strasse', scheiben:2, label:`Straße ${a}-${b}-${c}!`, value: a }; + + // Normale Zahl + return { type:'normal', scheiben:1, label:'Normal', value: sorted.reduce((s,v)=>s+v,0) }; +} + +// Wertungsvergleich: wer hat schlechtere Würfel (bekommt Scheiben) +// Gibt -1 wenn a schlechter, 1 wenn b schlechter, 0 bei echtem Gleichstand +const TYPE_ORDER = ['normal','strasse','general','schock','julchen','schock_aus']; +function compareResults(a, b) { + const ai = TYPE_ORDER.indexOf(a.result.type); + const bi = TYPE_ORDER.indexOf(b.result.type); + if (ai !== bi) return ai < bi ? -1 : 1; // niedrigerer Typ = schlechter + + // Gleicher Typ — Detailvergleich + if (a.result.type === 'normal') { + // Niedrigere Summe ist schlechter — aber beide haben immer 1 Scheibe, kein Unterschied im Typ + // Tatsächlich keine Hierarchie bei normalen Würfen außer Scheiben=1 + return 0; + } + if (a.result.type === 'strasse') { + if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1; + return 0; + } + if (a.result.type === 'schock') { + if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1; + return 0; + } + if (a.result.type === 'general') { + if (a.result.value !== b.result.value) return a.result.value < b.result.value ? -1 : 1; + return 0; + } + return 0; +} + +function sendPush(userId, title, message) { + const cfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId); + if (!cfg?.app_token || !cfg?.user_key) return; + fetch('https://api.pushover.net/1/messages.json', { + method:'POST', headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }), + }).catch(() => {}); +} + +const uid = req => req.user.id; + +// ── Alle Spiele des Users ───────────────────────────────────────────────────── +router.get('/', authenticate, (req, res) => { + const me = uid(req); + const games = db.prepare(` + SELECT * FROM schocken_games + WHERE players LIKE ? ORDER BY updated_at DESC LIMIT 20 + `).all(`%"id":${me}%`); + res.json(games.map(g => ({ + ...g, + players: JSON.parse(g.players), + player_chips: JSON.parse(g.player_chips), + drink_losses: JSON.parse(g.drink_losses), + current_round: g.current_round ? JSON.parse(g.current_round) : null, + }))); +}); + +// ── Users für Einladen ──────────────────────────────────────────────────────── +router.get('/users', authenticate, (req, res) => { + const me = uid(req); + const isAdmin = req.user?.role === 'admin'; + const users = isAdmin + ? db.prepare('SELECT id, username FROM users WHERE id != ? ORDER BY username').all(me) + : db.prepare('SELECT id, username FROM users WHERE id != ? AND hidden != 1 ORDER BY username').all(me); + res.json(users); +}); + +// ── Einzelnes Spiel ─────────────────────────────────────────────────────────── +router.get('/:id', authenticate, (req, res) => { + const me = uid(req); + const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + const players = JSON.parse(game.players); + if (!players.find(p => p.id === me)) return res.status(403).json({ error: 'Kein Zugriff' }); + res.json({ + ...game, + players, + player_chips: JSON.parse(game.player_chips), + drink_losses: JSON.parse(game.drink_losses), + current_round: game.current_round ? JSON.parse(game.current_round) : null, + }); +}); + +// ── Neues Spiel erstellen ───────────────────────────────────────────────────── +router.post('/', authenticate, (req, res) => { + const me = uid(req); + const { player_ids = [] } = req.body; + if (player_ids.length < 1) return res.status(400).json({ error: 'Mindestens 1 weiterer Spieler' }); + + const allIds = [me, ...player_ids.filter(id => id !== me)]; + const userRows = db.prepare(`SELECT id, username FROM users WHERE id IN (${allIds.map(()=>'?').join(',')}) ORDER BY username`).all(...allIds); + + // Reihenfolge: Ersteller zuerst, dann die anderen + const ordered = [ + userRows.find(u => u.id === me), + ...allIds.slice(1).map(id => userRows.find(u => u.id === id)).filter(Boolean), + ]; + const players = ordered.map((u, i) => ({ id: u.id, username: u.username, order: i })); + const playerChips = Object.fromEntries(players.map(p => [p.id, 0])); + const drinkLosses = Object.fromEntries(players.map(p => [p.id, 0])); + + const result = db.prepare(` + INSERT INTO schocken_games (players, stock, player_chips, drink_losses, beginner_id, status) + VALUES (?, 15, ?, ?, ?, 'lobby') + `).run(JSON.stringify(players), JSON.stringify(playerChips), JSON.stringify(drinkLosses), me); + + // Pushover an andere Spieler + const myName = ordered[0].username; + for (const p of players.slice(1)) { + sendPush(p.id, '🎲 Schocken', `${myName} lädt dich zu einer Runde Schocken ein!`); + } + + res.json({ id: result.lastInsertRowid }); +}); + +// ── Spiel starten (Lobby → Hälfte 1) ───────────────────────────────────────── +router.post('/:id/start', authenticate, (req, res) => { + const me = uid(req); + const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + const players = JSON.parse(game.players); + if (players[0].id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann starten' }); + if (game.status !== 'lobby') return res.status(400).json({ error: 'Spiel läuft schon' }); + + db.prepare(`UPDATE schocken_games SET status='half1', first_round=1, beginner_id=?, + current_player_idx=0, updated_at=datetime('now','localtime') WHERE id=?`).run(me, game.id); + + for (const p of players.slice(1)) { + sendPush(p.id, '🎲 Schocken', 'Das Spiel beginnt! Du bist dran.'); + } + + res.json(getGame(game.id, me)); +}); + +// ── Würfeln ─────────────────────────────────────────────────────────────────── +router.post('/:id/roll', authenticate, (req, res) => { + const me = uid(req); + const { keep_dice = [], flip_sixes = false, go_dark = false } = req.body; + // keep_dice: array of indices (0-2) der Würfel die stehen bleiben + // flip_sixes: zwei Sechsen zu Eins umdrehen + // go_dark: dunkel legen nach diesem Wurf + + const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + + const players = JSON.parse(game.players); + const activePlayers = getActivePlayers(game); + const currentPlayer = activePlayers[game.current_player_idx % activePlayers.length]; + + if (!currentPlayer || currentPlayer.id !== me) + return res.status(400).json({ error: 'Nicht dein Zug' }); + + let round = game.current_round ? JSON.parse(game.current_round) : initRound(game, activePlayers); + const myState = round.playerStates[me]; + + if (myState.done) return res.status(400).json({ error: 'Du hast schon gewürfelt' }); + if (myState.dark) return res.status(400).json({ error: 'Du bist dunkel' }); + + // Flip sixes: nur wenn noch Würfe übrig und nicht letzter Wurf + if (flip_sixes) { + const sixes = myState.dice.reduce((acc, v, i) => v === 6 ? [...acc, i] : acc, []); + if (sixes.length >= 2) { + myState.dice[sixes[0]] = 1; + myState.dice[sixes[1]] = 1; + // Kostet keinen Wurf, braucht aber noch einen + } + } + + // Würfeln + const rollCount = myState.roll_count; + const isFirstRound = !!game.first_round; + + // Neue Würfel für nicht-stehen-gelassene + if (!isFirstRound || rollCount === 0) { + const newDice = rollDice(3 - keep_dice.length); + let newIdx = 0; + for (let i = 0; i < 3; i++) { + if (!keep_dice.includes(i)) myState.dice[i] = newDice[newIdx++]; + } + } + + myState.roll_count++; + + // Straße nur gültig wenn im ersten Wurf (keine stehen gelassenen Würfel) + const classified = classifyDice(myState.dice); + if (classified.type === 'strasse' && (keep_dice.length > 0 || myState.roll_count > 1)) { + // Straße ungültig — als normal werten + classified.type = 'normal'; + classified.scheiben = 1; + classified.label = 'Normal (Straße ungültig)'; + } + + // Max rolls durch Beginner bestimmt + const maxRolls = round.max_rolls || 3; + const isDark = go_dark || myState.roll_count >= maxRolls || isFirstRound; + const isDone = isDark || myState.roll_count >= maxRolls || isFirstRound; + + myState.dark = go_dark || (myState.roll_count >= maxRolls && !isDone) || isFirstRound; + myState.done = isDone; + myState.result = isDone && !go_dark ? classified : null; // bei dunkel: result erst am Ende + + // Wenn Beginner (erster Spieler): max_rolls setzen + if (currentPlayer.id === round.beginner_id && round.max_rolls === null) { + round.max_rolls = myState.roll_count; + } + + // Nächsten Spieler + const nextIdx = (game.current_player_idx + 1) % activePlayers.length; + const allDone = activePlayers.every(p => round.playerStates[p.id]?.done); + + round.playerStates[me] = myState; + + if (allDone) { + // Alle dunkel aufdecken + for (const p of activePlayers) { + const ps = round.playerStates[p.id]; + if (!ps.result) { + ps.result = classifyDice(ps.dice); + // Straße bei dunkel-legen auch ungültig wenn keep_dice vorhanden (nicht trackbar nachträglich) + } + } + round.phase = 'reveal'; + } + + db.prepare(`UPDATE schocken_games SET current_round=?, current_player_idx=?, + updated_at=datetime('now','localtime') WHERE id=?`) + .run(JSON.stringify(round), allDone ? game.current_player_idx : nextIdx, game.id); + + // Pushover an nächsten Spieler + if (!allDone) { + const next = activePlayers[nextIdx]; + if (next && next.id !== me) { + sendPush(next.id, '🎲 Schocken', `${currentPlayer.username} hat gewürfelt — du bist dran!`); + } + } + + res.json(getGame(game.id, me)); +}); + +// ── Runde auswerten ─────────────────────────────────────────────────────────── +router.post('/:id/evaluate', authenticate, (req, res) => { + const me = uid(req); + const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + + const players = JSON.parse(game.players); + const activePlayers = getActivePlayers(game); + if (players[0].id !== me && activePlayers[0]?.id !== me) + return res.status(403).json({ error: 'Kein Zugriff' }); + + const round = JSON.parse(game.current_round); + if (round.phase !== 'reveal') return res.status(400).json({ error: 'Noch nicht alle fertig' }); + + const playerChips = JSON.parse(game.player_chips); + let stock = game.stock; + + // Schock aus prüfen + const schockAus = activePlayers.find(p => round.playerStates[p.id]?.result?.type === 'schock_aus'); + if (schockAus) { + return handleSchockAus(game, round, activePlayers, res, me); + } + + // Bestes Ergebnis finden (höchster Typ) + const results = activePlayers.map(p => ({ + player: p, + state: round.playerStates[p.id], + result: round.playerStates[p.id]?.result, + rolls: round.playerStates[p.id]?.roll_count || 1, + })).filter(r => r.result); + + // Schlechtestes Ergebnis = bekommt Scheiben + results.sort((a, b) => { + const cmp = compareResults(a, b); + if (cmp !== 0) return cmp; // -1 wenn a schlechter + // Gleicher Typ: mehr Würfe = schlechter + if (a.rolls !== b.rolls) return b.rolls - a.rolls; // mehr Würfe = schlechter (hinten) + // Nachleger: später in Reihenfolge = schlechter + return activePlayers.indexOf(b.player) - activePlayers.indexOf(a.player); + }); + + const loser = results[0]; // schlechtestes Ergebnis + const scheiben = loser.result.scheiben; + + // Scheiben verteilen + let fromStock = 0; + let newStock = stock; + if (stock >= scheiben) { + fromStock = scheiben; + newStock = stock - scheiben; + } else { + fromStock = stock; + newStock = 0; + } + + const fromOthers = scheiben - fromStock; + playerChips[loser.player.id] = (playerChips[loser.player.id] || 0) + scheiben; + + // Wenn vom Stock nicht genug: Rest von aktivsten Spielern nehmen (noch nicht implementiert vereinfacht) + if (fromOthers > 0) { + // Scheiben von anderen Spielern umverteilen (von denen mit meisten Scheiben) + const donors = activePlayers + .filter(p => p.id !== loser.player.id && playerChips[p.id] > 0) + .sort((a,b) => playerChips[b.id] - playerChips[a.id]); + let remaining = fromOthers; + for (const donor of donors) { + if (remaining <= 0) break; + const take = Math.min(playerChips[donor.id], remaining); + playerChips[donor.id] -= take; + remaining -= take; + } + } + + // Hälfte/Endkampf vorbei? Prüfen ob Stock leer und nur einer Scheiben hat + const playersWithChips = activePlayers.filter(p => playerChips[p.id] > 0); + const phaseOver = newStock === 0 && playersWithChips.length <= 1; + + let newStatus = game.status; + let newPhaseStatus = 'playing'; + let loserH1 = game.loser_h1; + let loserH2 = game.loser_h2; + let has16th = game.has_16th; + let eventMsg = null; + + if (phaseOver) { + const phaseLoser = playersWithChips[0] || loser.player; + + if (game.status === 'half1') { + // Verlierer H1 bestimmt + loserH1 = phaseLoser.id; + has16th = phaseLoser.id; + newStatus = 'half2'; + // Chips reset, Stock reset + Object.keys(playerChips).forEach(k => { playerChips[k] = 0; }); + newStock = 15; + eventMsg = `${phaseLoser.username} hat die erste Hälfte verloren. Die erste Hälfte kostet nur Nerven! 😅`; + // Pushover + for (const p of players) { + sendPush(p.id, '🎲 Schocken', eventMsg); + } + } else if (game.status === 'half2') { + loserH2 = phaseLoser.id; + if (loserH1 === loserH2) { + // Doppelfeige! + newStatus = 'finished'; + newPhaseStatus = 'finished'; + const dl = JSON.parse(game.drink_losses); + dl[phaseLoser.id] = (dl[phaseLoser.id] || 0) + 1; + eventMsg = `${phaseLoser.username} ist DOPPELFEIGE! 🪶 Die Runde ist vorbei.`; + db.prepare(`UPDATE schocken_games SET status=?, phase_status=?, loser_h2=?, has_16th=?, + player_chips=?, stock=?, drink_losses=?, current_round=NULL, + updated_at=datetime('now','localtime') WHERE id=?`) + .run(newStatus, newPhaseStatus, loserH2, has16th, JSON.stringify(playerChips), + newStock, dl, game.id); + for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg); + return res.json(getGame(game.id, me)); + } + // Endkampf + newStatus = 'endkampf'; + Object.keys(playerChips).forEach(k => { playerChips[k] = 0; }); + newStock = 15; + eventMsg = `${phaseLoser.username} verliert die zweite Hälfte! Endkampf: ${players.find(p=>p.id===loserH1)?.username} vs ${phaseLoser.username}`; + for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg); + } else if (game.status === 'endkampf') { + newStatus = 'finished'; + newPhaseStatus = 'finished'; + const dl = JSON.parse(game.drink_losses); + dl[phaseLoser.id] = (dl[phaseLoser.id] || 0) + 1; + eventMsg = `${phaseLoser.username} hat den Endkampf verloren und muss die nächste Runde zahlen! 🍺`; + db.prepare(`UPDATE schocken_games SET status=?, phase_status=?, loser_h2=?, + player_chips=?, stock=?, drink_losses=?, current_round=NULL, + updated_at=datetime('now','localtime') WHERE id=?`) + .run(newStatus, newPhaseStatus, loserH2, JSON.stringify(playerChips), newStock, JSON.stringify(dl), game.id); + for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg); + return res.json(getGame(game.id, me)); + } + } + + // Nächste Runde vorbereiten + const nextBeginner = loser.player; + db.prepare(`UPDATE schocken_games SET player_chips=?, stock=?, status=?, loser_h1=?, loser_h2=?, + has_16th=?, beginner_id=?, current_round=NULL, current_player_idx=0, first_round=0, + updated_at=datetime('now','localtime') WHERE id=?`) + .run(JSON.stringify(playerChips), newStock, newStatus, loserH1, loserH2, + has16th, nextBeginner.id, game.id); + + // Pushover an Verlierer der Runde (er fängt an) + sendPush(loser.player.id, '🎲 Schocken', `Du hast die Runde verloren und fängst die nächste an! (${scheiben} Scheibe${scheiben!==1?'n':''})`); + + res.json({ ...getGame(game.id, me), round_result: { loser: loser.player, scheiben, eventMsg } }); +}); + +// ── Becher umdrehen (Runde bereit signalisieren) ────────────────────────────── +router.post('/:id/ready', authenticate, (req, res) => { + const me = uid(req); + const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(req.params.id); + if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); + + const players = JSON.parse(game.players); + const playerChips = JSON.parse(game.player_chips); + const activePlayers = getActivePlayers(game); + + // Spieler der eigentlich raus wäre aber trotzdem ready drückt → wird wieder aktiv (Falle!) + const isActuallyActive = activePlayers.find(p => p.id === me); + const isOut = !isActuallyActive && playerChips[me] === 0 && game.stock === 0; + + let round = game.current_round ? JSON.parse(game.current_round) : null; + if (!round) { + round = initRound(game, isOut ? [...activePlayers, players.find(p=>p.id===me)] : activePlayers); + } + + round.ready = round.ready || {}; + round.ready[me] = true; + + // Wenn isOut und drückt trotzdem: wird als "gefangen" markiert + if (isOut) { + round.trapped = round.trapped || []; + if (!round.trapped.includes(me)) round.trapped.push(me); + } + + db.prepare(`UPDATE schocken_games SET current_round=?, updated_at=datetime('now','localtime') WHERE id=?`) + .run(JSON.stringify(round), game.id); + + res.json(getGame(game.id, me)); +}); + +// ── Hilfsfunktionen ─────────────────────────────────────────────────────────── +function getActivePlayers(game) { + const players = JSON.parse(game.players); + const playerChips = JSON.parse(game.player_chips); + const stock = game.stock; + + if (game.status === 'endkampf') { + return players.filter(p => p.id === game.loser_h1 || p.id === game.loser_h2); + } + + // Wenn Stock noch voll oder erste Runde: alle mitspielen + if (stock > 0) return players; + + // Stock leer: nur Spieler mit Scheiben + return players.filter(p => playerChips[p.id] > 0); +} + +function initRound(game, activePlayers) { + const playerStates = {}; + for (const p of activePlayers) { + playerStates[p.id] = { + dice: [null, null, null], + roll_count: 0, + done: false, + dark: false, + result: null, + }; + } + return { + phase: 'rolling', + beginner_id: game.beginner_id, + max_rolls: game.first_round ? 1 : null, + ready: {}, + trapped: [], + playerStates, + }; +} + +function handleSchockAus(game, round, activePlayers, res, me) { + const players = JSON.parse(game.players); + const playerChips = JSON.parse(game.player_chips); + + // Alle Scheiben (Stock + Spieler) → schlechtester Spieler + const allChips = Object.values(playerChips).reduce((s,v) => s+v, 0) + game.stock; + + // Wer hat die wenigsten Punkte (außer dem der Schock aus hat)? + const schockAusPlayer = activePlayers.find(p => round.playerStates[p.id]?.result?.type === 'schock_aus'); + const others = activePlayers.filter(p => p.id !== schockAusPlayer?.id); + + // Vereinfacht: Schock aus Spieler hat gewonnen, alle Scheiben gehen an den schlechtesten der anderen + // (Schock aus steht über allem) + const newChips = Object.fromEntries(Object.keys(playerChips).map(k => [k, 0])); + + // Verlierer = wer die wenigsten Punkte hat unter den anderen + // Bei nur einem anderen: der verliert + const phaseLoser = others.length > 0 ? others[others.length - 1] : schockAusPlayer; + newChips[phaseLoser.id] = allChips; + + let newStatus = game.status; + let loserH1 = game.loser_h1; + let has16th = game.has_16th; + + if (game.status === 'half1') { + loserH1 = phaseLoser.id; + has16th = phaseLoser.id; + newStatus = 'half2'; + Object.keys(newChips).forEach(k => { newChips[k] = 0; }); + const eventMsg = `SCHOCK AUS! ${phaseLoser.username} verliert die erste Hälfte! Die erste Hälfte kostet nur Nerven! 😅`; + for (const p of players) sendPush(p.id, '🎲 Schocken', eventMsg); + db.prepare(`UPDATE schocken_games SET player_chips=?, stock=15, status=?, loser_h1=?, has_16th=?, + beginner_id=?, current_round=NULL, current_player_idx=0, first_round=1, + updated_at=datetime('now','localtime') WHERE id=?`) + .run(JSON.stringify(newChips), newStatus, loserH1, has16th, phaseLoser.id, game.id); + } + + res.json({ ...getGame(game.id, me), schock_aus: true, phase_loser: phaseLoser }); +} + +function getGame(id, requesterId) { + const game = db.prepare('SELECT * FROM schocken_games WHERE id=?').get(id); + if (!game) return null; + return { + ...game, + players: JSON.parse(game.players), + player_chips: JSON.parse(game.player_chips), + drink_losses: JSON.parse(game.drink_losses), + current_round: game.current_round ? JSON.parse(game.current_round) : null, + }; +} + +module.exports = router; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ffed280..8d471fa 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -155,7 +155,7 @@ function Login({ onLogin }) { // ── Update Modal ────────────────────────────────────────────────────────────── // ── Desktop Sidebar ─────────────────────────────────────────────────────────── -function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0 }) { +function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false }) { const [appsOpen, setAppsOpen] = useState(true); const [collapsed, setCollapsed] = useState(false); const [collapsedGroups, setCollapsedGroups] = useState({}); @@ -255,7 +255,7 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns {!collapsed && Apps} {!collapsed && } - {appsOpen && getGroupedTools().map(([group, tools]) => { + {appsOpen && getGroupedTools(isAdmin).map(([group, tools]) => { const isGroupCollapsed = !!collapsedGroups[group]; return (
@@ -296,7 +296,7 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns } // ── Mobile Bottom Navigation ────────────────────────────────────────────────── -function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0 }) { +function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns=0, whiteboardUnread=0, isAdmin=false }) { const [menuOpen, setMenuOpen] = useState(false); const [appsOpen, setAppsOpen] = useState(false); const [mobileCollapsedGroups, setMobileCollapsedGroups] = useState({}); @@ -360,7 +360,7 @@ function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTur {/* Apps Sheet mit Gruppenüberschriften */} setAppsOpen(false)}>
- {getGroupedTools().map(([group, tools]) => { + {getGroupedTools(user?.role==='admin').map(([group, tools]) => { const isGrpCollapsed = !!mobileCollapsedGroups[group]; return (
@@ -3833,7 +3833,7 @@ export default function App() { {localStorage.removeItem('sk_token');setUser(null);}} - unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread}/> + unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread} isAdmin={user?.role==='admin'}/> )}
{active==='dashboard' && { setUnreadBoard(0); setUnreadPromoted(0); }} unreadChangelog={unreadChangelog} onChangelogRead={()=>setUnreadChangelog(0)} unackedFavs={unackedFavs} onClearUnackedFavs={()=>setUnackedFavs({count:0,isAdmin:false})} user={user}/>} @@ -3845,7 +3845,7 @@ export default function App() { {localStorage.removeItem('sk_token');setUser(null);}} - unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread}/> + unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread} isAdmin={user?.role==='admin'}/> )} diff --git a/frontend/src/icons.jsx b/frontend/src/icons.jsx index 5dc8f1b..2d487cc 100644 --- a/frontend/src/icons.jsx +++ b/frontend/src/icons.jsx @@ -186,6 +186,15 @@ export const WhiteboardIcon = ({size=20,color='currentColor',sw}) => base(<> , size, color, sw); +export const SchockenIcon = ({size=20,color='currentColor',sw}) => base(<> + + + + + + +, size, color, sw); + export const GeoIcon = ({size=20,color='currentColor',sw}) => base(<> diff --git a/frontend/src/toolRegistry.js b/frontend/src/toolRegistry.js index e97fb26..ab89e1e 100644 --- a/frontend/src/toolRegistry.js +++ b/frontend/src/toolRegistry.js @@ -12,7 +12,8 @@ import PaywallKiller from './tools/paywallkiller.jsx'; import Kanban from './tools/kanban.jsx'; import Whiteboard from './tools/whiteboard.jsx'; import Gebietseroberung from './tools/gebietseroberung.jsx'; -import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon, PaywallIcon, ChartIcon, KanbanIcon, WhiteboardIcon, GeoIcon } from './icons.jsx'; +import Schocken from './tools/schocken.jsx'; +import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon, SkizzeIcon, WrenchIcon, FilmIcon, PaywallIcon, ChartIcon, KanbanIcon, WhiteboardIcon, GeoIcon, SchockenIcon } from './icons.jsx'; export const TOOLS = [ { @@ -71,6 +72,15 @@ export const TOOLS = [ group: 'Freizeit', component: Gebietseroberung, }, + { + id: 'schocken', + Icon: SchockenIcon, + label: 'Schocken', + navLabel: 'Schocken', + group: 'Freizeit', + component: Schocken, + adminOnly: true, + }, { id: 'dateien', Icon: DatabaseIcon, @@ -138,9 +148,10 @@ export const TOOLS = [ // Neues Tool: { id:'...', Icon:..., label:'...', navLabel:'...', group:'...', component:... }, ]; -export function getGroupedTools() { +export function getGroupedTools(isAdmin = false) { const groups = {}; for (const t of TOOLS) { + if (t.adminOnly && !isAdmin) continue; const g = t.group || 'Allgemein'; if (!groups[g]) groups[g] = []; groups[g].push(t); diff --git a/frontend/src/tools/schocken.jsx b/frontend/src/tools/schocken.jsx new file mode 100644 index 0000000..15508c5 --- /dev/null +++ b/frontend/src/tools/schocken.jsx @@ -0,0 +1,595 @@ +import { useState, useEffect, useCallback } from 'react'; +import { api, S } from '../lib.js'; + +const MY_COLOR = '#4ecdc4'; + +function getMyId() { + try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).id; } catch { return null; } +} +function getMyRole() { + try { return JSON.parse(atob(localStorage.getItem('sk_token').split('.')[1])).role; } catch { return null; } +} + +// ── Würfel-Visualisierung ───────────────────────────────────────────────────── +const DOTS = { + 1: [[50,50]], + 2: [[25,25],[75,75]], + 3: [[25,25],[50,50],[75,75]], + 4: [[25,25],[75,25],[25,75],[75,75]], + 5: [[25,25],[75,25],[50,50],[25,75],[75,75]], + 6: [[25,25],[75,25],[25,50],[75,50],[25,75],[75,75]], +}; + +function Die({ value, kept, selected, onClick, dark, size=56 }) { + const dots = value ? DOTS[value] : []; + const bg = dark ? 'rgba(40,40,60,0.9)' : kept ? 'rgba(78,205,196,0.15)' : 'rgba(255,255,255,0.08)'; + const border = selected ? '2px solid #4ecdc4' : kept ? '2px solid rgba(78,205,196,0.4)' : '1px solid rgba(255,255,255,0.15)'; + return ( +
+ {dark &&
🎲
} + {!dark && dots.map(([x,y],i) => ( +
40?10:7, height:size>40?10:7, + borderRadius:'50%', background:'#fff', + }}/> + ))} + {!value && !dark &&
?
} +
+ ); +} + +// ── Spieler-Zeile ───────────────────────────────────────────────────────────── +function PlayerRow({ player, chips, has16th, isMe, isCurrent, result, drinkLosses, game }) { + const isLoserH1 = game.has_16th === player.id; + return ( +
+
+
+
+ {isCurrent && } + {player.username} + {isMe && (du)} + {isLoserH1 && 🪶} +
+
+
+ {drinkLosses > 0 && ( + 🍺×{drinkLosses} + )} +
+
0?'#ff6b9d':MY_COLOR,fontFamily:'Space Mono,monospace',fontSize:18,fontWeight:700,lineHeight:1}}>{chips}
+
Scheiben
+
+
+
+ {result && ( +
+ {result.label} +
+ )} +
+ ); +} + +// ── Würfelbereich ───────────────────────────────────────────────────────────── +function DiceArea({ game, myId, onRoll, onReady, onEvaluate }) { + const [selectedKeep, setSelectedKeep] = useState([]); + const [goDark, setGoDark] = useState(false); + const [flipSixes, setFlipSixes] = useState(false); + const [rolling, setRolling] = useState(false); + + const round = game.current_round; + const players = game.players; + const activePlayers = getActivePlayers(game); + const myState = round?.playerStates?.[myId]; + const currentPlayer = activePlayers[game.current_player_idx % activePlayers.length]; + const isMyTurn = currentPlayer?.id === myId; + const allReady = activePlayers.every(p => round?.ready?.[p.id]); + const allDone = round?.phase === 'reveal'; + const isFirstRound = !!game.first_round; + const myDice = myState?.dice || [null, null, null]; + const myRolls = myState?.roll_count || 0; + const maxRolls = round?.max_rolls || 3; + const canFlipSixes = myDice.filter(d => d===6).length >= 2 && myRolls < maxRolls - 1; + + // Becher umdrehen Phase — warte bis alle ready + if (!round || !allReady) { + const iMReady = round?.ready?.[myId]; + const readyCount = Object.keys(round?.ready || {}).length; + const activeCount = activePlayers.length; + return ( +
+
+ {readyCount}/{activeCount} haben den Becher umgedreht +
+ {!iMReady && ( + + )} + {iMReady && ( +
+ ✓ Becher umgedreht — warte auf andere… +
+ )} + {isFirstRound && iMReady && readyCount === activeCount && ( +
+ Alle bereit — Becher werden gleichzeitig gehoben! +
+ )} +
+ ); + } + + // Auswertungsphase + if (allDone) { + const isFirstPlayer = activePlayers[0]?.id === myId || players[0]?.id === myId; + return ( +
+
+ Alle haben gewürfelt — Ergebnis auswerten! +
+ {isFirstPlayer && ( + + )} +
+ ); + } + + // Mein Würfeln + return ( +
+ {/* Status */} +
+ {isMyTurn ? `▶ Dein Wurf (${myRolls}/${maxRolls||'?'})` : `⏳ ${currentPlayer?.username} würfelt…`} +
+ + {/* Würfel */} +
+ {myDice.map((d, i) => ( + 0 && !myState?.done ? () => { + setSelectedKeep(prev => + prev.includes(i) ? prev.filter(x=>x!==i) : [...prev,i] + ); + } : undefined} + size={64} + /> + ))} +
+ + {/* Optionen */} + {isMyTurn && !myState?.done && ( +
+ {myRolls > 0 && ( +
+ {selectedKeep.length > 0 && 📌 {selectedKeep.length} Würfel stehen lassen} +
+ )} + {canFlipSixes && ( + + )} + {myRolls > 0 && myRolls < (maxRolls||3) && ( + + )} + +
+ )} + + {myState?.done && !allDone && ( +
+ {myState.dark ? '🎲 Du bist dunkel — warte auf andere…' : `✓ Du stehst im ${myRolls}. — warte auf andere…`} +
+ )} + + {/* Andere Spieler Status */} +
+ {activePlayers.filter(p => p.id !== myId).map(p => { + const ps = round.playerStates?.[p.id]; + return ( +
+ {ps?.done ? '✓' : '⏳'} {p.username} {ps?.dark && '🌑'} +
+ ); + })} +
+
+ ); +} + +// ── Schockstock ─────────────────────────────────────────────────────────────── +function Schockstock({ total=15, stock, players, chips }) { + const used = total - stock; + return ( +
+
+ SCHOCKSTOCK + {stock} verbleibend +
+
+ {Array.from({length:16}).map((_,i) => { + const is16th = i === 15; + const isUsed = !is16th && i >= stock; + return ( +
+ ); + })} +
+
+ ); +} + +// ── Neue Runde Button ───────────────────────────────────────────────────────── +function NewRoundButton({ game, myId, onReady }) { + // Der Beginner der neuen Runde startet mit Becher umdrehen + const isStarter = game.beginner_id === myId; + return ( +
+
+ Neue Runde — {isStarter ? 'du fängst an!' : `${game.players.find(p=>p.id===game.beginner_id)?.username} fängt an`} +
+ +
+ ); +} + +// ── Spielende ───────────────────────────────────────────────────────────────── +function GameOver({ game }) { + const dl = game.drink_losses; + const loser = game.players.find(p => dl[p.id] > 0); + const isDoppelfeige = game.loser_h1 === game.loser_h2; + return ( +
+
{isDoppelfeige ? '🪶🪶' : '🍺'}
+ {isDoppelfeige ? ( +
+ DOPPELFEIGE! +
+ ) : ( +
+ RUNDE VORBEI +
+ )} + {loser && ( +
+ {loser.username} zahlt die nächste Runde! 🍺 +
+ )} +
+
GETRÄNKE-STATISTIK
+ {game.players.map(p => ( +
+ {p.username} + 0?'#f59e0b':MY_COLOR}}>🍺 {dl[p.id]||0} +
+ ))} +
+
+ ); +} + +// ── Aktive Spieler berechnen (Frontend) ─────────────────────────────────────── +function getActivePlayers(game) { + const { players, player_chips, stock, status, loser_h1, loser_h2 } = game; + if (status === 'endkampf') { + return players.filter(p => p.id === loser_h1 || p.id === loser_h2); + } + if (stock > 0) return players; + return players.filter(p => player_chips[p.id] > 0); +} + +// ── Neues Spiel Modal ───────────────────────────────────────────────────────── +function NewGameModal({ onClose, onCreate, toast }) { + const [users, setUsers] = useState([]); + const [picked, setPicked] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { api('/tools/schocken/users').then(setUsers).catch(()=>{}); }, []); + + const toggle = id => setPicked(p => p.includes(id) ? p.filter(x=>x!==id) : [...p,id]); + + const create = async () => { + if (!picked.length) return; + setLoading(true); + try { + const r = await api('/tools/schocken', { body: { player_ids: picked } }); + toast('Spiel erstellt! 🎲'); + onCreate(r.id); + } catch(e) { toast(e.message||'Fehler','error'); } + finally { setLoading(false); } + }; + + return ( +
+
+
+ NEUES SPIEL + +
+
MITSPIELER WÄHLEN
+
+ {users.map(u => ( +
toggle(u.id)} style={{ + display:'flex',alignItems:'center',gap:10,padding:'8px 10px', + cursor:'pointer',borderRadius:8,marginBottom:4, + background: picked.includes(u.id) ? 'rgba(78,205,196,0.1)' : 'rgba(255,255,255,0.03)', + border: picked.includes(u.id) ? '1px solid rgba(78,205,196,0.3)' : '1px solid rgba(255,255,255,0.06)', + }}> + + {picked.includes(u.id)?'☑':'☐'} + + {u.username} +
+ ))} +
+
+ + +
+
+
+ ); +} + +// ── Spielliste ──────────────────────────────────────────────────────────────── +function GameList({ games, myId, onSelect, onNew }) { + const active = games.filter(g => g.status !== 'finished'); + const finished = games.filter(g => g.status === 'finished'); + + const statusLabel = s => ({ + lobby:'Lobby', half1:'1. Hälfte', half2:'2. Hälfte', endkampf:'Endkampf', finished:'Beendet' + }[s]||s); + + const GameRow = ({ g }) => { + const others = g.players.filter(p=>p.id!==myId).map(p=>p.username).join(', '); + return ( + + ); + }; + + return ( +
+ + {active.length > 0 && <> +
LAUFEND ({active.length})
+ {active.map(g=>)} + } + {finished.length > 0 && <> +
BEENDET
+ {finished.map(g=>)} + } + {games.length===0 && ( +
+ Noch keine Spiele — starte eines! +
+ )} +
+ ); +} + +// ── Spielansicht ────────────────────────────────────────────────────────────── +function GameView({ game, myId, onRefresh, toast }) { + const players = game.players; + const chips = game.player_chips; + const dl = game.drink_losses; + const round = game.current_round; + const activePlayers = getActivePlayers(game); + const currentPlayer = activePlayers[game.current_player_idx % activePlayers.length]; + + const handleRoll = async (keepDice, flipSixes, goDark) => { + await api(`/tools/schocken/${game.id}/roll`, { + body: { keep_dice: keepDice, flip_sixes: flipSixes, go_dark: goDark } + }); + onRefresh(); + }; + + const handleReady = async () => { + await api(`/tools/schocken/${game.id}/ready`, { body: {} }); + onRefresh(); + }; + + const handleEvaluate = async () => { + const result = await api(`/tools/schocken/${game.id}/evaluate`, { body: {} }); + if (result.round_result?.eventMsg) toast(result.round_result.eventMsg); + onRefresh(); + }; + + const handleStart = async () => { + await api(`/tools/schocken/${game.id}/start`, { body: {} }); + onRefresh(); + }; + + const statusLabel = { lobby:'Lobby', half1:'1. Hälfte', half2:'2. Hälfte', endkampf:'Endkampf', finished:'Beendet' }; + + return ( +
+ {/* Phase-Header */} +
+ {statusLabel[game.status]} + + Runde {game.round_number} +
+ + {/* Schockstock */} + + + {/* Spieler */} +
+ {players.map(p => ( + + ))} +
+ + {/* Lobby */} + {game.status==='lobby' && ( +
+ {players[0]?.id===myId ? ( + + ) : ( +
+ Warte auf {players[0]?.username} um das Spiel zu starten… +
+ )} +
+ )} + + {/* Spielfeld */} + {game.status!=='lobby' && game.status!=='finished' && ( +
+ +
+ )} + + {/* Spielende */} + {game.status==='finished' && } +
+ ); +} + +// ── Hauptkomponente ─────────────────────────────────────────────────────────── +export default function Schocken({ toast }) { + const [games, setGames] = useState([]); + const [activeId, setActiveId] = useState(null); + const [game, setGame] = useState(null); + const [showNew, setShowNew] = useState(false); + const [loading, setLoading] = useState(true); + + const myId = getMyId(); + + const loadGames = useCallback(async () => { + try { setGames(await api('/tools/schocken')); } + catch { toast?.('Fehler beim Laden','error'); } + finally { setLoading(false); } + }, []); + + const loadGame = useCallback(async () => { + if (!activeId) { setGame(null); return; } + try { setGame(await api(`/tools/schocken/${activeId}`)); } + catch {} + }, [activeId]); + + useEffect(() => { loadGames(); }, [loadGames]); + useEffect(() => { loadGame(); }, [loadGame]); + + // Polling wenn Spiel aktiv + useEffect(() => { + if (!activeId) return; + const iv = setInterval(loadGame, 5000); + return () => clearInterval(iv); + }, [activeId, loadGame]); + + if (loading) return
Lade…
; + + return ( +
+
+ {activeId && ( + + )} +

+ 🎲 SCHOCKEN +

+
+ + {!activeId + ? setShowNew(true)}/> + : game && myId + ? + :
Lade…
+ } + + {showNew && setShowNew(false)} toast={toast} onCreate={id=>{setShowNew(false);setActiveId(id);loadGames();}}/>} +
+ ); +}