feat: Schocken - erstes spielbares Grundgerüst (admin only)
This commit is contained in:
581
backend/src/tools/schocken/routes.js
Normal file
581
backend/src/tools/schocken/routes.js
Normal 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;
|
||||
@@ -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 && <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'}/>}
|
||||
</button>
|
||||
{appsOpen && getGroupedTools().map(([group, tools]) => {
|
||||
{appsOpen && getGroupedTools(isAdmin).map(([group, tools]) => {
|
||||
const isGroupCollapsed = !!collapsedGroups[group];
|
||||
return (
|
||||
<div key={group}>
|
||||
@@ -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 */}
|
||||
<Sheet open={appsOpen} onClose={()=>setAppsOpen(false)}>
|
||||
<div style={{ overflowY:'auto', maxHeight:'60vh', paddingBottom:8 }}>
|
||||
{getGroupedTools().map(([group, tools]) => {
|
||||
{getGroupedTools(user?.role==='admin').map(([group, tools]) => {
|
||||
const isGrpCollapsed = !!mobileCollapsedGroups[group];
|
||||
return (
|
||||
<div key={group}>
|
||||
@@ -3833,7 +3833,7 @@ export default function App() {
|
||||
<Sidebar active={active} setActive={setActive} user={user}
|
||||
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}>
|
||||
{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}
|
||||
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}/>
|
||||
<Toast msg={toastMsg.msg} type={toastMsg.type}/>
|
||||
|
||||
@@ -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"/>
|
||||
<line x1="3" y1="19" x2="21" y2="19" stroke={color} strokeWidth={sw||1.5} strokeLinecap="round"/>
|
||||
</>, 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(<>
|
||||
<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"/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
595
frontend/src/tools/schocken.jsx
Normal file
595
frontend/src/tools/schocken.jsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user