feat: Schocken - erstes spielbares Grundgerüst (admin only)

This commit is contained in:
2026-07-05 00:08:26 +02:00
parent 772cbc62ba
commit 19619cb57c
5 changed files with 1204 additions and 8 deletions

View File

@@ -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;

View File

@@ -155,7 +155,7 @@ function Login({ onLogin }) {
// ── Update Modal ────────────────────────────────────────────────────────────── // ── Update Modal ──────────────────────────────────────────────────────────────
// ── Desktop Sidebar ─────────────────────────────────────────────────────────── // ── 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 [appsOpen, setAppsOpen] = useState(true);
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState({}); const [collapsedGroups, setCollapsedGroups] = useState({});
@@ -255,7 +255,7 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns
{!collapsed && <span style={{ flex:1, color:'rgba(255,255,255,0.65)' }}>Apps</span>} {!collapsed && <span style={{ flex:1, color:'rgba(255,255,255,0.65)' }}>Apps</span>}
{!collapsed && <ChevronIcon size={13} color="rgba(255,255,255,0.35)" dir={appsOpen?'down':'right'}/>} {!collapsed && <ChevronIcon size={13} color="rgba(255,255,255,0.35)" dir={appsOpen?'down':'right'}/>}
</button> </button>
{appsOpen && getGroupedTools().map(([group, tools]) => { {appsOpen && getGroupedTools(isAdmin).map(([group, tools]) => {
const isGroupCollapsed = !!collapsedGroups[group]; const isGroupCollapsed = !!collapsedGroups[group];
return ( return (
<div key={group}> <div key={group}>
@@ -296,7 +296,7 @@ function Sidebar({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTurns
} }
// ── Mobile Bottom Navigation ────────────────────────────────────────────────── // ── 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 [menuOpen, setMenuOpen] = useState(false);
const [appsOpen, setAppsOpen] = useState(false); const [appsOpen, setAppsOpen] = useState(false);
const [mobileCollapsedGroups, setMobileCollapsedGroups] = useState({}); const [mobileCollapsedGroups, setMobileCollapsedGroups] = useState({});
@@ -360,7 +360,7 @@ function BottomNav({ active, setActive, user, onLogout, unreadMsgs=0, hexWarsTur
{/* Apps Sheet mit Gruppenüberschriften */} {/* Apps Sheet mit Gruppenüberschriften */}
<Sheet open={appsOpen} onClose={()=>setAppsOpen(false)}> <Sheet open={appsOpen} onClose={()=>setAppsOpen(false)}>
<div style={{ overflowY:'auto', maxHeight:'60vh', paddingBottom:8 }}> <div style={{ overflowY:'auto', maxHeight:'60vh', paddingBottom:8 }}>
{getGroupedTools().map(([group, tools]) => { {getGroupedTools(user?.role==='admin').map(([group, tools]) => {
const isGrpCollapsed = !!mobileCollapsedGroups[group]; const isGrpCollapsed = !!mobileCollapsedGroups[group];
return ( return (
<div key={group}> <div key={group}>
@@ -3833,7 +3833,7 @@ export default function App() {
<Sidebar active={active} setActive={setActive} user={user} <Sidebar active={active} setActive={setActive} user={user}
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}} onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}}
unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread}/> unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread} isAdmin={user?.role==='admin'}/>
)} )}
<main ref={mainRef} style={mainStyle}> <main ref={mainRef} style={mainStyle}>
{active==='dashboard' && <Dashboard key={dashboardKey} toast={toast} mobile={mobile} setActive={setActive} setDevToolsNav={setDevToolsNav} unreadMsgs={unreadMsgs} unreadBoard={unreadBoard} unreadPromoted={unreadPromoted} onBoardRead={()=>{ setUnreadBoard(0); setUnreadPromoted(0); }} unreadChangelog={unreadChangelog} onChangelogRead={()=>setUnreadChangelog(0)} unackedFavs={unackedFavs} onClearUnackedFavs={()=>setUnackedFavs({count:0,isAdmin:false})} user={user}/>} {active==='dashboard' && <Dashboard key={dashboardKey} toast={toast} mobile={mobile} setActive={setActive} setDevToolsNav={setDevToolsNav} unreadMsgs={unreadMsgs} unreadBoard={unreadBoard} unreadPromoted={unreadPromoted} onBoardRead={()=>{ 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() {
<BottomNav active={active} setActive={setActive} user={user} <BottomNav active={active} setActive={setActive} user={user}
onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}} onLogout={()=>{localStorage.removeItem('sk_token');setUser(null);}}
unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread}/> unreadMsgs={unreadMsgs} hexWarsTurns={hexWarsTurns} whiteboardUnread={whiteboardUnread} isAdmin={user?.role==='admin'}/>
)} )}
<ScrollToTop scrollRef={mainRef}/> <ScrollToTop scrollRef={mainRef}/>
<Toast msg={toastMsg.msg} type={toastMsg.type}/> <Toast msg={toastMsg.msg} type={toastMsg.type}/>

View File

@@ -186,6 +186,15 @@ export const WhiteboardIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M7 13 L10 9 L13 11 L16 7" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/> <path d="M7 13 L10 9 L13 11 L16 7" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="3" y1="19" x2="21" y2="19" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/> <line x1="3" y1="19" x2="21" y2="19" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
</>, size, color, sw); </>, size, color, sw);
export const SchockenIcon = ({size=20,color='currentColor',sw}) => base(<>
<circle cx="12" cy="12" r="9" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<circle cx="8.5" cy="9.5" r="1.2" fill={color}/>
<circle cx="15.5" cy="9.5" r="1.2" fill={color}/>
<circle cx="8.5" cy="14.5" r="1.2" fill={color}/>
<circle cx="15.5" cy="14.5" r="1.2" fill={color}/>
<circle cx="12" cy="12" r="1.2" fill={color}/>
</>, size, color, sw);
export const GeoIcon = ({size=20,color='currentColor',sw}) => base(<> export const GeoIcon = ({size=20,color='currentColor',sw}) => base(<>
<rect x="3" y="3" width="18" height="18" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none"/> <rect x="3" y="3" width="18" height="18" rx="2" stroke={color} strokeWidth={sw||1.5} fill="none"/>
<rect x="3" y="3" width="8" height="8" rx="1" fill={color} opacity="0.7"/> <rect x="3" y="3" width="8" height="8" rx="1" fill={color} opacity="0.7"/>

View File

@@ -12,7 +12,8 @@ import PaywallKiller from './tools/paywallkiller.jsx';
import Kanban from './tools/kanban.jsx'; import Kanban from './tools/kanban.jsx';
import Whiteboard from './tools/whiteboard.jsx'; import Whiteboard from './tools/whiteboard.jsx';
import Gebietseroberung from './tools/gebietseroberung.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 = [ export const TOOLS = [
{ {
@@ -71,6 +72,15 @@ export const TOOLS = [
group: 'Freizeit', group: 'Freizeit',
component: Gebietseroberung, component: Gebietseroberung,
}, },
{
id: 'schocken',
Icon: SchockenIcon,
label: 'Schocken',
navLabel: 'Schocken',
group: 'Freizeit',
component: Schocken,
adminOnly: true,
},
{ {
id: 'dateien', id: 'dateien',
Icon: DatabaseIcon, Icon: DatabaseIcon,
@@ -138,9 +148,10 @@ export const TOOLS = [
// Neues Tool: { id:'...', Icon:..., label:'...', navLabel:'...', group:'...', component:... }, // Neues Tool: { id:'...', Icon:..., label:'...', navLabel:'...', group:'...', component:... },
]; ];
export function getGroupedTools() { export function getGroupedTools(isAdmin = false) {
const groups = {}; const groups = {};
for (const t of TOOLS) { for (const t of TOOLS) {
if (t.adminOnly && !isAdmin) continue;
const g = t.group || 'Allgemein'; const g = t.group || 'Allgemein';
if (!groups[g]) groups[g] = []; if (!groups[g]) groups[g] = [];
groups[g].push(t); groups[g].push(t);

View File

@@ -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 (
<div onClick={onClick} style={{
width:size, height:size, borderRadius:10, background:bg, border,
cursor:onClick?'pointer':'default', position:'relative', flexShrink:0,
transition:'all 0.15s', boxShadow: selected ? '0 0 12px rgba(78,205,196,0.4)' : 'none',
}}>
{dark && <div style={{position:'absolute',inset:0,display:'flex',alignItems:'center',justifyContent:'center',color:'rgba(255,255,255,0.2)',fontSize:22}}>🎲</div>}
{!dark && dots.map(([x,y],i) => (
<div key={i} style={{
position:'absolute',
left:`${x}%`, top:`${y}%`,
transform:'translate(-50%,-50%)',
width:size>40?10:7, height:size>40?10:7,
borderRadius:'50%', background:'#fff',
}}/>
))}
{!value && !dark && <div style={{position:'absolute',inset:0,display:'flex',alignItems:'center',justifyContent:'center',color:'rgba(255,255,255,0.15)',fontSize:10,fontFamily:'monospace'}}>?</div>}
</div>
);
}
// ── Spieler-Zeile ─────────────────────────────────────────────────────────────
function PlayerRow({ player, chips, has16th, isMe, isCurrent, result, drinkLosses, game }) {
const isLoserH1 = game.has_16th === player.id;
return (
<div style={{
...S.card, padding:'10px 14px', marginBottom:8,
border: isCurrent ? '1px solid rgba(78,205,196,0.4)' : '1px solid rgba(255,255,255,0.07)',
background: isMe ? 'rgba(78,205,196,0.04)' : 'rgba(255,255,255,0.02)',
}}>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<div style={{flex:1}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,display:'flex',alignItems:'center',gap:6}}>
{isCurrent && <span style={{color:MY_COLOR}}></span>}
<strong>{player.username}</strong>
{isMe && <span style={{color:'rgba(255,255,255,0.3)',fontSize:10}}>(du)</span>}
{isLoserH1 && <span title="Verlierer 1. Hälfte" style={{fontSize:14}}>🪶</span>}
</div>
</div>
<div style={{display:'flex',alignItems:'center',gap:12}}>
{drinkLosses > 0 && (
<span style={{color:'#f59e0b',fontFamily:'monospace',fontSize:11}}>🍺×{drinkLosses}</span>
)}
<div style={{textAlign:'center'}}>
<div style={{color:chips>0?'#ff6b9d':MY_COLOR,fontFamily:'Space Mono,monospace',fontSize:18,fontWeight:700,lineHeight:1}}>{chips}</div>
<div style={{color:'rgba(255,255,255,0.25)',fontSize:9,fontFamily:'monospace'}}>Scheiben</div>
</div>
</div>
</div>
{result && (
<div style={{marginTop:6,padding:'4px 8px',background:'rgba(255,255,255,0.04)',borderRadius:6,
color:MY_COLOR,fontFamily:'monospace',fontSize:11}}>
{result.label}
</div>
)}
</div>
);
}
// ── 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 (
<div>
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:12,textAlign:'center',marginBottom:12}}>
{readyCount}/{activeCount} haben den Becher umgedreht
</div>
{!iMReady && (
<button onClick={onReady} style={{...S.btn('#4ecdc4'),width:'100%',fontSize:14,padding:'12px'}}>
🎲 Becher umdrehen
</button>
)}
{iMReady && (
<div style={{color:MY_COLOR,fontFamily:'monospace',fontSize:12,textAlign:'center',padding:'12px 0'}}>
Becher umgedreht warte auf andere
</div>
)}
{isFirstRound && iMReady && readyCount === activeCount && (
<div style={{color:'rgba(255,255,255,0.5)',fontFamily:'monospace',fontSize:11,textAlign:'center',marginTop:8}}>
Alle bereit Becher werden gleichzeitig gehoben!
</div>
)}
</div>
);
}
// Auswertungsphase
if (allDone) {
const isFirstPlayer = activePlayers[0]?.id === myId || players[0]?.id === myId;
return (
<div>
<div style={{color:MY_COLOR,fontFamily:'monospace',fontSize:12,textAlign:'center',marginBottom:12}}>
Alle haben gewürfelt Ergebnis auswerten!
</div>
{isFirstPlayer && (
<button onClick={onEvaluate} style={{...S.btn('#ffe66d'),width:'100%'}}>
Runde auswerten &amp; Scheiben verteilen
</button>
)}
</div>
);
}
// Mein Würfeln
return (
<div>
{/* Status */}
<div style={{color:isMyTurn?MY_COLOR:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:11,textAlign:'center',marginBottom:10}}>
{isMyTurn ? `▶ Dein Wurf (${myRolls}/${maxRolls||'?'})` : `${currentPlayer?.username} würfelt…`}
</div>
{/* Würfel */}
<div style={{display:'flex',gap:10,justifyContent:'center',marginBottom:14}}>
{myDice.map((d, i) => (
<Die
key={i}
value={d}
dark={myState?.dark}
kept={selectedKeep.includes(i)}
selected={selectedKeep.includes(i)}
onClick={isMyTurn && myRolls > 0 && !myState?.done ? () => {
setSelectedKeep(prev =>
prev.includes(i) ? prev.filter(x=>x!==i) : [...prev,i]
);
} : undefined}
size={64}
/>
))}
</div>
{/* Optionen */}
{isMyTurn && !myState?.done && (
<div style={{display:'flex',flexDirection:'column',gap:8}}>
{myRolls > 0 && (
<div style={{display:'flex',gap:8,fontSize:11,fontFamily:'monospace',color:'rgba(255,255,255,0.5)'}}>
{selectedKeep.length > 0 && <span>📌 {selectedKeep.length} Würfel stehen lassen</span>}
</div>
)}
{canFlipSixes && (
<label style={{display:'flex',alignItems:'center',gap:8,cursor:'pointer',fontFamily:'monospace',fontSize:11,color:'rgba(255,255,255,0.6)'}}>
<input type="checkbox" checked={flipSixes} onChange={e=>setFlipSixes(e.target.checked)}/>
Zwei Sechsen zu Einer Eins umdrehen
</label>
)}
{myRolls > 0 && myRolls < (maxRolls||3) && (
<label style={{display:'flex',alignItems:'center',gap:8,cursor:'pointer',fontFamily:'monospace',fontSize:11,color:'rgba(255,255,255,0.6)'}}>
<input type="checkbox" checked={goDark} onChange={e=>setGoDark(e.target.checked)}/>
Dunkel legen (Becher nicht heben)
</label>
)}
<button
disabled={rolling}
onClick={async () => {
setRolling(true);
try { await onRoll(selectedKeep, flipSixes, goDark); setSelectedKeep([]); setFlipSixes(false); setGoDark(false); }
finally { setRolling(false); }
}}
style={{...S.btn(MY_COLOR), padding:'10px', fontSize:13}}
>
{rolling ? '🎲 Würfle…' : myRolls === 0 ? '🎲 Becher heben' : goDark ? '🎲 Dunkel würfeln' : '🎲 Nochmal würfeln'}
</button>
</div>
)}
{myState?.done && !allDone && (
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:11,textAlign:'center',padding:'8px 0'}}>
{myState.dark ? '🎲 Du bist dunkel — warte auf andere…' : `✓ Du stehst im ${myRolls}. — warte auf andere…`}
</div>
)}
{/* Andere Spieler Status */}
<div style={{marginTop:12,display:'flex',flexWrap:'wrap',gap:6}}>
{activePlayers.filter(p => p.id !== myId).map(p => {
const ps = round.playerStates?.[p.id];
return (
<div key={p.id} style={{
padding:'4px 10px', borderRadius:20,
background: ps?.done ? 'rgba(78,205,196,0.1)' : 'rgba(255,255,255,0.04)',
border: ps?.done ? '1px solid rgba(78,205,196,0.3)' : '1px solid rgba(255,255,255,0.08)',
color: ps?.done ? MY_COLOR : 'rgba(255,255,255,0.4)',
fontFamily:'monospace', fontSize:10,
}}>
{ps?.done ? '✓' : '⏳'} {p.username} {ps?.dark && '🌑'}
</div>
);
})}
</div>
</div>
);
}
// ── Schockstock ───────────────────────────────────────────────────────────────
function Schockstock({ total=15, stock, players, chips }) {
const used = total - stock;
return (
<div style={{...S.card,padding:'10px 14px',marginBottom:12}}>
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:8}}>
<span style={{color:'rgba(255,255,255,0.55)',fontFamily:'monospace',fontSize:11,letterSpacing:1}}>SCHOCKSTOCK</span>
<span style={{color:MY_COLOR,fontFamily:'monospace',fontSize:11,marginLeft:'auto'}}>{stock} verbleibend</span>
</div>
<div style={{display:'flex',gap:3,flexWrap:'wrap'}}>
{Array.from({length:16}).map((_,i) => {
const is16th = i === 15;
const isUsed = !is16th && i >= stock;
return (
<div key={i} style={{
width:14, height:14, borderRadius:'50%',
background: is16th
? 'rgba(245,158,11,0.6)'
: isUsed
? 'rgba(255,255,255,0.1)'
: '#4ecdc4',
border: is16th ? '1px solid #f59e0b' : '1px solid rgba(255,255,255,0.1)',
title: is16th ? '16. Scheibe' : '',
}}/>
);
})}
</div>
</div>
);
}
// ── Neue Runde Button ─────────────────────────────────────────────────────────
function NewRoundButton({ game, myId, onReady }) {
// Der Beginner der neuen Runde startet mit Becher umdrehen
const isStarter = game.beginner_id === myId;
return (
<div style={{...S.card,padding:'14px',textAlign:'center',marginBottom:12,border:'1px solid rgba(78,205,196,0.2)'}}>
<div style={{color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:12,marginBottom:10}}>
Neue Runde {isStarter ? 'du fängst an!' : `${game.players.find(p=>p.id===game.beginner_id)?.username} fängt an`}
</div>
<button onClick={onReady} style={{...S.btn('#4ecdc4'),width:'100%',fontSize:14,padding:'12px'}}>
🎲 Becher umdrehen
</button>
</div>
);
}
// ── 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 (
<div style={{...S.card,padding:'20px',textAlign:'center',border:'1px solid rgba(245,158,11,0.3)'}}>
<div style={{fontSize:48,marginBottom:12}}>{isDoppelfeige ? '🪶🪶' : '🍺'}</div>
{isDoppelfeige ? (
<div style={{color:'#f59e0b',fontFamily:'Space Mono,monospace',fontSize:16,fontWeight:700,marginBottom:8}}>
DOPPELFEIGE!
</div>
) : (
<div style={{color:'#f59e0b',fontFamily:'Space Mono,monospace',fontSize:16,fontWeight:700,marginBottom:8}}>
RUNDE VORBEI
</div>
)}
{loser && (
<div style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13,marginBottom:16}}>
{loser.username} zahlt die nächste Runde! 🍺
</div>
)}
<div style={{marginTop:12}}>
<div style={{...S.head,marginBottom:8}}>GETRÄNKE-STATISTIK</div>
{game.players.map(p => (
<div key={p.id} style={{display:'flex',justifyContent:'space-between',padding:'4px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)',fontFamily:'monospace',fontSize:12}}>
<span style={{color:'rgba(255,255,255,0.7)'}}>{p.username}</span>
<span style={{color:dl[p.id]>0?'#f59e0b':MY_COLOR}}>🍺 {dl[p.id]||0}</span>
</div>
))}
</div>
</div>
);
}
// ── 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 (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',display:'flex',alignItems:'center',justifyContent:'center',zIndex:1000,padding:20}}>
<div style={{...S.card,maxWidth:380,width:'100%'}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:18}}>
<span style={{color:'#fff',fontFamily:'Space Mono,monospace',fontSize:13,fontWeight:700,letterSpacing:1}}>NEUES SPIEL</span>
<button onClick={onClose} style={S.btn('#666666',true)}></button>
</div>
<div style={{...S.head,marginBottom:8}}>MITSPIELER WÄHLEN</div>
<div style={{maxHeight:240,overflowY:'auto',marginBottom:16}}>
{users.map(u => (
<div key={u.id} onClick={()=>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)',
}}>
<span style={{color:picked.includes(u.id)?MY_COLOR:'rgba(255,255,255,0.5)',fontSize:14}}>
{picked.includes(u.id)?'☑':'☐'}
</span>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13}}>{u.username}</span>
</div>
))}
</div>
<div style={{display:'flex',gap:8}}>
<button onClick={onClose} style={{...S.btn('#666666'),flex:1}}>Abbrechen</button>
<button onClick={create} disabled={!picked.length||loading} style={{...S.btn('#4ecdc4'),flex:1}}>
{loading ? 'Erstelle…' : `Starten (${picked.length+1} Spieler)`}
</button>
</div>
</div>
</div>
);
}
// ── 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 (
<button onClick={()=>onSelect(g.id)} style={{
...S.card,width:'100%',textAlign:'left',cursor:'pointer',
padding:'12px 14px',marginBottom:8,display:'flex',alignItems:'center',gap:10,
border:'1px solid rgba(255,255,255,0.07)',
}}>
<div style={{flex:1}}>
<div style={{color:'#fff',fontFamily:'monospace',fontSize:13,marginBottom:3}}>
🎲 mit <strong>{others}</strong>
</div>
<div style={{color:'rgba(255,255,255,0.35)',fontSize:11,fontFamily:'monospace'}}>
{g.players.length} Spieler
</div>
</div>
<span style={{color:g.status==='finished'?'rgba(255,255,255,0.3)':MY_COLOR,fontSize:11,fontFamily:'monospace',flexShrink:0}}>
{statusLabel(g.status)}
</span>
</button>
);
};
return (
<div>
<button onClick={onNew} style={{...S.btn('#4ecdc4'),width:'100%',marginBottom:20,padding:'10px 14px',fontSize:13}}>
+ Neues Spiel starten
</button>
{active.length > 0 && <>
<div style={{...S.head,marginBottom:8}}>LAUFEND ({active.length})</div>
{active.map(g=><GameRow key={g.id} g={g}/>)}
</>}
{finished.length > 0 && <>
<div style={{...S.head,marginTop:16,marginBottom:8}}>BEENDET</div>
{finished.map(g=><GameRow key={g.id} g={g}/>)}
</>}
{games.length===0 && (
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:13,textAlign:'center',paddingTop:40}}>
Noch keine Spiele starte eines!
</div>
)}
</div>
);
}
// ── 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 (
<div>
{/* Phase-Header */}
<div style={{...S.card,padding:'8px 14px',marginBottom:12,display:'flex',alignItems:'center',gap:10}}>
<span style={{color:MY_COLOR,fontFamily:'monospace',fontSize:11,letterSpacing:1}}>{statusLabel[game.status]}</span>
<span style={{flex:1}}/>
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>Runde {game.round_number}</span>
</div>
{/* Schockstock */}
<Schockstock stock={game.stock} players={players} chips={chips}/>
{/* Spieler */}
<div style={{marginBottom:12}}>
{players.map(p => (
<PlayerRow
key={p.id}
player={p}
chips={chips[p.id]||0}
has16th={game.has_16th}
isMe={p.id===myId}
isCurrent={currentPlayer?.id===p.id}
result={round?.playerStates?.[p.id]?.result}
drinkLosses={dl[p.id]||0}
game={game}
/>
))}
</div>
{/* Lobby */}
{game.status==='lobby' && (
<div style={{textAlign:'center'}}>
{players[0]?.id===myId ? (
<button onClick={handleStart} style={{...S.btn('#4ecdc4'),width:'100%',fontSize:14,padding:'12px'}}>
🎲 Spiel starten
</button>
) : (
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12,padding:'20px 0'}}>
Warte auf {players[0]?.username} um das Spiel zu starten
</div>
)}
</div>
)}
{/* Spielfeld */}
{game.status!=='lobby' && game.status!=='finished' && (
<div style={{...S.card,padding:'14px',border:'1px solid rgba(78,205,196,0.15)'}}>
<DiceArea
game={game}
myId={myId}
onRoll={handleRoll}
onReady={handleReady}
onEvaluate={handleEvaluate}
/>
</div>
)}
{/* Spielende */}
{game.status==='finished' && <GameOver game={game}/>}
</div>
);
}
// ── 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 <div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',padding:40,textAlign:'center'}}>Lade</div>;
return (
<div style={{maxWidth:560,margin:'0 auto'}}>
<div style={{display:'flex',alignItems:'center',gap:10,marginBottom:24,flexWrap:'wrap'}}>
{activeId && (
<button onClick={()=>{setActiveId(null);setGame(null);loadGames();}} style={S.btn('#666666',true)}> Zurück</button>
)}
<h2 style={{margin:0,fontSize:15,fontFamily:'monospace',color:'rgba(255,255,255,0.55)',letterSpacing:2,fontWeight:400}}>
🎲 SCHOCKEN
</h2>
</div>
{!activeId
? <GameList games={games} myId={myId} onSelect={setActiveId} onNew={()=>setShowNew(true)}/>
: game && myId
? <GameView game={game} myId={myId} onRefresh={loadGame} toast={toast}/>
: <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',textAlign:'center',paddingTop:40}}>Lade</div>
}
{showNew && <NewGameModal onClose={()=>setShowNew(false)} toast={toast} onCreate={id=>{setShowNew(false);setActiveId(id);loadGames();}}/>}
</div>
);
}